#!/usr/bin/env python # Copyright (c) 2019, Ulf Magnusson # SPDX-License-Identifier: ISC """ Overview ======== A Tkinter-based menuconfig implementation, based around a treeview control and a help display. The interface should feel familiar to people used to qconf ('make xconfig'). Compatible with both Python 2 and Python 3. The display can be toggled between showing the full tree and showing just a single menu (like menuconfig.py). Only single-menu mode distinguishes between symbols defined with 'config' and symbols defined with 'menuconfig'. A show-all mode is available that shows invisible items in red. Supports both mouse and keyboard controls. The following keyboard shortcuts are available: Ctrl-S : Save configuration Ctrl-O : Open configuration Ctrl-A : Toggle show-all mode Ctrl-N : Toggle show-name mode Ctrl-M : Toggle single-menu mode Ctrl-F, /: Open jump-to dialog ESC : Close Running ======= guiconfig.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 <filename>.old (e.g. .config.old). $srctree is supported through Kconfiglib. """ # Note: There's some code duplication with menuconfig.py below, especially for # the help text. Maybe some of it could be moved into kconfiglib.py or a shared # helper script, but OTOH it's pretty nice to have things standalone and # customizable. import errno import os import sys _PY2 = sys.version_info[0] < 3 if _PY2: # Python 2 from Tkinter import * import ttk import tkFont as font import tkFileDialog as filedialog import tkMessageBox as messagebox else: # Python 3 from tkinter import * import tkinter.ttk as ttk import tkinter.font as font from tkinter import filedialog, messagebox 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 # If True, use GIF image data embedded in this file instead of separate GIF # files. See _load_images(). _USE_EMBEDDED_IMAGES = True # Help text for the jump-to dialog _JUMP_TO_HELP = """\ Type one or more strings/regexes and press Enter to list items that match all of them. Python's regex flavor is used (see the 're' module). Double-clicking an item will jump to it. Item values can be toggled directly within the dialog.\ """ def _main(): menuconfig(standard_kconfig()) # Global variables used below: # # _root: # The Toplevel instance for the main window # # _tree: # The Treeview in the main window # # _jump_to_tree: # The Treeview in the jump-to dialog. None if the jump-to dialog isn't # open. Doubles as a flag. # # _jump_to_matches: # List of Nodes shown in the jump-to dialog # # _menupath: # The Label that shows the menu path of the selected item # # _backbutton: # The button shown in single-menu mode for jumping to the parent menu # # _status_label: # Label with status text shown at the bottom of the main window # ("Modified", "Saved to ...", etc.) # # _id_to_node: # We can't use Node objects directly as Treeview item IDs, so we use their # id()s instead. This dictionary maps Node id()s back to Nodes. (The keys # are actually str(id(node)), just to simplify lookups.) # # _cur_menu: # The current menu. Ignored outside single-menu mode. # # _show_all_var/_show_name_var/_single_menu_var: # Tkinter Variable instances bound to the corresponding checkboxes # # _show_all/_single_menu: # Plain Python bools that track _show_all_var and _single_menu_var, to # speed up and simplify things a bit # # _conf_filename: # File to save the configuration to # # _minconf_filename: # File to save minimal configurations to # # _conf_changed: # True if the configuration has been changed. If False, we don't bother # showing the save-and-quit dialog. # # We reset this to False whenever the configuration is saved. # # _*_img: # PhotoImage instances for images def menuconfig(kconf): """ Launches the configuration interface, returning after the user exits. kconf: Kconfig instance to be configured """ global _kconf global _conf_filename global _minconf_filename global _jump_to_tree global _cur_menu _kconf = kconf _jump_to_tree = None _create_id_to_node() _create_ui() # Filename to save configuration to _conf_filename = standard_config_filename() # Load existing configuration and check if it's outdated _set_conf_changed(_load_config()) # Filename to save minimal configuration to _minconf_filename = "defconfig" # Current menu in single-menu mode _cur_menu = _kconf.top_node # Any visible items in the top menu? if not _shown_menu_nodes(kconf.top_node): # Nothing visible. Start in show-all mode and try again. _show_all_var.set(True) if not _shown_menu_nodes(kconf.top_node): # Give up and show an error. It's nice to be able to assume that # the tree is non-empty in the rest of the code. _root.wait_visibility() messagebox.showerror( "Error", "Empty configuration -- nothing to configure.\n\n" "Check that environment variables are set properly.") _root.destroy() return # Build the initial tree _update_tree() # Select the first item and focus the Treeview, so that keyboard controls # work immediately _select(_tree, _tree.get_children()[0]) _tree.focus_set() # Make geometry information available for centering the window. This # indirectly creates the window, so hide it so that it's never shown at the # old location. _root.withdraw() _root.update_idletasks() # Center the window _root.geometry("+{}+{}".format( (_root.winfo_screenwidth() - _root.winfo_reqwidth())//2, (_root.winfo_screenheight() - _root.winfo_reqheight())//2)) # Show it _root.deiconify() # Prevent the window from being automatically resized. Otherwise, it # changes size when scrollbars appear/disappear before the user has # manually resized it. _root.geometry(_root.geometry()) _root.mainloop() def _load_config(): # Loads any existing .config file. See the Kconfig.load_config() docstring. # # Returns True if .config is missing or outdated. We always prompt for # saving the configuration in that case. print(_kconf.load_config()) if not os.path.exists(_conf_filename): # No .config return True return _needs_save() def _needs_save(): # Returns True if a just-loaded .config file is outdated (would get # modified when saving) if _kconf.missing_syms: # Assignments to undefined symbols in the .config return True for sym in _kconf.unique_defined_syms: if sym.user_value is None: if sym.config_string: # Unwritten symbol return True elif sym.orig_type in (BOOL, TRISTATE): if sym.tri_value != sym.user_value: # Written bool/tristate symbol, new value return True elif sym.str_value != sym.user_value: # Written string/int/hex symbol, new value return True # No need to prompt for save return False def _create_id_to_node(): global _id_to_node _id_to_node = {str(id(node)): node for node in _kconf.node_iter()} def _create_ui(): # Creates the main window UI global _root global _tree # Create the root window. This initializes Tkinter and makes e.g. # PhotoImage available, so do it early. _root = Tk() _load_images() _init_misc_ui() _fix_treeview_issues() _create_top_widgets() # Create the pane with the Kconfig tree and description text panedwindow, _tree = _create_kconfig_tree_and_desc(_root) panedwindow.grid(column=0, row=1, sticky="nsew") _create_status_bar() _root.columnconfigure(0, weight=1) # Only the pane with the Kconfig tree and description grows vertically _root.rowconfigure(1, weight=1) # Start with show-name disabled _do_showname() _tree.bind("<Left>", _tree_left_key) _tree.bind("<Right>", _tree_right_key) # Note: Binding this for the jump-to tree as well would cause issues due to # the Tk bug mentioned in _tree_open() _tree.bind("<<TreeviewOpen>>", _tree_open) # add=True to avoid overriding the description text update _tree.bind("<<TreeviewSelect>>", _update_menu_path, add=True) _root.bind("<Control-s>", _save) _root.bind("<Control-o>", _open) _root.bind("<Control-a>", _toggle_showall) _root.bind("<Control-n>", _toggle_showname) _root.bind("<Control-m>", _toggle_tree_mode) _root.bind("<Control-f>", _jump_to_dialog) _root.bind("/", _jump_to_dialog) _root.bind("<Escape>", _on_quit) def _load_images(): # Loads GIF images, creating the global _*_img PhotoImage variables. # Base64-encoded images embedded in this script are used if # _USE_EMBEDDED_IMAGES is True, and separate image files in the same # directory as the script otherwise. # # Using a global variable indirectly prevents the image from being # garbage-collected. Passing an image to a Tkinter function isn't enough to # keep it alive. def load_image(name, data): var_name = "_{}_img".format(name) if _USE_EMBEDDED_IMAGES: globals()[var_name] = PhotoImage(data=data, format="gif") else: globals()[var_name] = PhotoImage( file=os.path.join(os.path.dirname(__file__), name + ".gif"), format="gif") # Note: Base64 data can be put on the clipboard with # $ base64 -w0 foo.gif | xclip load_image("icon", "R0lGODlhIwAjAPcAAAAAAAAAMwAAZgAAmQAAzAAA/wArAAArMwArZgArmQArzAAr/wBVAABVMwBVZgBVmQBVzABV/wCAAACAMwCAZgCAmQCAzACA/wCqAACqMwCqZgCqmQCqzACq/wDVAADVMwDVZgDVmQDVzADV/wD/AAD/MwD/ZgD/mQD/zAD//zMAADMAMzMAZjMAmTMAzDMA/zMrADMrMzMrZjMrmTMrzDMr/zNVADNVMzNVZjNVmTNVzDNV/zOAADOAMzOAZjOAmTOAzDOA/zOqADOqMzOqZjOqmTOqzDOq/zPVADPVMzPVZjPVmTPVzDPV/zP/ADP/MzP/ZjP/mTP/zDP//2YAAGYAM2YAZmYAmWYAzGYA/2YrAGYrM2YrZmYrmWYrzGYr/2ZVAGZVM2ZVZmZVmWZVzGZV/2aAAGaAM2aAZmaAmWaAzGaA/2aqAGaqM2aqZmaqmWaqzGaq/2bVAGbVM2bVZmbVmWbVzGbV/2b/AGb/M2b/Zmb/mWb/zGb//5kAAJkAM5kAZpkAmZkAzJkA/5krAJkrM5krZpkrmZkrzJkr/5lVAJlVM5lVZplVmZlVzJlV/5mAAJmAM5mAZpmAmZmAzJmA/5mqAJmqM5mqZpmqmZmqzJmq/5nVAJnVM5nVZpnVmZnVzJnV/5n/AJn/M5n/Zpn/mZn/zJn//8wAAMwAM8wAZswAmcwAzMwA/8wrAMwrM8wrZswrmcwrzMwr/8xVAMxVM8xVZsxVmcxVzMxV/8yAAMyAM8yAZsyAmcyAzMyA/8yqAMyqM8yqZsyqmcyqzMyq/8zVAMzVM8zVZszVmczVzMzV/8z/AMz/M8z/Zsz/mcz/zMz///8AAP8AM/8AZv8Amf8AzP8A//8rAP8rM/8rZv8rmf8rzP8r//9VAP9VM/9VZv9Vmf9VzP9V//+AAP+AM/+AZv+Amf+AzP+A//+qAP+qM/+qZv+qmf+qzP+q///VAP/VM//VZv/Vmf/VzP/V////AP//M///Zv//mf//zP///wAAAAAAAAAAAAAAACH5BAEAAPwALAAAAAAjACMAAAj/AKPtE0hwoMGCAiUF28VwoaJIyg4a3LeMIkVly5QR00gsk8eNvBZGYrhr4SJhmixWJFgPkyReIyVFisTrEi9eu2aOXBhsZE5dkYYtK1gR00lhydoha5dMGFOkwhr6bEhSUTKLApVFEjZsHtdkw56CjRoJ0iJIOYHlJLlL0kqKmXZ1RdoO6bCkw+QJ86nrLK2duy7tAhpx6D5Jwujenat02DBhZx/2LSu54UmB+zTtsrt06eKkyKKSjNT34UO0gyOt1DpvGLvFSF/Lc7yXIVqdihYpyo120bCEXJHNbQ1WeF5JqW2fXbQITBjTkH5H45W4ujCFMGHOXOSTZ8iQwRRu/80ZLdr4tSPTo2e7ViT7YNfpHW4nmKQuhvfZily4NjDD+nsNtRlDPtXXU0mkTVXSew0Jw0tEkgxj4GgL6rfgTpHwN1JikigTzYAILqghTz4BRlJ9JTkI4TD88Uegi1S1WCFPm3X4oWgH9iSjYC4eWOGCie1SEWKiKRIGGLql11xzRx7JnHNQhsGQMJh4SJ1IYGzhnBY2LBLMllmCgSSUWjoZDHxCHuZgMLxoAUaGi2g5mps0XQiGFlNRZ6NcPMEgZU5+btZTGFsAyVAYWkR1JppDsriZFnguYiaBbtpEI6IZfkelh3zmFAYMYMTwJptoImqoJFl6x2hLwwiqRRiSauRx0oWvjifMQp+GFNU613Ea5C43gFESpqTy4pyIIYkZ0mZBDkXdrcK8ulkkhKYYiZY4QbsLorqmaCMvLIYkLa5IihZGGGgqemRUt7IT1TItuQufrVFhB5+Bwqzz3ZT6wmdjUAvdKuiit5KamMHwJfwsOxAd5q4w5eQbFTu8sLOZOcFYrDGbFq/JzpnvTsfVw4mRTLJ1JadcncWYDDVMJMiwk9TMeTHmlDDuPvxwxkEGtQ9cvLjUFiZtIScJ0S5dd13QNL0kU9CZYEYQRtFQTU80y0CzzDL6ZFR11ltrLfbW+9QTTUAAOw==") load_image("n_bool", "R0lGODlhDgAOAHAAACH5BAEAAPwALAAAAAAOAA4AhwAAAAAAMwAAZgAAmQAAzAAA/wArAAArMwArZgArmQArzAAr/wBVAABVMwBVZgBVmQBVzABV/wCAAACAMwCAZgCAmQCAzACA/wCqAACqMwCqZgCqmQCqzACq/wDVAADVMwDVZgDVmQDVzADV/wD/AAD/MwD/ZgD/mQD/zAD//zMAADMAMzMAZjMAmTMAzDMA/zMrADMrMzMrZjMrmTMrzDMr/zNVADNVMzNVZjNVmTNVzDNV/zOAADOAMzOAZjOAmTOAzDOA/zOqADOqMzOqZjOqmTOqzDOq/zPVADPVMzPVZjPVmTPVzDPV/zP/ADP/MzP/ZjP/mTP/zDP//2YAAGYAM2YAZmYAmWYAzGYA/2YrAGYrM2YrZmYrmWYrzGYr/2ZVAGZVM2ZVZmZVmWZVzGZV/2aAAGaAM2aAZmaAmWaAzGaA/2aqAGaqM2aqZmaqmWaqzGaq/2bVAGbVM2bVZmbVmWbVzGbV/2b/AGb/M2b/Zmb/mWb/zGb//5kAAJkAM5kAZpkAmZkAzJkA/5krAJkrM5krZpkrmZkrzJkr/5lVAJlVM5lVZplVmZlVzJlV/5mAAJmAM5mAZpmAmZmAzJmA/5mqAJmqM5mqZpmqmZmqzJmq/5nVAJnVM5nVZpnVmZnVzJnV/5n/AJn/M5n/Zpn/mZn/zJn//8wAAMwAM8wAZswAmcwAzMwA/8wrAMwrM8wrZswrmcwrzMwr/8xVAMxVM8xVZsxVmcxVzMxV/8yAAMyAM8yAZsyAmcyAzMyA/8yqAMyqM8yqZsyqmcyqzMyq/8zVAMzVM8zVZszVmczVzMzV/8z/AMz/M8z/Zsz/mcz/zMz///8AAP8AM/8AZv8Amf8AzP8A//8rAP8rM/8rZv8rmf8rzP8r//9VAP9VM/9VZv9Vmf9VzP9V//+AAP+AM/+AZv+Amf+AzP+A//+qAP+qM/+qZv+qmf+qzP+q///VAP/VM//VZv/Vmf/VzP/V////AP//M///Zv//mf//zP///wAAAAAAAAAAAAAAAAgxAAEIHEiw4L6DCBMeFKiw4T6GDhNCjLgQAEWEEylmjLjRYceGHxWGlGjx4sOCKAcGBAA7") load_image("y_bool", "R0lGODlhDwAPAHAAACH5BAEAAPwALAAAAAAPAA8AhwAAAAAAMwAAZgAAmQAAzAAA/wArAAArMwArZgArmQArzAAr/wBVAABVMwBVZgBVmQBVzABV/wCAAACAMwCAZgCAmQCAzACA/wCqAACqMwCqZgCqmQCqzACq/wDVAADVMwDVZgDVmQDVzADV/wD/AAD/MwD/ZgD/mQD/zAD//zMAADMAMzMAZjMAmTMAzDMA/zMrADMrMzMrZjMrmTMrzDMr/zNVADNVMzNVZjNVmTNVzDNV/zOAADOAMzOAZjOAmTOAzDOA/zOqADOqMzOqZjOqmTOqzDOq/zPVADPVMzPVZjPVmTPVzDPV/zP/ADP/MzP/ZjP/mTP/zDP//2YAAGYAM2YAZmYAmWYAzGYA/2YrAGYrM2YrZmYrmWYrzGYr/2ZVAGZVM2ZVZmZVmWZVzGZV/2aAAGaAM2aAZmaAmWaAzGaA/2aqAGaqM2aqZmaqmWaqzGaq/2bVAGbVM2bVZmbVmWbVzGbV/2b/AGb/M2b/Zmb/mWb/zGb//5kAAJkAM5kAZpkAmZkAzJkA/5krAJkrM5krZpkrmZkrzJkr/5lVAJlVM5lVZplVmZlVzJlV/5mAAJmAM5mAZpmAmZmAzJmA/5mqAJmqM5mqZpmqmZmqzJmq/5nVAJnVM5nVZpnVmZnVzJnV/5n/AJn/M5n/Zpn/mZn/zJn//8wAAMwAM8wAZswAmcwAzMwA/8wrAMwrM8wrZswrmcwrzMwr/8xVAMxVM8xVZsxVmcxVzMxV/8yAAMyAM8yAZsyAmcyAzMyA/8yqAMyqM8yqZsyqmcyqzMyq/8zVAMzVM8zVZszVmczVzMzV/8z/AMz/M8z/Zsz/mcz/zMz///8AAP8AM/8AZv8Amf8AzP8A//8rAP8rM/8rZv8rmf8rzP8r//9VAP9VM/9VZv9Vmf9VzP9V//+AAP+AM/+AZv+Amf+AzP+A//+qAP+qM/+qZv+qmf+qzP+q///VAP/VM//VZv/Vmf/VzP/V////AP//M///Zv//mf//zP///wAAAAAAAAAAAAAAAAhLAAEIHEiwIIB9CBMqTChwoUOEDR8iJHZjX0SJDS86HGjx4MGFBiJm/IhwxcWRB5WFJNkRosAYGlvuq0dwI0llMV1KXJjzocGfAwMCADs=") load_image("n_tri", "R0lGODlhEAAQAPD/AAEBAf///yH5BAUKAAIALAAAAAAQABAAAAInlI+pBrAKQnCPSUlXvFhznlkfeGwjKZhnJ65h6nrfi6h0st2QXikFADs=") load_image("m_tri", "R0lGODlhEAAQAPEDAAEBAeQMuv///wAAACH5BAUKAAMALAAAAAAQABAAAAI5nI+pBrAWAhPCjYhiAJQCnWmdoElHGVBoiK5M21ofXFpXRIrgiecqxkuNciZIhNOZFRNI24PhfEoLADs=") load_image("y_tri", "R0lGODlhEAAQAPEDAAICAgDQAP///wAAACH5BAUKAAMALAAAAAAQABAAAAI0nI+pBrAYBhDCRRUypfmergmgZ4xjMpmaw2zmxk7cCB+pWiVqp4MzDwn9FhGZ5WFjIZeGAgA7") load_image("m_my", "R0lGODlhEAAQAPEDAAAAAOQMuv///wAAACH5BAUKAAMALAAAAAAQABAAAAI5nIGpxiAPI2ghxFinq/ZygQhc94zgZopmOLYf67anGr+oZdp02emfV5n9MEHN5QhqICETxkABbQ4KADs=") load_image("y_my", "R0lGODlhEAAQAPH/AAAAAADQAAPRA////yH5BAUKAAQALAAAAAAQABAAAAM+SArcrhCMSSuIM9Q8rxxBWIXawIBkmWonupLd565Um9G1PIs59fKmzw8WnAlusBYR2SEIN6DmAmqBLBxYSAIAOw==") load_image("n_locked", "R0lGODlhEAAQAPABAAAAAP///yH5BAUKAAEALAAAAAAQABAAAAIgjB8AyKwN04pu0vMutpqqz4Hih4ydlnUpyl2r23pxUAAAOw==") load_image("m_locked", "R0lGODlhEAAQAPD/AAAAAOQMuiH5BAUKAAIALAAAAAAQABAAAAIylC8AyKwN04ohnGcqqlZmfXDWI26iInZoyiore05walolV39ftxsYHgL9QBBMBGFEFAAAOw==") load_image("y_locked", "R0lGODlhDwAPAHAAACH5BAEAAPwALAAAAAAPAA8AhwAAAAAAMwAAZgAAmQAAzAAA/wArAAArMwArZgArmQArzAAr/wBVAABVMwBVZgBVmQBVzABV/wCAAACAMwCAZgCAmQCAzACA/wCqAACqMwCqZgCqmQCqzACq/wDVAADVMwDVZgDVmQDVzADV/wD/AAD/MwD/ZgD/mQD/zAD//zMAADMAMzMAZjMAmTMAzDMA/zMrADMrMzMrZjMrmTMrzDMr/zNVADNVMzNVZjNVmTNVzDNV/zOAADOAMzOAZjOAmTOAzDOA/zOqADOqMzOqZjOqmTOqzDOq/zPVADPVMzPVZjPVmTPVzDPV/zP/ADP/MzP/ZjP/mTP/zDP//2YAAGYAM2YAZmYAmWYAzGYA/2YrAGYrM2YrZmYrmWYrzGYr/2ZVAGZVM2ZVZmZVmWZVzGZV/2aAAGaAM2aAZmaAmWaAzGaA/2aqAGaqM2aqZmaqmWaqzGaq/2bVAGbVM2bVZmbVmWbVzGbV/2b/AGb/M2b/Zmb/mWb/zGb//5kAAJkAM5kAZpkAmZkAzJkA/5krAJkrM5krZpkrmZkrzJkr/5lVAJlVM5lVZplVmZlVzJlV/5mAAJmAM5mAZpmAmZmAzJmA/5mqAJmqM5mqZpmqmZmqzJmq/5nVAJnVM5nVZpnVmZnVzJnV/5n/AJn/M5n/Zpn/mZn/zJn//8wAAMwAM8wAZswAmcwAzMwA/8wrAMwrM8wrZswrmcwrzMwr/8xVAMxVM8xVZsxVmcxVzMxV/8yAAMyAM8yAZsyAmcyAzMyA/8yqAMyqM8yqZsyqmcyqzMyq/8zVAMzVM8zVZszVmczVzMzV/8z/AMz/M8z/Zsz/mcz/zMz///8AAP8AM/8AZv8Amf8AzP8A//8rAP8rM/8rZv8rmf8rzP8r//9VAP9VM/9VZv9Vmf9VzP9V//+AAP+AM/+AZv+Amf+AzP+A//+qAP+qM/+qZv+qmf+qzP+q///VAP/VM//VZv/Vmf/VzP/V////AP//M///Zv//mf//zP///wAAAAAAAAAAAAAAAAg1AAEIHEiwIIB9CBMqTChwoUOEDR8ujCiR4cGKFjFmNFhwX0OOBD1e1EgRY8mKJyWmfAgSZEAAOw==") load_image("not_selected", "R0lGODlhEAAQAPD/AAAAAP///yH5BAUKAAIALAAAAAAQABAAAAIrlA2px6IBw2IpWglOvTYhzmUbGD3kNZ5QqrKn2YrqigCxZoMelU6No9gdCgA7") load_image("selected", "R0lGODlhEAAQAPD/AAAAAP///yH5BAUKAAIALAAAAAAQABAAAAIzlA2px6IBw2IpWglOvTah/kTZhimASJomiqonlLov1qptHTsgKSEzh9H8QI0QzNPwmRoFADs=") load_image("edit", "R0lGODlhEAAQAPIFAAAAAKOLAMuuEPvXCvrxvgAAAAAAAAAAACH5BAUKAAUALAAAAAAQABAAAANCWLqw/gqMBp8cszJxcwVC2FEOEIAi5kVBi3IqWZhuCGMyfdpj2e4pnK+WAshmvxeAcETWlsxPkkBtsqBMa8TIBSQAADs=") def _fix_treeview_issues(): # Fixes some Treeview issues global _treeview_rowheight style = ttk.Style() # The treeview rowheight isn't adjusted automatically on high-DPI displays, # so do it ourselves. The font will probably always be TkDefaultFont, but # play it safe and look it up. _treeview_rowheight = font.Font(font=style.lookup("Treeview", "font")) \ .metrics("linespace") + 2 style.configure("Treeview", rowheight=_treeview_rowheight) # Work around regression in https://core.tcl.tk/tk/tktview?name=509cafafae, # which breaks tag background colors for option in "foreground", "background": # Filter out any styles starting with ("!disabled", "!selected", ...). # style.map() returns an empty list for missing options, so this should # be future-safe. style.map( "Treeview", **{option: [elm for elm in style.map("Treeview", query_opt=option) if elm[:2] != ("!disabled", "!selected")]}) def _init_misc_ui(): # Does misc. UI initialization, like setting the title, icon, and theme _root.title(_kconf.mainmenu_text) # iconphoto() isn't available in Python 2's Tkinter _root.tk.call("wm", "iconphoto", _root._w, "-default", _icon_img) # Reducing the width of the window to 1 pixel makes it move around, at # least on GNOME. Prevent weird stuff like that. _root.minsize(128, 128) _root.protocol("WM_DELETE_WINDOW", _on_quit) # Use the 'clam' theme on *nix if it's available. It looks nicer than the # 'default' theme. style = ttk.Style() style.theme_use("default") if _root.tk.call("tk", "windowingsystem") == "x11": if "clam" in style.theme_names(): style.theme_use("clam") def _create_top_widgets(): # Creates the controls above the Kconfig tree in the main window global _show_all_var global _show_name_var global _single_menu_var global _menupath global _backbutton topframe = ttk.Frame(_root) topframe.grid(column=0, row=0, sticky="ew") ttk.Button(topframe, text="Save", command=_save) \ .grid(column=0, row=0, sticky="ew", padx=".05c", pady=".05c") ttk.Button(topframe, text="Save as...", command=_save_as) \ .grid(column=1, row=0, sticky="ew") ttk.Button(topframe, text="Save minimal (advanced)...", command=_save_minimal) \ .grid(column=2, row=0, sticky="ew", padx=".05c") ttk.Button(topframe, text="Open...", command=_open) \ .grid(column=3, row=0) ttk.Button(topframe, text="Jump to...", command=_jump_to_dialog) \ .grid(column=4, row=0, padx=".05c") _show_name_var = BooleanVar() ttk.Checkbutton(topframe, text="Show name", command=_do_showname, variable=_show_name_var) \ .grid(column=0, row=1, sticky="nsew", padx=".05c", pady="0 .05c", ipady=".2c") _show_all_var = BooleanVar() ttk.Checkbutton(topframe, text="Show all", command=_do_showall, variable=_show_all_var) \ .grid(column=1, row=1, sticky="nsew", pady="0 .05c") # Allow the show-all and single-menu status to be queried via plain global # Python variables, which is faster and simpler def show_all_updated(*_): global _show_all _show_all = _show_all_var.get() _trace_write(_show_all_var, show_all_updated) _show_all_var.set(False) _single_menu_var = BooleanVar() ttk.Checkbutton(topframe, text="Single-menu mode", command=_do_tree_mode, variable=_single_menu_var) \ .grid(column=2, row=1, sticky="nsew", padx=".05c", pady="0 .05c") _backbutton = ttk.Button(topframe, text="<--", command=_leave_menu, state="disabled") _backbutton.grid(column=0, row=4, sticky="nsew", padx=".05c", pady="0 .05c") def tree_mode_updated(*_): global _single_menu _single_menu = _single_menu_var.get() if _single_menu: _backbutton.grid() else: _backbutton.grid_remove() _trace_write(_single_menu_var, tree_mode_updated) _single_menu_var.set(False) # Column to the right of the buttons that the menu path extends into, so # that it can grow wider than the buttons topframe.columnconfigure(5, weight=1) _menupath = ttk.Label(topframe) _menupath.grid(column=0, row=3, columnspan=6, sticky="w", padx="0.05c", pady="0 .05c") def _create_kconfig_tree_and_desc(parent): # Creates a Panedwindow with a Treeview that shows Kconfig nodes and a Text # that shows a description of the selected node. Returns a tuple with the # Panedwindow and the Treeview. This code is shared between the main window # and the jump-to dialog. panedwindow = ttk.Panedwindow(parent, orient=VERTICAL) tree_frame, tree = _create_kconfig_tree(panedwindow) desc_frame, desc = _create_kconfig_desc(panedwindow) panedwindow.add(tree_frame, weight=1) panedwindow.add(desc_frame) def tree_select(_): # The Text widget does not allow editing the text in its disabled # state. We need to temporarily enable it. desc["state"] = "normal" sel = tree.selection() if not sel: desc.delete("1.0", "end") desc["state"] = "disabled" return # Text.replace() is not available in Python 2's Tkinter desc.delete("1.0", "end") desc.insert("end", _info_str(_id_to_node[sel[0]])) desc["state"] = "disabled" tree.bind("<<TreeviewSelect>>", tree_select) tree.bind("<1>", _tree_click) tree.bind("<Double-1>", _tree_double_click) tree.bind("<Return>", _tree_enter) tree.bind("<KP_Enter>", _tree_enter) tree.bind("<space>", _tree_toggle) tree.bind("n", _tree_set_val(0)) tree.bind("m", _tree_set_val(1)) tree.bind("y", _tree_set_val(2)) return panedwindow, tree def _create_kconfig_tree(parent): # Creates a Treeview for showing Kconfig nodes frame = ttk.Frame(parent) tree = ttk.Treeview(frame, selectmode="browse", height=20, columns=("name",)) tree.heading("#0", text="Option", anchor="w") tree.heading("name", text="Name", anchor="w") tree.tag_configure("n-bool", image=_n_bool_img) tree.tag_configure("y-bool", image=_y_bool_img) tree.tag_configure("m-tri", image=_m_tri_img) tree.tag_configure("n-tri", image=_n_tri_img) tree.tag_configure("m-tri", image=_m_tri_img) tree.tag_configure("y-tri", image=_y_tri_img) tree.tag_configure("m-my", image=_m_my_img) tree.tag_configure("y-my", image=_y_my_img) tree.tag_configure("n-locked", image=_n_locked_img) tree.tag_configure("m-locked", image=_m_locked_img) tree.tag_configure("y-locked", image=_y_locked_img) tree.tag_configure("not-selected", image=_not_selected_img) tree.tag_configure("selected", image=_selected_img) # tree.tag_configure("edit", image=_edit_img) tree.tag_configure("invisible", foreground="red") tree.grid(column=0, row=0, sticky="nsew") _add_vscrollbar(frame, tree) frame.columnconfigure(0, weight=1) frame.rowconfigure(0, weight=1) # Create items for all menu nodes. These can be detached/moved later. # Micro-optimize this a bit. insert = tree.insert id_ = id Symbol_ = Symbol for node in _kconf.node_iter(): item = node.item insert("", "end", iid=id_(node), values=item.name if item.__class__ is Symbol_ else "") return frame, tree def _create_kconfig_desc(parent): # Creates a Text for showing the description of the selected Kconfig node frame = ttk.Frame(parent) desc = Text(frame, height=12, wrap="none", borderwidth=0, state="disabled") desc.grid(column=0, row=0, sticky="nsew") # Work around not being to Ctrl-C/V text from a disabled Text widget, with a # tip found in https://stackoverflow.com/questions/3842155/is-there-a-way-to-make-the-tkinter-text-widget-read-only desc.bind("<1>", lambda _: desc.focus_set()) _add_vscrollbar(frame, desc) frame.columnconfigure(0, weight=1) frame.rowconfigure(0, weight=1) return frame, desc def _add_vscrollbar(parent, widget): # Adds a vertical scrollbar to 'widget' that's only shown as needed vscrollbar = ttk.Scrollbar(parent, orient="vertical", command=widget.yview) vscrollbar.grid(column=1, row=0, sticky="ns") def yscrollcommand(first, last): # Only show the scrollbar when needed. 'first' and 'last' are # strings. if float(first) <= 0.0 and float(last) >= 1.0: vscrollbar.grid_remove() else: vscrollbar.grid() vscrollbar.set(first, last) widget["yscrollcommand"] = yscrollcommand def _create_status_bar(): # Creates the status bar at the bottom of the main window global _status_label _status_label = ttk.Label(_root, anchor="e", padding="0 0 0.4c 0") _status_label.grid(column=0, row=3, sticky="ew") def _set_status(s): # Sets the text in the status bar to 's' _status_label["text"] = s def _set_conf_changed(changed): # Updates the status re. whether there are unsaved changes global _conf_changed _conf_changed = changed if changed: _set_status("Modified") def _update_tree(): # Updates the Kconfig tree in the main window by first detaching all nodes # and then updating and reattaching them. The tree structure might have # changed. # If a selected/focused item is detached and later reattached, it stays # selected/focused. That can give multiple selections even though # selectmode=browse. Save and later restore the selection and focus as a # workaround. old_selection = _tree.selection() old_focus = _tree.focus() # Detach all tree items before re-stringing them. This is relatively fast, # luckily. _tree.detach(*_id_to_node.keys()) if _single_menu: _build_menu_tree() else: _build_full_tree(_kconf.top_node) _tree.selection_set(old_selection) _tree.focus(old_focus) def _build_full_tree(menu): # Updates the tree starting from menu.list, in full-tree mode. To speed # things up, only open menus are updated. The menu-at-a-time logic here is # to deal with invisible items that can show up outside show-all mode (see # _shown_full_nodes()). for node in _shown_full_nodes(menu): _add_to_tree(node, _kconf.top_node) # _shown_full_nodes() includes nodes from menus rooted at symbols, so # we only need to check "real" menus/choices here if node.list and not isinstance(node.item, Symbol): if _tree.item(id(node), "open"): _build_full_tree(node) else: # We're just probing here, so _shown_menu_nodes() will work # fine, and might be a bit faster shown = _shown_menu_nodes(node) if shown: # Dummy element to make the open/closed toggle appear _tree.move(id(shown[0]), id(shown[0].parent), "end") def _shown_full_nodes(menu): # Returns the list of menu nodes shown in 'menu' (a menu node for a menu) # for full-tree mode. A tricky detail is that invisible items need to be # shown if they have visible children. def rec(node): res = [] while node: if _visible(node) or _show_all: res.append(node) if node.list and isinstance(node.item, Symbol): # Nodes from menu created from dependencies res += rec(node.list) elif node.list and isinstance(node.item, Symbol): # Show invisible symbols (defined with either 'config' and # 'menuconfig') if they have visible children. This can happen # for an m/y-valued symbol with an optional prompt # ('prompt "foo" is COND') that is currently disabled. shown_children = rec(node.list) if shown_children: res.append(node) res += shown_children node = node.next return res return rec(menu.list) def _build_menu_tree(): # Updates the tree in single-menu mode. See _build_full_tree() as well. for node in _shown_menu_nodes(_cur_menu): _add_to_tree(node, _cur_menu) def _shown_menu_nodes(menu): # Used for single-menu mode. Similar to _shown_full_nodes(), but doesn't # include children of symbols defined with 'menuconfig'. def rec(node): res = [] while node: if _visible(node) or _show_all: res.append(node) if node.list and not node.is_menuconfig: res += rec(node.list) elif node.list and isinstance(node.item, Symbol): shown_children = rec(node.list) if shown_children: # Invisible item with visible children res.append(node) if not node.is_menuconfig: res += shown_children node = node.next return res return rec(menu.list) def _visible(node): # Returns True if the node should appear in the menu (outside show-all # mode) return node.prompt and expr_value(node.prompt[1]) and not \ (node.item == MENU and not expr_value(node.visibility)) def _add_to_tree(node, top): # Adds 'node' to the tree, at the end of its menu. We rely on going through # the nodes linearly to get the correct order. 'top' holds the menu that # corresponds to the top-level menu, and can vary in single-menu mode. parent = node.parent _tree.move(id(node), "" if parent is top else id(parent), "end") _tree.item( id(node), text=_node_str(node), # The _show_all test avoids showing invisible items in red outside # show-all mode, which could look confusing/broken. Invisible symbols # are shown outside show-all mode if an invisible symbol has visible # children in an implicit menu. tags=_img_tag(node) if _visible(node) or not _show_all else _img_tag(node) + " invisible") def _node_str(node): # Returns the string shown to the right of the image (if any) for the node if node.prompt: if node.item == COMMENT: s = "*** {} ***".format(node.prompt[0]) else: s = node.prompt[0] if isinstance(node.item, Symbol): sym = node.item # Print "(NEW)" next to symbols without a user value (from e.g. a # .config), but skip it for choice symbols in choices in y mode, # and for symbols of UNKNOWN type (which generate a warning though) if sym.user_value is None and sym.type and not \ (sym.choice and sym.choice.tri_value == 2): s += " (NEW)" elif isinstance(node.item, Symbol): # Symbol without prompt (can show up in show-all) s = "<{}>".format(node.item.name) else: # Choice without prompt. Use standard_sc_expr_str() so that it shows up # as '<choice (name if any)>'. s = standard_sc_expr_str(node.item) if isinstance(node.item, Symbol): sym = node.item if sym.orig_type == STRING: s += ": " + sym.str_value elif sym.orig_type in (INT, HEX): s = "({}) {}".format(sym.str_value, s) elif isinstance(node.item, Choice) and node.item.tri_value == 2: # Print the prompt of the selected symbol after the choice for # choices in y mode sym = node.item.selection if sym: for sym_node in sym.nodes: # Use the prompt used at this choice location, in case the # choice symbol is defined in multiple locations if sym_node.parent is node and sym_node.prompt: s += " ({})".format(sym_node.prompt[0]) break else: # If the symbol isn't defined at this choice location, then # just use whatever prompt we can find for it for sym_node in sym.nodes: if sym_node.prompt: s += " ({})".format(sym_node.prompt[0]) break # In single-menu mode, print "--->" next to nodes that have menus that can # potentially be entered. Print "----" if the menu is empty. We don't allow # those to be entered. if _single_menu and node.is_menuconfig: s += " --->" if _shown_menu_nodes(node) else " ----" return s def _img_tag(node): # Returns the tag for the image that should be shown next to 'node', or the # empty string if it shouldn't have an image item = node.item if item in (MENU, COMMENT) or not item.orig_type: return "" if item.orig_type in (STRING, INT, HEX): return "edit" # BOOL or TRISTATE if _is_y_mode_choice_sym(item): # Choice symbol in y-mode choice return "selected" if item.choice.selection is item else "not-selected" if len(item.assignable) <= 1: # Pinned to a single value return "" if isinstance(item, Choice) else item.str_value + "-locked" if item.type == BOOL: return item.str_value + "-bool" # item.type == TRISTATE if item.assignable == (1, 2): return item.str_value + "-my" return item.str_value + "-tri" def _is_y_mode_choice_sym(item): # The choice mode is an upper bound on the visibility of choice symbols, so # we can check the choice symbols' own visibility to see if the choice is # in y mode return isinstance(item, Symbol) and item.choice and item.visibility == 2 def _tree_click(event): # Click on the Kconfig Treeview tree = event.widget if tree.identify_element(event.x, event.y) == "image": item = tree.identify_row(event.y) # Select the item before possibly popping up a dialog for # string/int/hex items, so that its help is visible _select(tree, item) _change_node(_id_to_node[item], tree.winfo_toplevel()) return "break" def _tree_double_click(event): # Double-click on the Kconfig treeview # Do an extra check to avoid weirdness when double-clicking in the tree # heading area if not _in_heading(event): return _tree_enter(event) def _in_heading(event): # Returns True if 'event' took place in the tree heading tree = event.widget return hasattr(tree, "identify_region") and \ tree.identify_region(event.x, event.y) in ("heading", "separator") def _tree_enter(event): # Enter press or double-click within the Kconfig treeview. Prefer to # open/close/enter menus, but toggle the value if that's not possible. tree = event.widget sel = tree.focus() if sel: node = _id_to_node[sel] if tree.get_children(sel): _tree_toggle_open(sel) elif _single_menu_mode_menu(node, tree): _enter_menu_and_select_first(node) else: _change_node(node, tree.winfo_toplevel()) return "break" def _tree_toggle(event): # Space press within the Kconfig treeview. Prefer to toggle the value, but # open/close/enter the menu if that's not possible. tree = event.widget sel = tree.focus() if sel: node = _id_to_node[sel] if _changeable(node): _change_node(node, tree.winfo_toplevel()) elif _single_menu_mode_menu(node, tree): _enter_menu_and_select_first(node) elif tree.get_children(sel): _tree_toggle_open(sel) return "break" def _tree_left_key(_): # Left arrow key press within the Kconfig treeview if _single_menu: # Leave the current menu in single-menu mode _leave_menu() return "break" # Otherwise, default action def _tree_right_key(_): # Right arrow key press within the Kconfig treeview sel = _tree.focus() if sel: node = _id_to_node[sel] # If the node can be entered in single-menu mode, do it if _single_menu_mode_menu(node, _tree): _enter_menu_and_select_first(node) return "break" # Otherwise, default action def _single_menu_mode_menu(node, tree): # Returns True if single-menu mode is on and 'node' is an (interface) # menu that can be entered return _single_menu and tree is _tree and node.is_menuconfig and \ _shown_menu_nodes(node) def _changeable(node): # Returns True if 'node' is a Symbol/Choice whose value can be changed sc = node.item if not isinstance(sc, (Symbol, Choice)): return False # This will hit for invisible symbols, which appear in show-all mode and # when an invisible symbol has visible children (which can happen e.g. for # symbols with optional prompts) if not (node.prompt and expr_value(node.prompt[1])): return False return sc.orig_type in (STRING, INT, HEX) or len(sc.assignable) > 1 \ or _is_y_mode_choice_sym(sc) def _tree_toggle_open(item): # Opens/closes the Treeview item 'item' if _tree.item(item, "open"): _tree.item(item, open=False) else: node = _id_to_node[item] if not isinstance(node.item, Symbol): # Can only get here in full-tree mode _build_full_tree(node) _tree.item(item, open=True) def _tree_set_val(tri_val): def tree_set_val(event): # n/m/y press within the Kconfig treeview # Sets the value of the currently selected item to 'tri_val', if that # value can be assigned sel = event.widget.focus() if sel: sc = _id_to_node[sel].item if isinstance(sc, (Symbol, Choice)) and tri_val in sc.assignable: _set_val(sc, tri_val) return tree_set_val def _tree_open(_): # Lazily populates the Kconfig tree when menus are opened in full-tree mode if _single_menu: # Work around https://core.tcl.tk/tk/tktview?name=368fa4561e # ("ttk::treeview open/closed indicators can be toggled while hidden"). # Clicking on the hidden indicator will call _build_full_tree() in # single-menu mode otherwise. return node = _id_to_node[_tree.focus()] # _shown_full_nodes() includes nodes from menus rooted at symbols, so we # only need to check "real" menus and choices here if not isinstance(node.item, Symbol): _build_full_tree(node) def _update_menu_path(_): # Updates the displayed menu path when nodes are selected in the Kconfig # treeview sel = _tree.selection() _menupath["text"] = _menu_path_info(_id_to_node[sel[0]]) if sel else "" def _item_row(item): # Returns the row number 'item' appears on within the Kconfig treeview, # starting from the top of the tree. Used to preserve scrolling. # # ttkTreeview.c in the Tk sources defines a RowNumber() function that does # the same thing, but it's not exposed. row = 0 while True: prev = _tree.prev(item) if prev: item = prev row += _n_rows(item) else: item = _tree.parent(item) if not item: return row row += 1 def _n_rows(item): # _item_row() helper. Returns the number of rows occupied by 'item' and # # its children. rows = 1 if _tree.item(item, "open"): for child in _tree.get_children(item): rows += _n_rows(child) return rows def _attached(item): # Heuristic for checking if a Treeview item is attached. Doesn't seem to be # good APIs for this. Might fail for super-obscure cases with tiny trees, # but you'd just get a small scroll mess-up. return bool(_tree.next(item) or _tree.prev(item) or _tree.parent(item)) def _change_node(node, parent): # Toggles/changes the value of 'node'. 'parent' is the parent window # (either the main window or the jump-to dialog), in case we need to pop up # a dialog. if not _changeable(node): return # sc = symbol/choice sc = node.item if sc.type in (INT, HEX, STRING): s = _set_val_dialog(node, parent) # Tkinter can return 'unicode' strings on Python 2, which Kconfiglib # can't deal with. UTF-8-encode the string to work around it. if _PY2 and isinstance(s, unicode): s = s.encode("utf-8", "ignore") if s is not None: _set_val(sc, s) elif len(sc.assignable) == 1: # Handles choice symbols for choices in y mode, which are a special # case: .assignable can be (2,) while .tri_value is 0. _set_val(sc, sc.assignable[0]) else: # Set the symbol to the value after the current value in # sc.assignable, with wrapping val_index = sc.assignable.index(sc.tri_value) _set_val(sc, sc.assignable[(val_index + 1) % len(sc.assignable)]) def _set_val(sc, val): # Wrapper around Symbol/Choice.set_value() for updating the menu state and # _conf_changed # Use the string representation of tristate values. This makes the format # consistent for all symbol types. if val in TRI_TO_STR: val = TRI_TO_STR[val] if val != sc.str_value: sc.set_value(val) _set_conf_changed(True) # Update the tree and try to preserve the scroll. Do a cheaper variant # than in the show-all case, that might mess up the scroll slightly in # rare cases, but is fast and flicker-free. stayput = _loc_ref_item() # Item to preserve scroll for old_row = _item_row(stayput) _update_tree() # If the reference item disappeared (can happen if the change was done # from the jump-to dialog), then avoid messing with the scroll and hope # for the best if _attached(stayput): _tree.yview_scroll(_item_row(stayput) - old_row, "units") if _jump_to_tree: _update_jump_to_display() def _set_val_dialog(node, parent): # Pops up a dialog for setting the value of the string/int/hex # symbol at node 'node'. 'parent' is the parent window. def ok(_=None): # No 'nonlocal' in Python 2 global _entry_res s = entry.get() if sym.type == HEX and not s.startswith(("0x", "0X")): s = "0x" + s if _check_valid(dialog, entry, sym, s): _entry_res = s dialog.destroy() def cancel(_=None): global _entry_res _entry_res = None dialog.destroy() sym = node.item dialog = Toplevel(parent) dialog.title("Enter {} value".format(TYPE_TO_STR[sym.type])) dialog.resizable(False, False) dialog.transient(parent) dialog.protocol("WM_DELETE_WINDOW", cancel) ttk.Label(dialog, text=node.prompt[0] + ":") \ .grid(column=0, row=0, columnspan=2, sticky="w", padx=".3c", pady=".2c .05c") entry = ttk.Entry(dialog, width=30) # Start with the previous value in the editbox, selected entry.insert(0, sym.str_value) entry.selection_range(0, "end") entry.grid(column=0, row=1, columnspan=2, sticky="ew", padx=".3c") entry.focus_set() range_info = _range_info(sym) if range_info: ttk.Label(dialog, text=range_info) \ .grid(column=0, row=2, columnspan=2, sticky="w", padx=".3c", pady=".2c 0") ttk.Button(dialog, text="OK", command=ok) \ .grid(column=0, row=4 if range_info else 3, sticky="e", padx=".3c", pady=".4c") ttk.Button(dialog, text="Cancel", command=cancel) \ .grid(column=1, row=4 if range_info else 3, padx="0 .3c") # Give all horizontal space to the grid cell with the OK button, so that # Cancel moves to the right dialog.columnconfigure(0, weight=1) _center_on_root(dialog) # Hack to scroll the entry so that the end of the text is shown, from # https://stackoverflow.com/questions/29334544/why-does-tkinters-entry-xview-moveto-fail. # Related Tk ticket: https://core.tcl.tk/tk/info/2513186fff def scroll_entry(_): _root.update_idletasks() entry.unbind("<Expose>") entry.xview_moveto(1) entry.bind("<Expose>", scroll_entry) # The dialog must be visible before we can grab the input dialog.wait_visibility() dialog.grab_set() dialog.bind("<Return>", ok) dialog.bind("<KP_Enter>", ok) dialog.bind("<Escape>", cancel) # Wait for the user to be done with the dialog parent.wait_window(dialog) # Regrab the input in the parent parent.grab_set() return _entry_res def _center_on_root(dialog): # Centers 'dialog' on the root window. It often ends up at some bad place # like the top-left corner of the screen otherwise. See the menuconfig() # function, which has similar logic. dialog.withdraw() _root.update_idletasks() dialog_width = dialog.winfo_reqwidth() dialog_height = dialog.winfo_reqheight() screen_width = _root.winfo_screenwidth() screen_height = _root.winfo_screenheight() x = _root.winfo_rootx() + (_root.winfo_width() - dialog_width)//2 y = _root.winfo_rooty() + (_root.winfo_height() - dialog_height)//2 # Clamp so that no part of the dialog is outside the screen if x + dialog_width > screen_width: x = screen_width - dialog_width elif x < 0: x = 0 if y + dialog_height > screen_height: y = screen_height - dialog_height elif y < 0: y = 0 dialog.geometry("+{}+{}".format(x, y)) dialog.deiconify() def _check_valid(dialog, entry, sym, s): # Returns True if the string 's' is a well-formed value for 'sym'. # Otherwise, pops up an error and returns False. if sym.type not in (INT, HEX): # Anything goes for non-int/hex symbols return True base = 10 if sym.type == INT else 16 try: int(s, base) except ValueError: messagebox.showerror( "Bad value", "'{}' is a malformed {} value".format( s, TYPE_TO_STR[sym.type]), parent=dialog) entry.focus_set() return False for low_sym, high_sym, cond in sym.ranges: if expr_value(cond): low_s = low_sym.str_value high_s = high_sym.str_value if not int(low_s, base) <= int(s, base) <= int(high_s, base): messagebox.showerror( "Value out of range", "{} is outside the range {}-{}".format(s, low_s, high_s), parent=dialog) entry.focus_set() return False break return True def _range_info(sym): # Returns a string with information about the valid range for the symbol # 'sym', or None if 'sym' doesn't have a range if sym.type in (INT, HEX): for low, high, cond in sym.ranges: if expr_value(cond): return "Range: {}-{}".format(low.str_value, high.str_value) return None def _save(_=None): # Tries to save the configuration if _try_save(_kconf.write_config, _conf_filename, "configuration"): _set_conf_changed(False) _tree.focus_set() def _save_as(): # Pops up a dialog for saving the configuration to a specific location global _conf_filename filename = _conf_filename while True: filename = filedialog.asksaveasfilename( title="Save configuration as", initialdir=os.path.dirname(filename), initialfile=os.path.basename(filename), parent=_root) if not filename: break if _try_save(_kconf.write_config, filename, "configuration"): _conf_filename = filename break _tree.focus_set() def _save_minimal(): # Pops up a dialog for saving a minimal configuration (defconfig) to a # specific location global _minconf_filename filename = _minconf_filename while True: filename = filedialog.asksaveasfilename( title="Save minimal configuration as", initialdir=os.path.dirname(filename), initialfile=os.path.basename(filename), parent=_root) if not filename: break if _try_save(_kconf.write_min_config, filename, "minimal configuration"): _minconf_filename = filename break _tree.focus_set() def _open(_=None): # Pops up a dialog for loading a configuration global _conf_filename if _conf_changed and \ not messagebox.askokcancel( "Unsaved changes", "You have unsaved changes. Load new configuration anyway?"): return filename = _conf_filename while True: filename = filedialog.askopenfilename( title="Open configuration", initialdir=os.path.dirname(filename), initialfile=os.path.basename(filename), parent=_root) if not filename: break if _try_load(filename): # Maybe something fancier could be done here later to try to # preserve the scroll _conf_filename = filename _set_conf_changed(_needs_save()) if _single_menu and not _shown_menu_nodes(_cur_menu): # Turn on show-all if we're in single-menu mode and would end # up with an empty menu _show_all_var.set(True) _update_tree() break _tree.focus_set() def _toggle_showname(_): # Toggles show-name mode on/off _show_name_var.set(not _show_name_var.get()) _do_showname() def _do_showname(): # Updates the UI for the current show-name setting # Columns do not automatically shrink/expand, so we have to update # column widths ourselves tree_width = _tree.winfo_width() if _show_name_var.get(): _tree["displaycolumns"] = ("name",) _tree["show"] = "tree headings" name_width = tree_width//3 _tree.column("#0", width=max(tree_width - name_width, 1)) _tree.column("name", width=name_width) else: _tree["displaycolumns"] = () _tree["show"] = "tree" _tree.column("#0", width=tree_width) _tree.focus_set() def _toggle_showall(_): # Toggles show-all mode on/off _show_all_var.set(not _show_all) _do_showall() def _do_showall(): # Updates the UI for the current show-all setting # Don't allow turning off show-all if we're in single-menu mode and the # current menu would become empty if _single_menu and not _shown_menu_nodes(_cur_menu): _show_all_var.set(True) return # Save scroll information. old_scroll can end up negative here, if the # reference item isn't shown (only invisible items on the screen, and # show-all being turned off). stayput = _vis_loc_ref_item() # Probe the middle of the first row, to play it safe. identify_row(0) seems # to return the row before the top row. old_scroll = _item_row(stayput) - \ _item_row(_tree.identify_row(_treeview_rowheight//2)) _update_tree() if _show_all: # Deep magic: Unless we call update_idletasks(), the scroll adjustment # below is restricted to the height of the old tree, instead of the # height of the new tree. Since the tree with show-all on is guaranteed # to be taller, and we want the maximum range, we only call it when # turning show-all on. # # Strictly speaking, something similar ought to be done when changing # symbol values, but it causes annoying flicker, and in 99% of cases # things work anyway there (with usually minor scroll mess-ups in the # 1% case). _root.update_idletasks() # Restore scroll _tree.yview(_item_row(stayput) - old_scroll) _tree.focus_set() def _toggle_tree_mode(_): # Toggles single-menu mode on/off _single_menu_var.set(not _single_menu) _do_tree_mode() def _do_tree_mode(): # Updates the UI for the current tree mode (full-tree or single-menu) loc_ref_node = _id_to_node[_loc_ref_item()] if not _single_menu: # _jump_to() -> _enter_menu() already updates the tree, but # _jump_to() -> load_parents() doesn't, because it isn't always needed. # We always need to update the tree here, e.g. to add/remove "--->". _update_tree() _jump_to(loc_ref_node) _tree.focus_set() def _enter_menu_and_select_first(menu): # Enters the menu 'menu' and selects the first item. Used in single-menu # mode. _enter_menu(menu) _select(_tree, _tree.get_children()[0]) def _enter_menu(menu): # Enters the menu 'menu'. Used in single-menu mode. global _cur_menu _cur_menu = menu _update_tree() _backbutton["state"] = "disabled" if menu is _kconf.top_node else "normal" def _leave_menu(): # Leaves the current menu. Used in single-menu mode. global _cur_menu if _cur_menu is not _kconf.top_node: old_menu = _cur_menu _cur_menu = _parent_menu(_cur_menu) _update_tree() _select(_tree, id(old_menu)) if _cur_menu is _kconf.top_node: _backbutton["state"] = "disabled" _tree.focus_set() def _select(tree, item): # Selects, focuses, and see()s 'item' in 'tree' tree.selection_set(item) tree.focus(item) tree.see(item) def _loc_ref_item(): # Returns a Treeview item that can serve as a reference for the current # scroll location. We try to make this item stay on the same row on the # screen when updating the tree. # If the selected item is visible, use that sel = _tree.selection() if sel and _tree.bbox(sel[0]): return sel[0] # Otherwise, use the middle item on the screen. If it doesn't exist, the # tree is probably really small, so use the first item in the entire tree. return _tree.identify_row(_tree.winfo_height()//2) or \ _tree.get_children()[0] def _vis_loc_ref_item(): # Like _loc_ref_item(), but finds a visible item around the reference item. # Used when changing show-all mode, where non-visible (red) items will # disappear. item = _loc_ref_item() vis_before = _vis_before(item) if vis_before and _tree.bbox(vis_before): return vis_before vis_after = _vis_after(item) if vis_after and _tree.bbox(vis_after): return vis_after return vis_before or vis_after def _vis_before(item): # _vis_loc_ref_item() helper. Returns the first visible (not red) item, # searching backwards from 'item'. while item: if not _tree.tag_has("invisible", item): return item prev = _tree.prev(item) item = prev if prev else _tree.parent(item) return None def _vis_after(item): # _vis_loc_ref_item() helper. Returns the first visible (not red) item, # searching forwards from 'item'. while item: if not _tree.tag_has("invisible", item): return item next = _tree.next(item) if next: item = next else: item = _tree.parent(item) if not item: break item = _tree.next(item) return None def _on_quit(_=None): # Called when the user wants to exit if not _conf_changed: _quit("No changes to save (for '{}')".format(_conf_filename)) return while True: ync = messagebox.askyesnocancel("Quit", "Save changes?") if ync is None: return if not ync: _quit("Configuration ({}) was not saved".format(_conf_filename)) return if _try_save(_kconf.write_config, _conf_filename, "configuration"): # _try_save() already prints the "Configuration saved to ..." # message _quit() return def _quit(msg=None): # Quits the application # Do not call sys.exit() here, in case we're being run from a script _root.destroy() if msg: print(msg) def _try_save(save_fn, filename, description): # Tries to save a configuration file. Pops up an error and returns False on # failure. # # save_fn: # Function to call with 'filename' to save the file # # description: # String describing the thing being saved try: # save_fn() returns a message to print msg = save_fn(filename) _set_status(msg) print(msg) return True except EnvironmentError as e: messagebox.showerror( "Error saving " + description, "Error saving {} to '{}': {} (errno: {})" .format(description, e.filename, e.strerror, errno.errorcode[e.errno])) return False def _try_load(filename): # Tries to load a configuration file. Pops up an error and returns False on # failure. # # filename: # Configuration file to load try: msg = _kconf.load_config(filename) _set_status(msg) print(msg) return True except EnvironmentError as e: messagebox.showerror( "Error loading configuration", "Error loading '{}': {} (errno: {})" .format(filename, e.strerror, errno.errorcode[e.errno])) return False def _jump_to_dialog(_=None): # Pops up a dialog for jumping directly to a particular node. Symbol values # can also be changed within the dialog. # # Note: There's nothing preventing this from doing an incremental search # like menuconfig.py does, but currently it's a bit jerky for large Kconfig # trees, at least when inputting the beginning of the search string. We'd # need to somehow only update the tree items that are shown in the Treeview # to fix it. global _jump_to_tree def search(_=None): _update_jump_to_matches(msglabel, entry.get()) def jump_to_selected(event=None): # Jumps to the selected node and closes the dialog # Ignore double clicks on the image and in the heading area if event and (tree.identify_element(event.x, event.y) == "image" or _in_heading(event)): return sel = tree.selection() if not sel: return node = _id_to_node[sel[0]] if node not in _shown_menu_nodes(_parent_menu(node)): _show_all_var.set(True) if not _single_menu: # See comment in _do_tree_mode() _update_tree() _jump_to(node) dialog.destroy() def tree_select(_): jumpto_button["state"] = "normal" if tree.selection() else "disabled" dialog = Toplevel(_root) dialog.geometry("+{}+{}".format( _root.winfo_rootx() + 50, _root.winfo_rooty() + 50)) dialog.title("Jump to symbol/choice/menu/comment") dialog.minsize(128, 128) # See _create_ui() dialog.transient(_root) ttk.Label(dialog, text=_JUMP_TO_HELP) \ .grid(column=0, row=0, columnspan=2, sticky="w", padx=".1c", pady=".1c") entry = ttk.Entry(dialog) entry.grid(column=0, row=1, sticky="ew", padx=".1c", pady=".1c") entry.focus_set() entry.bind("<Return>", search) entry.bind("<KP_Enter>", search) ttk.Button(dialog, text="Search", command=search) \ .grid(column=1, row=1, padx="0 .1c", pady="0 .1c") msglabel = ttk.Label(dialog) msglabel.grid(column=0, row=2, sticky="w", pady="0 .1c") panedwindow, tree = _create_kconfig_tree_and_desc(dialog) panedwindow.grid(column=0, row=3, columnspan=2, sticky="nsew") # Clear tree tree.set_children("") _jump_to_tree = tree jumpto_button = ttk.Button(dialog, text="Jump to selected item", state="disabled", command=jump_to_selected) jumpto_button.grid(column=0, row=4, columnspan=2, sticky="ns", pady=".1c") dialog.columnconfigure(0, weight=1) # Only the pane with the Kconfig tree and description grows vertically dialog.rowconfigure(3, weight=1) # See the menuconfig() function _root.update_idletasks() dialog.geometry(dialog.geometry()) # The dialog must be visible before we can grab the input dialog.wait_visibility() dialog.grab_set() tree.bind("<Double-1>", jump_to_selected) tree.bind("<Return>", jump_to_selected) tree.bind("<KP_Enter>", jump_to_selected) # add=True to avoid overriding the description text update tree.bind("<<TreeviewSelect>>", tree_select, add=True) dialog.bind("<Escape>", lambda _: dialog.destroy()) # Wait for the user to be done with the dialog _root.wait_window(dialog) _jump_to_tree = None _tree.focus_set() def _update_jump_to_matches(msglabel, search_string): # Searches for nodes matching the search string and updates # _jump_to_matches. Puts a message in 'msglabel' if there are no matches, # or regex errors. global _jump_to_matches _jump_to_tree.selection_set(()) try: # We could use re.IGNORECASE here instead of lower(), but this is # faster for regexes like '.*debug$' (though the '.*' is redundant # there). Those probably have bad interactions with re.search(), which # matches anywhere in the string. regex_searches = [re.compile(regex).search for regex in search_string.lower().split()] except re.error as e: msg = "Bad regular expression" # re.error.msg was added in Python 3.5 if hasattr(e, "msg"): msg += ": " + e.msg msglabel["text"] = msg # Clear tree _jump_to_tree.set_children("") return _jump_to_matches = [] add_match = _jump_to_matches.append for node in _sorted_sc_nodes(): # Symbol/choice sc = node.item for search in regex_searches: # Both the name and the prompt might be missing, since # we're searching both symbols and choices # Does the regex match either the symbol name or the # prompt (if any)? if not (sc.name and search(sc.name.lower()) or node.prompt and search(node.prompt[0].lower())): # Give up on the first regex that doesn't match, to # speed things up a bit when multiple regexes are # entered break else: add_match(node) # Search menus and comments for node in _sorted_menu_comment_nodes(): for search in regex_searches: if not search(node.prompt[0].lower()): break else: add_match(node) msglabel["text"] = "" if _jump_to_matches else "No matches" _update_jump_to_display() if _jump_to_matches: item = id(_jump_to_matches[0]) _jump_to_tree.selection_set(item) _jump_to_tree.focus(item) def _update_jump_to_display(): # Updates the images and text for the items in _jump_to_matches, and sets # them as the items of _jump_to_tree # Micro-optimize a bit item = _jump_to_tree.item id_ = id node_str = _node_str img_tag = _img_tag visible = _visible for node in _jump_to_matches: item(id_(node), text=node_str(node), tags=img_tag(node) if visible(node) else img_tag(node) + " invisible") _jump_to_tree.set_children("", *map(id, _jump_to_matches)) def _jump_to(node): # Jumps directly to 'node' and selects it if _single_menu: _enter_menu(_parent_menu(node)) else: _load_parents(node) _select(_tree, id(node)) # Obscure Python: We never pass a value for cached_nodes, and it keeps pointing # to the same list. This avoids a global. def _sorted_sc_nodes(cached_nodes=[]): # Returns a sorted list of symbol and choice nodes to search. The symbol # nodes appear first, sorted by name, and then the choice nodes, sorted by # prompt and (secondarily) name. if not cached_nodes: # Add symbol nodes for sym in sorted(_kconf.unique_defined_syms, key=lambda sym: sym.name): # += is in-place for lists cached_nodes += sym.nodes # Add choice nodes choices = sorted(_kconf.unique_choices, key=lambda choice: choice.name or "") cached_nodes += sorted( [node for choice in choices for node in choice.nodes], key=lambda node: node.prompt[0] if node.prompt else "") return cached_nodes def _sorted_menu_comment_nodes(cached_nodes=[]): # Returns a list of menu and comment nodes to search, sorted by prompt, # with the menus first if not cached_nodes: def prompt_text(mc): return mc.prompt[0] cached_nodes += sorted(_kconf.menus, key=prompt_text) cached_nodes += sorted(_kconf.comments, key=prompt_text) return cached_nodes def _load_parents(node): # Menus are lazily populated as they're opened in full-tree mode, but # jumping to an item needs its parent menus to be populated. This function # populates 'node's parents. # Get all parents leading up to 'node', sorted with the root first parents = [] cur = node.parent while cur is not _kconf.top_node: parents.append(cur) cur = cur.parent parents.reverse() for i, parent in enumerate(parents): if not _tree.item(id(parent), "open"): # Found a closed menu. Populate it and all the remaining menus # leading up to 'node'. for parent in parents[i:]: # We only need to populate "real" menus/choices. Implicit menus # are populated when their parents menus are entered. if not isinstance(parent.item, Symbol): _build_full_tree(parent) return def _parent_menu(node): # Returns the menu node of the menu that contains 'node'. In addition to # proper 'menu's, this might also be a 'menuconfig' symbol or a 'choice'. # "Menu" here means a menu in the interface. menu = node.parent while not menu.is_menuconfig: menu = menu.parent return menu def _trace_write(var, fn): # Makes fn() be called whenever the Tkinter Variable 'var' changes value # trace_variable() is deprecated according to the docstring, # which recommends trace_add() if hasattr(var, "trace_add"): var.trace_add("write", fn) else: var.trace_variable("w", fn) def _info_str(node): # Returns information about the menu node 'node' as a string. # # The helper functions are responsible for adding newlines. This allows # them to return "" if they don't want to add any output. if isinstance(node.item, Symbol): sym = node.item return ( _name_info(sym) + _help_info(sym) + _direct_dep_info(sym) + _defaults_info(sym) + _select_imply_info(sym) + _kconfig_def_info(sym) ) if isinstance(node.item, Choice): choice = node.item return ( _name_info(choice) + _help_info(choice) + 'Mode: {}\n\n'.format(choice.str_value) + _choice_syms_info(choice) + _direct_dep_info(choice) + _defaults_info(choice) + _kconfig_def_info(choice) ) # node.item in (MENU, COMMENT) return _kconfig_def_info(node) def _name_info(sc): # Returns a string with the name of the symbol/choice. Choices are shown as # <choice (name if any)>. return (sc.name if sc.name else standard_sc_expr_str(sc)) + "\n\n" def _value_info(sym): # Returns a string showing 'sym's value # Only put quotes around the value for string symbols return "Value: {}\n".format( '"{}"'.format(sym.str_value) if sym.orig_type == STRING else sym.str_value) def _choice_syms_info(choice): # Returns a string listing the choice symbols in 'choice'. Adds # "(selected)" next to the selected one. s = "Choice symbols:\n" for sym in choice.syms: s += " - " + sym.name if sym is choice.selection: s += " (selected)" s += "\n" return s + "\n" def _help_info(sc): # Returns a string with the help text(s) of 'sc' (Symbol or Choice). # Symbols and choices defined in multiple locations can have multiple help # texts. s = "" for node in sc.nodes: if node.help is not None: s += node.help + "\n\n" return s def _direct_dep_info(sc): # Returns a string describing the direct dependencies of 'sc' (Symbol or # Choice). The direct dependencies are the OR of the dependencies from each # definition location. The dependencies at each definition location come # from 'depends on' and dependencies inherited from parent items. return "" if sc.direct_dep is _kconf.y else \ 'Direct dependencies (={}):\n{}\n' \ .format(TRI_TO_STR[expr_value(sc.direct_dep)], _split_expr_info(sc.direct_dep, 2)) def _defaults_info(sc): # Returns a string describing the defaults of 'sc' (Symbol or Choice) if not sc.defaults: return "" s = "Defaults:\n" for val, cond in sc.orig_defaults: s += " - " if isinstance(sc, Symbol): s += _expr_str(val) # Skip the tristate value hint if the expression is just a single # symbol. _expr_str() already shows its value as a string. # # This also avoids showing the tristate value for string/int/hex # defaults, which wouldn't make any sense. if isinstance(val, tuple): s += ' (={})'.format(TRI_TO_STR[expr_value(val)]) else: # Don't print the value next to the symbol name for choice # defaults, as it looks a bit confusing s += val.name s += "\n" if cond is not _kconf.y: s += " Condition (={}):\n{}" \ .format(TRI_TO_STR[expr_value(cond)], _split_expr_info(cond, 4)) return s + "\n" def _split_expr_info(expr, indent): # Returns a string with 'expr' split into its top-level && or || operands, # with one operand per line, together with the operand's value. This is # usually enough to get something readable for long expressions. A fancier # recursive thingy would be possible too. # # indent: # Number of leading spaces to add before the split expression. if len(split_expr(expr, AND)) > 1: split_op = AND op_str = "&&" else: split_op = OR op_str = "||" s = "" for i, term in enumerate(split_expr(expr, split_op)): s += "{}{} {}".format(indent*" ", " " if i == 0 else op_str, _expr_str(term)) # Don't bother showing the value hint if the expression is just a # single symbol. _expr_str() already shows its value. if isinstance(term, tuple): s += " (={})".format(TRI_TO_STR[expr_value(term)]) s += "\n" return s def _select_imply_info(sym): # Returns a string with information about which symbols 'select' or 'imply' # 'sym'. The selecting/implying symbols are grouped according to which # value they select/imply 'sym' to (n/m/y). def sis(expr, val, title): # sis = selects/implies sis = [si for si in split_expr(expr, OR) if expr_value(si) == val] if not sis: return "" res = title for si in sis: res += " - {}\n".format(split_expr(si, AND)[0].name) return res + "\n" s = "" if sym.rev_dep is not _kconf.n: s += sis(sym.rev_dep, 2, "Symbols currently y-selecting this symbol:\n") s += sis(sym.rev_dep, 1, "Symbols currently m-selecting this symbol:\n") s += sis(sym.rev_dep, 0, "Symbols currently n-selecting this symbol (no effect):\n") if sym.weak_rev_dep is not _kconf.n: s += sis(sym.weak_rev_dep, 2, "Symbols currently y-implying this symbol:\n") s += sis(sym.weak_rev_dep, 1, "Symbols currently m-implying this symbol:\n") s += sis(sym.weak_rev_dep, 0, "Symbols currently n-implying this symbol (no effect):\n") return s def _kconfig_def_info(item): # Returns a string with the definition of 'item' in Kconfig syntax, # together with the definition location(s) and their include and menu paths nodes = [item] if isinstance(item, MenuNode) else item.nodes s = "Kconfig definition{}, with parent deps. propagated to 'depends on'\n" \ .format("s" if len(nodes) > 1 else "") s += (len(s) - 1)*"=" for node in nodes: s += "\n\n" \ "At {}:{}\n" \ "{}" \ "Menu path: {}\n\n" \ "{}" \ .format(node.filename, node.linenr, _include_path_info(node), _menu_path_info(node), node.custom_str(_name_and_val_str)) return s def _include_path_info(node): if not node.include_path: # In the top-level Kconfig file return "" return "Included via {}\n".format( " -> ".join("{}:{}".format(filename, linenr) for filename, linenr in node.include_path)) def _menu_path_info(node): # Returns a string describing the menu path leading up to 'node' path = "" while node.parent is not _kconf.top_node: node = node.parent # Promptless choices might appear among the parents. Use # standard_sc_expr_str() for them, so that they show up as # '<choice (name if any)>'. path = " -> " + (node.prompt[0] if node.prompt else standard_sc_expr_str(node.item)) + path return "(Top)" + path def _name_and_val_str(sc): # Custom symbol/choice printer that shows symbol values after symbols # Show the values of non-constant (non-quoted) symbols that don't look like # numbers. Things like 123 are actually symbol references, and only work as # expected due to undefined symbols getting their name as their value. # Showing the symbol value for those isn't helpful though. if isinstance(sc, Symbol) and not sc.is_constant and not _is_num(sc.name): if not sc.nodes: # Undefined symbol reference return "{}(undefined/n)".format(sc.name) return '{}(={})'.format(sc.name, sc.str_value) # For other items, use the standard format return standard_sc_expr_str(sc) def _expr_str(expr): # Custom expression printer that shows symbol values return expr_str(expr, _name_and_val_str) def _is_num(name): # Heuristic to see if a symbol name looks like a number, for nicer output # when printing expressions. Things like 16 are actually symbol names, only # they get their name as their value when the symbol is undefined. try: int(name) except ValueError: if not name.startswith(("0x", "0X")): return False try: int(name, 16) except ValueError: return False return True if __name__ == "__main__": _main()