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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  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='', highlighted='', text=''):
  40. self.option = option
  41. self.highlighted = highlighted
  42. self.text = text
  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(text='bold', highlighted='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(text='bold', highlighted='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. option_prefix=' ', option_suffix=''):
  70. self._items = []
  71. self._item_num = 0
  72. self._ran = False
  73. self._success = None
  74. self._pos = 0
  75. self._initial_pos = initial_pos
  76. self._option_prefix = option_prefix
  77. self._option_suffix = option_suffix
  78. self._option_indent = indent
  79. self._header_indent = indent
  80. self._dedent_selection = dedent_selection
  81. self._cursor = cursor if cursor is not None else self.default_cursor
  82. self._style = style if style is not None else self.default_style
  83. if header:
  84. self.add_text(header, indent=False)
  85. if options:
  86. for option in options:
  87. if isinstance(option, tuple):
  88. self.add_option(*option)
  89. elif isinstance(option, dict):
  90. self.add_option(**option)
  91. elif isinstance(option, str):
  92. self.add_option(option, option)
  93. else:
  94. raise ValueError("Option needs to be either tuple, dict or string, found '{}' of type {}"
  95. .format(option, type(option)))
  96. def add_header(self, *args, **kwargs):
  97. return self.add_text(*args, **kwargs)
  98. def add_text(self, title, indent=True):
  99. for text in title.split('\n'):
  100. self._items.append(_CliMenuHeader(text, indent=indent))
  101. def add_option(self, text, item=None):
  102. self._items.append(_CliMenuOption(text, self._item_num, item=item))
  103. self._item_num += 1
  104. @property
  105. def success(self):
  106. if not self._ran:
  107. self._run()
  108. return self._success
  109. def get_options(self):
  110. return [_item for _item in self._items if isinstance(_item, _CliMenuOption)]
  111. @property
  112. def num_options(self):
  113. return self._item_num
  114. def get_selection(self):
  115. if self.success:
  116. item = self._items[self._pos]
  117. return (item.num, item.item)
  118. else:
  119. return (None, None)
  120. def get_selection_num(self):
  121. return self.get_selection()[0]
  122. def get_selection_item(self):
  123. return self.get_selection()[1]
  124. def _transform_prefix(self, item, lineno, prefix):
  125. return prefix
  126. def _transform_line(self, ti):
  127. if len(list(ti.fragments)) == 0:
  128. return Transformation(ti.fragments)
  129. style, text = list(ti.fragments)[0]
  130. item = self._items[ti.lineno]
  131. s = self._style
  132. # cursor
  133. indent = ''
  134. prefix = ''
  135. suffix = ''
  136. if item.focusable:
  137. indent += ' ' * self._option_indent
  138. suffix = self._option_suffix
  139. if ti.lineno == self._pos:
  140. prefix += '{}{}'.format(self._cursor, self._option_prefix)
  141. style = s.highlighted
  142. else:
  143. prefix += ' ' * len(self._cursor) + self._option_prefix + ' ' * self._dedent_selection
  144. style = s.option
  145. else:
  146. if item.indent:
  147. indent += ' ' * (self._header_indent + len(self._cursor) + 1)
  148. style = s.text
  149. items = [(s if s else style, t) for s, t in ti.fragments]
  150. prefix = self._transform_prefix(item, ti.lineno, prefix)
  151. return Transformation([('', indent), (style, prefix)] + items + [(style, suffix)])
  152. def next_item(self, direction):
  153. if not any(item.focusable for item in self._items):
  154. raise RuntimeError("No focusable item found")
  155. while True:
  156. self._pos = (self._pos + direction) % len(self._items)
  157. # move cursor of buffer along with the selected option
  158. self._buf.cursor_position = self._doc.translate_row_col_to_index(self._pos, 0)
  159. if self._items[self._pos].focusable:
  160. break
  161. def sync_cursor_to_line(self, line, sync_dir=1):
  162. """Sync cursor to next fousable item starting on `line`"""
  163. assert sync_dir in (1, -1)
  164. self._pos = line
  165. while not self._items[self._pos].focusable:
  166. self._pos = (self._pos + sync_dir) % len(self._items)
  167. self._buf.cursor_position = self._doc.translate_row_col_to_index(self._pos, 0)
  168. def _get_search_result_lines(self):
  169. """Get a list of all lines that have a match with the current search result"""
  170. if not self._bufctrl.search_state.text:
  171. return []
  172. idx_list = []
  173. i = 1
  174. while True:
  175. next_idx = self._buf.get_search_position(self._bufctrl.search_state, count=i,
  176. include_current_position=False)
  177. if next_idx in idx_list:
  178. break
  179. idx_list.append(next_idx)
  180. i += 1
  181. lines = []
  182. for idx in idx_list:
  183. line, _ = self._doc.translate_index_to_position(idx)
  184. if line not in lines:
  185. lines.append(line)
  186. return lines
  187. def _register_extra_kb_cbs(self, kb):
  188. pass
  189. def _preflight(self):
  190. if self._initial_pos < 0 or self._initial_pos >= self._item_num:
  191. raise ValueError("Initial position {} is out of range, needs to be in range of [0, {})"
  192. .format(self._initial_pos, self._item_num))
  193. def _accept(self, event):
  194. self._success = True
  195. event.app.exit()
  196. def _run(self):
  197. self._preflight()
  198. class MenuColorizer(Processor):
  199. def apply_transformation(_self, ti):
  200. return self._transform_line(ti)
  201. # keybindings
  202. self._kb = KeyBindings()
  203. @self._kb.add('q', filter=~is_searching)
  204. @self._kb.add('c-c')
  205. def quit(event):
  206. event.app.exit()
  207. @self._kb.add('down', filter=~is_searching)
  208. @self._kb.add('j', filter=~is_searching)
  209. def down(event):
  210. self.next_item(1)
  211. @self._kb.add('up', filter=~is_searching)
  212. @self._kb.add('k', filter=~is_searching)
  213. def up(event):
  214. self.next_item(-1)
  215. @self._kb.add('N', filter=~is_searching)
  216. @self._kb.add('n', filter=~is_searching)
  217. def search_inc(event):
  218. if not self._bufctrl.search_state.text:
  219. return
  220. search_dir = 1 if event.data == 'n' else -1
  221. sr_lines = self._get_search_result_lines()
  222. if sr_lines:
  223. line = sr_lines[search_dir] if len(sr_lines) > 1 else sr_lines[0]
  224. self.sync_cursor_to_line(line, search_dir)
  225. @self._kb.add('c-m', filter=~is_searching)
  226. @self._kb.add('right', filter=~is_searching)
  227. def accept(event):
  228. self._accept(event)
  229. @self._kb.add('c-m', filter=is_searching)
  230. def accept_search(event):
  231. search.accept_search()
  232. new_line, _ = self._doc.translate_index_to_position(self._buf.cursor_position)
  233. self.sync_cursor_to_line(new_line)
  234. self._register_extra_kb_cbs(self._kb)
  235. self._searchbar = SearchToolbar(ignore_case=True)
  236. text = '\n'.join(map(lambda _x: _x.text, self._items))
  237. self._doc = Document(text, cursor_position=self._pos)
  238. self._buf = Buffer(read_only=True, document=self._doc)
  239. self._bufctrl = BufferControl(self._buf,
  240. search_buffer_control=self._searchbar.control,
  241. preview_search=True,
  242. input_processors=[MenuColorizer()])
  243. split = HSplit([Window(self._bufctrl,
  244. wrap_lines=True,
  245. always_hide_cursor=True),
  246. self._searchbar])
  247. # set initial pos
  248. for _ in range(self._initial_pos + 1):
  249. self.next_item(1)
  250. app = Application(layout=Layout(split),
  251. key_bindings=self._kb,
  252. full_screen=False,
  253. mouse_support=False)
  254. app.run()
  255. self._ran = True
  256. class CliMultiMenu(CliMenu):
  257. default_selection_icons = CliSelectionStyle.SQUARE_BRACKETS
  258. @classmethod
  259. def set_default_selector_icons(cls, selection_icons):
  260. cls.default_selection_icons = selection_icons
  261. def __init__(self, *args, selection_icons=None, min_selection_count=0, **kwargs):
  262. self._multi_selected = []
  263. self._min_selection_count = min_selection_count
  264. self._selection_icons = selection_icons if selection_icons is not None else self.default_selection_icons
  265. super().__init__(*args, **kwargs)
  266. def add_option(self, text, item=None, selected=False):
  267. super().add_option(text, item)
  268. if selected:
  269. self._multi_selected.append(len(self._items) - 1)
  270. def get_selection(self):
  271. if self.success:
  272. return [(self._items[n].num, self._items[n].item) for n in self._multi_selected]
  273. else:
  274. return None
  275. def get_selection_num(self):
  276. if self.success:
  277. return [self._items[n].num for n in self._multi_selected]
  278. else:
  279. return None
  280. def get_selection_item(self):
  281. if self.success:
  282. return [self._items[n].item for n in self._multi_selected]
  283. else:
  284. return None
  285. def _register_extra_kb_cbs(self, kb):
  286. @kb.add('space', filter=~is_searching)
  287. @kb.add('right', filter=~is_searching)
  288. def mark(event):
  289. if self._pos not in self._multi_selected:
  290. self._multi_selected.append(self._pos)
  291. else:
  292. self._multi_selected.remove(self._pos)
  293. def _transform_prefix(self, item, lineno, prefix):
  294. if item.focusable:
  295. if lineno in self._multi_selected:
  296. icon = self._selection_icons[0]
  297. else:
  298. icon = self._selection_icons[1]
  299. return "{}{} ".format(prefix, icon)
  300. else:
  301. return prefix
  302. def _preflight(self):
  303. super()._preflight()
  304. if self._min_selection_count > self._item_num:
  305. raise ValueError("A minimum of {} items was requested for successful selection but only {} exist"
  306. .format(self._min_selection_count, self._item_num))
  307. def _accept(self, event):
  308. if len(self._multi_selected) >= self._min_selection_count:
  309. super()._accept(event)
  310. def cli_select_item(options, header=None, abort_exc=ValueError, abort_text="Selection aborted.", style=None,
  311. return_single=True):
  312. """Helper function to quickly get a selection with just a few arguments"""
  313. menu = CliMenu(header=header, options=options, style=style)
  314. if return_single and menu.num_options == 1:
  315. item = menu.get_options()[0]
  316. return item.num, item.item
  317. if not menu.success:
  318. raise abort_exc(abort_text)
  319. return menu.get_selection()