No Description
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

climenu.py 13KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. # Written by Sebastian Lohff <seba@someserver.de>
  2. # Licensed under Apache License 2.0
  3. from prompt_toolkit.application import Application
  4. from prompt_toolkit.buffer import Buffer
  5. from prompt_toolkit.document import Document
  6. from prompt_toolkit.filters import is_searching
  7. from prompt_toolkit.key_binding import KeyBindings
  8. from prompt_toolkit.layout import Layout, Window, HSplit
  9. from prompt_toolkit.layout.controls import BufferControl
  10. from prompt_toolkit.layout.processors import Processor, Transformation
  11. from prompt_toolkit import search
  12. from prompt_toolkit.widgets import SearchToolbar
  13. class _CliMenuHeader:
  14. """Hold a menu header"""
  15. def __init__(self, text, indent=False):
  16. self.text = text
  17. self.indent = indent
  18. self.focusable = False
  19. class _CliMenuOption:
  20. """Hold a menu option"""
  21. def __init__(self, text, num, item=None):
  22. self.text = text
  23. self.num = num
  24. self.item = item
  25. self.focusable = True
  26. class CliMenuCursor:
  27. """Collection of cursors pointing at the active menu item"""
  28. BULLET = '●'
  29. TRIANGLE = '▶'
  30. ASCII_STAR = '*'
  31. ASCII_ARROW = '-->'
  32. ASCII_CAT = '=^.^='
  33. CAT = '😸'
  34. ARROW = '→'
  35. class CliMenuStyle:
  36. """Style for a menu
  37. Allows to select header, option and selected option color
  38. """
  39. def __init__(self, option_style='', highlight_style='', header_style=''):
  40. self.option_style = option_style
  41. self.highlight_style = highlight_style
  42. self.header_style = header_style
  43. class CliSelectionStyle:
  44. SQUARE_BRACKETS = ('[x]', '[ ]')
  45. ROUND_BRACKETS = ('(x)', '( )')
  46. CHECKMARK = ('✔', '✖')
  47. THUMBS = ('👍', '👎')
  48. SMILEY = ('🙂', '🙁')
  49. SMILEY_EXTREME = ('😁', '😨')
  50. class CliMenuTheme:
  51. BASIC = CliMenuStyle()
  52. BASIC_BOLD = CliMenuStyle(header_style='bold', highlight_style='bold')
  53. RED = CliMenuStyle('#aa0000', '#ee0000', '#aa0000')
  54. CYAN = CliMenuStyle('cyan', 'lightcyan', 'cyan')
  55. BLUE = CliMenuStyle('ansiblue', 'ansired', 'ansiblue')
  56. ANSI_CYAN = CliMenuStyle('ansicyan', 'ansibrightcyan', 'ansicyan')
  57. BOLD_HIGHLIGHT = CliMenuStyle(header_style='bold', highlight_style='bold fg:black bg:white')
  58. class CliMenu:
  59. default_style = CliMenuTheme.BASIC
  60. default_cursor = CliMenuCursor.TRIANGLE
  61. @classmethod
  62. def set_default_style(cls, style):
  63. cls.default_style = style
  64. @classmethod
  65. def set_default_cursor(cls, cursor):
  66. cls.default_cursor = cursor
  67. def __init__(self, options=None, header=None, cursor=None, style=None,
  68. indent=2, dedent_selection=False, initial_pos=0):
  69. self._items = []
  70. self._item_num = 0
  71. self._ran = False
  72. self._success = None
  73. self._pos = 0
  74. self._initial_pos = initial_pos
  75. self._option_indent = indent
  76. self._header_indent = indent
  77. self._dedent_selection = dedent_selection
  78. self._cursor = cursor if cursor is not None else self.default_cursor
  79. self._style = style if style is not None else self.default_style
  80. if header:
  81. self.add_text(header, indent=False)
  82. if options:
  83. for option in options:
  84. if isinstance(option, tuple):
  85. self.add_option(*option)
  86. elif isinstance(option, dict):
  87. self.add_option(**option)
  88. elif isinstance(option, str):
  89. self.add_option(option, option)
  90. else:
  91. raise ValueError("Option needs to be either tuple, dict or string, found '{}' of type {}"
  92. .format(option, type(option)))
  93. def add_header(self, *args, **kwargs):
  94. return self.add_text(*args, **kwargs)
  95. def add_text(self, title, indent=True):
  96. for text in title.split('\n'):
  97. self._items.append(_CliMenuHeader(text, indent=indent))
  98. def add_option(self, text, item=None):
  99. self._items.append(_CliMenuOption(text, self._item_num, item=item))
  100. self._item_num += 1
  101. @property
  102. def success(self):
  103. if not self._ran:
  104. self._run()
  105. return self._success
  106. def get_options(self):
  107. return [_item for _item in self._items if isinstance(_item, _CliMenuOption)]
  108. @property
  109. def num_options(self):
  110. return self._item_num
  111. def get_selection(self):
  112. if self.success:
  113. item = self._items[self._pos]
  114. return (item.num, item.item)
  115. else:
  116. return (None, None)
  117. def get_selection_num(self):
  118. return self.get_selection()[0]
  119. def get_selection_item(self):
  120. return self.get_selection()[1]
  121. def _transform_prefix(self, item, lineno, prefix):
  122. return prefix
  123. def _transform_line(self, ti):
  124. if len(list(ti.fragments)) == 0:
  125. return Transformation(ti.fragments)
  126. style, text = list(ti.fragments)[0]
  127. item = self._items[ti.lineno]
  128. s = self._style
  129. # cursor
  130. indent = ''
  131. prefix = ''
  132. if item.focusable:
  133. indent += ' ' * self._option_indent
  134. if ti.lineno == self._pos:
  135. prefix += '{} '.format(self._cursor)
  136. style = s.highlight_style
  137. else:
  138. prefix += ' ' * (len(self._cursor) + 1 + 1 * self._dedent_selection)
  139. style = s.option_style
  140. else:
  141. if item.indent:
  142. indent += ' ' * (self._header_indent + len(self._cursor) + 1)
  143. style = s.header_style
  144. items = [(s if s else style, t) for s, t in ti.fragments]
  145. prefix = self._transform_prefix(item, ti.lineno, prefix)
  146. return Transformation([('', indent), (style, prefix)] + items)
  147. def next_item(self, direction):
  148. if not any(item.focusable for item in self._items):
  149. raise RuntimeError("No focusable item found")
  150. while True:
  151. self._pos = (self._pos + direction) % len(self._items)
  152. # move cursor of buffer along with the selected option
  153. self._buf.cursor_position = self._doc.translate_row_col_to_index(self._pos, 0)
  154. if self._items[self._pos].focusable:
  155. break
  156. def sync_cursor_to_line(self, line, sync_dir=1):
  157. """Sync cursor to next fousable item starting on `line`"""
  158. assert sync_dir in (1, -1)
  159. self._pos = line
  160. while not self._items[self._pos].focusable:
  161. self._pos = (self._pos + sync_dir) % len(self._items)
  162. self._buf.cursor_position = self._doc.translate_row_col_to_index(self._pos, 0)
  163. def _get_search_result_lines(self):
  164. """Get a list of all lines that have a match with the current search result"""
  165. if not self._bufctrl.search_state.text:
  166. return []
  167. idx_list = []
  168. i = 1
  169. while True:
  170. next_idx = self._buf.get_search_position(self._bufctrl.search_state, count=i,
  171. include_current_position=False)
  172. if next_idx in idx_list:
  173. break
  174. idx_list.append(next_idx)
  175. i += 1
  176. lines = []
  177. for idx in idx_list:
  178. line, _ = self._doc.translate_index_to_position(idx)
  179. if line not in lines:
  180. lines.append(line)
  181. return lines
  182. def _register_extra_kb_cbs(self, kb):
  183. pass
  184. def _preflight(self):
  185. if self._initial_pos < 0 or self._initial_pos >= self._item_num:
  186. raise ValueError("Initial position {} is out of range, needs to be in range of [0, {})"
  187. .format(self._initial_pos, self._item_num))
  188. def _accept(self, event):
  189. self._success = True
  190. event.app.exit()
  191. def _run(self):
  192. self._preflight()
  193. class MenuColorizer(Processor):
  194. def apply_transformation(_self, ti):
  195. return self._transform_line(ti)
  196. # keybindings
  197. self._kb = KeyBindings()
  198. @self._kb.add('q', filter=~is_searching)
  199. @self._kb.add('c-c')
  200. def quit(event):
  201. event.app.exit()
  202. @self._kb.add('down', filter=~is_searching)
  203. @self._kb.add('j', filter=~is_searching)
  204. def down(event):
  205. self.next_item(1)
  206. @self._kb.add('up', filter=~is_searching)
  207. @self._kb.add('k', filter=~is_searching)
  208. def up(event):
  209. self.next_item(-1)
  210. @self._kb.add('N', filter=~is_searching)
  211. @self._kb.add('n', filter=~is_searching)
  212. def search_inc(event):
  213. if not self._bufctrl.search_state.text:
  214. return
  215. search_dir = 1 if event.data == 'n' else -1
  216. sr_lines = self._get_search_result_lines()
  217. if sr_lines:
  218. line = sr_lines[search_dir] if len(sr_lines) > 1 else sr_lines[0]
  219. self.sync_cursor_to_line(line, search_dir)
  220. @self._kb.add('c-m', filter=~is_searching)
  221. @self._kb.add('right', filter=~is_searching)
  222. def accept(event):
  223. self._accept(event)
  224. @self._kb.add('c-m', filter=is_searching)
  225. def accept_search(event):
  226. search.accept_search()
  227. new_line, _ = self._doc.translate_index_to_position(self._buf.cursor_position)
  228. self.sync_cursor_to_line(new_line)
  229. self._register_extra_kb_cbs(self._kb)
  230. self._searchbar = SearchToolbar(ignore_case=True)
  231. text = '\n'.join(map(lambda _x: _x.text, self._items))
  232. self._doc = Document(text, cursor_position=self._pos)
  233. self._buf = Buffer(read_only=True, document=self._doc)
  234. self._bufctrl = BufferControl(self._buf,
  235. search_buffer_control=self._searchbar.control,
  236. preview_search=True,
  237. input_processors=[MenuColorizer()])
  238. split = HSplit([Window(self._bufctrl,
  239. wrap_lines=True,
  240. always_hide_cursor=True),
  241. self._searchbar])
  242. # set initial pos
  243. for _ in range(self._initial_pos + 1):
  244. self.next_item(1)
  245. app = Application(layout=Layout(split),
  246. key_bindings=self._kb,
  247. full_screen=False,
  248. mouse_support=False)
  249. app.run()
  250. self._ran = True
  251. class CliMultiMenu(CliMenu):
  252. default_selection_icons = CliSelectionStyle.SQUARE_BRACKETS
  253. @classmethod
  254. def set_default_selector_icons(cls, selection_icons):
  255. cls.default_selection_icons = selection_icons
  256. def __init__(self, *args, selection_icons=None, min_selection_count=0, **kwargs):
  257. self._multi_selected = []
  258. self._min_selection_count = min_selection_count
  259. self._selection_icons = selection_icons if selection_icons is not None else self.default_selection_icons
  260. super().__init__(*args, **kwargs)
  261. def add_option(self, text, item=None, selected=False):
  262. super().add_option(text, item)
  263. if selected:
  264. self._multi_selected.append(len(self._items) - 1)
  265. def get_selection(self):
  266. if self.success:
  267. return [(self._items[n].num, self._items[n].item) for n in self._multi_selected]
  268. else:
  269. return None
  270. def get_selection_num(self):
  271. if self.success:
  272. return [self._items[n].num for n in self._multi_selected]
  273. else:
  274. return None
  275. def get_selection_item(self):
  276. if self.success:
  277. return [self._items[n].item for n in self._multi_selected]
  278. else:
  279. return None
  280. def _register_extra_kb_cbs(self, kb):
  281. @kb.add('space', filter=~is_searching)
  282. @kb.add('right', filter=~is_searching)
  283. def mark(event):
  284. if self._pos not in self._multi_selected:
  285. self._multi_selected.append(self._pos)
  286. else:
  287. self._multi_selected.remove(self._pos)
  288. def _transform_prefix(self, item, lineno, prefix):
  289. if item.focusable:
  290. if lineno in self._multi_selected:
  291. icon = self._selection_icons[0]
  292. else:
  293. icon = self._selection_icons[1]
  294. return "{}{} ".format(prefix, icon)
  295. else:
  296. return prefix
  297. def _preflight(self):
  298. super()._preflight()
  299. if self._min_selection_count > self._item_num:
  300. raise ValueError("A minimum of {} items was requested for successful selection but only {} exist"
  301. .format(self._min_selection_count, self._item_num))
  302. def _accept(self, event):
  303. if len(self._multi_selected) >= self._min_selection_count:
  304. super()._accept(event)
  305. def cli_select_item(options, header=None, abort_exc=ValueError, abort_text="Selection aborted.", style=None,
  306. return_single=True):
  307. """Helper function to quickly get a selection with just a few arguments"""
  308. menu = CliMenu(header=header, options=options, style=style)
  309. if return_single and menu.num_options == 1:
  310. item = menu.get_options()[0]
  311. return item.num, item.item
  312. if not menu.success:
  313. raise abort_exc(abort_text)
  314. return menu.get_selection()