xref: /nrf52832-nimble/rt-thread/tools/pymenuconfig.py (revision 104654410c56c573564690304ae786df310c91fc)
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