#!/usr/bin/env python3 # Copyright (c) 2018-2019, Nordic Semiconductor ASA and Ulf Magnusson # SPDX-License-Identifier: ISC """ Overview ======== A curses-based Python 2/3 menuconfig implementation. 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 [Space] toggles values if possible, and enters menus otherwise. [Enter] works the other way around. 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 jumping directly to any symbol (including invisible symbols), choice, menu or comment (as in a Kconfig 'comment "Foo"') is available instead. A few different modes are available: F: Toggle show-help mode, which shows the help text of the currently selected item in the window at the bottom of the menu display. This is handy when browsing through options. C: Toggle show-name mode, which shows the symbol name before each symbol menu entry A: Toggle show-all mode, which shows all items, including currently invisible items and items that lack a prompt. Invisible items are drawn in a different style to make them stand out. Running ======= menuconfig.py can be run either as a standalone executable or by calling the menuconfig() function with an existing Kconfig instance. The second option is a bit inflexible in that it will still load and save .config, etc. When run in standalone mode, the top-level Kconfig file to load can be passed as a command-line argument. With no argument, it defaults to "Kconfig". The KCONFIG_CONFIG environment variable specifies the .config file to load (if it exists) and save. If KCONFIG_CONFIG is unset, ".config" is used. When overwriting a configuration file, the old version is saved to .old (e.g. .config.old). $srctree is supported through Kconfiglib. Color schemes ============= It is possible to customize the color scheme by setting the MENUCONFIG_STYLE environment variable. For example, setting it to 'aquatic' will enable an alternative, less yellow, more 'make menuconfig'-like color scheme, contributed by Mitja Horvat (pinkfluid). This is the current list of built-in styles: - default classic Kconfiglib theme with a yellow accent - monochrome colorless theme (uses only bold and standout) attributes, this style is used if the terminal doesn't support colors - aquatic blue-tinted style loosely resembling the lxdialog theme 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: - 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. - list List of items, e.g. the main display - selection Style for the selected item - inv-list Like list, but for invisible items. Used in show-all mode. - inv-selection Like selection, but for invisible items. Used in show-all mode. - 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 - edit Edit box in pop-up dialogs - jump-edit Edit box in jump-to dialog - text Symbol information text The color definition is a comma separated list of attributes: - fg:COLOR Set the foreground/background colors. COLOR can be one of * or * the basic 16 colors (black, red, green, yellow, blue, - bg:COLOR magenta, cyan, white and brighter versions, for example, 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. If the background or foreground color of an element is not specified, it defaults to -1, representing the default terminal foreground or background color. Note: On some terminals a bright version of the color implies bold. - bold Use bold text - underline Use underline text - standout Standout text attribute (reverse color) More often than not, some UI elements share the same color definition. In such cases the right value may specify an UI element from which the color definition will be copied. For example, "separator=help" will apply the current color definition for "help" to "separator". A keyword without the '=' is assumed to be a style template. The template name is looked up in the built-in styles list and the style definition is expanded in-place. With this, built-in styles can be used as basis for new styles. For example, take the aquatic theme and give it a red selection bar: MENUCONFIG_STYLE="aquatic selection=fg:white,bg:red" If there's an error in the style definition or if a missing style is assigned to, the assignment will be ignored, along with a warning being printed on stderr. The 'default' theme is always implicitly parsed first, so the following two settings have the same effect: MENUCONFIG_STYLE="selection=fg:white,bg:red" MENUCONFIG_STYLE="default 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. Other features ============== - Seamless terminal resizing - No dependencies on *nix, as the 'curses' module is in the Python standard library - Unicode text entry - Improved information screen compared to mconf: * Expressions are split up by their top-level &&/|| operands to improve readability * Undefined symbols in expressions are pointed out * Menus and comments have information displays * Kconfig definitions are printed * 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. """ from __future__ import print_function import os import sys _IS_WINDOWS = os.name == "nt" # Are we running on Windows? try: import curses except ImportError as e: if not _IS_WINDOWS: raise sys.exit("""\ menuconfig failed to import the standard Python 'curses' library. Try installing a package like windows-curses (https://github.com/zephyrproject-rtos/windows-curses) by running this command in cmd.exe: pip install windows-curses Starting with Kconfiglib 13.0.0, windows-curses is no longer automatically installed when installing Kconfiglib via pip on Windows (because it breaks installation on MSYS2). Exception: {}: {}""".format(type(e).__name__, e)) import errno import locale import re import textwrap from kconfiglib import Symbol, Choice, MENU, COMMENT, MenuNode, \ BOOL, TRISTATE, STRING, INT, HEX, \ AND, OR, \ expr_str, expr_value, split_expr, \ standard_sc_expr_str, \ TRI_TO_STR, TYPE_TO_STR, \ standard_kconfig, standard_config_filename # # Configuration variables # # 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. # # Related PEP: https://www.python.org/dev/peps/pep-0538/ _CHANGE_C_LC_CTYPE_TO_UTF8 = True # How many steps an implicit submenu will be indented. Implicit submenus are # created when an item depends on the symbol before it. Note that symbols # defined with 'menuconfig' create a separate menu instead of indenting. _SUBMENU_INDENT = 4 # Number of steps for Page Up/Down to jump _PG_JUMP = 6 # Height of the help window in show-help mode _SHOW_HELP_HEIGHT = 8 # How far the cursor needs to be from the edge of the window before it starts # to scroll. Used for the main menu display, the information display, the # search display, and for text boxes. _SCROLL_OFFSET = 5 # Minimum width of dialogs that ask for text input _INPUT_DIALOG_MIN_WIDTH = 30 # 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") # Lines of help text shown at the bottom of the information dialog _INFO_HELP_LINES = """ [ESC/q] Return to menu [/] Jump to symbol """[1:-1].split("\n") # Lines of help text shown at the bottom of the search dialog _JUMP_TO_HELP_LINES = """ Type text to narrow the search. Regexes are supported (via Python's 're' module). The up/down cursor keys step in the list. [Enter] jumps to the selected symbol. [ESC] aborts the search. Type multiple space-separated strings/regexes to find entries that match all of them. Type Ctrl-F to view the help of the selected item without leaving the dialog. """[1:-1].split("\n") # # Styling # _STYLES = { "default": """ path=fg:black,bg:white,bold separator=fg:black,bg:yellow,bold list=fg:black,bg:white selection=fg:white,bg:blue,bold inv-list=fg:red,bg:white inv-selection=fg:red,bg:blue help=path show-help=list frame=fg:black,bg:yellow,bold body=fg:white,bg:black edit=fg:white,bg:blue jump-edit=edit text=list """, # This style is forced on terminals that do no support colors "monochrome": """ path=bold separator=bold,standout list= selection=bold,standout inv-list=bold inv-selection=bold,standout help=bold show-help= frame=bold,standout body= edit=standout jump-edit= text= """, # Blue-tinted style loosely resembling lxdialog "aquatic": """ path=fg:white,bg:blue separator=fg:white,bg:cyan help=path frame=fg:white,bg:cyan body=fg:white,bg:blue edit=fg:black,bg:white """ } _NAMED_COLORS = { # 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, } 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.") 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 return color_index 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) # - _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 return best def _parse_style(style_str, parsing_default): # Parses a string with '=