diff options
Diffstat (limited to 'guiconfig.py')
| -rwxr-xr-x | guiconfig.py | 2309 |
1 files changed, 2309 insertions, 0 deletions
diff --git a/guiconfig.py b/guiconfig.py new file mode 100755 index 0000000..886025d --- /dev/null +++ b/guiconfig.py @@ -0,0 +1,2309 @@ +#!/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. + +$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, UNKNOWN, \ + 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 image +# files. This avoids having to carry around a bunch of .gifs. 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()) + + +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() + + # Load existing configuration and check if it's outdated + _set_conf_changed(_load_config()) + + # Filename to save configuration to + _conf_filename = standard_config_filename() + + # 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. + + if not _kconf.load_config(): + # 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 + + +# 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 _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", "R0lGODlhMAAwAPEDAAAAAADQAO7u7v///yH5BAUKAAMALAAAAAAwADAAAAL/nI+gy+2Pokyv2jazuZxryQjiSJZmyXxHeLbumH6sEATvW8OLNtf5bfLZRLFITzgEipDJ4mYxYv6A0ubuqYhWk66tVTE4enHer7jcKvt0LLUw6P45lvEprT6c0+v7OBuqhYdHohcoqIbSAHc4ljhDwrh1UlgSydRCWWlp5wiYZvmSuSh4IzrqV6p4cwhkCsmY+nhK6uJ6t1mrOhuJqfu6+WYiCiwl7HtLjNSZZZis/MeM7NY3TaRKS40ooDeoiVqIultsrav92bi9c3a5KkkOsOJZpSS99m4k/0zPng4Gks9JSbB+8DIcoQfnjwpZCHv5W+ip4aQrKrB0uOikYhiMCBw1/uPoQUMBADs=") + load_image("n_bool", "R0lGODdhEAAQAPAAAAgICP///ywAAAAAEAAQAAACIISPacHtvp5kcb5qG85hZ2+BkyiRF8BBaEqtrKkqslEAADs=") + load_image("y_bool", "R0lGODdhEAAQAPEAAAgICADQAP///wAAACwAAAAAEAAQAAACMoSPacLtvlh4YrIYsst2cV19AvaVF9CUXBNJJoum7ymrsKuCnhiupIWjSSjAFuWhSCIKADs=") + 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", "R0lGODlhEAAQAPD/AAAAAADQACH5BAUKAAIALAAAAAAQABAAAAIylC8AyKzNgnlCtoDTwvZwrHydIYpQmR3KWq4uK74IOnp0HQPmnD3cOVlUIAgKsShkFAAAOw==") + 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. + if _root.tk.call("tk", "windowingsystem") == "x11": + style = ttk.Style() + 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 + for node in _kconf.node_iter(): + insert("", "end", iid=id_(node), + values=node.item.name if node.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 + _set_status("Modified" if changed else "") + + +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 = int(low_sym.str_value, base) + val = int(s, base) + high = int(high_sym.str_value, base) + + if not low <= val <= high: + messagebox.showerror( + "Value out of range", + "{} is outside the range {}-{}".format( + s, low_sym.str_value, high_sym.str_value), + 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) + _set_status("Configuration saved to " + _conf_filename) + + _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"): + _set_status("Configuration saved to " + filename) + _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() + + _set_status("Configuration loaded from " + filename) + 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(filename) + print("{} saved to '{}'".format(description, filename)) + return True + except (OSError, IOError) 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: + _kconf.load_config(filename) + print("configuration loaded from " + filename) + return True + except (OSError, IOError) 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.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 propagated dependencies\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() |
