diff --git a/.ci/validate-rawterm.py b/.ci/validate-rawterm.py new file mode 100755 index 0000000..2d07590 --- /dev/null +++ b/.ci/validate-rawterm.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +"""Validate rawterm and menuconfig on all platforms. + +Exercises rawterm Color/Style/Region compositing, Windows console API +(GetStdHandle/SetConsoleMode), Unix termios terminal init/close, and +menuconfig headless mode with style parsing. + +Run from the project root: python .ci/validate-rawterm.py +""" + +import os +import sys + +# Ensure the project root (CWD) is on the import path, since Python +# adds the script's directory (.ci/) rather than CWD by default. +sys.path.insert(0, os.getcwd()) + +_IS_WINDOWS = os.name == "nt" + + +def check_rawterm_units(): + """rawterm Color, Style, Key, Box -- no terminal required.""" + from rawterm import Style, Color, Key, Box, NAMED_COLORS + + # Color construction and equality + c1 = Color.RED + c2 = Color.index(196) + c3 = Color.rgb(255, 0, 0) + assert c1 == Color.RED, "named color identity" + assert c2 == Color.index(196), "index color identity" + assert c3 == Color.rgb(255, 0, 0), "rgb color identity" + assert c1 != c2, "named vs index differ" + assert hash(c1) == hash(Color.RED), "color hash stable" + assert Color.DEFAULT == Color.DEFAULT, "DEFAULT identity" + + # Style construction and attributes + s1 = Style(fg=c1, bg=Color.DEFAULT) + s2 = Style(fg=c1, bg=Color.DEFAULT, bold=True) + assert s1 != s2, "bold changes style" + assert s1 == Style(fg=c1, bg=Color.DEFAULT), "style equality" + assert s2.bold is True, "bold attribute" + assert s1.standout is False, "standout default" + + # Key constants exist + for attr in ( + "UP", + "DOWN", + "LEFT", + "RIGHT", + "HOME", + "END", + "PAGE_UP", + "PAGE_DOWN", + "BACKSPACE", + "DELETE", + "RESIZE", + ): + assert getattr(Key, attr) is not None, "Key." + attr + + # Box-drawing constants are single characters + for attr in ( + "HLINE", + "VLINE", + "ULCORNER", + "URCORNER", + "LLCORNER", + "LRCORNER", + "LTEE", + "RTEE", + "UARROW", + "DARROW", + "RARROW", + ): + ch = getattr(Box, attr) + assert isinstance(ch, str) and len(ch) == 1, "Box." + attr + + # NAMED_COLORS has the 16 standard colors + for name in ( + "black", + "red", + "green", + "yellow", + "blue", + "magenta", + "cyan", + "white", + ): + assert name in NAMED_COLORS, "missing " + name + assert "bright" + name in NAMED_COLORS, "missing bright" + name + + print("rawterm unit checks passed") + + +def _windows_has_console(): + """Probe whether real Windows console handles are available. + + Returns True if GetStdHandle/GetConsoleMode succeed on stdout, + meaning we have a native console (cmd/powershell) rather than a + mintty/MSYS2 PTY that lacks Win32 console handles. + """ + if not _IS_WINDOWS: + return False + try: + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.windll.kernel32 + handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE + if handle == -1 or handle == 0: + return False + mode = wintypes.DWORD() + if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)): + return False + return True + except (OSError, AttributeError): + return False + + +def check_windows_console(): + """Validate Windows console API at the ctypes level. + + Tests GetStdHandle, GetConsoleMode, SetConsoleMode VT100 toggle + without entering alternate screen or changing terminal state + permanently. Only runs on Windows with a real console. + """ + if not _IS_WINDOWS: + print("Windows console checks skipped (not Windows)") + return + + if not _windows_has_console(): + print("Windows console checks skipped (no native console handle)") + return + + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.windll.kernel32 + + # Validate stdout handle + STD_OUTPUT_HANDLE = -11 + STD_INPUT_HANDLE = -10 + stdout_h = kernel32.GetStdHandle(STD_OUTPUT_HANDLE) + stdin_h = kernel32.GetStdHandle(STD_INPUT_HANDLE) + assert stdout_h not in (-1, 0, None), "invalid stdout handle" + assert stdin_h not in (-1, 0, None), "invalid stdin handle" + + # Read current console modes + out_mode = wintypes.DWORD() + in_mode = wintypes.DWORD() + assert kernel32.GetConsoleMode( + stdout_h, ctypes.byref(out_mode) + ), "GetConsoleMode(stdout) failed" + assert kernel32.GetConsoleMode( + stdin_h, ctypes.byref(in_mode) + ), "GetConsoleMode(stdin) failed" + + # Toggle VT100 output processing on, then restore + ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 + new_out = out_mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING + assert kernel32.SetConsoleMode( + stdout_h, new_out + ), "SetConsoleMode(stdout, VT100) failed" + # Restore original mode + assert kernel32.SetConsoleMode( + stdout_h, out_mode.value + ), "SetConsoleMode(stdout, restore) failed" + + # Test VT100 input mode (may fail on older Windows -- not fatal) + ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200 + new_in = (in_mode.value | ENABLE_VIRTUAL_TERMINAL_INPUT) & ~0x0007 + vt_input_ok = kernel32.SetConsoleMode(stdin_h, new_in) + kernel32.SetConsoleMode(stdin_h, in_mode.value) # always restore + print( + " VT100 input: {}".format( + "supported" if vt_input_ok else "not supported (ReadConsoleInputW fallback)" + ) + ) + + print("Windows console checks passed") + + +def check_terminal_init(): + """Terminal init/close -- full rawterm.Terminal lifecycle. + + On Unix, requires a real TTY on stdin/stdout. + On Windows, requires native console handles (cmd/powershell, not + MSYS2/mintty). The workflow uses 'shell: cmd' on Windows to + provide this. + """ + from rawterm import Style, Color, Box + + if _IS_WINDOWS: + can_init = _windows_has_console() + else: + can_init = os.isatty(sys.stdin.fileno()) and os.isatty(sys.stdout.fileno()) + + if not can_init: + reason = "no native console" if _IS_WINDOWS else "no TTY" + print("Terminal init/close skipped ({})".format(reason)) + return + + from rawterm import Terminal + + term = Terminal() + assert term.width > 0, "terminal width" + assert term.height > 0, "terminal height" + + # Create a region and verify compositing operations + reg = term.region(3, 10) + reg.fill(Style(fg=Color.WHITE, bg=Color.BLACK)) + reg.write(0, 0, "test") + reg.write_char(1, 0, Box.HLINE) + assert reg._cells[0][0][0] == "t", "region write" + reg.clear() + assert reg._cells[0][0][0] == " ", "region clear" + reg.move(1, 2) + assert reg.y == 1 and reg.x == 2, "region move" + reg.resize(5, 20) + assert reg.height == 5 and reg.width == 20, "region resize" + term.update() + reg.close() + term.close() + print("Terminal init/close passed") + + +def check_menuconfig_headless(): + """menuconfig headless mode and style parsing.""" + from rawterm import Style, Color + from kconfiglib import Kconfig + import menuconfig + + kconf = Kconfig("examples/Kmenuconfig") + menuconfig.menuconfig(kconf, headless=True) + + # Verify style parsing (exercises _parse_color, _style_from_def) + menuconfig._init_styles() + for key in ( + "body", + "list", + "screen", + "frame", + "selection", + "border", + "title", + "edit", + "help", + "show-help", + ): + assert key in menuconfig._style, key + " style missing" + assert isinstance(menuconfig._style[key], Style), key + " type" + + # Verify MENUCONFIG_STYLE env override (save/restore any existing value) + old_mstyle = os.environ.get("MENUCONFIG_STYLE") + os.environ["MENUCONFIG_STYLE"] = "selection=fg:red,bg:white,bold" + menuconfig._style.clear() + menuconfig._init_styles() + sel = menuconfig._style["selection"] + assert sel.fg == Color.RED, "MENUCONFIG_STYLE fg override" + assert sel.bg == Color.WHITE, "MENUCONFIG_STYLE bg override" + if old_mstyle is None: + del os.environ["MENUCONFIG_STYLE"] + else: + os.environ["MENUCONFIG_STYLE"] = old_mstyle + + print("menuconfig headless + style validation passed") + + +if __name__ == "__main__": + check_rawterm_units() + check_windows_console() + check_terminal_init() + check_menuconfig_headless() + print("All checks passed") diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aa6a0ea..8fa1309 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,9 +18,9 @@ jobs: strategy: fail-fast: false matrix: - # NOTE: Windows runs only headless + menuconfig import tests (no kernel - # tree, no Unix shell). Linux/macOS run selftests, compatibility - # tests, and example scripts. + # NOTE: Windows runs only headless tests (no kernel tree, no Unix + # shell). Linux/macOS run selftests, compatibility tests, and + # example scripts. target: # Python 3.12 - python: '3.12' @@ -94,27 +94,18 @@ jobs: run: | Kconfiglib/tests/reltest python - - name: Install windows-curses (Windows only) - if: matrix.target.os == 'Windows' + - name: Validate rawterm and menuconfig (Unix) + # Exercises rawterm Color/Style/Region compositing, terminal init/close + # (termios on Unix), and menuconfig headless mode with style parsing. + if: ${{ matrix.target.os != 'Windows' }} + working-directory: Kconfiglib run: | - python -m pip install windows-curses + python .ci/validate-rawterm.py - - name: Test headless mode - # Use root dir for Windows, Kconfiglib subdir for Linux/macOS - working-directory: ${{ matrix.target.headless-only && '.' || 'Kconfiglib' }} - run: | - python << 'EOF' - from kconfiglib import Kconfig - import menuconfig - print('Testing headless mode...') - kconf = Kconfig('examples/Kmenuconfig') - menuconfig.menuconfig(kconf, headless=True) - print('Headless mode test passed') - EOF - - - name: Test menuconfig import (Windows Python 3.12) - if: matrix.target.os == 'Windows' - # Use root dir for Windows (headless-only mode) - working-directory: ${{ matrix.target.headless-only && '.' || 'Kconfiglib' }} + - name: Validate rawterm and menuconfig (Windows) + # On Windows, use cmd so Python gets real console handles (bash + # runs through MSYS2/mintty which lacks a native Windows console). + if: ${{ matrix.target.os == 'Windows' }} + shell: cmd run: | - python -c "import menuconfig; print('menuconfig import successful')" + python .ci/validate-rawterm.py diff --git a/README.md b/README.md index 3f2dfe3..65cf17a 100644 --- a/README.md +++ b/README.md @@ -86,9 +86,8 @@ Most changes thus far have addressed small issues introduced in the early days o ### Manual installation Just drop `kconfiglib.py` and the scripts you want somewhere. -There are no third-party dependencies, but the terminal `menuconfig` will not work on Windows unless a package like -[windows-curses](https://github.com/zephyrproject-rtos/windows-curses) -is installed. +There are no third-party dependencies. +The terminal `menuconfig` uses a pure-Python terminal I/O module and works on all platforms without additional packages. ### Installation for the Linux kernel @@ -345,7 +344,7 @@ This warning can also be toggled by setting `Kconfig.warn_assign_undef` to `True Three configuration interfaces are currently available: -- [menuconfig.py](menuconfig.py) is a terminal-based configuration interface implemented using the standard Python `curses` module. +- [menuconfig.py](menuconfig.py) is a terminal-based configuration interface using a pure-Python terminal I/O module. It includes `xconfig` features such as showing invisible symbols and symbol names, and it allows jumping directly to a symbol in the menu tree (even if it is currently invisible). @@ -353,15 +352,7 @@ Three configuration interfaces are currently available: There is also a show-help mode that displays the help text of the currently selected symbol in the bottom help window. - `menuconfig.py` requires Python 3.6+. - - There are no third-party dependencies on Unix-like systems. - On Windows, the `curses` module is not included by default, but can be added by installing the `windows-curses` package: - ```shell - pip install windows-curses - ``` - These wheels are built from [this repository](https://github.com/zephyrproject-rtos/windows-curses), - which is based on Christoph Gohlke's [Python Extension Packages for Windows](https://www.cgohlke.com/#curses). + `menuconfig.py` requires Python 3.6+. No third-party dependencies or C extensions are needed on any platform. See the docstring at the top of [menuconfig.py](menuconfig.py) for more information about the terminal menuconfig implementation. diff --git a/kconfiglib.py b/kconfiglib.py index 8b9dc76..258c213 100644 --- a/kconfiglib.py +++ b/kconfiglib.py @@ -52,7 +52,7 @@ make kmenuconfig ---------------- -This target runs the curses menuconfig interface with Python 3. As of +This target runs the terminal menuconfig interface with Python 3. As of Kconfiglib 12.2.0, Python 3 (3.6+) is required. diff --git a/menuconfig.py b/menuconfig.py index c54e59f..55f6ee1 100755 --- a/menuconfig.py +++ b/menuconfig.py @@ -1,27 +1,30 @@ #!/usr/bin/env python3 -# Copyright (c) 2018-2019 Nordic Semiconductor ASA and Ulf Magnusson +# Copyright (c) 2018-2020 Nordic Semiconductor ASA and Ulf Magnusson +# Copyright (c) 2024-2026 Kconfiglib contributors # SPDX-License-Identifier: ISC """ Overview ======== -A curses-based menuconfig implementation. The interface should feel -familiar to people used to mconf ('make menuconfig'). +A terminal-based menuconfig implementation using rawterm (pure-Python terminal +I/O). The interface should feel familiar to people used to mconf +('make menuconfig'). Supports the same keys as mconf, and also supports a set of keybindings inspired by Vi: - J/K : Down/Up - L : Enter menu/Toggle item - H : Leave menu - Ctrl-D/U: Page Down/Page Up - G/End : Jump to end of list - g/Home : Jump to beginning of list + J/K : Down/Up + L : Enter menu/Toggle item + H : Leave menu + Ctrl-D/U : Page Down/Page Up + G/End : Jump to end of list + g/Home : Jump to beginning of list -[Space] toggles values if possible, and enters menus otherwise. [Enter] works -the other way around. +The bottom of the dialog shows five buttons: Select, Exit, Help, Save, Load. +[Tab]/[Left]/[Right] cycle between buttons. [Enter] activates the focused +button. [Space] toggles values if possible, and enters menus otherwise. The mconf feature where pressing a key jumps to a menu entry with that character in it in the current menu isn't supported. A jump-to feature for @@ -76,6 +79,7 @@ It is possible to customize the current style by changing colors of UI elements on the screen. This is the list of elements that can be stylized: + - screen Full-screen background behind the dialog - path Top row in the main display, with the menu path - separator Separator lines between windows. Also used for the top line in the symbol information display. @@ -87,11 +91,15 @@ - help Help text windows at the bottom of various fullscreen dialogs - show-help Window showing the help text in show-help mode - - frame Frame around dialog boxes - - body Body of dialog boxes + - frame Frame around pop-up dialog boxes + - body Body of the main dialog - edit Edit box in pop-up dialogs - jump-edit Edit box in jump-to dialog - text Symbol information text + - title Dialog title text + - border Border of the main dialog box + - menubox Inner menu box border (left/top edges) + - menubox-border Inner menu box border (right/bottom edges and fill) The color definition is a comma separated list of attributes: @@ -101,16 +109,14 @@ brightred). On terminals that support more than 8 colors, you can also directly put in a color number, e.g. fg:123 (hexadecimal and octal constants are accepted as well). - Colors outside the range -1..curses.COLORS-1 (which is - terminal-dependent) are ignored (with a warning). The COLOR - can be also specified using a RGB value in the HTML - notation, for example #RRGGBB. If the terminal supports - color changing, the color is rendered accurately. - Otherwise, the visually nearest color is used. + Colors outside the range -1..255 are ignored (with a + warning). The COLOR can be also specified using a RGB + value in the HTML notation, for example #RRGGBB. The + color is rendered using the closest available + representation for the terminal. If the background or foreground color of an element is not - specified, it defaults to -1, representing the default - terminal foreground or background color. + specified, it defaults to the terminal default color. Note: On some terminals a bright version of the color implies bold. @@ -142,8 +148,7 @@ MENUCONFIG_STYLE="linux selection=fg:white,bg:red" If the terminal doesn't support colors, the 'monochrome' theme is used, and -MENUCONFIG_STYLE is ignored. The assumption is that the environment is broken -somehow, and that the important thing is to get something usable. +MENUCONFIG_STYLE is ignored. Other features @@ -151,8 +156,8 @@ - Seamless terminal resizing - - No dependencies on *nix, as the 'curses' module is in the Python standard - library + - No curses dependency -- uses rawterm, a pure-Python terminal I/O module. + Works on Unix and Windows 10+ without any pip install. - Unicode text entry @@ -170,16 +175,6 @@ * The include path is shown, listing the locations of the 'source' statements that included the Kconfig file of the symbol (or other item) - - -Limitations -=========== - -Doesn't work out of the box on Windows, but can be made to work with - - pip install windows-curses - -See the https://github.com/zephyrproject-rtos/windows-curses repository. """ import os @@ -187,15 +182,14 @@ _IS_WINDOWS = os.name == "nt" # Are we running on Windows? -# Defer curses import to runtime - it will be imported by _ensure_curses() -# when menuconfig() is called. This avoids import errors in headless CI/CD -# environments where menuconfig UI is not used. - import errno import locale import re import textwrap +import rawterm +from rawterm import Key, Box, Style, Color, NAMED_COLORS + from kconfiglib import ( Symbol, Choice, @@ -226,8 +220,8 @@ # # If True, try to change LC_CTYPE to a UTF-8 locale if it is set to the C -# locale (which implies ASCII). This fixes curses Unicode I/O issues on systems -# with bad defaults. ncurses configures itself from the locale settings. +# locale (which implies ASCII). This fixes Unicode I/O issues on systems with +# bad defaults. # # Related PEP: https://www.python.org/dev/peps/pep-0538/ _CHANGE_C_LC_CTYPE_TO_UTF8 = True @@ -254,13 +248,20 @@ # Number of arrows pointing up/down to draw when a window is scrolled _N_SCROLL_ARROWS = 14 -# Lines of help text shown at the bottom of the "main" display -_MAIN_HELP_LINES = """ -[Space/Enter] Toggle/enter [ESC] Leave menu [S] Save -[O] Load [?] Symbol info [/] Jump to symbol -[F] Toggle show-help mode [C] Toggle show-name mode [A] Toggle show-all mode -[Q] Quit (prompts for save) [D] Save minimal config (advanced) -"""[1:-1].split("\n") +# Instruction text shown inside the mconf-style dialog, above the menu box. +# Matches the menu_instructions[] string in mconf/mconf.c. +_MENU_INSTRUCTIONS = ( + "Arrow keys navigate the menu. " + " selects submenus ---> (or empty submenus ----). " + "Highlighted letters are hotkeys. " + "Pressing includes, excludes, modularizes features. " + "Press to exit, for Help, for Search. " + "Legend: [*] built-in [ ] excluded module < > module capable" +) + +# Button labels for the main menu (matching mconf/lxdialog/menubox.c +# print_buttons()). Spaces in labels provide visual padding. +_MENU_BUTTONS = ["Select", " Exit ", " Help ", " Save ", " Load "] # Lines of help text shown at the bottom of the information dialog _INFO_HELP_LINES = """ @@ -284,6 +285,7 @@ # Style matching the Linux kernel's menuconfig (mconf/lxdialog) # Precisely matches lxdialog's set_bluetitle_theme() and set_classic_theme() "linux": """ + screen=fg:cyan,bg:blue,bold path=fg:white,bg:blue,bold separator=fg:white,bg:blue list=fg:black,bg:white @@ -297,18 +299,23 @@ edit=fg:black,bg:white jump-edit=fg:black,bg:white text=fg:black,bg:white + title=fg:blue,bg:white,bold + border=fg:white,bg:white,bold + menubox=fg:black,bg:white + menubox-border=fg:white,bg:white,bold shadow=fg:black,bg:black,bold - button-active=fg:black,bg:white - button-inactive=fg:white,bg:blue,bold - button-key-active=fg:red,bg:white - button-key-inactive=fg:white,bg:blue,bold - button-label-active=fg:black,bg:white,bold - button-label-inactive=fg:yellow,bg:blue,bold + button-active=fg:white,bg:blue,bold + button-inactive=fg:black,bg:white + button-key-active=fg:yellow,bg:blue,bold + button-key-inactive=fg:red,bg:white + button-label-active=fg:white,bg:blue,bold + button-label-inactive=fg:black,bg:white,bold dialog=fg:black,bg:white dialog-frame=fg:white,bg:blue,bold """, # This style is forced on terminals that do not support colors "monochrome": """ + screen=bold path=bold separator=bold,standout list= @@ -322,199 +329,62 @@ edit=standout jump-edit= text= + title=bold + border=bold + menubox= + menubox-border=bold """, } -# Curses module - will be imported by _ensure_curses() -curses = None - -# Named colors cache - will be initialized by _get_named_colors() -_named_colors_cache = None - - -def _get_named_colors(): - """Returns the named colors dictionary. Initializes it on first call.""" - global _named_colors_cache - if _named_colors_cache is None: - _named_colors_cache = { - # Basic colors - "black": curses.COLOR_BLACK, - "red": curses.COLOR_RED, - "green": curses.COLOR_GREEN, - "yellow": curses.COLOR_YELLOW, - "blue": curses.COLOR_BLUE, - "magenta": curses.COLOR_MAGENTA, - "cyan": curses.COLOR_CYAN, - "white": curses.COLOR_WHITE, - # Bright versions - "brightblack": curses.COLOR_BLACK + 8, - "brightred": curses.COLOR_RED + 8, - "brightgreen": curses.COLOR_GREEN + 8, - "brightyellow": curses.COLOR_YELLOW + 8, - "brightblue": curses.COLOR_BLUE + 8, - "brightmagenta": curses.COLOR_MAGENTA + 8, - "brightcyan": curses.COLOR_CYAN + 8, - "brightwhite": curses.COLOR_WHITE + 8, - # Aliases - "purple": curses.COLOR_MAGENTA, - "brightpurple": curses.COLOR_MAGENTA + 8, - } - return _named_colors_cache - - -def _rgb_to_6cube(rgb): - # Converts an 888 RGB color to a 3-tuple (nice in that it's hashable) - # representing the closest xterm 256-color 6x6x6 color cube color. - # - # The xterm 256-color extension uses a RGB color palette with components in - # the range 0-5 (a 6x6x6 cube). The catch is that the mapping is nonlinear. - # Index 0 in the 6x6x6 cube is mapped to 0, index 1 to 95, then 135, 175, - # etc., in increments of 40. See the links below: - # - # https://commons.wikimedia.org/wiki/File:Xterm_256color_chart.svg - # https://github.com/tmux/tmux/blob/master/colour.c - - # 48 is the middle ground between 0 and 95. - return tuple(0 if x < 48 else int(round(max(1, (x - 55) / 40))) for x in rgb) - - -def _6cube_to_rgb(r6g6b6): - # Returns the 888 RGB color for a 666 xterm color cube index - - return tuple(0 if x == 0 else 40 * x + 55 for x in r6g6b6) - - -def _rgb_to_gray(rgb): - # Converts an 888 RGB color to the index of an xterm 256-color grayscale - # color with approx. the same perceived brightness - - # Calculate the luminance (gray intensity) of the color. See - # https://stackoverflow.com/questions/596216/formula-to-determine-brightness-of-rgb-color - # and - # https://www.w3.org/TR/AERT/#color-contrast - luma = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2] - - # Closest index in the grayscale palette, which starts at RGB 0x080808, - # with stepping 0x0A0A0A - index = int(round((luma - 8) / 10)) - - # Clamp the index to 0-23, corresponding to 232-255 - return max(0, min(index, 23)) - -def _gray_to_rgb(index): - # Convert a grayscale index to its closet single RGB component - - return 3 * (10 * index + 8,) # Returns a 3-tuple - - -# Obscure Python: We never pass a value for rgb2index, and it keeps pointing to -# the same dict. This avoids a global. -def _alloc_rgb(rgb, rgb2index={}): - # Initialize a new entry in the xterm palette to the given RGB color, - # returning its index. If the color has already been initialized, the index - # of the existing entry is returned. - # - # ncurses is palette-based, so we need to overwrite palette entries to make - # new colors. - # - # The colors from 0 to 15 are user-defined, and there's no way to query - # their RGB values, so we better leave them untouched. Also leave any - # hypothetical colors above 255 untouched (though we're unlikely to - # allocate that many colors anyway). - - if rgb in rgb2index: - return rgb2index[rgb] - - # Many terminals allow the user to customize the first 16 colors. Avoid - # changing their values. - color_index = 16 + len(rgb2index) - if color_index >= 256: - _warn( - "Unable to allocate new RGB color ", rgb, ". Too many colors " "allocated." +def _parse_color(color_def): + """Parse a color definition string, returning a rawterm.Color.""" + # HTML format, #RRGGBB + if re.match("^#[A-Fa-f0-9]{6}$", color_def): + return Color.rgb( + int(color_def[1:3], 16), + int(color_def[3:5], 16), + int(color_def[5:7], 16), ) - return 0 - # Map each RGB component from the range 0-255 to the range 0-1000, which is - # what curses uses - curses.init_color(color_index, *(int(round(1000 * x / 255)) for x in rgb)) - rgb2index[rgb] = color_index + if color_def in NAMED_COLORS: + return NAMED_COLORS[color_def] - return color_index + try: + num = int(color_def, 0) + if 0 <= num <= 255: + return Color.index(num) + _warn("Ignoring color {} outside range 0..255".format(color_def)) + return Color.DEFAULT + except ValueError: + _warn("Ignoring color", color_def, "that's neither predefined nor a number") + return Color.DEFAULT -def _color_from_num(num): - # Returns the index of a color that looks like color 'num' in the xterm - # 256-color palette (but that might not be 'num', if we're redefining - # colors) +def _style_from_def(style_def): + """Parse a style definition string, returning a rawterm.Style.""" + fg = Color.DEFAULT + bg = Color.DEFAULT + bold = False + standout = False + underline = False - # - _alloc_rgb() won't touch the first 16 colors or any (hypothetical) - # colors above 255, so we can always return them as-is - # - # - If the terminal doesn't support changing color definitions, or if - # curses.COLORS < 256, _alloc_rgb() won't touch any color, and all colors - # can be returned as-is - if num < 16 or num > 255 or not curses.can_change_color() or curses.COLORS < 256: - return num - - # _alloc_rgb() might redefine colors, so emulate the xterm 256-color - # palette by allocating new colors instead of returning color numbers - # directly - - if num < 232: - num -= 16 - return _alloc_rgb(_6cube_to_rgb(((num // 36) % 6, (num // 6) % 6, num % 6))) - - return _alloc_rgb(_gray_to_rgb(num - 232)) - - -def _color_from_rgb(rgb): - # Returns the index of a color matching the 888 RGB color 'rgb'. The - # returned color might be an ~exact match or an approximation, depending on - # terminal capabilities. - - # Calculates the Euclidean distance between two RGB colors - def dist(r1, r2): - return sum((x - y) ** 2 for x, y in zip(r1, r2)) - - if curses.COLORS >= 256: - # Assume we're dealing with xterm's 256-color extension - - if curses.can_change_color(): - # Best case -- the terminal supports changing palette entries via - # curses.init_color(). Initialize an unused palette entry and - # return it. - return _alloc_rgb(rgb) - - # Second best case -- pick between the xterm 256-color extension colors - - # Closest 6-cube "color" color - c6 = _rgb_to_6cube(rgb) - # Closest gray color - gray = _rgb_to_gray(rgb) - - if dist(rgb, _6cube_to_rgb(c6)) < dist(rgb, _gray_to_rgb(gray)): - # Use the "color" color from the 6x6x6 color palette. Calculate the - # color number from the 6-cube index triplet. - return 16 + 36 * c6[0] + 6 * c6[1] + c6[2] - - # Use the color from the gray palette - return 232 + gray - - # Terminal not in xterm 256-color mode. This is probably the best we can - # do, or is it? Submit patches. :) - min_dist = float("inf") - best = -1 - for color in range(curses.COLORS): - # ncurses uses the range 0..1000. Scale that down to 0..255. - d = dist( - rgb, tuple(int(round(255 * c / 1000)) for c in curses.color_content(color)) - ) - if d < min_dist: - min_dist = d - best = color + if style_def: + for field in style_def.split(","): + if field.startswith("fg:"): + fg = _parse_color(field.split(":", 1)[1]) + elif field.startswith("bg:"): + bg = _parse_color(field.split(":", 1)[1]) + elif field == "bold": + bold = not _IS_WINDOWS + elif field == "standout": + standout = True + elif field == "underline": + underline = True + else: + _warn("Ignoring unknown style attribute", field) - return best + return Style(fg=fg, bg=bg, bold=bold, standout=standout, underline=underline) def _parse_style(style_str, parsing_default): @@ -527,13 +397,9 @@ def _parse_style(style_str, parsing_default): # 'default'/'monochrome' style, to prevent warnings. for sline in style_str.split(): - # Words without a "=" character represents a style template if "=" in sline: key, data = sline.split("=", 1) - # The 'default' style template is assumed to define all keys. We - # run _style_to_curses() for non-existing keys as well, so that we - # print warnings for errors to the right of '=' for those too. if key not in _style and not parsing_default: _warn("Ignoring non-existent style", key) @@ -541,134 +407,25 @@ def _parse_style(style_str, parsing_default): if data in _style: _style[key] = _style[data] else: - _style[key] = _style_to_curses(data) + _style[key] = _style_from_def(data) elif sline in _STYLES: - # Recursively parse style template. Ignore styles that don't exist, - # for backwards/forwards compatibility. _parse_style(_STYLES[sline], parsing_default) else: _warn("Ignoring non-existent style template", sline) -# Dictionary mapping element types to the curses attributes used to display -# them +# Dictionary mapping element names to rawterm.Style objects _style = {} -def _style_to_curses(style_def): - # Parses a style definition string (=