1# SPDX-License-Identifier: ISC 2# -*- coding: utf-8 -*- 3 4""" 5Overview 6======== 7 8pymenuconfig is a small and simple frontend to Kconfiglib that's written 9entirely in Python using Tkinter as its GUI toolkit. 10 11Motivation 12========== 13 14Kconfig is a nice and powerful framework for build-time configuration and lots 15of projects already benefit from using it. Kconfiglib allows to utilize power of 16Kconfig by using scripts written in pure Python, without requiring one to build 17Linux kernel tools written in C (this can be quite tedious on anything that's 18not *nix). The aim of this project is to implement simple and small Kconfiglib 19GUI frontend that runs on as much systems as possible. 20 21Tkinter GUI toolkit is a natural choice if portability is considered, as it's 22a part of Python standard library and is available virtually in every CPython 23installation. 24 25 26User interface 27============== 28 29I've tried to replicate look and fill of Linux kernel 'menuconfig' tool that 30many users are used to, including keyboard-oriented control and textual 31representation of menus with fixed-width font. 32 33 34Usage 35===== 36 37The pymenuconfig module is executable and parses command-line args, so the 38most simple way to run menuconfig is to execute script directly: 39 40 python pymenuconfig.py --kconfig Kconfig 41 42As with most command-line tools list of options can be obtained with '--help': 43 44 python pymenuconfig.py --help 45 46If installed with setuptools, one can run it like this: 47 48 python -m pymenuconfig --kconfig Kconfig 49 50In case you're making a wrapper around menuconfig, you can either call main(): 51 52 import pymenuconfig 53 pymenuconfig.main(['--kconfig', 'Kconfig']) 54 55Or import MenuConfig class, instantiate it and manually run Tkinter's mainloop: 56 57 import tkinter 58 import kconfiglib 59 from pymenuconfig import MenuConfig 60 61 kconfig = kconfiglib.Kconfig() 62 mconf = MenuConfig(kconfig) 63 tkinter.mainloop() 64 65""" 66 67from __future__ import print_function 68 69import os 70import sys 71import argparse 72import kconfiglib 73 74# Tk is imported differently depending on python major version 75if sys.version_info[0] < 3: 76 import Tkinter as tk 77 import tkFont as font 78 import tkFileDialog as filedialog 79 import tkMessageBox as messagebox 80else: 81 import tkinter as tk 82 from tkinter import font 83 from tkinter import filedialog 84 from tkinter import messagebox 85 86 87class ListEntry(object): 88 """ 89 Represents visible menu node and holds all information related to displaying 90 menu node in a Listbox. 91 92 Instances of this class also handle all interaction with main window. 93 A node is displayed as a single line of text: 94 PREFIX INDENT BODY POSTFIX 95 - The PREFIX is always 3 characters or more and can take following values: 96 ' ' comment, menu, bool choice, etc. 97 Inside menus: 98 '< >' bool symbol has value 'n' 99 '<*>' bool symbol has value 'y' 100 '[ ]' tristate symbol has value 'n' 101 '[M]' tristate symbol has value 'm' 102 '[*]' tristate symbol has value 'y' 103 '- -' symbol has value 'n' that's not editable 104 '-M-' symbol has value 'm' that's not editable 105 '-*-' symbol has value 'y' that's not editable 106 '(M)' tristate choice has value 'm' 107 '(*)' tristate choice has value 'y' 108 '(some value)' value of non-bool/tristate symbols 109 Inside choices: 110 '( )' symbol has value 'n' 111 '(M)' symbol has value 'm' 112 '(*)' symbol has value 'y' 113 - INDENT is a sequence of space characters. It's used in implicit menus, and 114 adds 2 spaces for each nesting level 115 - BODY is a menu node prompt. '***' is added if node is a comment 116 - POSTFIX adds '(NEW)', '--->' and selected choice symbol where applicable 117 118 Attributes: 119 120 node: 121 MenuNode instance this ListEntry is created for. 122 123 visible: 124 Whether entry should be shown in main window. 125 126 text: 127 String to display in a main window's Listbox. 128 129 refresh(): 130 Updates .visible and .text attribute values. 131 132 set_tristate_value(): 133 Set value for bool/tristate symbols, value should be one of 0,1,2 or None. 134 Usually it's called when user presses 'y', 'n', 'm' key. 135 136 set_str_value(): 137 Set value for non-bool/tristate symbols, value is a string. Usually called 138 with a value returned by one of MenuConfig.ask_for_* methods. 139 140 toggle(): 141 Toggle bool/tristate symbol value. Called when '<Space>' key is pressed in 142 a main window. Also selects choice value. 143 144 select(): 145 Called when '<Return>' key is pressed in a main window with 'SELECT' 146 action selected. Displays submenu, choice selection menu, or just selects 147 choice value. For non-bool/tristate symbols asks MenuConfig window to 148 handle value input via one of MenuConfig.ask_for_* methods. 149 150 show_help(): 151 Called when '<Return>' key is pressed in a main window with 'HELP' action 152 selected. Prepares text help and calls MenuConfig.show_text() to display 153 text window. 154 """ 155 156 # How to display value of BOOL and TRISTATE symbols 157 TRI_TO_DISPLAY = { 158 0: ' ', 159 1: 'M', 160 2: '*' 161 } 162 163 def __init__(self, mconf, node, indent): 164 self.indent = indent 165 self.node = node 166 self.menuconfig = mconf 167 self.visible = False 168 self.text = None 169 170 def __str__(self): 171 return self.text 172 173 def _is_visible(self): 174 node = self.node 175 v = True 176 v = v and node.prompt is not None 177 # It should be enough to check if prompt expression is not false and 178 # for menu nodes whether 'visible if' is not false 179 v = v and kconfiglib.expr_value(node.prompt[1]) > 0 180 if node.item == kconfiglib.MENU: 181 v = v and kconfiglib.expr_value(node.visibility) > 0 182 # If node references Symbol, then we also account for symbol visibility 183 # TODO: need to re-think whether this is needed 184 if isinstance(node.item, kconfiglib.Symbol): 185 if node.item.type in (kconfiglib.BOOL, kconfiglib.TRISTATE): 186 v = v and len(node.item.assignable) > 0 187 else: 188 v = v and node.item.visibility > 0 189 return v 190 191 def _get_text(self): 192 """ 193 Compute textual representation of menu node (a line in ListView) 194 """ 195 node = self.node 196 item = node.item 197 # Determine prefix 198 prefix = ' ' 199 if (isinstance(item, kconfiglib.Symbol) and item.choice is None or 200 isinstance(item, kconfiglib.Choice) and item.type is kconfiglib.TRISTATE): 201 # The node is for either a symbol outside of choice statement 202 # or a tristate choice 203 if item.type in (kconfiglib.BOOL, kconfiglib.TRISTATE): 204 value = ListEntry.TRI_TO_DISPLAY[item.tri_value] 205 if len(item.assignable) > 1: 206 # Symbol is editable 207 if 1 in item.assignable: 208 prefix = '<{}>'.format(value) 209 else: 210 prefix = '[{}]'.format(value) 211 else: 212 # Symbol is not editable 213 prefix = '-{}-'.format(value) 214 else: 215 prefix = '({})'.format(item.str_value) 216 elif isinstance(item, kconfiglib.Symbol) and item.choice is not None: 217 # The node is for symbol inside choice statement 218 if item.type in (kconfiglib.BOOL, kconfiglib.TRISTATE): 219 value = ListEntry.TRI_TO_DISPLAY[item.tri_value] 220 if len(item.assignable) > 0: 221 # Symbol is editable 222 prefix = '({})'.format(value) 223 else: 224 # Symbol is not editable 225 prefix = '-{}-'.format(value) 226 else: 227 prefix = '({})'.format(item.str_value) 228 229 # Prefix should be at least 3 chars long 230 if len(prefix) < 3: 231 prefix += ' ' * (3 - len(prefix)) 232 # Body 233 body = '' 234 if node.prompt is not None: 235 if item is kconfiglib.COMMENT: 236 body = '*** {} ***'.format(node.prompt[0]) 237 else: 238 body = node.prompt[0] 239 # Suffix 240 is_menu = False 241 is_new = False 242 if (item is kconfiglib.MENU 243 or isinstance(item, kconfiglib.Symbol) and node.is_menuconfig 244 or isinstance(item, kconfiglib.Choice)): 245 is_menu = True 246 if isinstance(item, kconfiglib.Symbol) and item.user_value is None: 247 is_new = True 248 # For symbol inside choice that has 'y' value, '(NEW)' is not displayed 249 if (isinstance(item, kconfiglib.Symbol) 250 and item.choice and item.choice.tri_value == 2): 251 is_new = False 252 # Choice selection - displayed only for choices which have 'y' value 253 choice_selection = None 254 if isinstance(item, kconfiglib.Choice) and node.item.str_value == 'y': 255 choice_selection = '' 256 if item.selection is not None: 257 sym = item.selection 258 if sym.nodes and sym.nodes[0].prompt is not None: 259 choice_selection = sym.nodes[0].prompt[0] 260 text = ' {prefix} {indent}{body}{choice}{new}{menu}'.format( 261 prefix=prefix, 262 indent=' ' * self.indent, 263 body=body, 264 choice='' if choice_selection is None else ' ({})'.format( 265 choice_selection 266 ), 267 new=' (NEW)' if is_new else '', 268 menu=' --->' if is_menu else '' 269 ) 270 return text 271 272 def refresh(self): 273 self.visible = self._is_visible() 274 self.text = self._get_text() 275 276 def set_tristate_value(self, value): 277 """ 278 Call to change value of BOOL, TRISTATE symbols 279 280 It's preferred to use this instead of item.set_value as it handles 281 all necessary interaction with MenuConfig window when symbol value 282 changes 283 284 None value is accepted but ignored 285 """ 286 item = self.node.item 287 if (isinstance(item, (kconfiglib.Symbol, kconfiglib.Choice)) 288 and item.type in (kconfiglib.BOOL, kconfiglib.TRISTATE) 289 and value is not None): 290 if value in item.assignable: 291 item.set_value(value) 292 elif value == 2 and 1 in item.assignable: 293 print( 294 'Symbol {} value is limited to \'m\'. Setting value \'m\' instead of \'y\''.format(item.name), 295 file=sys.stderr 296 ) 297 item.set_value(1) 298 self.menuconfig.mark_as_changed() 299 self.menuconfig.refresh_display() 300 301 def set_str_value(self, value): 302 """ 303 Call to change value of HEX, INT, STRING symbols 304 305 It's preferred to use this instead of item.set_value as it handles 306 all necessary interaction with MenuConfig window when symbol value 307 changes 308 309 None value is accepted but ignored 310 """ 311 item = self.node.item 312 if (isinstance(item, kconfiglib.Symbol) 313 and item.type in (kconfiglib.INT, kconfiglib.HEX, kconfiglib.STRING) 314 and value is not None): 315 item.set_value(value) 316 self.menuconfig.mark_as_changed() 317 self.menuconfig.refresh_display() 318 319 def toggle(self): 320 """ 321 Called when <space> key is pressed 322 """ 323 item = self.node.item 324 if (isinstance(item, (kconfiglib.Symbol, kconfiglib.Choice)) 325 and item.type in (kconfiglib.BOOL, kconfiglib.TRISTATE)): 326 value = item.tri_value 327 # Find next value in Symbol/Choice.assignable, or use assignable[0] 328 try: 329 it = iter(item.assignable) 330 while value != next(it): 331 pass 332 self.set_tristate_value(next(it)) 333 except StopIteration: 334 self.set_tristate_value(item.assignable[0]) 335 336 def select(self): 337 """ 338 Called when <Return> key is pressed and SELECT action is selected 339 """ 340 item = self.node.item 341 # - Menu: dive into submenu 342 # - INT, HEX, STRING symbol: raise prompt to enter symbol value 343 # - BOOL, TRISTATE symbol inside 'y'-valued Choice: set 'y' value 344 if (item is kconfiglib.MENU 345 or isinstance(item, kconfiglib.Symbol) and self.node.is_menuconfig 346 or isinstance(item, kconfiglib.Choice)): 347 # Dive into submenu 348 self.menuconfig.show_submenu(self.node) 349 elif (isinstance(item, kconfiglib.Symbol) and item.type in 350 (kconfiglib.INT, kconfiglib.HEX, kconfiglib.STRING)): 351 # Raise prompt to enter symbol value 352 ident = self.node.prompt[0] if self.node.prompt is not None else None 353 title = 'Symbol: {}'.format(item.name) 354 if item.type is kconfiglib.INT: 355 # Find enabled ranges 356 ranges = [ 357 (int(start.str_value), int(end.str_value)) 358 for start, end, expr in item.ranges 359 if kconfiglib.expr_value(expr) > 0 360 ] 361 # Raise prompt 362 self.set_str_value(str(self.menuconfig.ask_for_int( 363 ident=ident, 364 title=title, 365 value=item.str_value, 366 ranges=ranges 367 ))) 368 elif item.type is kconfiglib.HEX: 369 # Find enabled ranges 370 ranges = [ 371 (int(start.str_value, base=16), int(end.str_value, base=16)) 372 for start, end, expr in item.ranges 373 if kconfiglib.expr_value(expr) > 0 374 ] 375 # Raise prompt 376 self.set_str_value(hex(self.menuconfig.ask_for_hex( 377 ident=ident, 378 title=title, 379 value=item.str_value, 380 ranges=ranges 381 ))) 382 elif item.type is kconfiglib.STRING: 383 # Raise prompt 384 self.set_str_value(self.menuconfig.ask_for_string( 385 ident=ident, 386 title=title, 387 value=item.str_value 388 )) 389 elif (isinstance(item, kconfiglib.Symbol) 390 and item.choice is not None and item.choice.tri_value == 2): 391 # Symbol inside choice -> set symbol value to 'y' 392 self.set_tristate_value(2) 393 394 def show_help(self): 395 node = self.node 396 item = self.node.item 397 if isinstance(item, (kconfiglib.Symbol, kconfiglib.Choice)): 398 title = 'Help for symbol: {}'.format(item.name) 399 if node.help: 400 help = node.help 401 else: 402 help = 'There is no help available for this option.\n' 403 lines = [] 404 lines.append(help) 405 lines.append( 406 'Symbol: {} [={}]'.format( 407 item.name if item.name else '<UNNAMED>', item.str_value 408 ) 409 ) 410 lines.append('Type : {}'.format(kconfiglib.TYPE_TO_STR[item.type])) 411 for n in item.nodes: 412 lines.append('Prompt: {}'.format(n.prompt[0] if n.prompt else '<EMPTY>')) 413 lines.append(' Defined at {}:{}'.format(n.filename, n.linenr)) 414 lines.append(' Depends on: {}'.format(kconfiglib.expr_str(n.dep))) 415 text = '\n'.join(lines) 416 else: 417 title = 'Help' 418 text = 'Help not available for this menu node.\n' 419 self.menuconfig.show_text(text, title) 420 self.menuconfig.refresh_display() 421 422 423class EntryDialog(object): 424 """ 425 Creates modal dialog (top-level Tk window) with labels, entry box and two 426 buttons: OK and CANCEL. 427 """ 428 def __init__(self, master, text, title, ident=None, value=None): 429 self.master = master 430 dlg = self.dlg = tk.Toplevel(master) 431 self.dlg.withdraw() #hiden window 432 dlg.title(title) 433 # Identifier label 434 if ident is not None: 435 self.label_id = tk.Label(dlg, anchor=tk.W, justify=tk.LEFT) 436 self.label_id['font'] = font.nametofont('TkFixedFont') 437 self.label_id['text'] = '# {}'.format(ident) 438 self.label_id.pack(fill=tk.X, padx=2, pady=2) 439 # Label 440 self.label = tk.Label(dlg, anchor=tk.W, justify=tk.LEFT) 441 self.label['font'] = font.nametofont('TkFixedFont') 442 self.label['text'] = text 443 self.label.pack(fill=tk.X, padx=10, pady=4) 444 # Entry box 445 self.entry = tk.Entry(dlg) 446 self.entry['font'] = font.nametofont('TkFixedFont') 447 self.entry.pack(fill=tk.X, padx=2, pady=2) 448 # Frame for buttons 449 self.frame = tk.Frame(dlg) 450 self.frame.pack(padx=2, pady=2) 451 # Button 452 self.btn_accept = tk.Button(self.frame, text='< Ok >', command=self.accept) 453 self.btn_accept['font'] = font.nametofont('TkFixedFont') 454 self.btn_accept.pack(side=tk.LEFT, padx=2) 455 self.btn_cancel = tk.Button(self.frame, text='< Cancel >', command=self.cancel) 456 self.btn_cancel['font'] = font.nametofont('TkFixedFont') 457 self.btn_cancel.pack(side=tk.LEFT, padx=2) 458 # Bind Enter and Esc keys 459 self.dlg.bind('<Return>', self.accept) 460 self.dlg.bind('<Escape>', self.cancel) 461 # Dialog is resizable only by width 462 self.dlg.resizable(1, 0) 463 # Set supplied value (if any) 464 if value is not None: 465 self.entry.insert(0, value) 466 self.entry.selection_range(0, tk.END) 467 # By default returned value is None. To caller this means that entry 468 # process was cancelled 469 self.value = None 470 # Modal dialog 471 dlg.transient(master) 472 dlg.grab_set() 473 # Center dialog window 474 _center_window_above_parent(master, dlg) 475 self.dlg.deiconify() # show window 476 # Focus entry field 477 self.entry.focus_set() 478 479 def accept(self, ev=None): 480 self.value = self.entry.get() 481 self.dlg.destroy() 482 483 def cancel(self, ev=None): 484 self.dlg.destroy() 485 486 487class TextDialog(object): 488 def __init__(self, master, text, title): 489 self.master = master 490 dlg = self.dlg = tk.Toplevel(master) 491 self.dlg.withdraw() #hiden window 492 dlg.title(title) 493 dlg.minsize(600,400) 494 # Text 495 self.text = tk.Text(dlg, height=1) 496 self.text['font'] = font.nametofont('TkFixedFont') 497 self.text.insert(tk.END, text) 498 # Make text read-only 499 self.text['state'] = tk.DISABLED 500 self.text.pack(fill=tk.BOTH, expand=1, padx=4, pady=4) 501 # Frame for buttons 502 self.frame = tk.Frame(dlg) 503 self.frame.pack(padx=2, pady=2) 504 # Button 505 self.btn_accept = tk.Button(self.frame, text='< Ok >', command=self.accept) 506 self.btn_accept['font'] = font.nametofont('TkFixedFont') 507 self.btn_accept.pack(side=tk.LEFT, padx=2) 508 # Bind Enter and Esc keys 509 self.dlg.bind('<Return>', self.accept) 510 self.dlg.bind('<Escape>', self.cancel) 511 # Modal dialog 512 dlg.transient(master) 513 dlg.grab_set() 514 # Center dialog window 515 _center_window_above_parent(master, dlg) 516 self.dlg.deiconify() # show window 517 # Focus entry field 518 self.text.focus_set() 519 520 def accept(self, ev=None): 521 self.dlg.destroy() 522 523 def cancel(self, ev=None): 524 self.dlg.destroy() 525 526 527class MenuConfig(object): 528 ( 529 ACTION_SELECT, 530 ACTION_EXIT, 531 ACTION_HELP, 532 ACTION_LOAD, 533 ACTION_SAVE, 534 ACTION_SAVE_AS 535 ) = range(6) 536 537 ACTIONS = ( 538 ('Select', ACTION_SELECT), 539 ('Exit', ACTION_EXIT), 540 ('Help', ACTION_HELP), 541 ('Load', ACTION_LOAD), 542 ('Save', ACTION_SAVE), 543 ('Save as', ACTION_SAVE_AS), 544 ) 545 546 def __init__(self, kconfig, __silent=None): 547 self.kconfig = kconfig 548 self.__silent = __silent 549 if self.__silent is True: 550 return 551 552 # Instantiate Tk widgets 553 self.root = tk.Tk() 554 self.root.withdraw() #hiden window 555 dlg = self.root 556 557 # Window title 558 dlg.title('pymenuconfig') 559 # Some empirical window size 560 dlg.minsize(500, 300) 561 dlg.geometry('800x600') 562 563 # Label that shows position in menu tree 564 self.label_position = tk.Label( 565 dlg, 566 anchor=tk.W, 567 justify=tk.LEFT, 568 font=font.nametofont('TkFixedFont') 569 ) 570 self.label_position.pack(fill=tk.X, padx=2) 571 572 # 'Tip' frame and text 573 self.frame_tip = tk.LabelFrame( 574 dlg, 575 text='Tip' 576 ) 577 self.label_tip = tk.Label( 578 self.frame_tip, 579 anchor=tk.W, 580 justify=tk.LEFT, 581 font=font.nametofont('TkFixedFont') 582 ) 583 self.label_tip['text'] = '\n'.join([ 584 'Arrow keys navigate the menu. <Enter> performs selected operation (set of buttons at the bottom)', 585 'Pressing <Y> includes, <N> excludes, <M> modularizes features', 586 'Press <Esc> to go one level up. Press <Esc> at top level to exit', 587 'Legend: [*] built-in [ ] excluded <M> module < > module capable' 588 ]) 589 self.label_tip.pack(fill=tk.BOTH, expand=1, padx=4, pady=4) 590 self.frame_tip.pack(fill=tk.X, padx=2) 591 592 # Main ListBox where all the magic happens 593 self.list = tk.Listbox( 594 dlg, 595 selectmode=tk.SINGLE, 596 activestyle=tk.NONE, 597 font=font.nametofont('TkFixedFont'), 598 height=1, 599 ) 600 self.list['foreground'] = 'Blue' 601 self.list['background'] = 'Gray95' 602 # Make selection invisible 603 self.list['selectbackground'] = self.list['background'] 604 self.list['selectforeground'] = self.list['foreground'] 605 self.list.pack(fill=tk.BOTH, expand=1, padx=20, ipadx=2) 606 607 # Frame with radio buttons 608 self.frame_radio = tk.Frame(dlg) 609 self.radio_buttons = [] 610 self.tk_selected_action = tk.IntVar() 611 for text, value in MenuConfig.ACTIONS: 612 btn = tk.Radiobutton( 613 self.frame_radio, 614 variable=self.tk_selected_action, 615 value=value 616 ) 617 btn['text'] = '< {} >'.format(text) 618 btn['font'] = font.nametofont('TkFixedFont') 619 btn['indicatoron'] = 0 620 btn.pack(side=tk.LEFT) 621 self.radio_buttons.append(btn) 622 self.frame_radio.pack(anchor=tk.CENTER, pady=4) 623 # Label with status information 624 self.tk_status = tk.StringVar() 625 self.label_status = tk.Label( 626 dlg, 627 textvariable=self.tk_status, 628 anchor=tk.W, 629 justify=tk.LEFT, 630 font=font.nametofont('TkFixedFont') 631 ) 632 self.label_status.pack(fill=tk.X, padx=4, pady=4) 633 # Center window 634 _center_window(self.root, dlg) 635 self.root.deiconify() # show window 636 # Disable keyboard focus on all widgets ... 637 self._set_option_to_all_children(dlg, 'takefocus', 0) 638 # ... except for main ListBox 639 self.list['takefocus'] = 1 640 self.list.focus_set() 641 # Bind keys 642 dlg.bind('<Escape>', self.handle_keypress) 643 dlg.bind('<space>', self.handle_keypress) 644 dlg.bind('<Return>', self.handle_keypress) 645 dlg.bind('<Right>', self.handle_keypress) 646 dlg.bind('<Left>', self.handle_keypress) 647 dlg.bind('<Up>', self.handle_keypress) 648 dlg.bind('<Down>', self.handle_keypress) 649 dlg.bind('n', self.handle_keypress) 650 dlg.bind('m', self.handle_keypress) 651 dlg.bind('y', self.handle_keypress) 652 # Register callback that's called when window closes 653 dlg.wm_protocol('WM_DELETE_WINDOW', self._close_window) 654 # Init fields 655 self.node = None 656 self.node_stack = [] 657 self.all_entries = [] 658 self.shown_entries = [] 659 self.config_path = None 660 self.unsaved_changes = False 661 self.status_string = 'NEW CONFIG' 662 self.update_status() 663 # Display first child of top level node (the top level node is 'mainmenu') 664 self.show_node(self.kconfig.top_node) 665 666 def _set_option_to_all_children(self, widget, option, value): 667 widget[option] = value 668 for n,c in widget.children.items(): 669 self._set_option_to_all_children(c, option, value) 670 671 def _invert_colors(self, idx): 672 self.list.itemconfig(idx, {'bg' : self.list['foreground']}) 673 self.list.itemconfig(idx, {'fg' : self.list['background']}) 674 675 @property 676 def _selected_entry(self): 677 # type: (...) -> ListEntry 678 active_idx = self.list.index(tk.ACTIVE) 679 if active_idx >= 0 and active_idx < len(self.shown_entries): 680 return self.shown_entries[active_idx] 681 return None 682 683 def _select_node(self, node): 684 # type: (kconfiglib.MenuNode) -> None 685 """ 686 Attempts to select entry that corresponds to given MenuNode in main listbox 687 """ 688 idx = None 689 for i, e in enumerate(self.shown_entries): 690 if e.node is node: 691 idx = i 692 break 693 if idx is not None: 694 self.list.activate(idx) 695 self.list.see(idx) 696 self._invert_colors(idx) 697 698 def handle_keypress(self, ev): 699 keysym = ev.keysym 700 if keysym == 'Left': 701 self._select_action(prev=True) 702 elif keysym == 'Right': 703 self._select_action(prev=False) 704 elif keysym == 'Up': 705 self.refresh_display(reset_selection=False) 706 elif keysym == 'Down': 707 self.refresh_display(reset_selection=False) 708 elif keysym == 'space': 709 self._selected_entry.toggle() 710 elif keysym in ('n', 'm', 'y'): 711 self._selected_entry.set_tristate_value(kconfiglib.STR_TO_TRI[keysym]) 712 elif keysym == 'Return': 713 action = self.tk_selected_action.get() 714 if action == self.ACTION_SELECT: 715 self._selected_entry.select() 716 elif action == self.ACTION_EXIT: 717 self._action_exit() 718 elif action == self.ACTION_HELP: 719 self._selected_entry.show_help() 720 elif action == self.ACTION_LOAD: 721 if self.prevent_losing_changes(): 722 self.open_config() 723 elif action == self.ACTION_SAVE: 724 self.save_config() 725 elif action == self.ACTION_SAVE_AS: 726 self.save_config(force_file_dialog=True) 727 elif keysym == 'Escape': 728 self._action_exit() 729 pass 730 731 def _close_window(self): 732 if self.prevent_losing_changes(): 733 print('Exiting..') 734 if self.__silent is True: 735 return 736 self.root.destroy() 737 738 def _action_exit(self): 739 if self.node_stack: 740 self.show_parent() 741 else: 742 self._close_window() 743 744 def _select_action(self, prev=False): 745 # Determine the radio button that's activated 746 action = self.tk_selected_action.get() 747 if prev: 748 action -= 1 749 else: 750 action += 1 751 action %= len(MenuConfig.ACTIONS) 752 self.tk_selected_action.set(action) 753 754 def _collect_list_entries(self, start_node, indent=0): 755 """ 756 Given first MenuNode of nodes list at some level in menu hierarchy, 757 collects nodes that may be displayed when viewing and editing that 758 hierarchy level. Includes implicit menu nodes, i.e. the ones dependent 759 on 'config' entry via 'if' statement which are internally represented 760 as children of their dependency 761 """ 762 entries = [] 763 n = start_node 764 while n is not None: 765 entries.append(ListEntry(self, n, indent)) 766 # If node refers to a symbol (X) and has children, it is either 767 # 'config' or 'menuconfig'. The children are items inside 'if X' 768 # block that immediately follows 'config' or 'menuconfig' entry. 769 # If it's a 'menuconfig' then corresponding MenuNode is shown as a 770 # regular menu entry. But if it's a 'config', then its children need 771 # to be shown in the same list with their texts indented 772 if (n.list is not None 773 and isinstance(n.item, kconfiglib.Symbol) 774 and n.is_menuconfig == False): 775 entries.extend( 776 self._collect_list_entries(n.list, indent=indent + 1) 777 ) 778 n = n.next 779 return entries 780 781 def refresh_display(self, reset_selection=False): 782 # Refresh list entries' attributes 783 for e in self.all_entries: 784 e.refresh() 785 # Try to preserve selection upon refresh 786 selected_entry = self._selected_entry 787 # Also try to preserve listbox scroll offset 788 # If not preserved, the see() method will make wanted item to appear 789 # at the bottom of the list, even if previously it was in center 790 scroll_offset = self.list.yview()[0] 791 # Show only visible entries 792 self.shown_entries = [e for e in self.all_entries if e.visible] 793 # Refresh listbox contents 794 self.list.delete(0, tk.END) 795 self.list.insert(0, *self.shown_entries) 796 if selected_entry and not reset_selection: 797 # Restore scroll position 798 self.list.yview_moveto(scroll_offset) 799 # Activate previously selected node 800 self._select_node(selected_entry.node) 801 else: 802 # Select the topmost entry 803 self.list.activate(0) 804 self._invert_colors(0) 805 # Select ACTION_SELECT on each refresh (mimic C menuconfig) 806 self.tk_selected_action.set(self.ACTION_SELECT) 807 # Display current location in configuration tree 808 pos = [] 809 for n in self.node_stack + [self.node]: 810 pos.append(n.prompt[0] if n.prompt else '[none]') 811 self.label_position['text'] = u'# ' + u' -> '.join(pos) 812 813 def show_node(self, node): 814 self.node = node 815 if node.list is not None: 816 self.all_entries = self._collect_list_entries(node.list) 817 else: 818 self.all_entries = [] 819 self.refresh_display(reset_selection=True) 820 821 def show_submenu(self, node): 822 self.node_stack.append(self.node) 823 self.show_node(node) 824 825 def show_parent(self): 826 if self.node_stack: 827 select_node = self.node 828 parent_node = self.node_stack.pop() 829 self.show_node(parent_node) 830 # Restore previous selection 831 self._select_node(select_node) 832 self.refresh_display(reset_selection=False) 833 834 def ask_for_string(self, ident=None, title='Enter string', value=None): 835 """ 836 Raises dialog with text entry widget and asks user to enter string 837 838 Return: 839 - str - user entered string 840 - None - entry was cancelled 841 """ 842 text = 'Please enter a string value\n' \ 843 'User <Enter> key to accept the value\n' \ 844 'Use <Esc> key to cancel entry\n' 845 d = EntryDialog(self.root, text, title, ident=ident, value=value) 846 self.root.wait_window(d.dlg) 847 self.list.focus_set() 848 return d.value 849 850 def ask_for_int(self, ident=None, title='Enter integer value', value=None, ranges=()): 851 """ 852 Raises dialog with text entry widget and asks user to enter decimal number 853 Ranges should be iterable of tuples (start, end), 854 where 'start' and 'end' specify allowed value range (inclusively) 855 856 Return: 857 - int - when valid number that falls within any one of specified ranges is entered 858 - None - invalid number or entry was cancelled 859 """ 860 text = 'Please enter a decimal value. Fractions will not be accepted\n' \ 861 'User <Enter> key to accept the value\n' \ 862 'Use <Esc> key to cancel entry\n' 863 d = EntryDialog(self.root, text, title, ident=ident, value=value) 864 self.root.wait_window(d.dlg) 865 self.list.focus_set() 866 ivalue = None 867 if d.value: 868 try: 869 ivalue = int(d.value) 870 except ValueError: 871 messagebox.showerror('Bad value', 'Entered value \'{}\' is not an integer'.format(d.value)) 872 if ivalue is not None and ranges: 873 allowed = False 874 for start, end in ranges: 875 allowed = allowed or start <= ivalue and ivalue <= end 876 if not allowed: 877 messagebox.showerror( 878 'Bad value', 879 'Entered value \'{:d}\' is out of range\n' 880 'Allowed:\n{}'.format( 881 ivalue, 882 '\n'.join([' {:d} - {:d}'.format(s,e) for s,e in ranges]) 883 ) 884 ) 885 ivalue = None 886 return ivalue 887 888 def ask_for_hex(self, ident=None, title='Enter hexadecimal value', value=None, ranges=()): 889 """ 890 Raises dialog with text entry widget and asks user to enter decimal number 891 Ranges should be iterable of tuples (start, end), 892 where 'start' and 'end' specify allowed value range (inclusively) 893 894 Return: 895 - int - when valid number that falls within any one of specified ranges is entered 896 - None - invalid number or entry was cancelled 897 """ 898 text = 'Please enter a hexadecimal value\n' \ 899 'User <Enter> key to accept the value\n' \ 900 'Use <Esc> key to cancel entry\n' 901 d = EntryDialog(self.root, text, title, ident=ident, value=value) 902 self.root.wait_window(d.dlg) 903 self.list.focus_set() 904 hvalue = None 905 if d.value: 906 try: 907 hvalue = int(d.value, base=16) 908 except ValueError: 909 messagebox.showerror('Bad value', 'Entered value \'{}\' is not a hexadecimal value'.format(d.value)) 910 if hvalue is not None and ranges: 911 allowed = False 912 for start, end in ranges: 913 allowed = allowed or start <= hvalue and hvalue <= end 914 if not allowed: 915 messagebox.showerror( 916 'Bad value', 917 'Entered value \'0x{:x}\' is out of range\n' 918 'Allowed:\n{}'.format( 919 hvalue, 920 '\n'.join([' 0x{:x} - 0x{:x}'.format(s,e) for s,e in ranges]) 921 ) 922 ) 923 hvalue = None 924 return hvalue 925 926 def show_text(self, text, title='Info'): 927 """ 928 Raises dialog with read-only text view that contains supplied text 929 """ 930 d = TextDialog(self.root, text, title) 931 self.root.wait_window(d.dlg) 932 self.list.focus_set() 933 934 def mark_as_changed(self): 935 """ 936 Marks current config as having unsaved changes 937 Should be called whenever config value is changed 938 """ 939 self.unsaved_changes = True 940 self.update_status() 941 942 def set_status_string(self, status): 943 """ 944 Sets status string displayed at the bottom of the window 945 """ 946 self.status_string = status 947 self.update_status() 948 949 def update_status(self): 950 """ 951 Updates status bar display 952 Status bar displays: 953 - unsaved status 954 - current config path 955 - status string (see set_status_string()) 956 """ 957 if self.__silent is True: 958 return 959 self.tk_status.set('{} [{}] {}'.format( 960 '<UNSAVED>' if self.unsaved_changes else '', 961 self.config_path if self.config_path else '', 962 self.status_string 963 )) 964 965 def _check_is_visible(self, node): 966 v = True 967 v = v and node.prompt is not None 968 # It should be enough to check if prompt expression is not false and 969 # for menu nodes whether 'visible if' is not false 970 v = v and kconfiglib.expr_value(node.prompt[1]) > 0 971 if node.item == kconfiglib.MENU: 972 v = v and kconfiglib.expr_value(node.visibility) > 0 973 # If node references Symbol, then we also account for symbol visibility 974 # TODO: need to re-think whether this is needed 975 if isinstance(node.item, kconfiglib.Symbol): 976 if node.item.type in (kconfiglib.BOOL, kconfiglib.TRISTATE): 977 v = v and len(node.item.assignable) > 0 978 else: 979 v = v and node.item.visibility > 0 980 return v 981 982 def config_is_changed(self): 983 is_changed = False 984 node = self.kconfig.top_node.list 985 if not node: 986 # Empty configuration 987 return is_changed 988 989 while 1: 990 item = node.item 991 if isinstance(item, kconfiglib.Symbol) and item.user_value is None and self._check_is_visible(node): 992 is_changed = True 993 print("Config \"# {}\" has changed, need save config file\n".format(node.prompt[0])) 994 break; 995 996 # Iterative tree walk using parent pointers 997 998 if node.list: 999 node = node.list 1000 elif node.next: 1001 node = node.next 1002 else: 1003 while node.parent: 1004 node = node.parent 1005 if node.next: 1006 node = node.next 1007 break 1008 else: 1009 break 1010 return is_changed 1011 1012 def prevent_losing_changes(self): 1013 """ 1014 Checks if there are unsaved changes and asks user to save or discard them 1015 This routine should be called whenever current config is going to be discarded 1016 1017 Raises the usual 'Yes', 'No', 'Cancel' prompt. 1018 1019 Return: 1020 - True: caller may safely drop current config state 1021 - False: user needs to continue work on current config ('Cancel' pressed or saving failed) 1022 """ 1023 if self.config_is_changed() == True: 1024 self.mark_as_changed() 1025 if not self.unsaved_changes: 1026 return True 1027 1028 if self.__silent: 1029 saved = self.save_config() 1030 return saved 1031 res = messagebox.askyesnocancel( 1032 parent=self.root, 1033 title='Unsaved changes', 1034 message='Config has unsaved changes. Do you want to save them?' 1035 ) 1036 if res is None: 1037 return False 1038 elif res is False: 1039 return True 1040 # Otherwise attempt to save config and succeed only if config has been saved successfully 1041 saved = self.save_config() 1042 return saved 1043 1044 def open_config(self, path=None): 1045 if path is None: 1046 # Create open dialog. Either existing file is selected or no file is selected as a result 1047 path = filedialog.askopenfilename( 1048 parent=self.root, 1049 title='Open config..', 1050 initialdir=os.path.dirname(self.config_path) if self.config_path else os.getcwd(), 1051 filetypes=(('.config files', '*.config'), ('All files', '*.*')) 1052 ) 1053 if not path or not os.path.isfile(path): 1054 return False 1055 path = os.path.abspath(path) 1056 print('Loading config: \'{}\''.format(path)) 1057 # Try to open given path 1058 # If path does not exist, we still set current config path to it but don't load anything 1059 self.unsaved_changes = False 1060 self.config_path = path 1061 if not os.path.exists(path): 1062 self.set_status_string('New config') 1063 self.mark_as_changed() 1064 return True 1065 # Load config and set status accordingly 1066 try: 1067 self.kconfig.load_config(path) 1068 except IOError as e: 1069 self.set_status_string('Failed to load: \'{}\''.format(path)) 1070 if not self.__silent: 1071 self.refresh_display() 1072 print('Failed to load config \'{}\': {}'.format(path, e)) 1073 return False 1074 self.set_status_string('Opened config') 1075 if not self.__silent: 1076 self.refresh_display() 1077 return True 1078 1079 def save_config(self, force_file_dialog=False): 1080 path = self.config_path 1081 if path is None or force_file_dialog: 1082 path = filedialog.asksaveasfilename( 1083 parent=self.root, 1084 title='Save config as..', 1085 initialdir=os.path.dirname(self.config_path) if self.config_path else os.getcwd(), 1086 initialfile=os.path.basename(self.config_path) if self.config_path else None, 1087 defaultextension='.config', 1088 filetypes=(('.config files', '*.config'), ('All files', '*.*')) 1089 ) 1090 if not path: 1091 return False 1092 path = os.path.abspath(path) 1093 print('Saving config: \'{}\''.format(path)) 1094 # Try to save config to selected path 1095 try: 1096 self.kconfig.write_config(path, header="#\n# Automatically generated file; DO NOT EDIT.\n") 1097 self.unsaved_changes = False 1098 self.config_path = path 1099 self.set_status_string('Saved config') 1100 except IOError as e: 1101 self.set_status_string('Failed to save: \'{}\''.format(path)) 1102 print('Save failed: {}'.format(e), file=sys.stderr) 1103 return False 1104 return True 1105 1106 1107def _center_window(root, window): 1108 # type: (tk.Tk, tk.Toplevel) -> None 1109 """ 1110 Attempts to center window on screen 1111 """ 1112 root.update_idletasks() 1113 # root.eval('tk::PlaceWindow {!s} center'.format( 1114 # window.winfo_pathname(window.winfo_id()) 1115 # )) 1116 w = window.winfo_width() 1117 h = window.winfo_height() 1118 ws = window.winfo_screenwidth() 1119 hs = window.winfo_screenheight() 1120 x = (ws / 2) - (w / 2) 1121 y = (hs / 2) - (h / 2) 1122 window.geometry('+{:d}+{:d}'.format(int(x), int(y))) 1123 window.lift() 1124 window.focus_force() 1125 1126 1127def _center_window_above_parent(root, window): 1128 # type: (tk.Tk, tk.Toplevel) -> None 1129 """ 1130 Attempts to center window above its parent window 1131 """ 1132 # root.eval('tk::PlaceWindow {!s} center'.format( 1133 # window.winfo_pathname(window.winfo_id()) 1134 # )) 1135 root.update_idletasks() 1136 parent = window.master 1137 w = window.winfo_width() 1138 h = window.winfo_height() 1139 px = parent.winfo_rootx() 1140 py = parent.winfo_rooty() 1141 pw = parent.winfo_width() 1142 ph = parent.winfo_height() 1143 x = px + (pw / 2) - (w / 2) 1144 y = py + (ph / 2) - (h / 2) 1145 window.geometry('+{:d}+{:d}'.format(int(x), int(y))) 1146 window.lift() 1147 window.focus_force() 1148 1149 1150def main(argv=None): 1151 if argv is None: 1152 argv = sys.argv[1:] 1153 # Instantiate cmd options parser 1154 parser = argparse.ArgumentParser( 1155 description='Interactive Kconfig configuration editor' 1156 ) 1157 parser.add_argument( 1158 '--kconfig', 1159 metavar='FILE', 1160 type=str, 1161 default='Kconfig', 1162 help='path to root Kconfig file' 1163 ) 1164 parser.add_argument( 1165 '--config', 1166 metavar='FILE', 1167 type=str, 1168 help='path to .config file to load' 1169 ) 1170 if "--silent" in argv: 1171 parser.add_argument( 1172 '--silent', 1173 dest = '_silent_', 1174 type=str, 1175 help='silent mode, not show window' 1176 ) 1177 args = parser.parse_args(argv) 1178 kconfig_path = args.kconfig 1179 config_path = args.config 1180 # Verify that Kconfig file exists 1181 if not os.path.isfile(kconfig_path): 1182 raise RuntimeError('\'{}\': no such file'.format(kconfig_path)) 1183 1184 # Parse Kconfig files 1185 kconf = kconfiglib.Kconfig(filename=kconfig_path) 1186 1187 if "--silent" not in argv: 1188 print("In normal mode. Will show menuconfig window.") 1189 mc = MenuConfig(kconf) 1190 # If config file was specified, load it 1191 if config_path: 1192 mc.open_config(config_path) 1193 1194 print("Enter mainloop. Waiting...") 1195 tk.mainloop() 1196 else: 1197 print("In silent mode. Don`t show menuconfig window.") 1198 mc = MenuConfig(kconf, True) 1199 # If config file was specified, load it 1200 if config_path: 1201 mc.open_config(config_path) 1202 mc._close_window() 1203 1204 1205if __name__ == '__main__': 1206 main() 1207