pymenuconfig.py 43 KB

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