Coverage for yuio / widget.py: 89%
1881 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-04 10:05 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-04 10:05 +0000
1# Yuio project, MIT license.
2#
3# https://github.com/taminomara/yuio/
4#
5# You're free to copy this file to your project and edit it for your needs,
6# just keep this copyright line please :3
8"""
9Basic blocks for building interactive elements.
11This is a low-level module upon which :mod:`yuio.io` builds
12its higher-level abstraction.
15Widget basics
16-------------
18All widgets are are derived from the :class:`Widget` class, where they implement
19event handlers, layout and rendering routines. Specifically,
20:meth:`Widget.layout` and :meth:`Widget.draw` are required to implement
21a widget.
23.. autoclass:: Widget
24 :members:
26.. autoclass:: Result
27 :members:
29.. autofunction:: bind
31.. autoclass:: Key
32 :members:
34.. autoclass:: KeyboardEvent
35 :members:
38Drawing and rendering widgets
39-----------------------------
41Widgets are rendered through :class:`RenderContext`. It provides simple facilities
42to print characters on screen and manipulate screen cursor.
44.. autoclass:: RenderContext
45 :members:
48Stacking widgets together
49-------------------------
51To get help with drawing multiple widgets and setting their own frames,
52you can use the :class:`VerticalLayout` class:
54.. autoclass:: VerticalLayout
56.. autoclass:: VerticalLayoutBuilder
57 :members:
60Widget help
61-----------
63Widgets automatically generate help: the help menu is available via the ``F1`` key,
64and there's also inline help that is displayed under the widget.
66By default, help items are generated from event handler docstrings:
67all event handlers that have them will be displayed in the help menu.
69You can control which keybindings appear in the help menu and inline help
70by supplying ``show_in_inline_help`` and ``show_in_detailed_help`` arguments
71to the :func:`bind` function.
73For even more detailed customization you can decorate an event handler with
74the :func:`help` decorator:
76.. autofunction:: help
78Lastly, you can override :attr:`Widget.help_data` and generate
79the :class:`WidgetHelp` yourself:
81.. autoclass:: WidgetHelp
82 :members:
84.. class:: ActionKey
86 A single key associated with an action.
87 Can be either a hotkey or a string with an arbitrary description.
89.. class:: ActionKeys
91 A list of keys associated with an action.
93.. class:: Action
95 An action itself, i.e. a set of hotkeys and a description for them.
98Pre-defined widgets
99-------------------
101.. autoclass:: Line
103.. autoclass:: Text
105.. autoclass:: Input
107.. autoclass:: SecretInput
109.. autoclass:: Grid
111.. autoclass:: Option
112 :members:
114.. autoclass:: Choice
116.. autoclass:: Multiselect
118.. autoclass:: InputWithCompletion
120.. autoclass:: Map
122.. autoclass:: Apply
124"""
126# ruff: noqa: RET503
128from __future__ import annotations
130import abc
131import contextlib
132import dataclasses
133import enum
134import functools
135import math
136import re
137import shutil
138import string
139import sys
140import typing
141from dataclasses import dataclass
143import yuio.color
144import yuio.complete
145import yuio.md
146import yuio.string
147import yuio.term
148from yuio import _typing as _t
149from yuio.color import Color as _Color
150from yuio.string import ColorizedString as _ColorizedString
151from yuio.string import Esc as _Esc
152from yuio.string import line_width as _line_width
153from yuio.term import Term as _Term
154from yuio.theme import Theme as _Theme
156__all__ = [
157 "Action",
158 "ActionKey",
159 "ActionKeys",
160 "Apply",
161 "Choice",
162 "Grid",
163 "Input",
164 "InputWithCompletion",
165 "Key",
166 "KeyboardEvent",
167 "Line",
168 "Map",
169 "Multiselect",
170 "Option",
171 "RenderContext",
172 "Result",
173 "SecretInput",
174 "Text",
175 "VerticalLayout",
176 "VerticalLayoutBuilder",
177 "Widget",
178 "WidgetHelp",
179 "bind",
180 "help",
181]
183_SPACE_BETWEEN_COLUMNS = 2
184_MIN_COLUMN_WIDTH = 10
186_UNPRINTABLE = "".join([chr(i) for i in range(32)]) + "\x7f"
187_UNPRINTABLE_TRANS = str.maketrans(_UNPRINTABLE, " " * len(_UNPRINTABLE))
188_UNPRINTABLE_RE = r"[" + re.escape(_UNPRINTABLE) + "]"
189_UNPRINTABLE_RE_WITHOUT_NL = r"[" + re.escape(_UNPRINTABLE.replace("\n", "")) + "]"
192T = _t.TypeVar("T")
193U = _t.TypeVar("U")
194T_co = _t.TypeVar("T_co", covariant=True)
197class Key(enum.Enum):
198 """
199 Non-character keys.
201 """
203 ENTER = enum.auto()
204 """
205 :kbd:`Enter` key.
207 """
209 ESCAPE = enum.auto()
210 """
211 :kbd:`Escape` key.
213 """
215 INSERT = enum.auto()
216 """
217 :kbd:`Insert` key.
219 """
221 DELETE = enum.auto()
222 """
223 :kbd:`Delete` key.
225 """
227 BACKSPACE = enum.auto()
228 """
229 :kbd:`Backspace` key.
231 """
233 TAB = enum.auto()
234 """
235 :kbd:`Tab` key.
237 """
239 HOME = enum.auto()
240 """
241 :kbd:`Home` key.
243 """
245 END = enum.auto()
246 """
247 :kbd:`End` key.
249 """
251 PAGE_UP = enum.auto()
252 """
253 :kbd:`PageUp` key.
255 """
257 PAGE_DOWN = enum.auto()
258 """
259 :kbd:`PageDown` key.
261 """
263 ARROW_UP = enum.auto()
264 """
265 :kbd:`ArrowUp` key.
267 """
269 ARROW_DOWN = enum.auto()
270 """
271 :kbd:`ArrowDown` key.
273 """
275 ARROW_LEFT = enum.auto()
276 """
277 :kbd:`ArrowLeft` key.
279 """
281 ARROW_RIGHT = enum.auto()
282 """
283 :kbd:`ArrowRight` key.
285 """
287 F1 = enum.auto()
288 """
289 :kbd:`F1` key.
291 """
293 F2 = enum.auto()
294 """
295 :kbd:`F2` key.
297 """
299 F3 = enum.auto()
300 """
301 :kbd:`F3` key.
303 """
305 F4 = enum.auto()
306 """
307 :kbd:`F4` key.
309 """
311 F5 = enum.auto()
312 """
313 :kbd:`F5` key.
315 """
317 F6 = enum.auto()
318 """
319 :kbd:`F6` key.
321 """
323 F7 = enum.auto()
324 """
325 :kbd:`F7` key.
327 """
329 F8 = enum.auto()
330 """
331 :kbd:`F8` key.
333 """
335 F9 = enum.auto()
336 """
337 :kbd:`F9` key.
339 """
341 F10 = enum.auto()
342 """
343 :kbd:`F10` key.
345 """
347 F11 = enum.auto()
348 """
349 :kbd:`F11` key.
351 """
353 F12 = enum.auto()
354 """
355 :kbd:`F12` key.
357 """
359 PASTE = enum.auto()
360 """
361 Triggered when a text is pasted into a terminal.
363 """
365 def __str__(self) -> str:
366 return self.name.replace("_", " ").title()
369@dataclass(frozen=True, slots=True)
370class KeyboardEvent:
371 """
372 A single keyboard event.
374 .. warning::
376 Protocol for interacting with terminals is quite old, and not all terminals
377 support all keystroke combinations.
379 Use ``python -m yuio.scripts.showkey`` to check how your terminal reports
380 keystrokes, and how Yuio interprets them.
382 """
384 key: Key | str
385 """
386 Which key was pressed? Can be a single character,
387 or a :class:`Key` for non-character keys.
389 """
391 ctrl: bool = False
392 """
393 Whether a :kbd:`Ctrl` modifier was pressed with keystroke.
395 For letter keys modified with control, the letter is always lowercase; if terminal
396 supports reporting :kbd:`Shift` being pressed, the :attr:`~KeyboardEvent.shift`
397 attribute will be set. This does not affect punctuation keys, though:
399 .. skip-next:
401 .. code-block:: python
403 # `Ctrl+X` was pressed.
404 KeyboardEvent("x", ctrl=True)
406 # `Ctrl+Shift+X` was pressed. Not all terminals are able
407 # to report this correctly, though.
408 KeyboardEvent("x", ctrl=True, shift=True)
410 # This can't happen.
411 KeyboardEvent("X", ctrl=True)
413 # `Ctrl+_` was pressed. On most keyboards, the actual keystroke
414 # is `Ctrl+Shift+-`, but most terminals can't properly report this.
415 KeyboardEvent("_", ctrl=True)
417 """
419 alt: bool = False
420 """
421 Whether an :kbd:`Alt` (:kbd:`Option` on macs) modifier was pressed with keystroke.
423 """
425 shift: bool = False
426 """
427 Whether a :kbd:`Shift` modifier was pressed with keystroke.
429 Note that, when letters are typed with shift, they will not have this flag.
430 Instead, their upper case version will be set as :attr:`~KeyboardEvent.key`:
432 .. skip-next:
434 .. code-block:: python
436 KeyboardEvent("x") # `X` was pressed.
437 KeyboardEvent("X") # `Shift+X` was pressed.
439 .. warning::
441 Only :kbd:`Shift+Tab` can be reliably reported by all terminals.
443 """
445 paste_str: str | None = dataclasses.field(default=None, compare=False, kw_only=True)
446 """
447 If ``key`` is :attr:`Key.PASTE`, this attribute will contain pasted string.
449 """
452@_t.final
453class RenderContext:
454 """
455 A canvas onto which widgets render themselves.
457 This class represents a canvas with size equal to the available space on the terminal.
458 Like a real terminal, it has a character grid and a virtual cursor that can be moved
459 around freely.
461 Before each render, context's canvas is cleared, and then widgets print themselves onto it.
462 When render ends, context compares new canvas with what's been rendered previously,
463 and then updates those parts of the real terminal's grid that changed between renders.
465 This approach allows simplifying widgets (they don't have to track changes and do conditional
466 screen updates themselves), while still minimizing the amount of data that's sent between
467 the program and the terminal. It is especially helpful with rendering larger widgets over ssh.
469 """
471 # For tests.
472 _override_wh: tuple[int, int] | None = None
474 def __init__(self, term: _Term, theme: _Theme, /):
475 self._term: _Term = term
476 self._theme: _Theme = theme
478 # We have three levels of abstraction here.
479 #
480 # First, we have the TTY which our process attached to.
481 # This TTY has cursor, current color,
482 # and different drawing capabilities.
483 #
484 # Second, we have the canvas. This canvas has same dimensions
485 # as the underlying TTY. Canvas' contents and actual TTY contents
486 # are synced in `render` function.
487 #
488 # Finally, we have virtual cursor,
489 # and a drawing frame which clips dimensions of a widget.
490 #
491 #
492 # Drawing frame
493 # ...................
494 # . ┌────────┐ .
495 # . │ hello │ .
496 # . │ world │ .
497 # . └────────┘ .
498 # ...................
499 # ↓
500 # Canvas
501 # ┌─────────────────┐
502 # │ > hello │
503 # │ world │
504 # │ │
505 # └─────────────────┘
506 # ↓
507 # Real terminal
508 # ┏━━━━━━━━━━━━━━━━━┯━━━┓
509 # ┃ > hello │ ┃
510 # ┃ world │ ┃
511 # ┃ │ ┃
512 # ┠───────────VT100─┤◆◆◆┃
513 # ┗█▇█▇█▇█▇█▇█▇█▇█▇█▇█▇█┛
515 # Drawing frame and virtual cursor
516 self._frame_x: int = 0
517 self._frame_y: int = 0
518 self._frame_w: int = 0
519 self._frame_h: int = 0
520 self._frame_cursor_x: int = 0 # relative to _frame_x
521 self._frame_cursor_y: int = 0 # relative to _frame_y
522 self._frame_cursor_color: str = ""
524 # Canvas
525 self._width: int = 0
526 self._height: int = 0
527 self._final_x: int = 0
528 self._final_y: int = 0
529 self._lines: list[list[str]] = []
530 self._colors: list[list[str]] = []
531 self._prev_lines: list[list[str]] = []
532 self._prev_colors: list[list[str]] = []
534 # Rendering status
535 self._full_redraw: bool = False
536 self._term_x: int = 0
537 self._term_y: int = 0
538 self._term_color: str = ""
539 self._max_term_y: int = 0
540 self._out: list[str] = []
541 self._bell: bool = False
542 self._in_alternative_buffer: bool = False
543 self._normal_buffer_term_x: int = 0
544 self._normal_buffer_term_y: int = 0
546 # Helpers
547 self._none_color: str = _Color.NONE.as_code(term.color_support)
549 # Used for tests and debug
550 self._renders: int = 0
551 self._bytes_rendered: int = 0
552 self._total_bytes_rendered: int = 0
554 @property
555 def term(self) -> _Term:
556 """
557 Terminal where we render the widgets.
559 """
561 return self._term
563 @property
564 def theme(self) -> _Theme:
565 """
566 Current color theme.
568 """
570 return self._theme
572 @contextlib.contextmanager
573 def frame(
574 self,
575 x: int,
576 y: int,
577 /,
578 *,
579 width: int | None = None,
580 height: int | None = None,
581 ):
582 """
583 Override drawing frame.
585 Widgets are always drawn in the frame's top-left corner,
586 and they can take the entire frame size.
588 The idea is that, if you want to draw a widget at specific coordinates,
589 you make a frame and draw the widget inside said frame.
591 When new frame is created, cursor's position and color are reset.
592 When frame is dropped, they are restored.
593 Therefore, drawing widgets in a frame will not affect current drawing state.
595 ..
596 >>> term = _Term(sys.stdout, sys.stdin)
597 >>> theme = _Theme()
598 >>> rc = RenderContext(term, theme)
599 >>> rc._override_wh = (20, 5)
601 Example::
603 >>> rc = RenderContext(term, theme) # doctest: +SKIP
604 >>> rc.prepare()
606 >>> # By default, our frame is located at (0, 0)...
607 >>> rc.write("+")
609 >>> # ...and spans the entire canvas.
610 >>> print(rc.width, rc.height)
611 20 5
613 >>> # Let's write something at (4, 0).
614 >>> rc.set_pos(4, 0)
615 >>> rc.write("Hello, world!")
617 >>> # Now we set our drawing frame to be at (2, 2).
618 >>> with rc.frame(2, 2):
619 ... # Out current pos was reset to the frame's top-left corner,
620 ... # which is now (2, 2).
621 ... rc.write("+")
622 ...
623 ... # Frame dimensions were automatically reduced.
624 ... print(rc.width, rc.height)
625 ...
626 ... # Set pos and all other functions work relative
627 ... # to the current frame, so writing at (4, 0)
628 ... # in the current frame will result in text at (6, 2).
629 ... rc.set_pos(4, 0)
630 ... rc.write("Hello, world!")
631 18 3
633 >>> rc.render() # doctest: +NORMALIZE_WHITESPACE
634 + Hello, world!
635 <BLANKLINE>
636 + Hello, world!
637 <BLANKLINE>
638 <BLANKLINE>
640 Usually you don't have to think about frames. If you want to stack
641 multiple widgets one on top of another, simply use :class:`VerticalLayout`.
642 In cases where it's not enough though, you'll have to call
643 :meth:`~Widget.layout` for each of the nested widgets, and then manually
644 create frames and execute :meth:`~Widget.draw` methods::
646 class MyWidget(Widget):
647 # Let's say we want to print a text indented by four spaces,
648 # and limit its with by 15. And we also want to print a small
649 # un-indented heading before it.
651 def __init__(self):
652 # This is the text we'll print.
653 self._nested_widget = Text(
654 "very long paragraph which potentially can span multiple lines"
655 )
657 def layout(self, rc: RenderContext) -> tuple[int, int]:
658 # The text will be placed at (4, 1), and we'll also limit
659 # its width. So we'll reflect those constrains
660 # by arranging a drawing frame.
661 with rc.frame(4, 1, width=min(rc.width - 4, 15)):
662 min_h, max_h = self._nested_widget.layout(rc)
664 # Our own widget will take as much space as the nested text,
665 # plus one line for our heading.
666 return min_h + 1, max_h + 1
668 def draw(self, rc: RenderContext):
669 # Print a small heading.
670 rc.set_color_path("bold")
671 rc.write("Small heading")
673 # And draw our nested widget, controlling its position
674 # via a frame.
675 with rc.frame(4, 1, width=min(rc.width - 4, 15)):
676 self._nested_widget.draw(rc)
678 """
680 prev_frame_x = self._frame_x
681 prev_frame_y = self._frame_y
682 prev_frame_w = self._frame_w
683 prev_frame_h = self._frame_h
684 prev_frame_cursor_x = self._frame_cursor_x
685 prev_frame_cursor_y = self._frame_cursor_y
686 prev_frame_cursor_color = self._frame_cursor_color
688 self._frame_x += x
689 self._frame_y += y
691 if width is not None:
692 self._frame_w = width
693 else:
694 self._frame_w -= x
695 if self._frame_w < 0:
696 self._frame_w = 0
698 if height is not None:
699 self._frame_h = height
700 else:
701 self._frame_h -= y
702 if self._frame_h < 0:
703 self._frame_h = 0
705 self._frame_cursor_x = 0
706 self._frame_cursor_y = 0
707 self._frame_cursor_color = self._none_color
709 try:
710 yield
711 finally:
712 self._frame_x = prev_frame_x
713 self._frame_y = prev_frame_y
714 self._frame_w = prev_frame_w
715 self._frame_h = prev_frame_h
716 self._frame_cursor_x = prev_frame_cursor_x
717 self._frame_cursor_y = prev_frame_cursor_y
718 self._frame_cursor_color = prev_frame_cursor_color
720 @property
721 def width(self) -> int:
722 """
723 Get width of the current frame.
725 """
727 return self._frame_w
729 @property
730 def height(self) -> int:
731 """
732 Get height of the current frame.
734 """
736 return self._frame_h
738 @property
739 def canvas_width(self) -> int:
740 """
741 Get width of the terminal.
743 """
745 return self._width
747 @property
748 def canvas_height(self) -> int:
749 """
750 Get height of the terminal.
752 """
754 return self._height
756 def set_pos(self, x: int, y: int, /):
757 """
758 Set current cursor position within the frame.
760 """
762 self._frame_cursor_x = x
763 self._frame_cursor_y = y
765 def move_pos(self, dx: int, dy: int, /):
766 """
767 Move current cursor position by the given amount.
769 """
771 self._frame_cursor_x += dx
772 self._frame_cursor_y += dy
774 def new_line(self):
775 """
776 Move cursor to new line within the current frame.
778 """
780 self._frame_cursor_x = 0
781 self._frame_cursor_y += 1
783 def set_final_pos(self, x: int, y: int, /):
784 """
785 Set position where the cursor should end up
786 after everything has been rendered.
788 By default, cursor will end up at the beginning of the last line.
789 Components such as :class:`Input` can modify this behavior
790 and move the cursor into the correct position.
792 """
794 self._final_x = x + self._frame_x
795 self._final_y = y + self._frame_y
797 def set_color_path(self, path: str, /):
798 """
799 Set current color by fetching it from the theme by path.
801 """
803 self._frame_cursor_color = self._theme.get_color(path).as_code(
804 self._term.color_support
805 )
807 def set_color(self, color: _Color, /):
808 """
809 Set current color.
811 """
813 self._frame_cursor_color = color.as_code(self._term.color_support)
815 def reset_color(self):
816 """
817 Set current color to the default color of the terminal.
819 """
821 self._frame_cursor_color = self._none_color
823 def write(self, text: yuio.string.AnyString, /, *, max_width: int | None = None):
824 """
825 Write string at the current position using the current color.
826 Move cursor while printing.
828 While the displayed text will not be clipped at frame's borders,
829 its width can be limited by passing `max_width`. Note that
830 ``rc.write(text, max_width)`` is not the same
831 as ``rc.write(text[:max_width])``, because the later case
832 doesn't account for double-width characters.
834 All whitespace characters in the text, including tabs and newlines,
835 will be treated as single spaces. If you need to print multiline text,
836 use :meth:`yuio.string.ColorizedString.wrap` and :meth:`~RenderContext.write_text`.
838 ..
839 >>> term = _Term(sys.stdout, sys.stdin)
840 >>> theme = _Theme()
841 >>> rc = RenderContext(term, theme)
842 >>> rc._override_wh = (20, 5)
844 Example::
846 >>> rc = RenderContext(term, theme) # doctest: +SKIP
847 >>> rc.prepare()
849 >>> rc.write("Hello, world!")
850 >>> rc.new_line()
851 >>> rc.write("Hello,\\nworld!")
852 >>> rc.new_line()
853 >>> rc.write(
854 ... "Hello, 🌍!<this text will be clipped>",
855 ... max_width=10
856 ... )
857 >>> rc.new_line()
858 >>> rc.write(
859 ... "Hello, 🌍!<this text will be clipped>"[:10]
860 ... )
861 >>> rc.new_line()
863 >>> rc.render()
864 Hello, world!
865 Hello, world!
866 Hello, 🌍!
867 Hello, 🌍!<
868 <BLANKLINE>
870 Notice that ``"\\n"`` on the second line was replaced with a space.
871 Notice also that the last line wasn't properly clipped.
873 """
875 if not isinstance(text, _ColorizedString):
876 text = _ColorizedString(text, _isolate_colors=False)
878 x = self._frame_x + self._frame_cursor_x
879 y = self._frame_y + self._frame_cursor_y
881 max_x = self._width
882 if max_width is not None:
883 max_x = min(max_x, x + max_width)
884 self._frame_cursor_x = min(self._frame_cursor_x + text.width, x + max_width)
885 else:
886 self._frame_cursor_x = self._frame_cursor_x + text.width
888 if not 0 <= y < self._height:
889 for s in text:
890 if isinstance(s, _Color):
891 self._frame_cursor_color = s.as_code(self._term.color_support)
892 return
894 ll = self._lines[y]
895 cc = self._colors[y]
897 for s in text:
898 if isinstance(s, _Color):
899 self._frame_cursor_color = s.as_code(self._term.color_support)
900 continue
901 elif s in (yuio.string.NO_WRAP_START, yuio.string.NO_WRAP_END):
902 continue
904 s = s.translate(_UNPRINTABLE_TRANS)
906 if s.isascii():
907 # Fast track.
908 if x + len(s) <= 0:
909 # We're beyond the left terminal border.
910 x += len(s)
911 continue
913 slice_begin = 0
914 if x < 0:
915 # We're partially beyond the left terminal border.
916 slice_begin = -x
917 x = 0
919 if x >= max_x:
920 # We're beyond the right terminal border.
921 x += len(s) - slice_begin
922 continue
924 slice_end = len(s)
925 if x + len(s) - slice_begin > max_x:
926 # We're partially beyond the right terminal border.
927 slice_end = slice_begin + max_x - x
929 l = slice_end - slice_begin
930 self._lines[y][x : x + l] = s[slice_begin:slice_end]
931 self._colors[y][x : x + l] = [self._frame_cursor_color] * l
932 x += l
933 continue
935 for c in s:
936 cw = _line_width(c)
937 if x + cw <= 0:
938 # We're beyond the left terminal border.
939 x += cw
940 continue
941 elif x < 0:
942 # This character was split in half by the terminal border.
943 ll[: x + cw] = [" "] * (x + cw)
944 cc[: x + cw] = [self._none_color] * (x + cw)
945 x += cw
946 continue
947 elif cw > 0 and x >= max_x:
948 # We're beyond the right terminal border.
949 x += cw
950 break
951 elif x + cw > max_x:
952 # This character was split in half by the terminal border.
953 ll[x:max_x] = " " * (max_x - x)
954 cc[x:max_x] = [self._frame_cursor_color] * (max_x - x)
955 x += cw
956 break
958 if cw == 0:
959 # This is a zero-width character.
960 # We'll append it to the previous cell.
961 if x > 0:
962 ll[x - 1] += c
963 continue
965 ll[x] = c
966 cc[x] = self._frame_cursor_color
968 x += 1
969 cw -= 1
970 if cw:
971 ll[x : x + cw] = [""] * cw
972 cc[x : x + cw] = [self._frame_cursor_color] * cw
973 x += cw
975 def write_text(
976 self,
977 lines: _t.Iterable[yuio.string.AnyString],
978 /,
979 *,
980 max_width: int | None = None,
981 ):
982 """
983 Write multiple lines.
985 Each line is printed using :meth:`~RenderContext.write`,
986 so newline characters and tabs within each line are replaced with spaces.
987 Use :meth:`yuio.string.ColorizedString.wrap` to properly handle them.
989 After each line, the cursor is moved one line down,
990 and back to its original horizontal position.
992 ..
993 >>> term = _Term(sys.stdout, sys.stdin)
994 >>> theme = _Theme()
995 >>> rc = RenderContext(term, theme)
996 >>> rc._override_wh = (20, 5)
998 Example::
1000 >>> rc = RenderContext(term, theme) # doctest: +SKIP
1001 >>> rc.prepare()
1003 >>> # Cursor is at (0, 0).
1004 >>> rc.write("+ > ")
1006 >>> # First line is printed at the cursor's position.
1007 >>> # All consequent lines are horizontally aligned with first line.
1008 >>> rc.write_text(["Hello,", "world!"])
1010 >>> # Cursor is at the last line.
1011 >>> rc.write("+")
1013 >>> rc.render() # doctest: +NORMALIZE_WHITESPACE
1014 + > Hello,
1015 world!+
1016 <BLANKLINE>
1017 <BLANKLINE>
1018 <BLANKLINE>
1020 """
1022 x = self._frame_cursor_x
1024 for i, line in enumerate(lines):
1025 if i > 0:
1026 self._frame_cursor_x = x
1027 self._frame_cursor_y += 1
1029 self.write(line, max_width=max_width)
1031 def bell(self):
1032 """
1033 Ring a terminal bell.
1035 """
1037 self._bell = True
1039 def prepare(self, *, full_redraw: bool = False, alternative_buffer: bool = False):
1040 """
1041 Reset output canvas and prepare context for a new round of widget formatting.
1043 """
1045 if self._override_wh:
1046 width, height = self._override_wh
1047 else:
1048 size = shutil.get_terminal_size()
1049 width = size.columns
1050 height = size.lines
1052 full_redraw = full_redraw or self._width != width or self._height != height
1053 if self._in_alternative_buffer != alternative_buffer:
1054 full_redraw = True
1055 self._in_alternative_buffer = alternative_buffer
1056 if alternative_buffer:
1057 self._out.append("\x1b[m\x1b[?1049h\x1b[2J\x1b[H")
1058 self._normal_buffer_term_x = self._term_x
1059 self._normal_buffer_term_y = self._term_y
1060 self._term_x, self._term_y = 0, 0
1061 self._term_color = self._none_color
1062 else:
1063 self._out.append("\x1b[m\x1b[?1049l")
1064 self._term_x = self._normal_buffer_term_x
1065 self._term_y = self._normal_buffer_term_y
1066 self._term_color = self._none_color
1068 # Drawing frame and virtual cursor
1069 self._frame_x = 0
1070 self._frame_y = 0
1071 self._frame_w = width
1072 self._frame_h = height
1073 self._frame_cursor_x = 0
1074 self._frame_cursor_y = 0
1075 self._frame_cursor_color = self._none_color
1077 # Canvas
1078 self._width = width
1079 self._height = height
1080 self._final_x = 0
1081 self._final_y = 0
1082 if full_redraw:
1083 self._max_term_y = 0
1084 self._prev_lines, self._prev_colors = self._make_empty_canvas()
1085 else:
1086 self._prev_lines, self._prev_colors = self._lines, self._colors
1087 self._lines, self._colors = self._make_empty_canvas()
1089 # Rendering status
1090 self._full_redraw = full_redraw
1092 def clear_screen(self):
1093 """
1094 Clear screen and prepare for a full redraw.
1096 """
1098 self._out.append("\x1b[2J\x1b[1H")
1099 self._term_x, self._term_y = 0, 0
1100 self.prepare(full_redraw=True, alternative_buffer=self._in_alternative_buffer)
1102 def _make_empty_canvas(
1103 self,
1104 ) -> tuple[list[list[str]], list[list[str]]]:
1105 lines = [l[:] for l in [[" "] * self._width] * self._height]
1106 colors = [
1107 c[:] for c in [[self._frame_cursor_color] * self._width] * self._height
1108 ]
1109 return lines, colors
1111 def render(self):
1112 """
1113 Render current canvas onto the terminal.
1115 """
1117 if not self.term.can_move_cursor:
1118 # For tests. Widgets can't work with dumb terminals
1119 self._render_dumb()
1120 return
1122 if self._bell:
1123 self._out.append("\a")
1124 self._bell = False
1126 if self._full_redraw:
1127 self._move_term_cursor(0, 0)
1128 self._out.append("\x1b[J")
1130 for y in range(self._height):
1131 line = self._lines[y]
1133 for x in range(self._width):
1134 prev_color = self._prev_colors[y][x]
1135 color = self._colors[y][x]
1137 if color != prev_color or line[x] != self._prev_lines[y][x]:
1138 self._move_term_cursor(x, y)
1140 if color != self._term_color:
1141 self._out.append(color)
1142 self._term_color = color
1144 self._out.append(line[x])
1145 self._term_x += 1
1147 final_x = max(0, min(self._width - 1, self._final_x))
1148 final_y = max(0, min(self._height - 1, self._final_y))
1149 self._move_term_cursor(final_x, final_y)
1151 rendered = "".join(self._out)
1152 self._term.ostream.write(rendered)
1153 self._term.ostream.flush()
1154 self._out.clear()
1156 if yuio._debug:
1157 self._renders += 1
1158 self._bytes_rendered = len(rendered.encode())
1159 self._total_bytes_rendered += self._bytes_rendered
1161 debug_msg = f"n={self._renders:>04},r={self._bytes_rendered:>04},t={self._total_bytes_rendered:>04}"
1162 term_x, term_y = self._term_x, self._term_y
1163 self._move_term_cursor(self._width - len(debug_msg), 0)
1164 color = yuio.color.Color.STYLE_INVERSE | yuio.color.Color.FORE_CYAN
1165 self._out.append(color.as_code(self._term.color_support))
1166 self._out.append(debug_msg)
1167 self._out.append(self._term_color)
1168 self._move_term_cursor(term_x, term_y)
1170 self._term.ostream.write("".join(self._out))
1171 self._term.ostream.flush()
1172 self._out.clear()
1174 def finalize(self):
1175 """
1176 Erase any rendered widget and move cursor to the initial position.
1178 """
1180 self.prepare(full_redraw=True)
1182 self._move_term_cursor(0, 0)
1183 self._out.append("\x1b[J")
1184 self._out.append(self._none_color)
1185 self._term.ostream.write("".join(self._out))
1186 self._term.ostream.flush()
1187 self._out.clear()
1188 self._term_color = self._none_color
1190 def _move_term_cursor(self, x: int, y: int):
1191 dy = y - self._term_y
1192 if y > self._max_term_y:
1193 self._out.append("\n" * dy)
1194 self._term_x = 0
1195 elif dy > 0:
1196 self._out.append(f"\x1b[{dy}B")
1197 elif dy < 0:
1198 self._out.append(f"\x1b[{-dy}A")
1199 self._term_y = y
1200 self._max_term_y = max(self._max_term_y, y)
1202 if x != self._term_x:
1203 self._out.append(f"\x1b[{x + 1}G")
1204 self._term_x = x
1206 def _render_dumb(self):
1207 prev_printed_color = self._none_color
1209 for line, colors in zip(self._lines, self._colors):
1210 for ch, color in zip(line, colors):
1211 if prev_printed_color != color:
1212 self._out.append(color)
1213 prev_printed_color = color
1214 self._out.append(ch)
1215 self._out.append("\n")
1217 self._term.ostream.writelines(
1218 [
1219 # Trim trailing spaces for doctests.
1220 re.sub(r" +$", "\n", line, flags=re.MULTILINE)
1221 for line in "".join(self._out).splitlines()
1222 ]
1223 )
1224 self._term.ostream.flush()
1225 self._out.clear()
1228@dataclass(frozen=True, slots=True)
1229class Result(_t.Generic[T_co]):
1230 """
1231 Result of a widget run.
1233 We have to wrap the return value of event processors into this class.
1234 Otherwise we won't be able to distinguish between returning `None`
1235 as result of a ``Widget[None]``, and not returning anything.
1237 """
1239 value: T_co
1240 """
1241 Result of a widget run.
1243 """
1246class Widget(abc.ABC, _t.Generic[T_co]):
1247 """
1248 Base class for all interactive console elements.
1250 Widgets are displayed with their :meth:`~Widget.run` method.
1251 They always go through the same event loop:
1253 .. raw:: html
1255 <p>
1256 <pre class="mermaid">
1257 flowchart TD
1258 Start([Start]) --> Layout["`layout()`"]
1259 Layout --> Draw["`draw()`"]
1260 Draw -->|Wait for keyboard event| Event["`Event()`"]
1261 Event --> Result{{Returned result?}}
1262 Result -->|no| Layout
1263 Result -->|yes| Finish([Finish])
1264 </pre>
1265 </p>
1267 Widgets run indefinitely until they stop themselves and return a value.
1268 For example, :class:`Input` will return when user presses :kbd:`Enter`.
1269 When widget needs to stop, it can return the :meth:`Result` class
1270 from its event handler.
1272 For typing purposes, :class:`Widget` is generic. That is, ``Widget[T]``
1273 returns ``T`` from its :meth:`~Widget.run` method. So, :class:`Input`,
1274 for example, is ``Widget[str]``.
1276 Some widgets are ``Widget[Never]`` (see :class:`typing.Never`), indicating that
1277 they don't ever stop. Others are ``Widget[None]``, indicating that they stop,
1278 but don't return a value.
1280 """
1282 __bindings: typing.ClassVar[dict[KeyboardEvent, _t.Callable[[_t.Any], _t.Any]]]
1283 __callbacks: typing.ClassVar[list[object]]
1285 __in_help_menu: bool = False
1286 __bell: bool = False
1288 _cur_event: KeyboardEvent | None = None
1289 """
1290 Current event that is being processed.
1291 Guaranteed to be not :data:`None` inside event handlers.
1293 """
1295 def __init_subclass__(cls, **kwargs):
1296 super().__init_subclass__(**kwargs)
1298 cls.__bindings = {}
1299 cls.__callbacks = []
1301 event_handler_names = []
1302 for base in reversed(cls.__mro__):
1303 for name, cb in base.__dict__.items():
1304 if (
1305 hasattr(cb, "__yuio_keybindings__")
1306 and name not in event_handler_names
1307 ):
1308 event_handler_names.append(name)
1310 for name in event_handler_names:
1311 cb = getattr(cls, name, None)
1312 if cb is not None and hasattr(cb, "__yuio_keybindings__"):
1313 bindings: list[_Binding] = cb.__yuio_keybindings__
1314 cls.__bindings.update((binding.event, cb) for binding in bindings)
1315 cls.__callbacks.append(cb)
1317 def event(self, e: KeyboardEvent, /) -> Result[T_co] | None:
1318 """
1319 Handle incoming keyboard event.
1321 By default, this function dispatches event to handlers registered
1322 via :func:`bind`. If no handler is found,
1323 it calls :meth:`~Widget.default_event_handler`.
1325 """
1327 self._cur_event = e
1328 if handler := self.__bindings.get(e):
1329 return handler(self)
1330 else:
1331 return self.default_event_handler(e)
1333 def default_event_handler(self, e: KeyboardEvent, /) -> Result[T_co] | None:
1334 """
1335 Process any event that wasn't caught by other event handlers.
1337 """
1339 @abc.abstractmethod
1340 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
1341 """
1342 Prepare widget for drawing, and recalculate its dimensions
1343 according to new frame dimensions.
1345 Yuio's widgets always take all available width. They should return
1346 their minimum height that they will definitely take, and their maximum
1347 height that they can potentially take.
1349 """
1351 raise NotImplementedError()
1353 @abc.abstractmethod
1354 def draw(self, rc: RenderContext, /):
1355 """
1356 Draw the widget.
1358 Render context's drawing frame dimensions are guaranteed to be between
1359 the minimum and the maximum height returned from the last call
1360 to :meth:`~Widget.layout`.
1362 """
1364 raise NotImplementedError()
1366 @_t.final
1367 def run(self, term: _Term, theme: _Theme, /) -> T_co:
1368 """
1369 Read user input and run the widget.
1371 """
1373 if not term.is_fully_interactive:
1374 raise RuntimeError("terminal doesn't support rendering widgets")
1376 with yuio.term._enter_raw_mode(
1377 term.ostream, term.istream, bracketed_paste=True, modify_keyboard=True
1378 ):
1379 rc = RenderContext(term, theme)
1381 events = _event_stream(term.ostream, term.istream)
1383 try:
1384 while True:
1385 rc.prepare(alternative_buffer=self.__in_help_menu)
1387 height = rc.height
1388 if self.__in_help_menu:
1389 min_h, max_h = self.__help_menu_layout(rc)
1390 inline_help_height = 0
1391 else:
1392 with rc.frame(0, 0):
1393 inline_help_height = self.__help_menu_layout_inline(rc)[0]
1394 if height > inline_help_height:
1395 height -= inline_help_height
1396 with rc.frame(0, 0, height=height):
1397 min_h, max_h = self.layout(rc)
1398 max_h = max(min_h, min(max_h, height))
1399 rc.set_final_pos(0, max_h + inline_help_height)
1400 if self.__in_help_menu:
1401 self.__help_menu_draw(rc)
1402 else:
1403 with rc.frame(0, 0, height=max_h):
1404 self.draw(rc)
1405 if max_h < rc.height:
1406 with rc.frame(0, max_h, height=rc.height - max_h):
1407 self.__help_menu_draw_inline(rc)
1409 if self.__bell:
1410 rc.bell()
1411 self.__bell = False
1412 rc.render()
1414 try:
1415 event = next(events)
1416 except StopIteration:
1417 assert False, "_event_stream supposed to be infinite"
1419 if event == KeyboardEvent("c", ctrl=True):
1420 # windows doesn't handle C-c for us.
1421 raise KeyboardInterrupt()
1422 elif event == KeyboardEvent("l", ctrl=True):
1423 rc.clear_screen()
1424 elif event == KeyboardEvent(Key.F1) and not self.__in_help_menu:
1425 self.__in_help_menu = True
1426 self.__help_menu_line = 0
1427 self.__last_help_data = None
1428 elif self.__in_help_menu:
1429 self.__help_menu_event(event)
1430 elif result := self.event(event):
1431 return result.value
1432 finally:
1433 rc.finalize()
1435 def _bell(self):
1436 self.__bell = True
1438 @property
1439 def help_data(self) -> WidgetHelp:
1440 """
1441 Data for displaying help messages.
1443 See :func:`help` for more info.
1445 """
1447 return self.__help_columns
1449 @functools.cached_property
1450 def __help_columns(self) -> WidgetHelp:
1451 inline_help: list[Action] = []
1452 groups: dict[str, list[Action]] = {}
1454 for cb in self.__callbacks:
1455 bindings: list[_Binding] = getattr(cb, "__yuio_keybindings__", [])
1456 help: _Help | None = getattr(cb, "__yuio_help__", None)
1457 if not bindings:
1458 continue
1459 if help is None:
1460 help = _Help(
1461 "Actions",
1462 getattr(cb, "__doc__", None),
1463 getattr(cb, "__doc__", None),
1464 )
1465 if not help.inline_msg and not help.long_msg:
1466 continue
1468 if help.inline_msg:
1469 inline_bindings = [
1470 binding.event
1471 for binding in reversed(bindings)
1472 if binding.show_in_inline_help
1473 ]
1474 if inline_bindings:
1475 inline_help.append((inline_bindings, help.inline_msg))
1477 if help.long_msg:
1478 menu_bindings = [
1479 binding.event
1480 for binding in reversed(bindings)
1481 if binding.show_in_detailed_help
1482 ]
1483 if menu_bindings:
1484 groups.setdefault(help.group, []).append(
1485 (menu_bindings, help.long_msg)
1486 )
1488 return WidgetHelp(inline_help, groups)
1490 __last_help_data: WidgetHelp | None = None
1491 __prepared_inline_help: list[tuple[list[str], str, str, int]]
1492 __prepared_groups: dict[str, list[tuple[list[str], str, str, int]]]
1493 __has_help: bool = True
1494 __width: int = 0
1495 __height: int = 0
1496 __menu_content_height: int = 0
1497 __help_menu_line: int = 0
1498 __help_menu_search: bool = False
1499 __help_menu_search_widget: Input
1500 __help_menu_search_layout: tuple[int, int] = 0, 0
1501 __key_width: int = 0
1502 __wrapped_groups: list[
1503 tuple[
1504 str, # Title
1505 list[ # Actions
1506 tuple[ # Action
1507 list[str], # Keys
1508 list[_ColorizedString], # Wrapped msg
1509 int, # Keys width
1510 ],
1511 ],
1512 ] # FML this type hint -___-
1513 ]
1514 __colorized_inline_help: list[
1515 tuple[ # Action
1516 list[str], # Keys
1517 _ColorizedString, # Title
1518 int, # Keys width
1519 ]
1520 ]
1522 def __help_menu_event(self, e: KeyboardEvent, /) -> Result[T_co] | None:
1523 if not self.__help_menu_search and e in [
1524 KeyboardEvent(Key.F1),
1525 KeyboardEvent(Key.ESCAPE),
1526 KeyboardEvent(Key.ENTER),
1527 KeyboardEvent("q"),
1528 KeyboardEvent("q", ctrl=True),
1529 ]:
1530 self.__in_help_menu = False
1531 self.__help_menu_line = 0
1532 self.__last_help_data = None
1533 elif e == KeyboardEvent(Key.ARROW_UP):
1534 self.__help_menu_line += 1
1535 elif e == KeyboardEvent(Key.HOME):
1536 self.__help_menu_line = 0
1537 elif e == KeyboardEvent(Key.PAGE_UP):
1538 self.__help_menu_line += self.__height
1539 elif e == KeyboardEvent(Key.END):
1540 self.__help_menu_line = -self.__menu_content_height
1541 elif e == KeyboardEvent(Key.ARROW_DOWN):
1542 self.__help_menu_line -= 1
1543 elif e == KeyboardEvent(Key.PAGE_DOWN):
1544 self.__help_menu_line -= self.__height
1545 elif not self.__help_menu_search and e == KeyboardEvent(" "):
1546 self.__help_menu_line -= self.__height
1547 elif not self.__help_menu_search and e == KeyboardEvent("/"):
1548 self.__help_menu_search = True
1549 self.__help_menu_search_widget = Input(decoration="/")
1550 elif self.__help_menu_search:
1551 if e == KeyboardEvent(Key.ESCAPE) or (
1552 e == KeyboardEvent(Key.BACKSPACE)
1553 and not self.__help_menu_search_widget.text
1554 ):
1555 self.__help_menu_search = False
1556 self.__last_help_data = None
1557 del self.__help_menu_search_widget
1558 self.__help_menu_search_layout = 0, 0
1559 else:
1560 self.__help_menu_search_widget.event(e)
1561 self.__last_help_data = None
1562 self.__help_menu_line = min(
1563 max(-self.__menu_content_height + self.__height, self.__help_menu_line), 0
1564 )
1566 def __clear_layout_cache(self, rc: RenderContext, /) -> bool:
1567 if self.__width == rc.width and self.__last_help_data == self.help_data:
1568 return False
1570 if self.__width != rc.width:
1571 self.__help_menu_line = 0
1573 self.__width = rc.width
1574 self.__height = rc.height
1576 if self.__last_help_data != self.help_data:
1577 self.__last_help_data = self.help_data
1578 self.__prepared_groups = self.__prepare_groups(self.__last_help_data)
1579 self.__prepared_inline_help = self.__prepare_inline_help(
1580 self.__last_help_data
1581 )
1582 self.__has_help = bool(
1583 self.__last_help_data.inline_help or self.__last_help_data.groups
1584 )
1586 return True
1588 def __help_menu_layout(self, rc: RenderContext, /) -> tuple[int, int]:
1589 if self.__help_menu_search:
1590 self.__help_menu_search_layout = self.__help_menu_search_widget.layout(rc)
1592 if not self.__clear_layout_cache(rc):
1593 return rc.height, rc.height
1595 self.__key_width = 10
1596 formatter = yuio.md.MdFormatter(
1597 rc.theme,
1598 width=min(rc.width, 90) - self.__key_width - 2,
1599 allow_headings=False,
1600 )
1602 self.__wrapped_groups = []
1603 for title, actions in self.__prepared_groups.items():
1604 wrapped_actions: list[tuple[list[str], list[_ColorizedString], int]] = []
1605 for keys, _, msg, key_width in actions:
1606 wrapped_actions.append((keys, formatter.format(msg), key_width))
1607 self.__wrapped_groups.append((title, wrapped_actions))
1609 return rc.height, rc.height
1611 def __help_menu_draw(self, rc: RenderContext, /):
1612 y = self.__help_menu_line
1614 if not self.__wrapped_groups:
1615 rc.set_color_path("menu/decoration:help_menu")
1616 rc.write("No actions to display")
1617 y += 1
1619 for title, actions in self.__wrapped_groups:
1620 rc.set_pos(0, y)
1621 if title:
1622 rc.set_color_path("menu/text/heading:help_menu")
1623 rc.write(title)
1624 y += 2
1626 for keys, lines, key_width in actions:
1627 if key_width > self.__key_width:
1628 rc.set_pos(0, y)
1629 y += 1
1630 else:
1631 rc.set_pos(self.__key_width - key_width, y)
1632 sep = ""
1633 for key in keys:
1634 rc.set_color_path("menu/text/help_sep:help_menu")
1635 rc.write(sep)
1636 rc.set_color_path("menu/text/help_key:help_menu")
1637 rc.write(key)
1638 sep = "/"
1640 rc.set_pos(0 + self.__key_width + 2, y)
1641 rc.write_text(lines)
1642 y += len(lines)
1644 y += 2
1646 self.__menu_content_height = y - self.__help_menu_line
1648 with rc.frame(0, rc.height - max(self.__help_menu_search_layout[0], 1)):
1649 if self.__help_menu_search:
1650 rc.write(" " * rc.width)
1651 rc.set_pos(0, 0)
1652 self.__help_menu_search_widget.draw(rc)
1653 else:
1654 rc.set_color_path("menu/decoration:help_menu")
1655 rc.write(":")
1656 rc.reset_color()
1657 rc.write(" " * (rc.width - 1))
1658 rc.set_final_pos(1, 0)
1660 def __help_menu_layout_inline(self, rc: RenderContext, /) -> tuple[int, int]:
1661 if not self.__clear_layout_cache(rc):
1662 return (1, 1) if self.__has_help else (0, 0)
1664 if not self.__has_help:
1665 return 0, 0
1667 self.__colorized_inline_help = []
1668 for keys, title, _, key_width in self.__prepared_inline_help:
1669 if keys:
1670 title_color = "menu/text/help_msg:help"
1671 else:
1672 title_color = "menu/text/help_info:help"
1673 colorized_title = yuio.string.colorize(
1674 title, default_color=title_color, ctx=rc.theme
1675 )
1676 self.__colorized_inline_help.append((keys, colorized_title, key_width))
1678 return 1, 1
1680 def __help_menu_draw_inline(self, rc: RenderContext, /):
1681 if not self.__has_help:
1682 return
1684 used_width = _line_width(self._KEY_SYMBOLS[Key.F1]) + 5
1685 col_sep = ""
1687 for keys, title, keys_width in self.__colorized_inline_help:
1688 action_width = keys_width + bool(keys_width) + title.width + 3
1689 if used_width + action_width > rc.width:
1690 break
1692 rc.set_color_path("menu/text/help_sep:help")
1693 rc.write(col_sep)
1695 sep = ""
1696 for key in keys:
1697 rc.set_color_path("menu/text/help_sep:help")
1698 rc.write(sep)
1699 rc.set_color_path("menu/text/help_key:help")
1700 rc.write(key)
1701 sep = "/"
1703 if keys_width:
1704 rc.move_pos(1, 0)
1705 rc.write(title)
1707 col_sep = " • "
1709 rc.set_color_path("menu/text/help_sep:help")
1710 rc.write(col_sep)
1711 rc.set_color_path("menu/text/help_key:help")
1712 rc.write(self._KEY_SYMBOLS[Key.F1])
1713 rc.move_pos(1, 0)
1714 rc.set_color_path("menu/text/help_msg:help")
1715 rc.write("help")
1717 _ALT = "M-"
1718 _CTRL = "C-"
1719 _SHIFT = "S-"
1721 _KEY_SYMBOLS = {
1722 Key.ENTER: "ret",
1723 Key.ESCAPE: "esc",
1724 Key.DELETE: "del",
1725 Key.BACKSPACE: "bsp",
1726 Key.TAB: "tab",
1727 Key.HOME: "home",
1728 Key.END: "end",
1729 Key.PAGE_UP: "pgup",
1730 Key.PAGE_DOWN: "pgdn",
1731 Key.ARROW_UP: "↑",
1732 Key.ARROW_DOWN: "↓",
1733 Key.ARROW_LEFT: "←",
1734 Key.ARROW_RIGHT: "→",
1735 Key.F1: "f1",
1736 Key.F2: "f2",
1737 Key.F3: "f3",
1738 Key.F4: "f4",
1739 " ": "␣",
1740 }
1742 def __prepare_inline_help(
1743 self, data: WidgetHelp
1744 ) -> list[tuple[list[str], str, str, int]]:
1745 return [
1746 prepared_action
1747 for action in data.inline_help
1748 if (prepared_action := self.__prepare_action(action)) and prepared_action[1]
1749 ]
1751 def __prepare_groups(
1752 self, data: WidgetHelp
1753 ) -> dict[str, list[tuple[list[str], str, str, int]]]:
1754 help_data = (
1755 data.with_action(
1756 self._KEY_SYMBOLS[Key.F1],
1757 group="Other Actions",
1758 long_msg="toggle help menu",
1759 )
1760 .with_action(
1761 self._CTRL + "l",
1762 group="Other Actions",
1763 long_msg="refresh screen",
1764 )
1765 .with_action(
1766 self._CTRL + "c",
1767 group="Other Actions",
1768 long_msg="send interrupt signal",
1769 )
1770 .with_action(
1771 "C-...",
1772 group="Legend",
1773 long_msg="means `Ctrl+...`",
1774 )
1775 .with_action(
1776 "M-...",
1777 group="Legend",
1778 long_msg=(
1779 "means `Option+...`"
1780 if sys.platform == "darwin"
1781 else "means `Alt+...`"
1782 ),
1783 )
1784 .with_action(
1785 "S-...",
1786 group="Legend",
1787 long_msg="means `Shift+...`",
1788 )
1789 .with_action(
1790 "ret",
1791 group="Legend",
1792 long_msg="means `Return` or `Enter`",
1793 )
1794 .with_action(
1795 "bsp",
1796 group="Legend",
1797 long_msg="means `Backspace`",
1798 )
1799 )
1801 # Make sure unsorted actions go first.
1802 groups = {"Actions": []}
1804 groups.update(
1805 {
1806 title: prepared_actions
1807 for title, actions in help_data.groups.items()
1808 if (
1809 prepared_actions := [
1810 prepared_action
1811 for action in actions
1812 if (prepared_action := self.__prepare_action(action))
1813 and prepared_action[1]
1814 ]
1815 )
1816 }
1817 )
1819 if not groups["Actions"]:
1820 del groups["Actions"]
1822 # Make sure other actions go last.
1823 if "Other Actions" in groups:
1824 groups["Other Actions"] = groups.pop("Other Actions")
1825 if "Legend" in groups:
1826 groups["Legend"] = groups.pop("Legend")
1828 return groups
1830 def __prepare_action(
1831 self, action: Action
1832 ) -> tuple[list[str], str, str, int] | None:
1833 if isinstance(action, tuple):
1834 action_keys, msg = action
1835 prepared_keys = self.__prepare_keys(action_keys)
1836 else:
1837 prepared_keys = []
1838 msg = action
1840 if self.__help_menu_search:
1841 pattern = self.__help_menu_search_widget.text
1842 if not any(pattern in key for key in prepared_keys) and pattern not in msg:
1843 return None
1845 title = msg.split("\n\n", maxsplit=1)[0]
1846 return prepared_keys, title, msg, _line_width("/".join(prepared_keys))
1848 def __prepare_keys(self, action_keys: ActionKeys) -> list[str]:
1849 if isinstance(action_keys, (str, Key, KeyboardEvent)):
1850 return [self.__prepare_key(action_keys)]
1851 else:
1852 return [self.__prepare_key(action_key) for action_key in action_keys]
1854 def __prepare_key(self, action_key: ActionKey) -> str:
1855 if isinstance(action_key, str):
1856 return action_key
1857 elif isinstance(action_key, KeyboardEvent):
1858 ctrl, alt, shift, key = (
1859 action_key.ctrl,
1860 action_key.alt,
1861 action_key.shift,
1862 action_key.key,
1863 )
1864 else:
1865 ctrl, alt, shift, key = False, False, False, action_key
1867 symbol = ""
1869 if isinstance(key, str) and key.lower() != key:
1870 shift = True
1871 key = key.lower()
1873 if shift:
1874 symbol += self._SHIFT
1876 if ctrl:
1877 symbol += self._CTRL
1879 if alt:
1880 symbol += self._ALT
1882 return symbol + (self._KEY_SYMBOLS.get(key) or str(key))
1885Widget.__init_subclass__()
1888@dataclass(frozen=True, slots=True)
1889class _Binding:
1890 event: KeyboardEvent
1891 show_in_inline_help: bool
1892 show_in_detailed_help: bool
1894 def __call__(self, fn: T, /) -> T:
1895 if not hasattr(fn, "__yuio_keybindings__"):
1896 setattr(fn, "__yuio_keybindings__", [])
1897 getattr(fn, "__yuio_keybindings__").append(self)
1899 return fn
1902def bind(
1903 key: Key | str,
1904 *,
1905 ctrl: bool = False,
1906 alt: bool = False,
1907 shift: bool = False,
1908 show_in_inline_help: bool = False,
1909 show_in_detailed_help: bool = True,
1910) -> _Binding:
1911 """
1912 Register an event handler for a widget.
1914 Widget's methods can be registered as handlers for keyboard events.
1915 When a new event comes in, it is checked to match arguments of this decorator.
1916 If there is a match, the decorated method is called
1917 instead of the :meth:`Widget.default_event_handler`.
1919 .. note::
1921 :kbd:`Ctrl+L` and :kbd:`F1` are always reserved by the widget itself.
1923 If ``show_in_help`` is :data:`True`, this binding will be shown in the widget's
1924 inline help. If ``show_in_detailed_help`` is :data:`True`,
1925 this binding will be shown in the widget's help menu.
1927 Example::
1929 class MyWidget(Widget):
1930 @bind(Key.ENTER)
1931 def enter(self):
1932 # all `ENTER` events go here.
1933 ...
1935 def default_event_handler(self, e: KeyboardEvent):
1936 # all non-`ENTER` events go here (including `ALT+ENTER`).
1937 ...
1939 """
1941 e = KeyboardEvent(key=key, ctrl=ctrl, alt=alt, shift=shift)
1942 return _Binding(e, show_in_inline_help, show_in_detailed_help)
1945@dataclass(frozen=True, slots=True)
1946class _Help:
1947 group: str = "Actions"
1948 inline_msg: str | None = None
1949 long_msg: str | None = None
1951 def __call__(self, fn: T, /) -> T:
1952 h = dataclasses.replace(
1953 self,
1954 inline_msg=(
1955 self.inline_msg
1956 if self.inline_msg is not None
1957 else getattr(fn, "__doc__", None)
1958 ),
1959 long_msg=(
1960 self.long_msg
1961 if self.long_msg is not None
1962 else getattr(fn, "__doc__", None)
1963 ),
1964 )
1965 setattr(fn, "__yuio_help__", h)
1967 return fn
1970def help(
1971 *,
1972 group: str = "Actions",
1973 inline_msg: str | None = None,
1974 long_msg: str | None = None,
1975 msg: str | None = None,
1976) -> _Help:
1977 """
1978 Set options for how this callback should be displayed.
1980 This decorator controls automatic generation of help messages for a widget.
1982 :param group:
1983 title of a group that this action will appear in when the user opens
1984 a help menu. Groups appear in order of declaration of their first element.
1985 :param inline_msg:
1986 this parameter overrides a message in the inline help. By default,
1987 it will be taken from a docstring.
1988 :param long_msg:
1989 this parameter overrides a message in the help menu. By default,
1990 it will be taken from a docstring.
1991 :param msg:
1992 a shortcut parameter for setting both ``inline_msg`` and ``long_msg``
1993 at the same time.
1995 Example::
1997 class MyWidget(Widget):
1998 NAVIGATE = "Navigate"
2000 @bind(Key.TAB)
2001 @help(group=NAVIGATE)
2002 def tab(self):
2003 \"""next item\"""
2004 ...
2006 @bind(Key.TAB, shift=True)
2007 @help(group=NAVIGATE)
2008 def shift_tab(self):
2009 \"""previous item\"""
2010 ...
2012 """
2014 if msg is not None and inline_msg is None:
2015 inline_msg = msg
2016 if msg is not None and long_msg is None:
2017 long_msg = msg
2019 return _Help(
2020 group,
2021 inline_msg,
2022 long_msg,
2023 )
2026ActionKey: _t.TypeAlias = Key | KeyboardEvent | str
2027"""
2028A single key associated with an action.
2029Can be either a hotkey or a string with an arbitrary description.
2031"""
2034ActionKeys: _t.TypeAlias = ActionKey | _t.Collection[ActionKey]
2035"""
2036A list of keys associated with an action.
2038"""
2041Action: _t.TypeAlias = str | tuple[ActionKeys, str]
2042"""
2043An action itself, i.e. a set of hotkeys and a description for them.
2045"""
2048@dataclass(frozen=True, slots=True)
2049class WidgetHelp:
2050 """
2051 Data for automatic help generation.
2053 .. warning::
2055 Do not modify contents of this class in-place. This might break layout
2056 caching in the widget rendering routine, which will cause displaying
2057 outdated help messages.
2059 Use the provided helpers to modify contents of this class.
2061 """
2063 inline_help: list[Action] = dataclasses.field(default_factory=list)
2064 """
2065 List of actions to show in the inline help.
2067 """
2069 groups: dict[str, list[Action]] = dataclasses.field(default_factory=dict)
2070 """
2071 Dict of group titles and actions to show in the help menu.
2073 """
2075 def with_action(
2076 self,
2077 *bindings: _Binding | ActionKey,
2078 group: str = "Actions",
2079 msg: str | None = None,
2080 inline_msg: str | None = None,
2081 long_msg: str | None = None,
2082 prepend: bool = False,
2083 prepend_group: bool = False,
2084 ) -> WidgetHelp:
2085 """
2086 Return a new :class:`WidgetHelp` that has an extra action.
2088 :param bindings:
2089 keys that trigger an action.
2090 :param group:
2091 title of a group that this action will appear in when the user opens
2092 a help menu. Groups appear in order of declaration of their first element.
2093 :param inline_msg:
2094 this parameter overrides a message in the inline help. By default,
2095 it will be taken from a docstring.
2096 :param long_msg:
2097 this parameter overrides a message in the help menu. By default,
2098 it will be taken from a docstring.
2099 :param msg:
2100 a shortcut parameter for setting both ``inline_msg`` and ``long_msg``
2101 at the same time.
2102 :param prepend:
2103 if :data:`True`, action will be added to the beginning of its group.
2104 :param prepend_group:
2105 if :data:`True`, group will be added to the beginning of the help menu.
2107 """
2109 return WidgetHelp(self.inline_help.copy(), self.groups.copy()).__add_action(
2110 *bindings,
2111 group=group,
2112 inline_msg=inline_msg,
2113 long_msg=long_msg,
2114 prepend=prepend,
2115 prepend_group=prepend_group,
2116 msg=msg,
2117 )
2119 def merge(self, other: WidgetHelp, /) -> WidgetHelp:
2120 """
2121 Merge this help data with another one and return
2122 a new instance of :class:`WidgetHelp`.
2124 :param other:
2125 other :class:`WidgetHelp` for merging.
2127 """
2129 result = WidgetHelp(self.inline_help.copy(), self.groups.copy())
2130 result.inline_help.extend(other.inline_help)
2131 for title, actions in other.groups.items():
2132 result.groups[title] = result.groups.get(title, []) + actions
2133 return result
2135 def without_group(self, title: str, /) -> WidgetHelp:
2136 """
2137 Return a new :class:`WidgetHelp` that has a group with the given title removed.
2139 :param title:
2140 title to remove.
2142 """
2144 result = WidgetHelp(self.inline_help.copy(), self.groups.copy())
2145 result.groups.pop(title, None)
2146 return result
2148 def rename_group(self, title: str, new_title: str, /) -> WidgetHelp:
2149 """
2150 Return a new :class:`WidgetHelp` that has a group with the given title renamed.
2152 :param title:
2153 title to replace.
2154 :param new_title:
2155 new title.
2157 """
2159 result = WidgetHelp(self.inline_help.copy(), self.groups.copy())
2160 if group := result.groups.pop(title, None):
2161 result.groups[new_title] = result.groups.get(new_title, []) + group
2162 return result
2164 def __add_action(
2165 self,
2166 *bindings: _Binding | ActionKey,
2167 group: str,
2168 inline_msg: str | None,
2169 long_msg: str | None,
2170 prepend: bool,
2171 prepend_group: bool,
2172 msg: str | None,
2173 ) -> WidgetHelp:
2174 settings = help(
2175 group=group,
2176 inline_msg=inline_msg,
2177 long_msg=long_msg,
2178 msg=msg,
2179 )
2181 if settings.inline_msg:
2182 inline_keys: ActionKeys = [
2183 binding.event if isinstance(binding, _Binding) else binding
2184 for binding in bindings
2185 if not isinstance(binding, _Binding) or binding.show_in_inline_help
2186 ]
2187 if prepend:
2188 self.inline_help.insert(0, (inline_keys, settings.inline_msg))
2189 else:
2190 self.inline_help.append((inline_keys, settings.inline_msg))
2192 if settings.long_msg:
2193 menu_keys: ActionKeys = [
2194 binding.event if isinstance(binding, _Binding) else binding
2195 for binding in bindings
2196 if not isinstance(binding, _Binding) or binding.show_in_detailed_help
2197 ]
2198 if prepend_group and settings.group not in self.groups:
2199 # Re-create self.groups with a new group as a first element.
2200 groups = {settings.group: [], **self.groups}
2201 self.groups.clear()
2202 self.groups.update(groups)
2203 if prepend:
2204 self.groups[settings.group] = [
2205 (menu_keys, settings.long_msg)
2206 ] + self.groups.get(settings.group, [])
2207 else:
2208 self.groups[settings.group] = self.groups.get(settings.group, []) + [
2209 (menu_keys, settings.long_msg)
2210 ]
2212 return self
2215@_t.final
2216class VerticalLayoutBuilder(_t.Generic[T]):
2217 """
2218 Builder for :class:`VerticalLayout` that allows for precise control
2219 of keyboard events.
2221 By default, :class:`VerticalLayout` does not handle incoming keyboard events.
2222 However, you can create :class:`VerticalLayout` that forwards all keyboard events
2223 to a particular widget within the stack::
2225 widget = VerticalLayout.builder() \\
2226 .add(Line("Enter something:")) \\
2227 .add(Input(), receive_events=True) \\
2228 .build()
2230 result = widget.run(term, theme)
2232 """
2234 if _t.TYPE_CHECKING:
2236 def __new__(cls) -> VerticalLayoutBuilder[_t.Never]: ...
2238 def __init__(self):
2239 self._widgets: list[Widget[_t.Any]] = []
2240 self._event_receiver: int | None = None
2242 @_t.overload
2243 def add(
2244 self, widget: Widget[_t.Any], /, *, receive_events: _t.Literal[False] = False
2245 ) -> VerticalLayoutBuilder[T]: ...
2247 @_t.overload
2248 def add(
2249 self, widget: Widget[U], /, *, receive_events: _t.Literal[True]
2250 ) -> VerticalLayoutBuilder[U]: ...
2252 def add(self, widget: Widget[_t.Any], /, *, receive_events=False) -> _t.Any:
2253 """
2254 Add a new widget to the bottom of the layout.
2256 If `receive_events` is `True`, all incoming events will be forwarded
2257 to the added widget. Only the latest widget added with ``receive_events=True``
2258 will receive events.
2260 This method does not mutate the builder, but instead returns a new one.
2261 Use it with method chaining.
2263 """
2265 other = VerticalLayoutBuilder()
2267 other._widgets = self._widgets.copy()
2268 other._event_receiver = self._event_receiver
2270 if isinstance(widget, VerticalLayout):
2271 if receive_events and widget._event_receiver is not None:
2272 other._event_receiver = len(other._widgets) + widget._event_receiver
2273 elif receive_events:
2274 other._event_receiver = None
2275 other._widgets.extend(widget._widgets)
2276 else:
2277 if receive_events:
2278 other._event_receiver = len(other._widgets)
2279 other._widgets.append(widget)
2281 return other
2283 def build(self) -> VerticalLayout[T]:
2284 layout = VerticalLayout()
2285 layout._widgets = self._widgets
2286 layout._event_receiver = self._event_receiver
2287 return _t.cast(VerticalLayout[T], layout)
2290class VerticalLayout(Widget[T], _t.Generic[T]):
2291 """
2292 Helper class for stacking widgets together.
2294 You can stack your widgets together, then calculate their layout
2295 and draw them all at once.
2297 You can use this class as a helper component inside your own widgets,
2298 or you can use it as a standalone widget. See :class:`~VerticalLayoutBuilder`
2299 for an example.
2301 .. automethod:: append
2303 .. automethod:: extend
2305 .. automethod:: event
2307 .. automethod:: layout
2309 .. automethod:: draw
2311 """
2313 if _t.TYPE_CHECKING:
2315 def __new__(cls, *widgets: Widget[object]) -> VerticalLayout[_t.Never]: ...
2317 def __init__(self, *widgets: Widget[object]):
2318 self._widgets: list[Widget[object]] = list(widgets)
2319 self._event_receiver: int | None = None
2321 self.__layouts: list[tuple[int, int]] = []
2322 self.__min_h: int = 0
2323 self.__max_h: int = 0
2325 def append(self, widget: Widget[_t.Any], /):
2326 """
2327 Add a widget to the end of the stack.
2329 """
2331 if isinstance(widget, VerticalLayout):
2332 self._widgets.extend(widget._widgets)
2333 else:
2334 self._widgets.append(widget)
2336 def extend(self, widgets: _t.Iterable[Widget[_t.Any]], /):
2337 """
2338 Add multiple widgets to the end of the stack.
2340 """
2342 for widget in widgets:
2343 self.append(widget)
2345 def event(self, e: KeyboardEvent) -> Result[T] | None:
2346 """
2347 Dispatch event to the widget that was added with ``receive_events=True``.
2349 See :class:`~VerticalLayoutBuilder` for details.
2351 """
2353 if self._event_receiver is not None:
2354 return _t.cast(
2355 Result[T] | None, self._widgets[self._event_receiver].event(e)
2356 )
2358 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
2359 """
2360 Calculate layout of the entire stack.
2362 """
2364 self.__layouts = [widget.layout(rc) for widget in self._widgets]
2365 assert all(l[0] <= l[1] for l in self.__layouts), "incorrect layout"
2366 self.__min_h = sum(l[0] for l in self.__layouts)
2367 self.__max_h = sum(l[1] for l in self.__layouts)
2368 return self.__min_h, self.__max_h
2370 def draw(self, rc: RenderContext, /):
2371 """
2372 Draw the stack according to the calculated layout and available height.
2374 """
2376 assert len(self._widgets) == len(self.__layouts), (
2377 "you need to call `VerticalLayout.layout()` before `VerticalLayout.draw()`"
2378 )
2380 if rc.height <= self.__min_h:
2381 scale = 0.0
2382 elif rc.height >= self.__max_h:
2383 scale = 1.0
2384 else:
2385 scale = (rc.height - self.__min_h) / (self.__max_h - self.__min_h)
2387 y1 = 0.0
2388 for widget, (min_h, max_h) in zip(self._widgets, self.__layouts):
2389 y2 = y1 + min_h + scale * (max_h - min_h)
2391 iy1 = round(y1)
2392 iy2 = round(y2)
2394 with rc.frame(0, iy1, height=iy2 - iy1):
2395 widget.draw(rc)
2397 y1 = y2
2399 @property
2400 def help_data(self) -> WidgetHelp:
2401 if self._event_receiver is not None:
2402 return self._widgets[self._event_receiver].help_data
2403 else:
2404 return WidgetHelp()
2407class Line(Widget[_t.Never]):
2408 """
2409 A widget that prints a single line of text.
2411 """
2413 def __init__(
2414 self,
2415 text: yuio.string.AnyString,
2416 /,
2417 *,
2418 color: _Color | str | None = None,
2419 ):
2420 self.__text = _ColorizedString(text)
2421 self.__colorized_text = None
2422 self.__color = color
2424 @property
2425 def text(self) -> _ColorizedString:
2426 """
2427 Currently displayed text.
2429 """
2430 return self.__text
2432 @text.setter
2433 def text(self, text: yuio.string.AnyString, /):
2434 self.__text = _ColorizedString(text)
2436 @property
2437 def color(self) -> _Color | str | None:
2438 """
2439 Color of the currently displayed text.
2441 """
2442 return self.__color
2444 @color.setter
2445 def color(self, color: _Color | str | None, /):
2446 self.__color = color
2448 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
2449 return 1, 1
2451 def draw(self, rc: RenderContext, /):
2452 if self.__colorized_text is None:
2453 if self.__color is None:
2454 self.__colorized_text = self.__text
2455 else:
2456 color = rc.theme.to_color(self.__color)
2457 self.__colorized_text = self.__text.with_base_color(color)
2459 rc.write(self.__colorized_text)
2462class Text(Widget[_t.Never]):
2463 """
2464 A widget that prints wrapped text.
2466 """
2468 def __init__(
2469 self,
2470 text: yuio.string.AnyString,
2471 /,
2472 ):
2473 self.__text = _ColorizedString(text)
2474 self.__wrapped_text: list[_ColorizedString] | None = None
2475 self.__wrapped_text_width: int = 0
2477 @property
2478 def text(self) -> _ColorizedString:
2479 """
2480 Currently displayed text.
2482 """
2483 return self.__text
2485 @text.setter
2486 def text(self, text: yuio.string.AnyString, /):
2487 self.__text = _ColorizedString(text)
2488 self.__wrapped_text = None
2489 self.__wrapped_text_width = 0
2491 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
2492 if self.__wrapped_text is None or self.__wrapped_text_width != rc.width:
2493 self.__wrapped_text = self.__text.wrap(
2494 rc.width,
2495 break_long_nowrap_words=True,
2496 )
2497 self.__wrapped_text_width = rc.width
2498 height = len(self.__wrapped_text)
2499 return height, height
2501 def draw(self, rc: RenderContext, /):
2502 assert self.__wrapped_text is not None
2503 rc.write_text(self.__wrapped_text)
2506_CHAR_NAMES = {
2507 "\u0000": "<NUL>",
2508 "\u0001": "<SOH>",
2509 "\u0002": "<STX>",
2510 "\u0003": "<ETX>",
2511 "\u0004": "<EOT>",
2512 "\u0005": "<ENQ>",
2513 "\u0006": "<ACK>",
2514 "\u0007": "\\a",
2515 "\u0008": "\\b",
2516 "\u0009": "\\t",
2517 "\u000b": "\\v",
2518 "\u000c": "\\f",
2519 "\u000d": "\\r",
2520 "\u000e": "<SO>",
2521 "\u000f": "<SI>",
2522 "\u0010": "<DLE>",
2523 "\u0011": "<DC1>",
2524 "\u0012": "<DC2>",
2525 "\u0013": "<DC3>",
2526 "\u0014": "<DC4>",
2527 "\u0015": "<NAK>",
2528 "\u0016": "<SYN>",
2529 "\u0017": "<ETB>",
2530 "\u0018": "<CAN>",
2531 "\u0019": "<EM>",
2532 "\u001a": "<SUB>",
2533 "\u001b": "<ESC>",
2534 "\u001c": "<FS>",
2535 "\u001d": "<GS>",
2536 "\u001e": "<RS>",
2537 "\u001f": "<US>",
2538 "\u007f": "<DEL>",
2539 "\u0080": "<PAD>",
2540 "\u0081": "<HOP>",
2541 "\u0082": "<BPH>",
2542 "\u0083": "<NBH>",
2543 "\u0084": "<IND>",
2544 "\u0085": "<NEL>",
2545 "\u0086": "<SSA>",
2546 "\u0087": "<ESA>",
2547 "\u0088": "<HTS>",
2548 "\u0089": "<HTJ>",
2549 "\u008a": "<VTS>",
2550 "\u008b": "<PLD>",
2551 "\u008c": "<PLU>",
2552 "\u008d": "<RI>",
2553 "\u008e": "<SS2>",
2554 "\u008f": "<SS3>",
2555 "\u0090": "<DCS>",
2556 "\u0091": "<PU1>",
2557 "\u0092": "<PU2>",
2558 "\u0093": "<STS>",
2559 "\u0094": "<CCH>",
2560 "\u0095": "<MW>",
2561 "\u0096": "<SPA>",
2562 "\u0097": "<EPA>",
2563 "\u0098": "<SOS>",
2564 "\u0099": "<SGCI>",
2565 "\u009a": "<SCI>",
2566 "\u009b": "<CSI>",
2567 "\u009c": "<ST>",
2568 "\u009d": "<OSC>",
2569 "\u009e": "<PM>",
2570 "\u009f": "<APC>",
2571 "\u00a0": "<NBSP>",
2572 "\u00ad": "<SHY>",
2573}
2575_ESC_RE = re.compile(r"([" + re.escape("".join(map(str, _CHAR_NAMES))) + "])")
2578def _replace_special_symbols(text: str, esc_color: _Color, n_color: _Color):
2579 raw: list[_Color | str] = [n_color]
2580 i = 0
2581 for match in _ESC_RE.finditer(text):
2582 if s := text[i : match.start()]:
2583 raw.append(s)
2584 raw.append(esc_color)
2585 raw.append(_Esc(_CHAR_NAMES[match.group(1)]))
2586 raw.append(n_color)
2587 i = match.end()
2588 if i < len(text):
2589 raw.append(text[i:])
2590 return raw
2593def _find_cursor_pos(text: list[_ColorizedString], text_width: int, offset: int):
2594 total_len = 0
2595 if not offset:
2596 return (0, 0)
2597 for y, line in enumerate(text):
2598 x = 0
2599 for part in line:
2600 if isinstance(part, _Esc):
2601 l = 1
2602 dx = len(part)
2603 elif isinstance(part, str):
2604 l = len(part)
2605 dx = _line_width(part)
2606 else:
2607 continue
2608 if total_len + l >= offset:
2609 if isinstance(part, _Esc):
2610 x += dx
2611 else:
2612 x += _line_width(part[: offset - total_len])
2613 if x >= text_width:
2614 return (0, y + 1)
2615 else:
2616 return (0 + x, y)
2617 break
2618 x += dx
2619 total_len += l
2620 total_len += len(line.explicit_newline)
2621 if total_len >= offset:
2622 return (0, y + 1)
2623 assert False
2626class Input(Widget[str]):
2627 """
2628 An input box.
2630 .. vhs:: /_tapes/widget_input.tape
2631 :alt: Demonstration of `Input` widget.
2632 :scale: 40%
2634 .. note::
2636 :class:`Input` is not optimized to handle long texts or long editing sessions.
2637 It's best used to get relatively short answers from users
2638 with :func:`yuio.io.ask`. If you need to edit large text, especially multiline,
2639 consider using :func:`yuio.io.edit` instead.
2641 :param text:
2642 initial text.
2643 :param pos:
2644 initial cursor position, calculated as an offset from beginning of the text.
2645 Should be ``0 <= pos <= len(text)``.
2646 :param placeholder:
2647 placeholder text, shown when input is empty.
2648 :param decoration:
2649 decoration printed before the input box.
2650 :param allow_multiline:
2651 if `True`, :kbd:`Enter` key makes a new line, otherwise it accepts input.
2652 In this mode, newlines in pasted text are also preserved.
2653 :param allow_special_characters:
2654 If `True`, special characters like tabs or escape symbols are preserved
2655 and not replaced with whitespaces.
2657 """
2659 # Characters that count as word separators, used when navigating input text
2660 # via hotkeys.
2661 _WORD_SEPARATORS = string.punctuation + string.whitespace
2663 # Character that replaces newlines and unprintable characters when
2664 # `allow_multiline`/`allow_special_characters` is `False`.
2665 _UNPRINTABLE_SUBSTITUTOR = " "
2667 class _CheckpointType(enum.Enum):
2668 """
2669 Types of entries in the history buffer.
2671 """
2673 USR = enum.auto()
2674 """
2675 User-initiated checkpoint.
2677 """
2679 SYM = enum.auto()
2680 """
2681 Checkpoint before a symbol was inserted.
2683 """
2685 SEP = enum.auto()
2686 """
2687 Checkpoint before a space was inserted.
2689 """
2691 DEL = enum.auto()
2692 """
2693 Checkpoint before something was deleted.
2695 """
2697 def __init__(
2698 self,
2699 *,
2700 text: str = "",
2701 pos: int | None = None,
2702 placeholder: str = "",
2703 decoration: str = ">",
2704 allow_multiline: bool = False,
2705 allow_special_characters: bool = False,
2706 ):
2707 self.__text: str = text
2708 self.__pos: int = len(text) if pos is None else max(0, min(pos, len(text)))
2709 self.__placeholder: str = placeholder
2710 self.__decoration: str = decoration
2711 self.__allow_multiline: bool = allow_multiline
2712 self.__allow_special_characters: bool = allow_special_characters
2714 self.__wrapped_text_width: int = 0
2715 self.__wrapped_text: list[_ColorizedString] | None = None
2716 self.__pos_after_wrap: tuple[int, int] | None = None
2718 # We keep track of edit history by saving input text
2719 # and cursor position in this list.
2720 self.__history: list[tuple[str, int, Input._CheckpointType]] = [
2721 (self.__text, self.__pos, Input._CheckpointType.SYM)
2722 ]
2723 # Sometimes we don't record all actions. For example, entering multiple spaces
2724 # one after the other, or entering multiple symbols one after the other,
2725 # will only generate one checkpoint. We keep track of how many items
2726 # were skipped this way since the last checkpoint.
2727 self.__history_skipped_actions = 0
2728 # After we move a cursor, the logic with skipping checkpoints
2729 # should be momentarily disabled. This avoids inconsistencies in situations
2730 # where we've typed a word, moved the cursor, then typed another word.
2731 self.__require_checkpoint: bool = False
2733 # All delete operations save deleted text here. Pressing `C-y` pastes deleted
2734 # text at the position of the cursor.
2735 self.__yanked_text: str = ""
2737 @property
2738 def text(self) -> str:
2739 """
2740 Current text in the input box.
2742 """
2743 return self.__text
2745 @text.setter
2746 def text(self, text: str, /):
2747 self.__text = text
2748 self.__wrapped_text = None
2749 if self.pos > len(text):
2750 self.pos = len(text)
2752 @property
2753 def pos(self) -> int:
2754 """
2755 Current cursor position, measured in code points before the cursor.
2757 That is, if the text is `"quick brown fox"` with cursor right before the word
2758 "brown", then :attr:`~Input.pos` is equal to `len("quick ")`.
2760 """
2761 return self.__pos
2763 @pos.setter
2764 def pos(self, pos: int, /):
2765 self.__pos = max(0, min(pos, len(self.__text)))
2766 self.__pos_after_wrap = None
2768 def checkpoint(self):
2769 """
2770 Manually create an entry in the history buffer.
2772 """
2773 self.__history.append((self.text, self.pos, Input._CheckpointType.USR))
2774 self.__history_skipped_actions = 0
2776 def restore_checkpoint(self):
2777 """
2778 Restore the last manually created checkpoint.
2780 """
2781 if self.__history[-1][2] is Input._CheckpointType.USR:
2782 self.undo()
2784 def _internal_checkpoint(self, action: Input._CheckpointType, text: str, pos: int):
2785 prev_text, prev_pos, prev_action = self.__history[-1]
2787 if action == prev_action and not self.__require_checkpoint:
2788 # If we're repeating the same action, don't create a checkpoint.
2789 # I.e. if we're typing a word, we don't want to create checkpoints
2790 # for every letter.
2791 self.__history_skipped_actions += 1
2792 return
2794 prev_skipped_actions = self.__history_skipped_actions
2795 self.__history_skipped_actions = 0
2797 if (
2798 action == Input._CheckpointType.SYM
2799 and prev_action == Input._CheckpointType.SEP
2800 and prev_skipped_actions == 0
2801 and not self.__require_checkpoint
2802 ):
2803 # If we're inserting a symbol after we've typed a single space,
2804 # we only want one checkpoint for both space and symbols.
2805 # Thus, we simply change the type of the last checkpoint.
2806 self.__history[-1] = prev_text, prev_pos, action
2807 return
2809 if text == prev_text and pos == prev_pos:
2810 # This could happen when user presses backspace while the cursor
2811 # is at the text's beginning. We don't want to create
2812 # a checkpoint for this.
2813 return
2815 self.__history.append((text, pos, action))
2816 if len(self.__history) > 50:
2817 self.__history.pop(0)
2819 self.__require_checkpoint = False
2821 @bind(Key.ENTER)
2822 def enter(self) -> Result[str] | None:
2823 if self.__allow_multiline:
2824 self.insert("\n")
2825 else:
2826 return self.alt_enter()
2828 @bind(Key.ENTER, alt=True)
2829 @bind("d", ctrl=True)
2830 def alt_enter(self) -> Result[str] | None:
2831 return Result(self.text)
2833 _NAVIGATE = "Navigate"
2835 @bind(Key.ARROW_UP)
2836 @bind("p", ctrl=True)
2837 @help(group=_NAVIGATE)
2838 def up(self, /, *, checkpoint: bool = True):
2839 """up"""
2840 pos = self.pos
2841 self.home()
2842 if self.pos:
2843 width = _line_width(self.text[self.pos : pos])
2845 self.left()
2846 self.home()
2848 pos = self.pos
2849 text = self.text
2850 cur_width = 0
2851 while pos < len(text) and text[pos] != "\n":
2852 if cur_width >= width:
2853 break
2854 cur_width += _line_width(text[pos])
2855 pos += 1
2857 self.pos = pos
2859 self.__require_checkpoint |= checkpoint
2861 @bind(Key.ARROW_DOWN)
2862 @bind("n", ctrl=True)
2863 @help(group=_NAVIGATE)
2864 def down(self, /, *, checkpoint: bool = True):
2865 """down"""
2866 pos = self.pos
2867 self.home()
2868 width = _line_width(self.text[self.pos : pos])
2869 self.end()
2871 if self.pos < len(self.text):
2872 self.right()
2874 pos = self.pos
2875 text = self.text
2876 cur_width = 0
2877 while pos < len(text) and text[pos] != "\n":
2878 if cur_width >= width:
2879 break
2880 cur_width += _line_width(text[pos])
2881 pos += 1
2883 self.pos = pos
2885 self.__require_checkpoint |= checkpoint
2887 @bind(Key.ARROW_LEFT)
2888 @bind("b", ctrl=True)
2889 @help(group=_NAVIGATE)
2890 def left(self, /, *, checkpoint: bool = True):
2891 """left"""
2892 self.pos -= 1
2893 self.__require_checkpoint |= checkpoint
2895 @bind(Key.ARROW_RIGHT)
2896 @bind("f", ctrl=True)
2897 @help(group=_NAVIGATE)
2898 def right(self, /, *, checkpoint: bool = True):
2899 """right"""
2900 self.pos += 1
2901 self.__require_checkpoint |= checkpoint
2903 @bind(Key.ARROW_LEFT, alt=True)
2904 @bind("b", alt=True)
2905 @help(group=_NAVIGATE)
2906 def left_word(self, /, *, checkpoint: bool = True):
2907 """left one word"""
2908 pos = self.pos
2909 text = self.text
2910 if pos:
2911 pos -= 1
2912 while pos and text[pos] in self._WORD_SEPARATORS and text[pos - 1] != "\n":
2913 pos -= 1
2914 while pos and text[pos - 1] not in self._WORD_SEPARATORS:
2915 pos -= 1
2916 self.pos = pos
2917 self.__require_checkpoint |= checkpoint
2919 @bind(Key.ARROW_RIGHT, alt=True)
2920 @bind("f", alt=True)
2921 @help(group=_NAVIGATE)
2922 def right_word(self, /, *, checkpoint: bool = True):
2923 """right one word"""
2924 pos = self.pos
2925 text = self.text
2926 if pos < len(text) and text[pos] == "\n":
2927 pos += 1
2928 while (
2929 pos < len(text) and text[pos] in self._WORD_SEPARATORS and text[pos] != "\n"
2930 ):
2931 pos += 1
2932 while pos < len(text) and text[pos] not in self._WORD_SEPARATORS:
2933 pos += 1
2934 self.pos = pos
2935 self.__require_checkpoint |= checkpoint
2937 @bind(Key.HOME)
2938 @bind("a", ctrl=True)
2939 @help(group=_NAVIGATE)
2940 def home(self, /, *, checkpoint: bool = True):
2941 """to line start"""
2942 self.pos = self.text.rfind("\n", 0, self.pos) + 1
2943 self.__require_checkpoint |= checkpoint
2945 @bind(Key.END)
2946 @bind("e", ctrl=True)
2947 @help(group=_NAVIGATE)
2948 def end(self, /, *, checkpoint: bool = True):
2949 """to line end"""
2950 next_nl = self.text.find("\n", self.pos)
2951 if next_nl == -1:
2952 self.pos = len(self.text)
2953 else:
2954 self.pos = next_nl
2955 self.__require_checkpoint |= checkpoint
2957 _MODIFY = "Modify"
2959 @bind(Key.BACKSPACE)
2960 @bind("h", ctrl=True)
2961 @help(group=_MODIFY)
2962 def backspace(self):
2963 """backspace"""
2964 prev_pos = self.pos
2965 self.left(checkpoint=False)
2966 if prev_pos != self.pos:
2967 self._internal_checkpoint(Input._CheckpointType.DEL, self.text, prev_pos)
2968 self.text = self.text[: self.pos] + self.text[prev_pos:]
2969 else:
2970 self._bell()
2972 @bind(Key.DELETE)
2973 @help(group=_MODIFY)
2974 def delete(self):
2975 """delete"""
2976 prev_pos = self.pos
2977 self.right(checkpoint=False)
2978 if prev_pos != self.pos:
2979 self._internal_checkpoint(Input._CheckpointType.DEL, self.text, prev_pos)
2980 self.text = self.text[:prev_pos] + self.text[self.pos :]
2981 self.pos = prev_pos
2982 else:
2983 self._bell()
2985 @bind(Key.BACKSPACE, alt=True)
2986 @bind("w", ctrl=True)
2987 @help(group=_MODIFY)
2988 def backspace_word(self):
2989 """backspace one word"""
2990 prev_pos = self.pos
2991 self.left_word(checkpoint=False)
2992 if prev_pos != self.pos:
2993 self._internal_checkpoint(Input._CheckpointType.DEL, self.text, prev_pos)
2994 self.__yanked_text = self.text[self.pos : prev_pos]
2995 self.text = self.text[: self.pos] + self.text[prev_pos:]
2996 else:
2997 self._bell()
2999 @bind(Key.DELETE, alt=True)
3000 @bind("d", alt=True)
3001 @help(group=_MODIFY)
3002 def delete_word(self):
3003 """delete one word"""
3004 prev_pos = self.pos
3005 self.right_word(checkpoint=False)
3006 if prev_pos != self.pos:
3007 self._internal_checkpoint(Input._CheckpointType.DEL, self.text, prev_pos)
3008 self.__yanked_text = self.text[prev_pos : self.pos]
3009 self.text = self.text[:prev_pos] + self.text[self.pos :]
3010 self.pos = prev_pos
3011 else:
3012 self._bell()
3014 @bind("u", ctrl=True)
3015 @help(group=_MODIFY)
3016 def backspace_home(self):
3017 """backspace to the beginning of a line"""
3018 prev_pos = self.pos
3019 self.home(checkpoint=False)
3020 if prev_pos != self.pos:
3021 self._internal_checkpoint(Input._CheckpointType.DEL, self.text, prev_pos)
3022 self.__yanked_text = self.text[self.pos : prev_pos]
3023 self.text = self.text[: self.pos] + self.text[prev_pos:]
3024 else:
3025 self._bell()
3027 @bind("k", ctrl=True)
3028 @help(group=_MODIFY)
3029 def delete_end(self):
3030 """delete to the ending of a line"""
3031 prev_pos = self.pos
3032 self.end(checkpoint=False)
3033 if prev_pos != self.pos:
3034 self._internal_checkpoint(Input._CheckpointType.DEL, self.text, prev_pos)
3035 self.__yanked_text = self.text[prev_pos : self.pos]
3036 self.text = self.text[:prev_pos] + self.text[self.pos :]
3037 self.pos = prev_pos
3038 else:
3039 self._bell()
3041 @bind("y", ctrl=True)
3042 @help(group=_MODIFY)
3043 def yank(self):
3044 """yank (paste the last deleted text)"""
3045 if self.__yanked_text:
3046 self.__require_checkpoint = True
3047 self.insert(self.__yanked_text)
3048 else:
3049 self._bell()
3051 # the actual shortcut is `C-7`, the rest produce the same code...
3052 @bind("7", ctrl=True, show_in_detailed_help=False)
3053 @bind("_", ctrl=True, show_in_detailed_help=False)
3054 @bind("-", ctrl=True)
3055 @bind("?", ctrl=True)
3056 @help(group=_MODIFY)
3057 def undo(self):
3058 """undo"""
3059 self.text, self.pos, _ = self.__history[-1]
3060 if len(self.__history) > 1:
3061 self.__history.pop()
3062 else:
3063 self._bell()
3065 def default_event_handler(self, e: KeyboardEvent):
3066 if e.key is Key.PASTE:
3067 self.__require_checkpoint = True
3068 s = e.paste_str or ""
3069 if self.__allow_special_characters and self.__allow_multiline:
3070 pass
3071 elif self.__allow_multiline:
3072 s = re.sub(_UNPRINTABLE_RE_WITHOUT_NL, self._UNPRINTABLE_SUBSTITUTOR, s)
3073 elif self.__allow_special_characters:
3074 s = s.replace("\n", self._UNPRINTABLE_SUBSTITUTOR)
3075 else:
3076 s = re.sub(_UNPRINTABLE_RE, self._UNPRINTABLE_SUBSTITUTOR, s)
3077 self.insert(s)
3078 elif e.key is Key.TAB:
3079 if self.__allow_special_characters:
3080 self.insert("\t")
3081 else:
3082 self.insert(self._UNPRINTABLE_SUBSTITUTOR)
3083 elif isinstance(e.key, str) and not e.alt and not e.ctrl:
3084 self.insert(e.key)
3086 def insert(self, s: str):
3087 if not s:
3088 return
3090 self._internal_checkpoint(
3091 (
3092 Input._CheckpointType.SEP
3093 if s in self._WORD_SEPARATORS
3094 else Input._CheckpointType.SYM
3095 ),
3096 self.text,
3097 self.pos,
3098 )
3100 self.text = self.text[: self.pos] + s + self.text[self.pos :]
3101 self.pos += len(s)
3103 @property
3104 def _decoration_width(self):
3105 if self.__decoration:
3106 return _line_width(self.__decoration) + 1
3107 else:
3108 return 0
3110 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
3111 decoration_width = self._decoration_width
3112 text_width = rc.width - decoration_width
3113 if text_width < 2:
3114 self.__wrapped_text_width = max(text_width, 0)
3115 self.__wrapped_text = None
3116 self.__pos_after_wrap = None
3117 return 0, 0
3119 if self.__wrapped_text is None or self.__wrapped_text_width != text_width:
3120 self.__wrapped_text_width = text_width
3122 # Note: don't use wrap with overflow here
3123 # or we won't be able to find the cursor position!
3124 if self.__text:
3125 self.__wrapped_text = self._prepare_display_text(
3126 self.__text,
3127 rc.theme.get_color("menu/text/esc:input"),
3128 rc.theme.get_color("menu/text:input"),
3129 ).wrap(
3130 text_width,
3131 preserve_spaces=True,
3132 break_long_nowrap_words=True,
3133 )
3134 self.__pos_after_wrap = None
3135 else:
3136 self.__wrapped_text = _ColorizedString(
3137 [
3138 rc.theme.get_color("menu/text/placeholder:input"),
3139 self.__placeholder,
3140 ]
3141 ).wrap(
3142 text_width,
3143 preserve_newlines=False,
3144 break_long_nowrap_words=True,
3145 )
3146 self.__pos_after_wrap = (decoration_width, 0)
3148 if self.__pos_after_wrap is None:
3149 x, y = _find_cursor_pos(self.__wrapped_text, text_width, self.__pos)
3150 self.__pos_after_wrap = (decoration_width + x, y)
3152 height = max(len(self.__wrapped_text), self.__pos_after_wrap[1] + 1)
3153 return height, height
3155 def draw(self, rc: RenderContext, /):
3156 if self.__decoration:
3157 rc.set_color_path("menu/decoration:input")
3158 rc.write(self.__decoration)
3159 rc.move_pos(1, 0)
3161 if self.__wrapped_text is not None:
3162 rc.write_text(self.__wrapped_text)
3164 if self.__pos_after_wrap is not None:
3165 rc.set_final_pos(*self.__pos_after_wrap)
3167 def _prepare_display_text(
3168 self, text: str, esc_color: _Color, n_color: _Color
3169 ) -> _ColorizedString:
3170 return _ColorizedString(_replace_special_symbols(text, esc_color, n_color))
3172 @property
3173 def help_data(self) -> WidgetHelp:
3174 if self.__allow_multiline:
3175 return (
3176 super()
3177 .help_data.with_action(
3178 KeyboardEvent(Key.ENTER, alt=True),
3179 KeyboardEvent("d", ctrl=True),
3180 msg="accept",
3181 prepend=True,
3182 )
3183 .with_action(
3184 KeyboardEvent(Key.ENTER),
3185 group=self._MODIFY,
3186 long_msg="new line",
3187 prepend=True,
3188 )
3189 )
3190 else:
3191 return super().help_data
3194class SecretInput(Input):
3195 """
3196 An input box that shows stars instead of entered symbols.
3198 :param text:
3199 initial text.
3200 :param pos:
3201 initial cursor position, calculated as an offset from beginning of the text.
3202 Should be ``0 <= pos <= len(text)``.
3203 :param placeholder:
3204 placeholder text, shown when input is empty.
3205 :param decoration:
3206 decoration printed before the input box.
3208 """
3210 _WORD_SEPARATORS = ""
3211 _UNPRINTABLE_SUBSTITUTOR = ""
3213 def __init__(
3214 self,
3215 *,
3216 text: str = "",
3217 pos: int | None = None,
3218 placeholder: str = "",
3219 decoration: str = ">",
3220 ):
3221 super().__init__(
3222 text=text,
3223 pos=pos,
3224 placeholder=placeholder,
3225 decoration=decoration,
3226 allow_multiline=False,
3227 allow_special_characters=False,
3228 )
3230 def _prepare_display_text(
3231 self, text: str, esc_color: _Color, n_color: _Color
3232 ) -> _ColorizedString:
3233 return _ColorizedString("*" * len(text))
3236@dataclass(slots=True)
3237class Option(_t.Generic[T_co]):
3238 """
3239 An option for the :class:`Grid` and :class:`Choice` widgets.
3241 """
3243 def __post_init__(self):
3244 if self.color_tag is None:
3245 object.__setattr__(self, "color_tag", "none")
3247 value: T_co
3248 """
3249 Option's value that will be returned from widget.
3251 """
3253 display_text: str
3254 """
3255 What should be displayed in the autocomplete list.
3257 """
3259 display_text_prefix: str = dataclasses.field(default="", kw_only=True)
3260 """
3261 Prefix that will be displayed before :attr:`~Option.display_text`.
3263 """
3265 display_text_suffix: str = dataclasses.field(default="", kw_only=True)
3266 """
3267 Suffix that will be displayed after :attr:`~Option.display_text`.
3269 """
3271 comment: str | None = dataclasses.field(default=None, kw_only=True)
3272 """
3273 Option's short comment.
3275 """
3277 color_tag: str | None = dataclasses.field(default=None, kw_only=True)
3278 """
3279 Option's color tag.
3281 This color tag will be used to display option.
3282 Specifically, color for the option will be looked up py path
3283 :samp:``menu/{element}:choice/{status}/{color_tag}``.
3285 """
3288class Grid(Widget[_t.Never], _t.Generic[T]):
3289 """
3290 A helper widget that shows up in :class:`Choice` and :class:`InputWithCompletion`.
3292 .. note::
3294 On its own, :class:`Grid` doesn't return when you press :kbd:`Enter`
3295 or :kbd:`Ctrl+D`. It's meant to be used as part of another widget.
3297 :param options:
3298 list of options displayed in the grid.
3299 :param decoration:
3300 decoration printed before the selected option.
3301 :param default_index:
3302 index of the initially selected option.
3303 :param min_rows:
3304 minimum number of rows that the grid should occupy before it starts
3305 splitting options into columns. This option is ignored if there isn't enough
3306 space on the screen.
3308 """
3310 def __init__(
3311 self,
3312 options: list[Option[T]],
3313 /,
3314 *,
3315 decoration: str = ">",
3316 default_index: int | None = 0,
3317 min_rows: int | None = 5,
3318 ):
3319 self.__options: list[Option[T]]
3320 self.__index: int | None
3321 self.__min_rows: int | None = min_rows
3322 self.__max_column_width: int
3323 self.__column_width: int
3324 self.__num_rows: int
3325 self.__num_columns: int
3327 self.__decoration = decoration
3329 self.set_options(options)
3330 self.index = default_index
3332 @property
3333 def _page_size(self) -> int:
3334 return self.__num_rows * self.__num_columns
3336 @property
3337 def index(self) -> int | None:
3338 """
3339 Index of the currently selected option.
3341 """
3343 return self.__index
3345 @index.setter
3346 def index(self, idx: int | None):
3347 if idx is None or not self.__options:
3348 self.__index = None
3349 elif self.__options:
3350 self.__index = idx % len(self.__options)
3352 def get_option(self) -> Option[T] | None:
3353 """
3354 Get the currently selected option,
3355 or `None` if there are no options selected.
3357 """
3359 if self.__options and self.__index is not None:
3360 return self.__options[self.__index]
3362 def has_options(self) -> bool:
3363 """
3364 Return :data:`True` if the options list is not empty.
3366 """
3368 return bool(self.__options)
3370 def get_options(self) -> _t.Sequence[Option[T]]:
3371 """
3372 Get all options.
3374 """
3376 return self.__options
3378 def set_options(
3379 self,
3380 options: list[Option[T]],
3381 /,
3382 default_index: int | None = 0,
3383 ):
3384 """
3385 Set a new list of options.
3387 """
3389 self.__options = options
3390 self.__max_column_width = max(
3391 0, _MIN_COLUMN_WIDTH, *map(self._get_option_width, options)
3392 )
3393 self.index = default_index
3395 _NAVIGATE = "Navigate"
3397 @bind(Key.ARROW_UP)
3398 @bind(Key.TAB, shift=True)
3399 @help(group=_NAVIGATE)
3400 def prev_item(self):
3401 """previous item"""
3402 if not self.__options:
3403 return
3405 if self.__index is None:
3406 self.__index = 0
3407 else:
3408 self.__index = (self.__index - 1) % len(self.__options)
3410 @bind(Key.ARROW_DOWN)
3411 @bind(Key.TAB)
3412 @help(group=_NAVIGATE)
3413 def next_item(self):
3414 """next item"""
3415 if not self.__options:
3416 return
3418 if self.__index is None:
3419 self.__index = 0
3420 else:
3421 self.__index = (self.__index + 1) % len(self.__options)
3423 @bind(Key.ARROW_LEFT)
3424 @help(group=_NAVIGATE)
3425 def prev_column(self):
3426 """previous column"""
3427 if not self.__options:
3428 return
3430 if self.__index is None:
3431 self.__index = 0
3432 else:
3433 total_grid_capacity = self.__num_rows * math.ceil(
3434 len(self.__options) / self.__num_rows
3435 )
3437 self.__index = (self.__index - self.__num_rows) % total_grid_capacity
3438 if self.__index >= len(self.__options):
3439 self.__index = len(self.__options) - 1
3441 @bind(Key.ARROW_RIGHT)
3442 @help(group=_NAVIGATE)
3443 def next_column(self):
3444 """next column"""
3445 if not self.__options:
3446 return
3448 if self.__index is None:
3449 self.__index = 0
3450 else:
3451 total_grid_capacity = self.__num_rows * math.ceil(
3452 len(self.__options) / self.__num_rows
3453 )
3455 self.__index = (self.__index + self.__num_rows) % total_grid_capacity
3456 if self.__index >= len(self.__options):
3457 self.__index = len(self.__options) - 1
3459 @bind(Key.PAGE_UP)
3460 @help(group=_NAVIGATE)
3461 def prev_page(self):
3462 """previous page"""
3463 if not self.__options:
3464 return
3466 if self.__index is None:
3467 self.__index = 0
3468 else:
3469 self.__index -= self.__index % self._page_size
3470 self.__index -= 1
3471 if self.__index < 0:
3472 self.__index = len(self.__options) - 1
3474 @bind(Key.PAGE_DOWN)
3475 @help(group=_NAVIGATE)
3476 def next_page(self):
3477 """next page"""
3478 if not self.__options:
3479 return
3481 if self.__index is None:
3482 self.__index = 0
3483 else:
3484 self.__index -= self.__index % self._page_size
3485 self.__index += self._page_size
3486 if self.__index > len(self.__options):
3487 self.__index = 0
3489 @bind(Key.HOME)
3490 @help(group=_NAVIGATE)
3491 def home(self):
3492 """first page"""
3493 if not self.__options:
3494 return
3496 if self.__index is None:
3497 self.__index = 0
3498 else:
3499 self.__index = 0
3501 @bind(Key.END)
3502 @help(group=_NAVIGATE)
3503 def end(self):
3504 """last page"""
3505 if not self.__options:
3506 return
3508 if self.__index is None:
3509 self.__index = 0
3510 else:
3511 self.__index = len(self.__options) - 1
3513 def default_event_handler(self, e: KeyboardEvent):
3514 if isinstance(e.key, str):
3515 key = e.key.casefold()
3516 if (
3517 self.__options
3518 and self.__index is not None
3519 and self.__options[self.__index].display_text.casefold().startswith(key)
3520 ):
3521 start = self.__index + 1
3522 else:
3523 start = 0
3524 for i in range(start, start + len(self.__options)):
3525 index = i % len(self.__options)
3526 if self.__options[index].display_text.casefold().startswith(key):
3527 self.__index = index
3528 break
3530 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
3531 self.__column_width = max(1, min(self.__max_column_width, rc.width))
3532 self.__num_columns = num_columns = max(1, rc.width // self.__column_width)
3533 self.__num_rows = max(
3534 1,
3535 min(self.__min_rows or 1, len(self.__options)),
3536 min(math.ceil(len(self.__options) / num_columns), rc.height),
3537 )
3539 additional_space = 0
3540 pages = math.ceil(len(self.__options) / self._page_size)
3541 if pages > 1:
3542 additional_space = 1
3544 return 1 + additional_space, self.__num_rows + additional_space
3546 def draw(self, rc: RenderContext, /):
3547 if not self.__options:
3548 rc.set_color_path("menu/decoration:choice")
3549 rc.write("No options to display")
3550 return
3552 # Adjust for the actual available height.
3553 self.__num_rows = max(1, min(self.__num_rows, rc.height))
3554 pages = math.ceil(len(self.__options) / self._page_size)
3555 if pages > 1 and self.__num_rows > 1:
3556 self.__num_rows -= 1
3558 column_width = self.__column_width
3559 num_rows = self.__num_rows
3560 page_size = self._page_size
3562 page_start_index = 0
3563 if page_size and self.__index is not None:
3564 page_start_index = self.__index - self.__index % page_size
3565 page = self.__options[page_start_index : page_start_index + page_size]
3567 if self.__num_columns > 1:
3568 available_column_width = column_width - _SPACE_BETWEEN_COLUMNS
3569 else:
3570 available_column_width = column_width
3572 for i, option in enumerate(page):
3573 x = i // num_rows
3574 y = i % num_rows
3576 rc.set_pos(x * column_width, y)
3578 index = i + page_start_index
3579 is_current = index == self.__index
3580 self._render_option(rc, available_column_width, option, is_current)
3582 pages = math.ceil(len(self.__options) / self._page_size)
3583 if pages > 1:
3584 page = (self.index or 0) // self._page_size + 1
3585 rc.set_pos(0, num_rows)
3586 rc.set_color_path("menu/text:choice/status_line")
3587 rc.write("Page ")
3588 rc.set_color_path("menu/text:choice/status_line/number")
3589 rc.write(f"{page}")
3590 rc.set_color_path("menu/text:choice/status_line")
3591 rc.write(" of ")
3592 rc.set_color_path("menu/text:choice/status_line/number")
3593 rc.write(f"{pages}")
3595 def _get_option_width(self, option: Option[object]):
3596 return (
3597 _SPACE_BETWEEN_COLUMNS
3598 + (_line_width(self.__decoration) + 1 if self.__decoration else 0)
3599 + (_line_width(option.display_text_prefix))
3600 + (_line_width(option.display_text))
3601 + (_line_width(option.display_text_suffix))
3602 + (3 if option.comment else 0)
3603 + (_line_width(option.comment) if option.comment else 0)
3604 )
3606 def _render_option(
3607 self,
3608 rc: RenderContext,
3609 width: int,
3610 option: Option[object],
3611 is_active: bool,
3612 ):
3613 left_prefix_width = _line_width(option.display_text_prefix)
3614 left_main_width = _line_width(option.display_text)
3615 left_suffix_width = _line_width(option.display_text_suffix)
3616 left_width = left_prefix_width + left_main_width + left_suffix_width
3617 left_decoration_width = (
3618 _line_width(self.__decoration) + 1 if self.__decoration else 0
3619 )
3621 right = option.comment or ""
3622 right_width = _line_width(right)
3623 right_decoration_width = 3 if right else 0
3625 total_width = (
3626 left_decoration_width + left_width + right_decoration_width + right_width
3627 )
3629 if total_width > width:
3630 right_width = max(right_width - (total_width - width), 0)
3631 if right_width == 0:
3632 right = ""
3633 right_decoration_width = 0
3634 total_width = (
3635 left_decoration_width
3636 + left_width
3637 + right_decoration_width
3638 + right_width
3639 )
3641 if total_width > width:
3642 left_width = max(left_width - (total_width - width), 3)
3643 total_width = left_decoration_width + left_width
3645 if is_active:
3646 status_tag = "active"
3647 else:
3648 status_tag = "normal"
3650 if self.__decoration and is_active:
3651 rc.set_color_path(f"menu/decoration:choice/{status_tag}/{option.color_tag}")
3652 rc.write(self.__decoration)
3653 rc.set_color_path(f"menu/text:choice/{status_tag}/{option.color_tag}")
3654 rc.write(" ")
3655 elif self.__decoration:
3656 rc.set_color_path(f"menu/text:choice/{status_tag}/{option.color_tag}")
3657 rc.write(" " * left_decoration_width)
3659 rc.set_color_path(f"menu/text/prefix:choice/{status_tag}/{option.color_tag}")
3660 rc.write(option.display_text_prefix, max_width=left_width)
3661 rc.set_color_path(f"menu/text:choice/{status_tag}/{option.color_tag}")
3662 rc.write(option.display_text, max_width=left_width - left_prefix_width)
3663 rc.set_color_path(f"menu/text/suffix:choice/{status_tag}/{option.color_tag}")
3664 rc.write(
3665 option.display_text_suffix,
3666 max_width=left_width - left_prefix_width - left_main_width,
3667 )
3668 rc.set_color_path(f"menu/text:choice/{status_tag}/{option.color_tag}")
3669 rc.write(
3670 " "
3671 * (
3672 width
3673 - left_decoration_width
3674 - left_width
3675 - right_decoration_width
3676 - right_width
3677 )
3678 )
3680 if right:
3681 rc.set_color_path(
3682 f"menu/decoration/comment:choice/{status_tag}/{option.color_tag}"
3683 )
3684 rc.write(" [")
3685 rc.set_color_path(
3686 f"menu/text/comment:choice/{status_tag}/{option.color_tag}"
3687 )
3688 rc.write(right, max_width=right_width)
3689 rc.set_color_path(
3690 f"menu/decoration/comment:choice/{status_tag}/{option.color_tag}"
3691 )
3692 rc.write("]")
3694 @property
3695 def help_data(self) -> WidgetHelp:
3696 return super().help_data.with_action(
3697 "1..9",
3698 "a..z",
3699 long_msg="quick select",
3700 )
3703class Choice(Widget[T], _t.Generic[T]):
3704 """
3705 Allows choosing from pre-defined options.
3707 .. vhs:: /_tapes/widget_choice.tape
3708 :alt: Demonstration of `Choice` widget.
3709 :scale: 40%
3711 :param options:
3712 list of choice options.
3713 :param mapper:
3714 maps option to a text that will be used for filtering. By default,
3715 uses :attr:`Option.display_text`. This argument is ignored
3716 if a custom ``filter`` is given.
3717 :param filter:
3718 customizes behavior of list filtering. The default filter extracts text
3719 from an option using the ``mapper``, and checks if it starts with the search
3720 query.
3721 :param default_index:
3722 index of the initially selected option.
3724 """
3726 @_t.overload
3727 def __init__(
3728 self,
3729 options: list[Option[T]],
3730 /,
3731 *,
3732 mapper: _t.Callable[[Option[T]], str] = lambda x: (
3733 x.display_text or str(x.value)
3734 ),
3735 default_index: int = 0,
3736 ): ...
3738 @_t.overload
3739 def __init__(
3740 self,
3741 options: list[Option[T]],
3742 /,
3743 *,
3744 filter: _t.Callable[[Option[T], str], bool],
3745 default_index: int = 0,
3746 ): ...
3748 def __init__(
3749 self,
3750 options: list[Option[T]],
3751 /,
3752 *,
3753 mapper: _t.Callable[[Option[T]], str] = lambda x: x.display_text
3754 or str(x.value),
3755 filter: _t.Callable[[Option[T], str], bool] | None = None,
3756 default_index: int = 0,
3757 ):
3758 self.__options = options
3760 if filter is None:
3761 filter = lambda x, q: mapper(x).lstrip().startswith(q)
3763 self.__filter = filter
3765 self.__default_index = default_index
3767 self.__input = Input(placeholder="Filter options...", decoration="/")
3768 self.__grid = Grid[T]([])
3770 self.__enable_search = False
3772 self.__layout: VerticalLayout[_t.Never]
3774 self.__update_completion()
3776 @bind("/")
3777 def search(self):
3778 """search"""
3779 if not self.__enable_search:
3780 self.__enable_search = True
3781 else:
3782 self.__input.event(KeyboardEvent("/"))
3783 self.__update_completion()
3785 @bind(Key.ENTER)
3786 @bind(Key.ENTER, alt=True, show_in_detailed_help=False)
3787 @bind("d", ctrl=True)
3788 def enter(self) -> Result[T] | None:
3789 """select"""
3790 option = self.__grid.get_option()
3791 if option is not None:
3792 return Result(option.value)
3793 else:
3794 self._bell()
3796 @bind(Key.ESCAPE)
3797 def esc(self):
3798 self.__input.text = ""
3799 self.__update_completion()
3800 self.__enable_search = False
3802 def default_event_handler(self, e: KeyboardEvent) -> Result[T] | None:
3803 if not self.__enable_search and e == KeyboardEvent(" "):
3804 return self.enter()
3805 if not self.__enable_search or e.key in (
3806 Key.ARROW_UP,
3807 Key.ARROW_DOWN,
3808 Key.TAB,
3809 Key.ARROW_LEFT,
3810 Key.ARROW_RIGHT,
3811 Key.PAGE_DOWN,
3812 Key.PAGE_UP,
3813 Key.HOME,
3814 Key.END,
3815 ):
3816 self.__grid.event(e)
3817 elif e == KeyboardEvent(Key.BACKSPACE) and not self.__input.text:
3818 self.__enable_search = False
3819 else:
3820 self.__input.event(e)
3821 self.__update_completion()
3823 def __update_completion(self):
3824 query = self.__input.text
3826 index = 0
3827 options = []
3828 cur_option = self.__grid.get_option()
3829 for i, option in enumerate(self.__options):
3830 if not query or self.__filter(option, query):
3831 if option is cur_option or (
3832 cur_option is None and i == self.__default_index
3833 ):
3834 index = len(options)
3835 options.append(option)
3837 self.__grid.set_options(options)
3838 self.__grid.index = index
3840 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
3841 self.__layout = VerticalLayout()
3842 self.__layout.append(self.__grid)
3844 if self.__enable_search:
3845 self.__layout.append(self.__input)
3847 return self.__layout.layout(rc)
3849 def draw(self, rc: RenderContext, /):
3850 self.__layout.draw(rc)
3852 @property
3853 def help_data(self) -> WidgetHelp:
3854 return super().help_data.merge(self.__grid.help_data)
3857class Multiselect(Widget[list[T]], _t.Generic[T]):
3858 """
3859 Like :class:`Choice`, but allows selecting multiple items.
3861 .. vhs:: /_tapes/widget_multiselect.tape
3862 :alt: Demonstration of `Multiselect` widget.
3863 :scale: 40%
3865 :param options:
3866 list of choice options.
3867 :param mapper:
3868 maps option to a text that will be used for filtering. By default,
3869 uses :attr:`Option.display_text`. This argument is ignored
3870 if a custom ``filter`` is given.
3871 :param filter:
3872 customizes behavior of list filtering. The default filter extracts text
3873 from an option using the ``mapper``, and checks if it starts with the search
3874 query.
3875 :param default_index:
3876 index of the initially selected option.
3878 """
3880 @_t.overload
3881 def __init__(
3882 self,
3883 options: list[Option[T]],
3884 /,
3885 *,
3886 mapper: _t.Callable[[Option[T]], str] = lambda x: x.display_text
3887 or str(x.value),
3888 ): ...
3890 @_t.overload
3891 def __init__(
3892 self,
3893 options: list[Option[T]],
3894 /,
3895 *,
3896 filter: _t.Callable[[Option[T], str], bool],
3897 ): ...
3899 def __init__(
3900 self,
3901 options: list[Option[T]],
3902 /,
3903 *,
3904 mapper: _t.Callable[[Option[T]], str] = lambda x: x.display_text
3905 or str(x.value),
3906 filter: _t.Callable[[Option[T], str], bool] | None = None,
3907 ):
3908 self.__options = [
3909 _t.cast(
3910 Option[tuple[T, bool]],
3911 dataclasses.replace(
3912 option,
3913 value=(option.value, False),
3914 display_text_prefix="- " + option.display_text_prefix,
3915 color_tag=None,
3916 ),
3917 )
3918 for option in options
3919 ]
3921 if filter is None:
3922 filter = lambda x, q: mapper(x).lstrip().startswith(q)
3924 self.__filter = filter
3926 self.__input = Input(placeholder="Filter options...", decoration="/")
3927 self.__grid = Grid[tuple[T, bool]]([])
3929 self.__enable_search = False
3931 self.__layout: VerticalLayout[_t.Never]
3933 self.__update_completion()
3935 @bind(Key.ENTER)
3936 @bind(" ")
3937 def select(self):
3938 """select"""
3939 if self.__enable_search and self._cur_event == KeyboardEvent(" "):
3940 self.__input.event(KeyboardEvent(" "))
3941 self.__update_completion()
3942 return
3943 option = self.__grid.get_option()
3944 if option is not None:
3945 option.value = (option.value[0], not option.value[1])
3946 option.display_text_prefix = (
3947 "*" if option.value[1] else "-"
3948 ) + option.display_text_prefix[1:]
3949 option.color_tag = "selected" if option.value[1] else None
3950 self.__update_completion()
3952 @bind(Key.ENTER, alt=True)
3953 @bind("d", ctrl=True, show_in_inline_help=True)
3954 def enter(self) -> Result[list[T]] | None:
3955 """accept"""
3956 return Result([option.value[0] for option in self.__options if option.value[1]])
3958 @bind("/")
3959 def search(self):
3960 """search"""
3961 if not self.__enable_search:
3962 self.__enable_search = True
3963 else:
3964 self.__input.event(KeyboardEvent("/"))
3965 self.__update_completion()
3967 @bind(Key.ESCAPE)
3968 def esc(self):
3969 """exit search"""
3970 self.__input.text = ""
3971 self.__update_completion()
3972 self.__enable_search = False
3974 def default_event_handler(self, e: KeyboardEvent) -> Result[list[T]] | None:
3975 if not self.__enable_search or e.key in (
3976 Key.ARROW_UP,
3977 Key.ARROW_DOWN,
3978 Key.TAB,
3979 Key.ARROW_LEFT,
3980 Key.ARROW_RIGHT,
3981 Key.PAGE_DOWN,
3982 Key.PAGE_UP,
3983 Key.HOME,
3984 Key.END,
3985 ):
3986 self.__grid.event(e)
3987 elif e == KeyboardEvent(Key.BACKSPACE) and not self.__input.text:
3988 self.__enable_search = False
3989 else:
3990 self.__input.event(e)
3991 self.__update_completion()
3993 def __update_completion(self):
3994 query = self.__input.text
3996 index = 0
3997 options = []
3998 cur_option = self.__grid.get_option()
3999 for option in self.__options:
4000 if not query or self.__filter(
4001 _t.cast(Option[T], dataclasses.replace(option, value=option.value[0])),
4002 query,
4003 ):
4004 if option is cur_option:
4005 index = len(options)
4006 options.append(option)
4008 self.__grid.set_options(options)
4009 self.__grid.index = index
4011 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
4012 self.__layout = VerticalLayout()
4013 self.__layout.append(self.__grid)
4015 if self.__enable_search:
4016 self.__layout.append(self.__input)
4018 return self.__layout.layout(rc)
4020 def draw(self, rc: RenderContext, /):
4021 self.__layout.draw(rc)
4023 @property
4024 def help_data(self) -> WidgetHelp:
4025 return super().help_data.merge(self.__grid.help_data)
4028class InputWithCompletion(Widget[str]):
4029 """
4030 An input box with tab completion.
4032 .. vhs:: /_tapes/widget_completion.tape
4033 :alt: Demonstration of `InputWithCompletion` widget.
4034 :scale: 40%
4036 """
4038 def __init__(
4039 self,
4040 completer: yuio.complete.Completer,
4041 /,
4042 *,
4043 placeholder: str = "",
4044 decoration: str = ">",
4045 completion_item_decoration: str = ">",
4046 ):
4047 self.__completer = completer
4049 self.__input = Input(placeholder=placeholder, decoration=decoration)
4050 self.__grid = Grid[yuio.complete.Completion](
4051 [], decoration=completion_item_decoration, min_rows=None
4052 )
4053 self.__grid_active = False
4055 self.__layout: VerticalLayout[_t.Never]
4056 self.__rsuffix: yuio.complete.Completion | None = None
4058 @bind(Key.ENTER)
4059 @bind("d", ctrl=True)
4060 @help(inline_msg="accept")
4061 def enter(self) -> Result[str] | None:
4062 """accept / select completion"""
4063 if self.__grid_active and (option := self.__grid.get_option()):
4064 self._set_input_state_from_completion(option.value)
4065 self._deactivate_completion()
4066 else:
4067 self._drop_rsuffix()
4068 return Result(self.__input.text)
4070 @bind(Key.TAB)
4071 def tab(self):
4072 """autocomplete"""
4073 if self.__grid_active:
4074 self.__grid.next_item()
4075 if option := self.__grid.get_option():
4076 self._set_input_state_from_completion(option.value)
4077 return
4079 completion = self.__completer.complete(self.__input.text, self.__input.pos)
4080 if len(completion) == 1:
4081 self.__input.checkpoint()
4082 self._set_input_state_from_completion(completion[0])
4083 elif completion:
4084 self.__input.checkpoint()
4085 self.__grid.set_options(
4086 [
4087 Option(
4088 c,
4089 c.completion,
4090 display_text_prefix=c.dprefix,
4091 display_text_suffix=c.dsuffix,
4092 comment=c.comment,
4093 color_tag=c.group_color_tag,
4094 )
4095 for c in completion
4096 ],
4097 default_index=None,
4098 )
4099 self._activate_completion()
4100 else:
4101 self._bell()
4103 @bind(Key.ESCAPE)
4104 def escape(self):
4105 """close autocomplete"""
4106 self._drop_rsuffix()
4107 if self.__grid_active:
4108 self.__input.restore_checkpoint()
4109 self._deactivate_completion()
4111 def default_event_handler(self, e: KeyboardEvent):
4112 if self.__grid_active and e.key in (
4113 Key.ARROW_UP,
4114 Key.ARROW_DOWN,
4115 Key.TAB,
4116 Key.PAGE_UP,
4117 Key.PAGE_DOWN,
4118 Key.HOME,
4119 Key.END,
4120 ):
4121 self._dispatch_completion_event(e)
4122 elif (
4123 self.__grid_active
4124 and self.__grid.index is not None
4125 and e.key in (Key.ARROW_RIGHT, Key.ARROW_LEFT)
4126 ):
4127 self._dispatch_completion_event(e)
4128 else:
4129 self._dispatch_input_event(e)
4131 def _activate_completion(self):
4132 self.__grid_active = True
4134 def _deactivate_completion(self):
4135 self.__grid_active = False
4137 def _set_input_state_from_completion(
4138 self, completion: yuio.complete.Completion, set_rsuffix: bool = True
4139 ):
4140 prefix = completion.iprefix + completion.completion
4141 if set_rsuffix:
4142 prefix += completion.rsuffix
4143 self.__rsuffix = completion
4144 else:
4145 self.__rsuffix = None
4146 self.__input.text = prefix + completion.isuffix
4147 self.__input.pos = len(prefix)
4149 def _dispatch_completion_event(self, e: KeyboardEvent):
4150 self.__rsuffix = None
4151 self.__grid.event(e)
4152 if option := self.__grid.get_option():
4153 self._set_input_state_from_completion(option.value)
4155 def _dispatch_input_event(self, e: KeyboardEvent):
4156 if self.__rsuffix:
4157 # We need to drop current rsuffix in some cases:
4158 if (not e.ctrl and not e.alt and isinstance(e.key, str)) or (
4159 e.key is Key.PASTE and e.paste_str
4160 ):
4161 text = e.key if e.key is not Key.PASTE else e.paste_str
4162 # When user prints something...
4163 if text and text[0] in self.__rsuffix.rsymbols:
4164 # ...that is in `rsymbols`...
4165 self._drop_rsuffix()
4166 elif e in [
4167 KeyboardEvent(Key.ARROW_UP),
4168 KeyboardEvent(Key.ARROW_DOWN),
4169 KeyboardEvent(Key.ARROW_LEFT),
4170 KeyboardEvent("b", ctrl=True),
4171 KeyboardEvent(Key.ARROW_RIGHT),
4172 KeyboardEvent("f", ctrl=True),
4173 KeyboardEvent(Key.ARROW_LEFT, alt=True),
4174 KeyboardEvent("b", alt=True),
4175 KeyboardEvent(Key.ARROW_RIGHT, alt=True),
4176 KeyboardEvent("f", alt=True),
4177 KeyboardEvent(Key.HOME),
4178 KeyboardEvent("a", ctrl=True),
4179 KeyboardEvent(Key.END),
4180 KeyboardEvent("e", ctrl=True),
4181 ]:
4182 # ...or when user moves cursor.
4183 self._drop_rsuffix()
4184 self.__rsuffix = None
4185 self.__input.event(e)
4186 self._deactivate_completion()
4188 def _drop_rsuffix(self):
4189 if self.__rsuffix:
4190 rsuffix = self.__rsuffix.rsuffix
4191 if self.__input.text[: self.__input.pos].endswith(rsuffix):
4192 self._set_input_state_from_completion(self.__rsuffix, set_rsuffix=False)
4194 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
4195 self.__layout = VerticalLayout()
4196 self.__layout.append(self.__input)
4197 if self.__grid_active:
4198 self.__layout.append(self.__grid)
4199 return self.__layout.layout(rc)
4201 def draw(self, rc: RenderContext, /):
4202 self.__layout.draw(rc)
4204 @property
4205 def help_data(self) -> WidgetHelp:
4206 return (
4207 (super().help_data)
4208 .merge(
4209 (self.__grid.help_data)
4210 .without_group("Actions")
4211 .rename_group(Grid._NAVIGATE, "Navigate Completions")
4212 )
4213 .merge(
4214 (self.__input.help_data)
4215 .without_group("Actions")
4216 .rename_group(Input._NAVIGATE, "Navigate Input")
4217 .rename_group(Input._MODIFY, "Modify Input")
4218 )
4219 )
4222class Map(Widget[T], _t.Generic[T, U]):
4223 """
4224 A wrapper that maps result of the given widget using the given function.
4226 ..
4227 >>> class Input(Widget):
4228 ... def event(self, e):
4229 ... return Result("10")
4230 ...
4231 ... def layout(self, rc):
4232 ... return 0, 0
4233 ...
4234 ... def draw(self, rc):
4235 ... pass
4236 >>> class Map(Map):
4237 ... def run(self, term, theme):
4238 ... return self.event(None).value
4239 >>> term, theme = None, None
4241 Example::
4243 >>> # Run `Input` widget, then parse user input as `int`.
4244 >>> int_input = Map(Input(), int)
4245 >>> int_input.run(term, theme)
4246 10
4248 """
4250 def __init__(self, inner: Widget[U], fn: _t.Callable[[U], T], /):
4251 self.__inner = inner
4252 self.__fn = fn
4254 def event(self, e: KeyboardEvent, /) -> Result[T] | None:
4255 if result := self.__inner.event(e):
4256 return Result(self.__fn(result.value))
4258 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
4259 return self.__inner.layout(rc)
4261 def draw(self, rc: RenderContext, /):
4262 self.__inner.draw(rc)
4264 @property
4265 def help_data(self) -> WidgetHelp:
4266 return self.__inner.help_data
4269class Apply(Map[T, T], _t.Generic[T]):
4270 """
4271 A wrapper that applies the given function to the result of a wrapped widget.
4273 ..
4274 >>> class Input(Widget):
4275 ... def event(self, e):
4276 ... return Result("foobar!")
4277 ...
4278 ... def layout(self, rc):
4279 ... return 0, 0
4280 ...
4281 ... def draw(self, rc):
4282 ... pass
4283 >>> class Apply(Apply):
4284 ... def run(self, term, theme):
4285 ... return self.event(None).value
4286 >>> term, theme = None, None
4288 Example::
4290 >>> # Run `Input` widget, then print its output before returning
4291 >>> print_output = Apply(Input(), print)
4292 >>> result = print_output.run(term, theme)
4293 foobar!
4294 >>> result
4295 'foobar!'
4297 """
4299 def __init__(self, inner: Widget[T], fn: _t.Callable[[T], None], /):
4300 def mapper(x: T) -> T:
4301 fn(x)
4302 return x
4304 super().__init__(inner, mapper)
4307@dataclass(slots=True)
4308class _EventStreamState:
4309 ostream: _t.TextIO
4310 istream: _t.TextIO
4311 key: str = ""
4312 index: int = 0
4314 def load(self):
4315 self.key = yuio.term._read_keycode(self.ostream, self.istream)
4316 self.index = 0
4318 def next(self):
4319 ch = self.peek()
4320 self.index += 1
4321 return ch
4323 def peek(self):
4324 if self.index >= len(self.key):
4325 return ""
4326 else:
4327 return self.key[self.index]
4329 def tail(self):
4330 return self.key[self.index :]
4333def _event_stream(ostream: _t.TextIO, istream: _t.TextIO) -> _t.Iterator[KeyboardEvent]:
4334 # Implementation is heavily inspired by libtermkey by Paul Evans, MIT license.
4336 state = _EventStreamState(ostream, istream)
4337 while True:
4338 ch = state.next()
4339 if not ch:
4340 state.load()
4341 ch = state.next()
4342 if ch == "\x1b":
4343 alt = False
4344 ch = state.next()
4345 while ch == "\x1b":
4346 alt = True
4347 ch = state.next()
4348 if not ch:
4349 yield KeyboardEvent(Key.ESCAPE, alt=alt)
4350 elif ch == "[":
4351 yield from _parse_csi(state, alt)
4352 elif ch in "N]":
4353 _parse_dcs(state)
4354 elif ch == "O":
4355 yield from _parse_ss3(state, alt)
4356 else:
4357 yield from _parse_char(ch, alt=True)
4358 elif ch == "\x9b":
4359 # CSI
4360 yield from _parse_csi(state, False)
4361 elif ch in "\x90\x9d":
4362 # DCS or SS2
4363 _parse_dcs(state)
4364 elif ch == "\x8f":
4365 # SS3
4366 yield from _parse_ss3(state, False)
4367 else:
4368 # Char
4369 yield from _parse_char(ch)
4372def _parse_ss3(state: _EventStreamState, alt: bool = False):
4373 ch = state.next()
4374 if not ch:
4375 yield KeyboardEvent("O", alt=True)
4376 else:
4377 yield from _parse_ss3_key(ch, alt=alt)
4380def _parse_dcs(state: _EventStreamState):
4381 while True:
4382 ch = state.next()
4383 if ch == "\x9c":
4384 break
4385 elif ch == "\x1b" and state.peek() == "\\":
4386 state.next()
4387 break
4388 elif not ch:
4389 state.load()
4392def _parse_csi(state: _EventStreamState, alt: bool = False):
4393 buffer = ""
4394 while state.peek() and not (0x40 <= ord(state.peek()) <= 0x80):
4395 buffer += state.next()
4396 cmd = state.next()
4397 if not cmd:
4398 yield KeyboardEvent("[", alt=True)
4399 return
4400 if buffer.startswith(("?", "<", ">", "=")):
4401 # Some command response, ignore.
4402 return
4403 args = buffer.split(";")
4405 shift = ctrl = False
4406 if len(args) > 1:
4407 try:
4408 modifiers = int(args[1]) - 1
4409 except ValueError:
4410 pass
4411 else:
4412 shift = bool(modifiers & 1)
4413 alt |= bool(modifiers & 2)
4414 ctrl = bool(modifiers & 4)
4416 if cmd == "~":
4417 if args[0] == "27":
4418 try:
4419 ch = chr(int(args[2]))
4420 except (ValueError, KeyError):
4421 pass
4422 else:
4423 yield from _parse_char(ch, ctrl=ctrl, alt=alt)
4424 elif args[0] == "200":
4425 yield KeyboardEvent(Key.PASTE, paste_str=_read_pasted_content(state))
4426 elif key := _CSI_CODES.get(args[0]):
4427 yield KeyboardEvent(key, ctrl=ctrl, alt=alt, shift=shift)
4428 elif cmd == "u":
4429 try:
4430 ch = chr(int(args[0]))
4431 except ValueError:
4432 pass
4433 else:
4434 yield from _parse_char(ch, ctrl=ctrl, alt=alt, shift=shift)
4435 elif cmd in "mMyR":
4436 # Some command response, ignore.
4437 pass
4438 else:
4439 yield from _parse_ss3_key(cmd, ctrl=ctrl, alt=alt, shift=shift)
4442def _parse_ss3_key(
4443 cmd: str, ctrl: bool = False, alt: bool = False, shift: bool = False
4444):
4445 if key := _SS3_CODES.get(cmd):
4446 if cmd == "Z":
4447 shift = True
4448 yield KeyboardEvent(key, ctrl=ctrl, alt=alt, shift=shift)
4451_SS3_CODES = {
4452 "A": Key.ARROW_UP,
4453 "B": Key.ARROW_DOWN,
4454 "C": Key.ARROW_RIGHT,
4455 "D": Key.ARROW_LEFT,
4456 "E": Key.HOME,
4457 "F": Key.END,
4458 "H": Key.HOME,
4459 "Z": Key.TAB,
4460 "P": Key.F1,
4461 "Q": Key.F2,
4462 "R": Key.F3,
4463 "S": Key.F4,
4464 "M": Key.ENTER,
4465 " ": " ",
4466 "I": Key.TAB,
4467 "X": "=",
4468 "j": "*",
4469 "k": "+",
4470 "l": ",",
4471 "m": "-",
4472 "n": ".",
4473 "o": "/",
4474 "p": "0",
4475 "q": "1",
4476 "r": "2",
4477 "s": "3",
4478 "t": "4",
4479 "u": "5",
4480 "v": "6",
4481 "w": "7",
4482 "x": "8",
4483 "y": "9",
4484}
4487_CSI_CODES = {
4488 "1": Key.HOME,
4489 "2": Key.INSERT,
4490 "3": Key.DELETE,
4491 "4": Key.END,
4492 "5": Key.PAGE_UP,
4493 "6": Key.PAGE_DOWN,
4494 "7": Key.HOME,
4495 "8": Key.END,
4496 "11": Key.F1,
4497 "12": Key.F2,
4498 "13": Key.F3,
4499 "14": Key.F4,
4500 "15": Key.F5,
4501 "17": Key.F6,
4502 "18": Key.F7,
4503 "19": Key.F8,
4504 "20": Key.F9,
4505 "21": Key.F10,
4506 "23": Key.F11,
4507 "24": Key.F12,
4508 "200": Key.PASTE,
4509}
4512def _parse_char(
4513 ch: str, ctrl: bool = False, alt: bool = False, shift: bool = False
4514) -> _t.Iterable[KeyboardEvent]:
4515 if ch == "\t":
4516 yield KeyboardEvent(Key.TAB, ctrl, alt, shift)
4517 elif ch in "\r\n":
4518 yield KeyboardEvent(Key.ENTER, ctrl, alt, shift)
4519 elif ch == "\x08":
4520 yield KeyboardEvent(Key.BACKSPACE, ctrl, alt, shift)
4521 elif ch == "\x1b":
4522 yield KeyboardEvent(Key.ESCAPE, ctrl, alt, shift)
4523 elif ch == "\x7f":
4524 yield KeyboardEvent(Key.BACKSPACE, ctrl, alt, shift)
4525 elif "\x00" <= ch <= "\x1a":
4526 yield KeyboardEvent(chr(ord(ch) + ord("a") - 0x1), True, alt, shift)
4527 elif "\x0c" <= ch <= "\x1f":
4528 yield KeyboardEvent(chr(ord(ch) + ord("4") - 0x1C), True, alt, shift)
4529 elif ch in string.printable or ord(ch) >= 160:
4530 yield KeyboardEvent(ch, ctrl, alt, shift)
4533def _read_pasted_content(state: _EventStreamState) -> str:
4534 buf = ""
4535 while True:
4536 index = state.tail().find("\x1b[201~")
4537 if index == -1:
4538 buf += state.tail()
4539 else:
4540 buf += state.tail()[:index]
4541 state.index += index
4542 return buf
4543 state.load()