menus.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. from __future__ import annotations
  2. from typing import Callable, Iterable, Sequence
  3. from prompt_toolkit.application.current import get_app
  4. from prompt_toolkit.filters import Condition
  5. from prompt_toolkit.formatted_text.base import OneStyleAndTextTuple, StyleAndTextTuples
  6. from prompt_toolkit.key_binding.key_bindings import KeyBindings, KeyBindingsBase
  7. from prompt_toolkit.key_binding.key_processor import KeyPressEvent
  8. from prompt_toolkit.keys import Keys
  9. from prompt_toolkit.layout.containers import (
  10. AnyContainer,
  11. ConditionalContainer,
  12. Container,
  13. Float,
  14. FloatContainer,
  15. HSplit,
  16. Window,
  17. )
  18. from prompt_toolkit.layout.controls import FormattedTextControl
  19. from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
  20. from prompt_toolkit.utils import get_cwidth
  21. from prompt_toolkit.widgets import Shadow
  22. from .base import Border
  23. __all__ = [
  24. "MenuContainer",
  25. "MenuItem",
  26. ]
  27. E = KeyPressEvent
  28. class MenuContainer:
  29. """
  30. :param floats: List of extra Float objects to display.
  31. :param menu_items: List of `MenuItem` objects.
  32. """
  33. def __init__(
  34. self,
  35. body: AnyContainer,
  36. menu_items: list[MenuItem],
  37. floats: list[Float] | None = None,
  38. key_bindings: KeyBindingsBase | None = None,
  39. ) -> None:
  40. self.body = body
  41. self.menu_items = menu_items
  42. self.selected_menu = [0]
  43. # Key bindings.
  44. kb = KeyBindings()
  45. @Condition
  46. def in_main_menu() -> bool:
  47. return len(self.selected_menu) == 1
  48. @Condition
  49. def in_sub_menu() -> bool:
  50. return len(self.selected_menu) > 1
  51. # Navigation through the main menu.
  52. @kb.add("left", filter=in_main_menu)
  53. def _left(event: E) -> None:
  54. self.selected_menu[0] = max(0, self.selected_menu[0] - 1)
  55. @kb.add("right", filter=in_main_menu)
  56. def _right(event: E) -> None:
  57. self.selected_menu[0] = min(
  58. len(self.menu_items) - 1, self.selected_menu[0] + 1
  59. )
  60. @kb.add("down", filter=in_main_menu)
  61. def _down(event: E) -> None:
  62. self.selected_menu.append(0)
  63. @kb.add("c-c", filter=in_main_menu)
  64. @kb.add("c-g", filter=in_main_menu)
  65. def _cancel(event: E) -> None:
  66. "Leave menu."
  67. event.app.layout.focus_last()
  68. # Sub menu navigation.
  69. @kb.add("left", filter=in_sub_menu)
  70. @kb.add("c-g", filter=in_sub_menu)
  71. @kb.add("c-c", filter=in_sub_menu)
  72. def _back(event: E) -> None:
  73. "Go back to parent menu."
  74. if len(self.selected_menu) > 1:
  75. self.selected_menu.pop()
  76. @kb.add("right", filter=in_sub_menu)
  77. def _submenu(event: E) -> None:
  78. "go into sub menu."
  79. if self._get_menu(len(self.selected_menu) - 1).children:
  80. self.selected_menu.append(0)
  81. # If This item does not have a sub menu. Go up in the parent menu.
  82. elif (
  83. len(self.selected_menu) == 2
  84. and self.selected_menu[0] < len(self.menu_items) - 1
  85. ):
  86. self.selected_menu = [
  87. min(len(self.menu_items) - 1, self.selected_menu[0] + 1)
  88. ]
  89. if self.menu_items[self.selected_menu[0]].children:
  90. self.selected_menu.append(0)
  91. @kb.add("up", filter=in_sub_menu)
  92. def _up_in_submenu(event: E) -> None:
  93. "Select previous (enabled) menu item or return to main menu."
  94. # Look for previous enabled items in this sub menu.
  95. menu = self._get_menu(len(self.selected_menu) - 2)
  96. index = self.selected_menu[-1]
  97. previous_indexes = [
  98. i
  99. for i, item in enumerate(menu.children)
  100. if i < index and not item.disabled
  101. ]
  102. if previous_indexes:
  103. self.selected_menu[-1] = previous_indexes[-1]
  104. elif len(self.selected_menu) == 2:
  105. # Return to main menu.
  106. self.selected_menu.pop()
  107. @kb.add("down", filter=in_sub_menu)
  108. def _down_in_submenu(event: E) -> None:
  109. "Select next (enabled) menu item."
  110. menu = self._get_menu(len(self.selected_menu) - 2)
  111. index = self.selected_menu[-1]
  112. next_indexes = [
  113. i
  114. for i, item in enumerate(menu.children)
  115. if i > index and not item.disabled
  116. ]
  117. if next_indexes:
  118. self.selected_menu[-1] = next_indexes[0]
  119. @kb.add("enter")
  120. def _click(event: E) -> None:
  121. "Click the selected menu item."
  122. item = self._get_menu(len(self.selected_menu) - 1)
  123. if item.handler:
  124. event.app.layout.focus_last()
  125. item.handler()
  126. # Controls.
  127. self.control = FormattedTextControl(
  128. self._get_menu_fragments, key_bindings=kb, focusable=True, show_cursor=False
  129. )
  130. self.window = Window(height=1, content=self.control, style="class:menu-bar")
  131. submenu = self._submenu(0)
  132. submenu2 = self._submenu(1)
  133. submenu3 = self._submenu(2)
  134. @Condition
  135. def has_focus() -> bool:
  136. return get_app().layout.current_window == self.window
  137. self.container = FloatContainer(
  138. content=HSplit(
  139. [
  140. # The titlebar.
  141. self.window,
  142. # The 'body', like defined above.
  143. body,
  144. ]
  145. ),
  146. floats=[
  147. Float(
  148. xcursor=True,
  149. ycursor=True,
  150. content=ConditionalContainer(
  151. content=Shadow(body=submenu), filter=has_focus
  152. ),
  153. ),
  154. Float(
  155. attach_to_window=submenu,
  156. xcursor=True,
  157. ycursor=True,
  158. allow_cover_cursor=True,
  159. content=ConditionalContainer(
  160. content=Shadow(body=submenu2),
  161. filter=has_focus
  162. & Condition(lambda: len(self.selected_menu) >= 1),
  163. ),
  164. ),
  165. Float(
  166. attach_to_window=submenu2,
  167. xcursor=True,
  168. ycursor=True,
  169. allow_cover_cursor=True,
  170. content=ConditionalContainer(
  171. content=Shadow(body=submenu3),
  172. filter=has_focus
  173. & Condition(lambda: len(self.selected_menu) >= 2),
  174. ),
  175. ),
  176. # --
  177. ]
  178. + (floats or []),
  179. key_bindings=key_bindings,
  180. )
  181. def _get_menu(self, level: int) -> MenuItem:
  182. menu = self.menu_items[self.selected_menu[0]]
  183. for i, index in enumerate(self.selected_menu[1:]):
  184. if i < level:
  185. try:
  186. menu = menu.children[index]
  187. except IndexError:
  188. return MenuItem("debug")
  189. return menu
  190. def _get_menu_fragments(self) -> StyleAndTextTuples:
  191. focused = get_app().layout.has_focus(self.window)
  192. # This is called during the rendering. When we discover that this
  193. # widget doesn't have the focus anymore. Reset menu state.
  194. if not focused:
  195. self.selected_menu = [0]
  196. # Generate text fragments for the main menu.
  197. def one_item(i: int, item: MenuItem) -> Iterable[OneStyleAndTextTuple]:
  198. def mouse_handler(mouse_event: MouseEvent) -> None:
  199. hover = mouse_event.event_type == MouseEventType.MOUSE_MOVE
  200. if (
  201. mouse_event.event_type == MouseEventType.MOUSE_DOWN
  202. or hover
  203. and focused
  204. ):
  205. # Toggle focus.
  206. app = get_app()
  207. if not hover:
  208. if app.layout.has_focus(self.window):
  209. if self.selected_menu == [i]:
  210. app.layout.focus_last()
  211. else:
  212. app.layout.focus(self.window)
  213. self.selected_menu = [i]
  214. yield ("class:menu-bar", " ", mouse_handler)
  215. if i == self.selected_menu[0] and focused:
  216. yield ("[SetMenuPosition]", "", mouse_handler)
  217. style = "class:menu-bar.selected-item"
  218. else:
  219. style = "class:menu-bar"
  220. yield style, item.text, mouse_handler
  221. result: StyleAndTextTuples = []
  222. for i, item in enumerate(self.menu_items):
  223. result.extend(one_item(i, item))
  224. return result
  225. def _submenu(self, level: int = 0) -> Window:
  226. def get_text_fragments() -> StyleAndTextTuples:
  227. result: StyleAndTextTuples = []
  228. if level < len(self.selected_menu):
  229. menu = self._get_menu(level)
  230. if menu.children:
  231. result.append(("class:menu", Border.TOP_LEFT))
  232. result.append(("class:menu", Border.HORIZONTAL * (menu.width + 4)))
  233. result.append(("class:menu", Border.TOP_RIGHT))
  234. result.append(("", "\n"))
  235. try:
  236. selected_item = self.selected_menu[level + 1]
  237. except IndexError:
  238. selected_item = -1
  239. def one_item(
  240. i: int, item: MenuItem
  241. ) -> Iterable[OneStyleAndTextTuple]:
  242. def mouse_handler(mouse_event: MouseEvent) -> None:
  243. if item.disabled:
  244. # The arrow keys can't interact with menu items that are disabled.
  245. # The mouse shouldn't be able to either.
  246. return
  247. hover = mouse_event.event_type == MouseEventType.MOUSE_MOVE
  248. if (
  249. mouse_event.event_type == MouseEventType.MOUSE_UP
  250. or hover
  251. ):
  252. app = get_app()
  253. if not hover and item.handler:
  254. app.layout.focus_last()
  255. item.handler()
  256. else:
  257. self.selected_menu = self.selected_menu[
  258. : level + 1
  259. ] + [i]
  260. if i == selected_item:
  261. yield ("[SetCursorPosition]", "")
  262. style = "class:menu-bar.selected-item"
  263. else:
  264. style = ""
  265. yield ("class:menu", Border.VERTICAL)
  266. if item.text == "-":
  267. yield (
  268. style + "class:menu-border",
  269. f"{Border.HORIZONTAL * (menu.width + 3)}",
  270. mouse_handler,
  271. )
  272. else:
  273. yield (
  274. style,
  275. f" {item.text}".ljust(menu.width + 3),
  276. mouse_handler,
  277. )
  278. if item.children:
  279. yield (style, ">", mouse_handler)
  280. else:
  281. yield (style, " ", mouse_handler)
  282. if i == selected_item:
  283. yield ("[SetMenuPosition]", "")
  284. yield ("class:menu", Border.VERTICAL)
  285. yield ("", "\n")
  286. for i, item in enumerate(menu.children):
  287. result.extend(one_item(i, item))
  288. result.append(("class:menu", Border.BOTTOM_LEFT))
  289. result.append(("class:menu", Border.HORIZONTAL * (menu.width + 4)))
  290. result.append(("class:menu", Border.BOTTOM_RIGHT))
  291. return result
  292. return Window(FormattedTextControl(get_text_fragments), style="class:menu")
  293. @property
  294. def floats(self) -> list[Float] | None:
  295. return self.container.floats
  296. def __pt_container__(self) -> Container:
  297. return self.container
  298. class MenuItem:
  299. def __init__(
  300. self,
  301. text: str = "",
  302. handler: Callable[[], None] | None = None,
  303. children: list[MenuItem] | None = None,
  304. shortcut: Sequence[Keys | str] | None = None,
  305. disabled: bool = False,
  306. ) -> None:
  307. self.text = text
  308. self.handler = handler
  309. self.children = children or []
  310. self.shortcut = shortcut
  311. self.disabled = disabled
  312. self.selected_item = 0
  313. @property
  314. def width(self) -> int:
  315. if self.children:
  316. return max(get_cwidth(c.text) for c in self.children)
  317. else:
  318. return 0