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