From 95107117fae66474fea7329b8750ceb21b32ad84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois?= Date: Sun, 26 Apr 2026 11:50:28 +0200 Subject: [PATCH] Color is automatically adapted to the theme of the console. Co-Authored-By: Claude Sonnet 4.6 --- src/testium/interpreter/utils/termlog.py | 198 +++++++++++++++++------ 1 file changed, 150 insertions(+), 48 deletions(-) diff --git a/src/testium/interpreter/utils/termlog.py b/src/testium/interpreter/utils/termlog.py index 1cef03d..e354629 100644 --- a/src/testium/interpreter/utils/termlog.py +++ b/src/testium/interpreter/utils/termlog.py @@ -1,32 +1,102 @@ -import colorama +import os import re +import sys +import colorama from colorama import Fore, Style -COLOR_DEFAULT = Fore.WHITE -COLOR_RESET = Fore.RESET + Style.RESET_ALL + COLOR_DEFAULT +def _detect_dark_background() -> bool: + """Detect whether the terminal has a dark background. -def colored_string(string: str, inputs: list) -> None: - """Function which calculate the coloring of strings with many layers. - Overlap of layers and inner layers are managed. + Tries the following methods in order: + 1. ``COLORFGBG`` environment variable (Konsole, rxvt, …) + 2. OSC 11 terminal query — reads the actual background colour from the + terminal emulator (xterm, VTE, kitty, WezTerm, …) + 3. ``darkdetect`` module — OS-level dark-mode preference (optional dep) + + Returns ``True`` for a dark background (default assumption). """ - cols = [COLOR_DEFAULT for i in range(len(string))] - for input in inputs: - for i in range(input[0][0], input[0][1]): - cols[i] = input[1] + # --- Method 1: COLORFGBG --- + colorfgbg = os.environ.get("COLORFGBG", "") + if colorfgbg: + try: + bg = int(colorfgbg.split(";")[-1]) + # 0-6: dark palette entries, 7-15: light palette entries + return bg < 7 + except (ValueError, IndexError): + pass + + # --- Method 2: OSC 11 terminal query --- + if sys.stdin.isatty() and sys.stdout.isatty(): + try: + import select + import termios + import tty + + fd = sys.stdin.fileno() + old = termios.tcgetattr(fd) + try: + tty.setraw(fd) + # Query background colour + sys.stdout.write("\033]11;?\007") + sys.stdout.flush() + ready, _, _ = select.select([sys.stdin], [], [], 0.2) + if ready: + response = "" + while True: + r2, _, _ = select.select([sys.stdin], [], [], 0.05) + if not r2: + break + chunk = os.read(fd, 64).decode("latin-1", errors="replace") + response += chunk + # Terminal answers with ESC]11;rgb:RR../GG../BB.. + if response.endswith("\007") or response.endswith("\033\\"): + break + m = re.search( + r"rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)", + response, + ) + if m: + # Components are 8- or 16-bit hex; normalise to 0-255 + def _norm(h: str) -> float: + return int(h[:2], 16) + + r_v = _norm(m.group(1)) + g_v = _norm(m.group(2)) + b_v = _norm(m.group(3)) + luminance = 0.299 * r_v + 0.587 * g_v + 0.114 * b_v + return luminance < 128 + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + except Exception: + pass + + # Default: assume dark terminal + return True + + +def _colored_string(string: str, inputs: list, color_default: str, color_reset: str) -> str: + """Return *string* with ANSI colour codes applied according to *inputs*. + + *inputs* is a list of ``[[start, end], color_code]`` pairs. + Overlapping layers are handled: the last listed colour wins. + """ + cols = [color_default for _ in range(len(string))] + for span, color in inputs: + for i in range(span[0], span[1]): + cols[i] = color - # construction of the string s = "" ilast = 0 - last_col = COLOR_DEFAULT + last_col = color_default for i in range(len(string)): if last_col != cols[i]: - s = s + string[ilast:i] + COLOR_RESET + cols[i] + s = s + string[ilast:i] + color_reset + cols[i] ilast = i last_col = cols[i] - return s + string[ilast:] + COLOR_RESET + return s + string[ilast:] + color_reset class TermLog: @@ -37,46 +107,74 @@ class TermLog: DEBUG = ["DEBUG"] BOOL = ["False", "True", "false", "true", "FALSE", "TRUE"] - def __init__(self, out) -> None: - """Class used to color the stdout in batch and terminal mode.""" + def __init__(self, out, dark_bg: bool = None) -> None: + """Class used to colour the stdout in batch and terminal mode. + + :param out: Underlying output stream. + :param dark_bg: ``True`` for dark background, ``False`` for light. + ``None`` (default) triggers auto-detection. + """ colorama.init() self.out = out - self.pats = [] - self.pats = self.pats + [ - [re.compile('(\\"[^\\"]+\\")'), Fore.LIGHTBLUE_EX + Style.BRIGHT], - [re.compile("(\\'[^\\']+\\')"), Fore.LIGHTBLUE_EX + Style.BRIGHT], - [re.compile("(<-----|----->) step"), Fore.BLUE], - [ - re.compile( - r"([\d\.]+)", - ), - Fore.MAGENTA, - ], - [re.compile(r"(@@\d+@@)"), Fore.BLACK], + self.residue = "" + + if dark_bg is None: + dark_bg = _detect_dark_background() + + if dark_bg: + color_default = Fore.WHITE + color_string = Fore.LIGHTBLUE_EX + Style.BRIGHT + color_number = Fore.MAGENTA + color_bool = Fore.MAGENTA + color_step = Fore.BLUE + color_marker = Fore.BLACK + color_warn = Fore.YELLOW + color_info = Style.BRIGHT + color_debug = Fore.BLUE + Style.BRIGHT + color_pass = Fore.GREEN + Style.BRIGHT + color_fail = Fore.RED + Style.BRIGHT + else: + color_default = Fore.RESET + color_string = Fore.BLUE + color_number = Fore.MAGENTA + color_bool = Fore.MAGENTA + color_step = Fore.BLUE + color_marker = Fore.RESET + color_warn = Fore.YELLOW + Style.BRIGHT + color_info = Fore.CYAN + color_debug = Fore.BLUE + color_pass = Fore.GREEN + color_fail = Fore.RED + Style.BRIGHT + + self._color_default = color_default + self._color_reset = Fore.RESET + Style.RESET_ALL + color_default + + self.pats = [ + [re.compile(r'("(?:[^"]+)")'), color_string], + [re.compile(r"('(?:[^']+)')"), color_string], + [re.compile(r"(<-----|----->) step"), color_step], + [re.compile(r"([\d\.]+)"), color_number], + [re.compile(r"(@@\d+@@)"), color_marker], ] for word in self.BOOL: - self.pats.append([re.compile("({})".format(word)), Fore.MAGENTA]) + self.pats.append([re.compile(r"({})".format(word)), color_bool]) for word in self.WARN: - self.pats.append([re.compile("({})".format(word)), Fore.YELLOW]) + self.pats.append([re.compile(r"({})".format(word)), color_warn]) for word in self.INFO: - self.pats.append([re.compile("({})".format(word)), Style.BRIGHT]) + self.pats.append([re.compile(r"({})".format(word)), color_info]) for word in self.DEBUG: - self.pats.append([re.compile("({})".format(word)), Fore.BLUE + Style.BRIGHT]) + self.pats.append([re.compile(r"({})".format(word)), color_debug]) for word in self.PASS: - self.pats.append( - [re.compile("({})".format(word)), Fore.GREEN + Style.BRIGHT] - ) + self.pats.append([re.compile(r"({})".format(word)), color_pass]) for word in self.FAIL: - self.pats.append([re.compile("({})".format(word)), Fore.RED + Style.BRIGHT]) - self.residue = "" + self.pats.append([re.compile(r"({})".format(word)), color_fail]) def find_pats(self, line): spans = [] - for p in self.pats: - it = p[0].finditer(line) - for m in it: + for p, color in self.pats: + for m in p.finditer(line): if m: - spans.append([m.span(), p[1]]) + spans.append([m.span(), color]) return spans def write(self, s: str) -> None: @@ -87,15 +185,19 @@ class TermLog: if s[-1:] != "\n": pos = s.rfind("\n") if pos >= 0: - self.residue = s[pos:] - s = s[:pos] + self.residue = s[pos + 1:] + s = s[:pos + 1] else: - # only one line - self.out.write(colored_string(s, self.find_pats(s))) + # single incomplete line — output immediately + self.out.write(_colored_string(s, self.find_pats(s), + self._color_default, self._color_reset)) return - # multiline case - for l in s.splitlines(): - self.out.write(colored_string(l, self.find_pats(l)) + "\n") + # one or more complete lines + for line in s.splitlines(): + self.out.write( + _colored_string(line, self.find_pats(line), + self._color_default, self._color_reset) + "\n" + ) def flush(self): if self.residue != "":