Coverage for yuio / widget.py: 95%
1931 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-05 11:41 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-05 11:41 +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 :kbd:`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 string
138import sys
139from dataclasses import dataclass
141import yuio.color
142import yuio.complete
143import yuio.md
144import yuio.string
145import yuio.term
146from yuio.color import Color as _Color
147from yuio.string import ColorizedString as _ColorizedString
148from yuio.string import Esc as _Esc
149from yuio.string import line_width as _line_width
150from yuio.term import Term as _Term
151from yuio.theme import Theme as _Theme
152from yuio.util import _UNPRINTABLE_RE, _UNPRINTABLE_RE_WITHOUT_NL, _UNPRINTABLE_TRANS
154import typing
155from typing import TYPE_CHECKING
157if TYPE_CHECKING:
158 import typing_extensions as _t
159else:
160 from yuio import _typing as _t
162__all__ = [
163 "Action",
164 "ActionKey",
165 "ActionKeys",
166 "Apply",
167 "Choice",
168 "Grid",
169 "Input",
170 "InputWithCompletion",
171 "Key",
172 "KeyboardEvent",
173 "Line",
174 "Map",
175 "Multiselect",
176 "Option",
177 "RenderContext",
178 "Result",
179 "SecretInput",
180 "Text",
181 "VerticalLayout",
182 "VerticalLayoutBuilder",
183 "Widget",
184 "WidgetHelp",
185 "bind",
186 "help",
187]
189_SPACE_BETWEEN_COLUMNS = 2
190_MIN_COLUMN_WIDTH = 10
193T = _t.TypeVar("T")
194U = _t.TypeVar("U")
195T_co = _t.TypeVar("T_co", covariant=True)
198class Key(enum.Enum):
199 """
200 Non-character keys.
202 """
204 ENTER = enum.auto()
205 """
206 :kbd:`Enter` key.
208 """
210 ESCAPE = enum.auto()
211 """
212 :kbd:`Escape` key.
214 """
216 INSERT = enum.auto()
217 """
218 :kbd:`Insert` key.
220 """
222 DELETE = enum.auto()
223 """
224 :kbd:`Delete` key.
226 """
228 BACKSPACE = enum.auto()
229 """
230 :kbd:`Backspace` key.
232 """
234 TAB = enum.auto()
235 """
236 :kbd:`Tab` key.
238 """
240 HOME = enum.auto()
241 """
242 :kbd:`Home` key.
244 """
246 END = enum.auto()
247 """
248 :kbd:`End` key.
250 """
252 PAGE_UP = enum.auto()
253 """
254 :kbd:`PageUp` key.
256 """
258 PAGE_DOWN = enum.auto()
259 """
260 :kbd:`PageDown` key.
262 """
264 ARROW_UP = enum.auto()
265 """
266 :kbd:`ArrowUp` key.
268 """
270 ARROW_DOWN = enum.auto()
271 """
272 :kbd:`ArrowDown` key.
274 """
276 ARROW_LEFT = enum.auto()
277 """
278 :kbd:`ArrowLeft` key.
280 """
282 ARROW_RIGHT = enum.auto()
283 """
284 :kbd:`ArrowRight` key.
286 """
288 F1 = enum.auto()
289 """
290 :kbd:`F1` key.
292 """
294 F2 = enum.auto()
295 """
296 :kbd:`F2` key.
298 """
300 F3 = enum.auto()
301 """
302 :kbd:`F3` key.
304 """
306 F4 = enum.auto()
307 """
308 :kbd:`F4` key.
310 """
312 F5 = enum.auto()
313 """
314 :kbd:`F5` key.
316 """
318 F6 = enum.auto()
319 """
320 :kbd:`F6` key.
322 """
324 F7 = enum.auto()
325 """
326 :kbd:`F7` key.
328 """
330 F8 = enum.auto()
331 """
332 :kbd:`F8` key.
334 """
336 F9 = enum.auto()
337 """
338 :kbd:`F9` key.
340 """
342 F10 = enum.auto()
343 """
344 :kbd:`F10` key.
346 """
348 F11 = enum.auto()
349 """
350 :kbd:`F11` key.
352 """
354 F12 = enum.auto()
355 """
356 :kbd:`F12` key.
358 """
360 PASTE = enum.auto()
361 """
362 Triggered when a text is pasted into a terminal.
364 """
366 def __str__(self) -> str:
367 return self.name.replace("_", " ").title()
370@dataclass(frozen=True, slots=True)
371class KeyboardEvent:
372 """
373 A single keyboard event.
375 .. warning::
377 Protocol for interacting with terminals is quite old, and not all terminals
378 support all keystroke combinations.
380 Use :flag:`python -m yuio.scripts.showkey` to check how your terminal reports
381 keystrokes, and how Yuio interprets them.
383 """
385 key: Key | str
386 """
387 Which key was pressed? Can be a single character,
388 or a :class:`Key` for non-character keys.
390 """
392 ctrl: bool = False
393 """
394 Whether a :kbd:`Ctrl` modifier was pressed with keystroke.
396 For letter keys modified with control, the letter is always lowercase; if terminal
397 supports reporting :kbd:`Shift` being pressed, the :attr:`~KeyboardEvent.shift`
398 attribute will be set. This does not affect punctuation keys, though:
400 .. skip-next:
402 .. code-block:: python
404 # `Ctrl+X` was pressed.
405 KeyboardEvent("x", ctrl=True)
407 # `Ctrl+Shift+X` was pressed. Not all terminals are able
408 # to report this correctly, though.
409 KeyboardEvent("x", ctrl=True, shift=True)
411 # This can't happen.
412 KeyboardEvent("X", ctrl=True)
414 # `Ctrl+_` was pressed. On most keyboards, the actual keystroke
415 # is `Ctrl+Shift+-`, but most terminals can't properly report this.
416 KeyboardEvent("_", ctrl=True)
418 """
420 alt: bool = False
421 """
422 Whether an :kbd:`Alt` (:kbd:`Option` on macs) modifier was pressed with keystroke.
424 """
426 shift: bool = False
427 """
428 Whether a :kbd:`Shift` modifier was pressed with keystroke.
430 Note that, when letters are typed with shift, they will not have this flag.
431 Instead, their upper case version will be set as :attr:`~KeyboardEvent.key`:
433 .. skip-next:
435 .. code-block:: python
437 KeyboardEvent("x") # `X` was pressed.
438 KeyboardEvent("X") # `Shift+X` was pressed.
440 .. warning::
442 Only :kbd:`Shift+Tab` can be reliably reported by all terminals.
444 """
446 paste_str: str | None = dataclasses.field(default=None, compare=False, kw_only=True)
447 """
448 If `key` is :attr:`Key.PASTE`, this attribute will contain pasted string.
450 """
453@_t.final
454class RenderContext:
455 """
456 A canvas onto which widgets render themselves.
458 This class represents a canvas with size equal to the available space on the terminal.
459 Like a real terminal, it has a character grid and a virtual cursor that can be moved
460 around freely.
462 Before each render, context's canvas is cleared, and then widgets print themselves onto it.
463 When render ends, context compares new canvas with what's been rendered previously,
464 and then updates those parts of the real terminal's grid that changed between renders.
466 This approach allows simplifying widgets (they don't have to track changes and do conditional
467 screen updates themselves), while still minimizing the amount of data that's sent between
468 the program and the terminal. It is especially helpful with rendering larger widgets over ssh.
470 """
472 # For tests.
473 _override_wh: tuple[int, int] | None = None
475 def __init__(self, term: _Term, theme: _Theme, /):
476 self._term: _Term = term
477 self._theme: _Theme = theme
479 # We have three levels of abstraction here.
480 #
481 # First, we have the TTY which our process attached to.
482 # This TTY has cursor, current color,
483 # and different drawing capabilities.
484 #
485 # Second, we have the canvas. This canvas has same dimensions
486 # as the underlying TTY. Canvas' contents and actual TTY contents
487 # are synced in `render` function.
488 #
489 # Finally, we have virtual cursor,
490 # and a drawing frame which clips dimensions of a widget.
491 #
492 #
493 # Drawing frame
494 # ...................
495 # . ┌────────┐ .
496 # . │ hello │ .
497 # . │ world │ .
498 # . └────────┘ .
499 # ...................
500 # ↓
501 # Canvas
502 # ┌─────────────────┐
503 # │ > hello │
504 # │ world │
505 # │ │
506 # └─────────────────┘
507 # ↓
508 # Real terminal
509 # ┏━━━━━━━━━━━━━━━━━┯━━━┓
510 # ┃ > hello │ ┃
511 # ┃ world │ ┃
512 # ┃ │ ┃
513 # ┠───────────VT100─┤◆◆◆┃
514 # ┗█▇█▇█▇█▇█▇█▇█▇█▇█▇█▇█┛
516 # Drawing frame and virtual cursor
517 self._frame_x: int = 0
518 self._frame_y: int = 0
519 self._frame_w: int = 0
520 self._frame_h: int = 0
521 self._frame_cursor_x: int = 0 # relative to _frame_x
522 self._frame_cursor_y: int = 0 # relative to _frame_y
523 self._frame_cursor_color: str = ""
525 # Canvas
526 self._width: int = 0
527 self._height: int = 0
528 self._final_x: int = 0
529 self._final_y: int = 0
530 self._lines: list[list[str]] = []
531 self._colors: list[list[str]] = []
532 self._prev_lines: list[list[str]] = []
533 self._prev_colors: list[list[str]] = []
535 # Rendering status
536 self._full_redraw: bool = False
537 self._term_x: int = 0
538 self._term_y: int = 0
539 self._term_color: str = ""
540 self._max_term_y: int = 0
541 self._out: list[str] = []
542 self._bell: bool = False
543 self._in_alternative_buffer: bool = False
544 self._normal_buffer_term_x: int = 0
545 self._normal_buffer_term_y: int = 0
547 # Helpers
548 self._none_color: str = _Color.NONE.as_code(term.color_support)
550 # Used for tests and debug
551 self._renders: int = 0
552 self._bytes_rendered: int = 0
553 self._total_bytes_rendered: int = 0
555 @property
556 def term(self) -> _Term:
557 """
558 Terminal where we render the widgets.
560 """
562 return self._term
564 @property
565 def theme(self) -> _Theme:
566 """
567 Current color theme.
569 """
571 return self._theme
573 @contextlib.contextmanager
574 def frame(
575 self,
576 x: int,
577 y: int,
578 /,
579 *,
580 width: int | None = None,
581 height: int | None = None,
582 ):
583 """
584 Override drawing frame.
586 Widgets are always drawn in the frame's top-left corner,
587 and they can take the entire frame size.
589 The idea is that, if you want to draw a widget at specific coordinates,
590 you make a frame and draw the widget inside said frame.
592 When new frame is created, cursor's position and color are reset.
593 When frame is dropped, they are restored.
594 Therefore, drawing widgets in a frame will not affect current drawing state.
596 ..
597 >>> term = _Term(sys.stdout, sys.stdin)
598 >>> theme = _Theme()
599 >>> rc = RenderContext(term, theme)
600 >>> rc._override_wh = (20, 5)
602 Example::
604 >>> rc = RenderContext(term, theme) # doctest: +SKIP
605 >>> rc.prepare()
607 >>> # By default, our frame is located at (0, 0)...
608 >>> rc.write("+")
610 >>> # ...and spans the entire canvas.
611 >>> print(rc.width, rc.height)
612 20 5
614 >>> # Let's write something at (4, 0).
615 >>> rc.set_pos(4, 0)
616 >>> rc.write("Hello, world!")
618 >>> # Now we set our drawing frame to be at (2, 2).
619 >>> with rc.frame(2, 2):
620 ... # Out current pos was reset to the frame's top-left corner,
621 ... # which is now (2, 2).
622 ... rc.write("+")
623 ...
624 ... # Frame dimensions were automatically reduced.
625 ... print(rc.width, rc.height)
626 ...
627 ... # Set pos and all other functions work relative
628 ... # to the current frame, so writing at (4, 0)
629 ... # in the current frame will result in text at (6, 2).
630 ... rc.set_pos(4, 0)
631 ... rc.write("Hello, world!")
632 18 3
634 >>> rc.render() # doctest: +NORMALIZE_WHITESPACE
635 + Hello, world!
636 <BLANKLINE>
637 + Hello, world!
638 <BLANKLINE>
639 <BLANKLINE>
641 Usually you don't have to think about frames. If you want to stack
642 multiple widgets one on top of another, simply use :class:`VerticalLayout`.
643 In cases where it's not enough though, you'll have to call
644 :meth:`~Widget.layout` for each of the nested widgets, and then manually
645 create frames and execute :meth:`~Widget.draw` methods::
647 class MyWidget(Widget):
648 # Let's say we want to print a text indented by four spaces,
649 # and limit its with by 15. And we also want to print a small
650 # un-indented heading before it.
652 def __init__(self):
653 # This is the text we'll print.
654 self._nested_widget = Text(
655 "very long paragraph which potentially can span multiple lines"
656 )
658 def layout(self, rc: RenderContext) -> tuple[int, int]:
659 # The text will be placed at (4, 1), and we'll also limit
660 # its width. So we'll reflect those constrains
661 # by arranging a drawing frame.
662 with rc.frame(4, 1, width=min(rc.width - 4, 15)):
663 min_h, max_h = self._nested_widget.layout(rc)
665 # Our own widget will take as much space as the nested text,
666 # plus one line for our heading.
667 return min_h + 1, max_h + 1
669 def draw(self, rc: RenderContext):
670 # Print a small heading.
671 rc.set_color_path("bold")
672 rc.write("Small heading")
674 # And draw our nested widget, controlling its position
675 # via a frame.
676 with rc.frame(4, 1, width=min(rc.width - 4, 15)):
677 self._nested_widget.draw(rc)
679 """
681 prev_frame_x = self._frame_x
682 prev_frame_y = self._frame_y
683 prev_frame_w = self._frame_w
684 prev_frame_h = self._frame_h
685 prev_frame_cursor_x = self._frame_cursor_x
686 prev_frame_cursor_y = self._frame_cursor_y
687 prev_frame_cursor_color = self._frame_cursor_color
689 self._frame_x += x
690 self._frame_y += y
692 if width is not None:
693 self._frame_w = width
694 else:
695 self._frame_w -= x
696 if self._frame_w < 0:
697 self._frame_w = 0
699 if height is not None:
700 self._frame_h = height
701 else:
702 self._frame_h -= y
703 if self._frame_h < 0:
704 self._frame_h = 0
706 self._frame_cursor_x = 0
707 self._frame_cursor_y = 0
708 self._frame_cursor_color = self._none_color
710 try:
711 yield
712 finally:
713 self._frame_x = prev_frame_x
714 self._frame_y = prev_frame_y
715 self._frame_w = prev_frame_w
716 self._frame_h = prev_frame_h
717 self._frame_cursor_x = prev_frame_cursor_x
718 self._frame_cursor_y = prev_frame_cursor_y
719 self._frame_cursor_color = prev_frame_cursor_color
721 @property
722 def width(self) -> int:
723 """
724 Get width of the current frame.
726 """
728 return self._frame_w
730 @property
731 def height(self) -> int:
732 """
733 Get height of the current frame.
735 """
737 return self._frame_h
739 @property
740 def canvas_width(self) -> int:
741 """
742 Get width of the terminal.
744 """
746 return self._width
748 @property
749 def canvas_height(self) -> int:
750 """
751 Get height of the terminal.
753 """
755 return self._height
757 def set_pos(self, x: int, y: int, /):
758 """
759 Set current cursor position within the frame.
761 """
763 self._frame_cursor_x = x
764 self._frame_cursor_y = y
766 def move_pos(self, dx: int, dy: int, /):
767 """
768 Move current cursor position by the given amount.
770 """
772 self._frame_cursor_x += dx
773 self._frame_cursor_y += dy
775 def new_line(self):
776 """
777 Move cursor to new line within the current frame.
779 """
781 self._frame_cursor_x = 0
782 self._frame_cursor_y += 1
784 def set_final_pos(self, x: int, y: int, /):
785 """
786 Set position where the cursor should end up
787 after everything has been rendered.
789 By default, cursor will end up at the beginning of the last line.
790 Components such as :class:`Input` can modify this behavior
791 and move the cursor into the correct position.
793 """
795 self._final_x = x + self._frame_x
796 self._final_y = y + self._frame_y
798 def set_color_path(self, path: str, /):
799 """
800 Set current color by fetching it from the theme by path.
802 """
804 self._frame_cursor_color = self._theme.get_color(path).as_code(
805 self._term.color_support
806 )
808 def set_color(self, color: _Color, /):
809 """
810 Set current color.
812 """
814 self._frame_cursor_color = color.as_code(self._term.color_support)
816 def reset_color(self):
817 """
818 Set current color to the default color of the terminal.
820 """
822 self._frame_cursor_color = self._none_color
824 def get_msg_decoration(self, name: str, /) -> str:
825 """
826 Get message decoration by name.
828 """
830 return self.theme.get_msg_decoration(name, is_unicode=self.term.is_unicode)
832 def write(self, text: yuio.string.AnyString, /, *, max_width: int | None = None):
833 """
834 Write string at the current position using the current color.
835 Move cursor while printing.
837 While the displayed text will not be clipped at frame's borders,
838 its width can be limited by passing `max_width`. Note that
839 ``rc.write(text, max_width)`` is not the same
840 as ``rc.write(text[:max_width])``, because the later case
841 doesn't account for double-width characters.
843 All whitespace characters in the text, including tabs and newlines,
844 will be treated as single spaces. If you need to print multiline text,
845 use :meth:`yuio.string.ColorizedString.wrap` and :meth:`~RenderContext.write_text`.
847 ..
848 >>> term = _Term(sys.stdout, sys.stdin)
849 >>> theme = _Theme()
850 >>> rc = RenderContext(term, theme)
851 >>> rc._override_wh = (20, 5)
853 Example::
855 >>> rc = RenderContext(term, theme) # doctest: +SKIP
856 >>> rc.prepare()
858 >>> rc.write("Hello, world!")
859 >>> rc.new_line()
860 >>> rc.write("Hello,\\nworld!")
861 >>> rc.new_line()
862 >>> rc.write(
863 ... "Hello, 🌍!<this text will be clipped>",
864 ... max_width=10
865 ... )
866 >>> rc.new_line()
867 >>> rc.write(
868 ... "Hello, 🌍!<this text will be clipped>"[:10]
869 ... )
870 >>> rc.new_line()
872 >>> rc.render()
873 Hello, world!
874 Hello, world!
875 Hello, 🌍!
876 Hello, 🌍!<
877 <BLANKLINE>
879 Notice that ``"\\n"`` on the second line was replaced with a space.
880 Notice also that the last line wasn't properly clipped.
882 """
884 if not isinstance(text, _ColorizedString):
885 text = _ColorizedString(text, _isolate_colors=False)
887 x = self._frame_x + self._frame_cursor_x
888 y = self._frame_y + self._frame_cursor_y
890 max_x = self._width
891 if max_width is not None:
892 max_x = min(max_x, x + max_width)
893 self._frame_cursor_x = min(self._frame_cursor_x + text.width, x + max_width)
894 else:
895 self._frame_cursor_x = self._frame_cursor_x + text.width
897 if not 0 <= y < self._height:
898 for s in text:
899 if isinstance(s, _Color):
900 self._frame_cursor_color = s.as_code(self._term.color_support)
901 return
903 ll = self._lines[y]
904 cc = self._colors[y]
906 for s in text:
907 if isinstance(s, _Color):
908 self._frame_cursor_color = s.as_code(self._term.color_support)
909 continue
910 elif s in (yuio.string.NO_WRAP_START, yuio.string.NO_WRAP_END):
911 continue
913 s = s.translate(_UNPRINTABLE_TRANS)
915 if s.isascii():
916 # Fast track.
917 if x + len(s) <= 0:
918 # We're beyond the left terminal border.
919 x += len(s)
920 continue
922 slice_begin = 0
923 if x < 0:
924 # We're partially beyond the left terminal border.
925 slice_begin = -x
926 x = 0
928 if x >= max_x:
929 # We're beyond the right terminal border.
930 x += len(s) - slice_begin
931 continue
933 slice_end = len(s)
934 if x + len(s) - slice_begin > max_x:
935 # We're partially beyond the right terminal border.
936 slice_end = slice_begin + max_x - x
938 l = slice_end - slice_begin
939 self._lines[y][x : x + l] = s[slice_begin:slice_end]
940 self._colors[y][x : x + l] = [self._frame_cursor_color] * l
941 x += l
942 continue
944 for c in s:
945 cw = _line_width(c)
946 if x + cw <= 0:
947 # We're beyond the left terminal border.
948 x += cw
949 continue
950 elif x < 0:
951 # This character was split in half by the terminal border.
952 ll[: x + cw] = [" "] * (x + cw)
953 cc[: x + cw] = [self._none_color] * (x + cw)
954 x += cw
955 continue
956 elif cw > 0 and x >= max_x:
957 # We're beyond the right terminal border.
958 x += cw
959 break
960 elif x + cw > max_x:
961 # This character was split in half by the terminal border.
962 ll[x:max_x] = " " * (max_x - x)
963 cc[x:max_x] = [self._frame_cursor_color] * (max_x - x)
964 x += cw
965 break
967 if cw == 0:
968 # This is a zero-width character.
969 # We'll append it to the previous cell.
970 if x > 0:
971 ll[x - 1] += c
972 continue
974 ll[x] = c
975 cc[x] = self._frame_cursor_color
977 x += 1
978 cw -= 1
979 if cw:
980 ll[x : x + cw] = [""] * cw
981 cc[x : x + cw] = [self._frame_cursor_color] * cw
982 x += cw
984 def write_text(
985 self,
986 lines: _t.Iterable[yuio.string.AnyString],
987 /,
988 *,
989 max_width: int | None = None,
990 ):
991 """
992 Write multiple lines.
994 Each line is printed using :meth:`~RenderContext.write`,
995 so newline characters and tabs within each line are replaced with spaces.
996 Use :meth:`yuio.string.ColorizedString.wrap` to properly handle them.
998 After each line, the cursor is moved one line down,
999 and back to its original horizontal position.
1001 ..
1002 >>> term = _Term(sys.stdout, sys.stdin)
1003 >>> theme = _Theme()
1004 >>> rc = RenderContext(term, theme)
1005 >>> rc._override_wh = (20, 5)
1007 Example::
1009 >>> rc = RenderContext(term, theme) # doctest: +SKIP
1010 >>> rc.prepare()
1012 >>> # Cursor is at (0, 0).
1013 >>> rc.write("+ > ")
1015 >>> # First line is printed at the cursor's position.
1016 >>> # All consequent lines are horizontally aligned with first line.
1017 >>> rc.write_text(["Hello,", "world!"])
1019 >>> # Cursor is at the last line.
1020 >>> rc.write("+")
1022 >>> rc.render() # doctest: +NORMALIZE_WHITESPACE
1023 + > Hello,
1024 world!+
1025 <BLANKLINE>
1026 <BLANKLINE>
1027 <BLANKLINE>
1029 """
1031 x = self._frame_cursor_x
1033 for i, line in enumerate(lines):
1034 if i > 0:
1035 self._frame_cursor_x = x
1036 self._frame_cursor_y += 1
1038 self.write(line, max_width=max_width)
1040 def bell(self):
1041 """
1042 Ring a terminal bell.
1044 """
1046 self._bell = True
1048 def make_repr_context(
1049 self,
1050 *,
1051 multiline: bool | None = None,
1052 highlighted: bool | None = None,
1053 max_depth: int | None = None,
1054 width: int | None = None,
1055 ) -> yuio.string.ReprContext:
1056 """
1057 Create a new :class:`~yuio.string.ReprContext` for rendering colorized strings
1058 inside widgets.
1060 :param multiline:
1061 sets initial value for
1062 :attr:`ReprContext.multiline <yuio.string.ReprContext.multiline>`.
1063 :param highlighted:
1064 sets initial value for
1065 :attr:`ReprContext.highlighted <yuio.string.ReprContext.highlighted>`.
1066 :param max_depth:
1067 sets initial value for
1068 :attr:`ReprContext.max_depth <yuio.string.ReprContext.max_depth>`.
1069 :param width:
1070 sets initial value for
1071 :attr:`ReprContext.width <yuio.string.ReprContext.width>`.
1072 If not given, uses current frame's width.
1073 :returns:
1074 a new repr context suitable for rendering colorized strings.
1076 """
1078 if width is None:
1079 width = self._frame_w
1080 return yuio.string.ReprContext(
1081 term=self._term,
1082 theme=self._theme,
1083 multiline=multiline,
1084 highlighted=highlighted,
1085 max_depth=max_depth,
1086 width=width,
1087 )
1089 def prepare(
1090 self,
1091 *,
1092 full_redraw: bool = False,
1093 alternative_buffer: bool = False,
1094 reset_term_pos: bool = False,
1095 ):
1096 """
1097 Reset output canvas and prepare context for a new round of widget formatting.
1099 """
1101 if self._override_wh:
1102 width, height = self._override_wh
1103 else:
1104 size = yuio.term.get_tty_size(fallback=(self._theme.fallback_width, 24))
1105 width = size.columns
1106 height = size.lines
1108 full_redraw = full_redraw or self._width != width or self._height != height
1110 if self._in_alternative_buffer != alternative_buffer:
1111 full_redraw = True
1112 self._in_alternative_buffer = alternative_buffer
1113 if alternative_buffer:
1114 self._out.append("\x1b[<u\x1b[?1049h\x1b[m\x1b[2J\x1b[H\x1b[>1u")
1115 self._normal_buffer_term_x = self._term_x
1116 self._normal_buffer_term_y = self._term_y
1117 self._term_x, self._term_y = 0, 0
1118 self._term_color = self._none_color
1119 else:
1120 self._out.append("\x1b[<u\x1b[?1049l\x1b[m\x1b[>1u")
1121 self._term_x = self._normal_buffer_term_x
1122 self._term_y = self._normal_buffer_term_y
1123 self._term_color = self._none_color
1125 if reset_term_pos:
1126 self._term_x, self._term_y = 0, 0
1127 full_redraw = True
1129 # Drawing frame and virtual cursor
1130 self._frame_x = 0
1131 self._frame_y = 0
1132 self._frame_w = width
1133 self._frame_h = height
1134 self._frame_cursor_x = 0
1135 self._frame_cursor_y = 0
1136 self._frame_cursor_color = self._none_color
1138 # Canvas
1139 self._width = width
1140 self._height = height
1141 self._final_x = 0
1142 self._final_y = 0
1143 if full_redraw:
1144 self._max_term_y = 0
1145 self._prev_lines, self._prev_colors = self._make_empty_canvas()
1146 else:
1147 self._prev_lines, self._prev_colors = self._lines, self._colors
1148 self._lines, self._colors = self._make_empty_canvas()
1150 # Rendering status
1151 self._full_redraw = full_redraw
1153 def clear_screen(self):
1154 """
1155 Clear screen and prepare for a full redraw.
1157 """
1159 self._out.append("\x1b[2J\x1b[1H")
1160 self._term_x, self._term_y = 0, 0
1161 self.prepare(full_redraw=True, alternative_buffer=self._in_alternative_buffer)
1163 def _make_empty_canvas(
1164 self,
1165 ) -> tuple[list[list[str]], list[list[str]]]:
1166 lines = [l[:] for l in [[" "] * self._width] * self._height]
1167 colors = [
1168 c[:] for c in [[self._frame_cursor_color] * self._width] * self._height
1169 ]
1170 return lines, colors
1172 def render(self):
1173 """
1174 Render current canvas onto the terminal.
1176 """
1178 if not self.term.ostream_is_tty:
1179 # For tests. Widgets can't work with dumb terminals
1180 self._render_dumb()
1181 return
1183 if self._bell:
1184 self._out.append("\a")
1185 self._bell = False
1187 if self._full_redraw:
1188 self._move_term_cursor(0, 0)
1189 self._out.append("\x1b[J")
1191 for y in range(self._height):
1192 line = self._lines[y]
1194 for x in range(self._width):
1195 prev_color = self._prev_colors[y][x]
1196 color = self._colors[y][x]
1198 if color != prev_color or line[x] != self._prev_lines[y][x]:
1199 self._move_term_cursor(x, y)
1201 if color != self._term_color:
1202 self._out.append(color)
1203 self._term_color = color
1205 self._out.append(line[x])
1206 self._term_x += 1
1208 final_x = max(0, min(self._width - 1, self._final_x))
1209 final_y = max(0, min(self._height - 1, self._final_y))
1210 self._move_term_cursor(final_x, final_y)
1212 rendered = "".join(self._out)
1213 self._term.ostream.write(rendered)
1214 self._term.ostream.flush()
1215 self._out.clear()
1217 if yuio._debug:
1218 self._renders += 1
1219 self._bytes_rendered = len(rendered.encode())
1220 self._total_bytes_rendered += self._bytes_rendered
1222 debug_msg = f"n={self._renders:>04},r={self._bytes_rendered:>04},t={self._total_bytes_rendered:>04}"
1223 term_x, term_y = self._term_x, self._term_y
1224 self._move_term_cursor(self._width - len(debug_msg), 0)
1225 color = yuio.color.Color.STYLE_INVERSE | yuio.color.Color.FORE_CYAN
1226 self._out.append(color.as_code(self._term.color_support))
1227 self._out.append(debug_msg)
1228 self._out.append(self._term_color)
1229 self._move_term_cursor(term_x, term_y)
1231 self._term.ostream.write("".join(self._out))
1232 self._term.ostream.flush()
1233 self._out.clear()
1235 def finalize(self):
1236 """
1237 Erase any rendered widget and move cursor to the initial position.
1239 """
1241 self.prepare(full_redraw=True)
1243 self._move_term_cursor(0, 0)
1244 self._out.append("\x1b[J")
1245 self._out.append(self._none_color)
1246 self._term.ostream.write("".join(self._out))
1247 self._term.ostream.flush()
1248 self._out.clear()
1249 self._term_color = self._none_color
1251 def _move_term_cursor(self, x: int, y: int):
1252 dy = y - self._term_y
1253 if y > self._max_term_y:
1254 self._out.append("\n" * dy)
1255 self._term_x = 0
1256 elif dy > 0:
1257 self._out.append(f"\x1b[{dy}B")
1258 elif dy < 0:
1259 self._out.append(f"\x1b[{-dy}A")
1260 self._term_y = y
1261 self._max_term_y = max(self._max_term_y, y)
1263 if x != self._term_x:
1264 self._out.append(f"\x1b[{x + 1}G")
1265 self._term_x = x
1267 def _render_dumb(self):
1268 prev_printed_color = self._none_color
1270 for line, colors in zip(self._lines, self._colors):
1271 for ch, color in zip(line, colors):
1272 if prev_printed_color != color:
1273 self._out.append(color)
1274 prev_printed_color = color
1275 self._out.append(ch)
1276 self._out.append("\n")
1278 self._term.ostream.writelines(
1279 # Trim trailing spaces for doctests.
1280 re.sub(r" +$", "\n", line, flags=re.MULTILINE)
1281 for line in "".join(self._out).splitlines()
1282 )
1285@dataclass(frozen=True, slots=True)
1286class Result(_t.Generic[T_co]):
1287 """
1288 Result of a widget run.
1290 We have to wrap the return value of event processors into this class.
1291 Otherwise we won't be able to distinguish between returning `None`
1292 as result of a ``Widget[None]``, and not returning anything.
1294 """
1296 value: T_co
1297 """
1298 Result of a widget run.
1300 """
1303class Widget(abc.ABC, _t.Generic[T_co]):
1304 """
1305 Base class for all interactive console elements.
1307 Widgets are displayed with their :meth:`~Widget.run` method.
1308 They always go through the same event loop:
1310 .. raw:: html
1312 <p>
1313 <pre class="mermaid">
1314 flowchart TD
1315 Start([Start]) --> Layout["`layout()`"]
1316 Layout --> Draw["`draw()`"]
1317 Draw -->|Wait for keyboard event| Event["`Event()`"]
1318 Event --> Result{{Returned result?}}
1319 Result -->|no| Layout
1320 Result -->|yes| Finish([Finish])
1321 </pre>
1322 </p>
1324 Widgets run indefinitely until they stop themselves and return a value.
1325 For example, :class:`Input` will return when user presses :kbd:`Enter`.
1326 When widget needs to stop, it can return the :meth:`Result` class
1327 from its event handler.
1329 For typing purposes, :class:`Widget` is generic. That is, ``Widget[T]``
1330 returns ``T`` from its :meth:`~Widget.run` method. So, :class:`Input`,
1331 for example, is ``Widget[str]``.
1333 Some widgets are ``Widget[Never]`` (see :class:`typing.Never`), indicating that
1334 they don't ever stop. Others are ``Widget[None]``, indicating that they stop,
1335 but don't return a value.
1337 """
1339 __bindings: typing.ClassVar[dict[KeyboardEvent, _t.Callable[[_t.Any], _t.Any]]]
1340 __callbacks: typing.ClassVar[list[object]]
1342 __in_help_menu: bool = False
1343 __bell: bool = False
1345 _cur_event: KeyboardEvent | None = None
1346 """
1347 Current event that is being processed.
1348 Guaranteed to be not :data:`None` inside event handlers.
1350 """
1352 def __init_subclass__(cls, **kwargs):
1353 super().__init_subclass__(**kwargs)
1355 cls.__bindings = {}
1356 cls.__callbacks = []
1358 event_handler_names = []
1359 for base in reversed(cls.__mro__):
1360 for name, cb in base.__dict__.items():
1361 if (
1362 hasattr(cb, "__yuio_keybindings__")
1363 and name not in event_handler_names
1364 ):
1365 event_handler_names.append(name)
1367 for name in event_handler_names:
1368 cb = getattr(cls, name, None)
1369 if cb is not None and hasattr(cb, "__yuio_keybindings__"):
1370 bindings: list[_Binding] = cb.__yuio_keybindings__
1371 cls.__bindings.update((binding.event, cb) for binding in bindings)
1372 cls.__callbacks.append(cb)
1374 def event(self, e: KeyboardEvent, /) -> Result[T_co] | None:
1375 """
1376 Handle incoming keyboard event.
1378 By default, this function dispatches event to handlers registered
1379 via :func:`bind`. If no handler is found,
1380 it calls :meth:`~Widget.default_event_handler`.
1382 """
1384 self._cur_event = e
1385 if handler := self.__bindings.get(e):
1386 return handler(self)
1387 else:
1388 return self.default_event_handler(e)
1390 def default_event_handler(self, e: KeyboardEvent, /) -> Result[T_co] | None:
1391 """
1392 Process any event that wasn't caught by other event handlers.
1394 """
1396 @abc.abstractmethod
1397 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
1398 """
1399 Prepare widget for drawing, and recalculate its dimensions
1400 according to new frame dimensions.
1402 Yuio's widgets always take all available width. They should return
1403 their minimum height that they will definitely take, and their maximum
1404 height that they can potentially take.
1406 """
1408 raise NotImplementedError()
1410 @abc.abstractmethod
1411 def draw(self, rc: RenderContext, /):
1412 """
1413 Draw the widget.
1415 Render context's drawing frame dimensions are guaranteed to be between
1416 the minimum and the maximum height returned from the last call
1417 to :meth:`~Widget.layout`.
1419 """
1421 raise NotImplementedError()
1423 @_t.final
1424 def run(self, term: _Term, theme: _Theme, /) -> T_co:
1425 """
1426 Read user input and run the widget.
1428 """
1430 if not term.can_run_widgets:
1431 raise RuntimeError("terminal doesn't support rendering widgets")
1433 with yuio.term._enter_raw_mode(
1434 term.ostream, term.istream, bracketed_paste=True, modify_keyboard=True
1435 ):
1436 rc = RenderContext(term, theme)
1438 events = _event_stream(term.ostream, term.istream)
1440 try:
1441 while True:
1442 rc.prepare(alternative_buffer=self.__in_help_menu)
1444 height = rc.height
1445 if self.__in_help_menu:
1446 min_h, max_h = self.__help_menu_layout(rc)
1447 inline_help_height = 0
1448 else:
1449 with rc.frame(0, 0):
1450 inline_help_height = self.__help_menu_layout_inline(rc)[0]
1451 if height > inline_help_height:
1452 height -= inline_help_height
1453 with rc.frame(0, 0, height=height):
1454 min_h, max_h = self.layout(rc)
1455 max_h = max(min_h, min(max_h, height))
1456 rc.set_final_pos(0, max_h + inline_help_height)
1457 if self.__in_help_menu:
1458 self.__help_menu_draw(rc)
1459 else:
1460 with rc.frame(0, 0, height=max_h):
1461 self.draw(rc)
1462 if max_h < rc.height:
1463 with rc.frame(0, max_h, height=rc.height - max_h):
1464 self.__help_menu_draw_inline(rc)
1466 if self.__bell:
1467 rc.bell()
1468 self.__bell = False
1469 rc.render()
1471 try:
1472 event = next(events)
1473 except StopIteration:
1474 assert False, "_event_stream supposed to be infinite"
1476 if event == KeyboardEvent("c", ctrl=True):
1477 raise KeyboardInterrupt()
1478 elif event == KeyboardEvent("l", ctrl=True):
1479 rc.clear_screen()
1480 elif event == KeyboardEvent(Key.F1) and not self.__in_help_menu:
1481 self.__in_help_menu = True
1482 self.__help_menu_line = 0
1483 self.__last_help_data = None
1484 elif self.__in_help_menu:
1485 self.__help_menu_event(event)
1486 elif result := self.event(event):
1487 return result.value
1488 finally:
1489 rc.finalize()
1491 def _bell(self):
1492 self.__bell = True
1494 @property
1495 def help_data(self) -> WidgetHelp:
1496 """
1497 Data for displaying help messages.
1499 See :func:`help` for more info.
1501 """
1503 return self.__help_columns
1505 @functools.cached_property
1506 def __help_columns(self) -> WidgetHelp:
1507 inline_help: list[Action] = []
1508 groups: dict[str, list[Action]] = {}
1510 for cb in self.__callbacks:
1511 bindings: list[_Binding] = getattr(cb, "__yuio_keybindings__", [])
1512 help: _Help | None = getattr(cb, "__yuio_help__", None)
1513 if not bindings:
1514 continue
1515 if help is None:
1516 help = _Help(
1517 "Actions",
1518 getattr(cb, "__doc__", None),
1519 getattr(cb, "__doc__", None),
1520 )
1521 if not help.inline_msg and not help.long_msg:
1522 continue
1524 if help.inline_msg:
1525 inline_bindings = [
1526 binding.event
1527 for binding in reversed(bindings)
1528 if binding.show_in_inline_help
1529 ]
1530 if inline_bindings:
1531 inline_help.append((inline_bindings, help.inline_msg))
1533 if help.long_msg:
1534 menu_bindings = [
1535 binding.event
1536 for binding in reversed(bindings)
1537 if binding.show_in_detailed_help
1538 ]
1539 if menu_bindings:
1540 groups.setdefault(help.group, []).append(
1541 (menu_bindings, help.long_msg)
1542 )
1544 return WidgetHelp(inline_help, groups)
1546 __last_help_data: WidgetHelp | None = None
1547 __prepared_inline_help: list[tuple[list[str], str, str, int]]
1548 __prepared_groups: dict[str, list[tuple[list[str], str, str, int]]]
1549 __has_help: bool = True
1550 __width: int = 0
1551 __height: int = 0
1552 __menu_content_height: int = 0
1553 __help_menu_line: int = 0
1554 __help_menu_search: bool = False
1555 __help_menu_search_widget: Input
1556 __help_menu_search_layout: tuple[int, int] = 0, 0
1557 __key_width: int = 0
1558 __wrapped_groups: list[
1559 tuple[
1560 str, # Title
1561 list[ # Actions
1562 tuple[ # Action
1563 list[str], # Keys
1564 list[_ColorizedString], # Wrapped msg
1565 int, # Keys width
1566 ],
1567 ],
1568 ] # FML this type hint -___-
1569 ]
1570 __colorized_inline_help: list[
1571 tuple[ # Action
1572 list[str], # Keys
1573 _ColorizedString, # Title
1574 int, # Keys width
1575 ]
1576 ]
1578 def __help_menu_event(self, e: KeyboardEvent, /) -> Result[T_co] | None:
1579 if not self.__help_menu_search and e in [
1580 KeyboardEvent(Key.F1),
1581 KeyboardEvent(Key.ESCAPE),
1582 KeyboardEvent(Key.ENTER),
1583 KeyboardEvent("q"),
1584 KeyboardEvent("q", ctrl=True),
1585 ]:
1586 self.__in_help_menu = False
1587 self.__help_menu_line = 0
1588 self.__last_help_data = None
1589 elif e == KeyboardEvent(Key.ARROW_UP):
1590 self.__help_menu_line += 1
1591 elif e == KeyboardEvent(Key.HOME):
1592 self.__help_menu_line = 0
1593 elif e == KeyboardEvent(Key.PAGE_UP):
1594 self.__help_menu_line += self.__height
1595 elif e == KeyboardEvent(Key.END):
1596 self.__help_menu_line = -self.__menu_content_height
1597 elif e == KeyboardEvent(Key.ARROW_DOWN):
1598 self.__help_menu_line -= 1
1599 elif e == KeyboardEvent(Key.PAGE_DOWN):
1600 self.__help_menu_line -= self.__height
1601 elif not self.__help_menu_search and e == KeyboardEvent(" "):
1602 self.__help_menu_line -= self.__height
1603 elif not self.__help_menu_search and e == KeyboardEvent("/"):
1604 self.__help_menu_search = True
1605 self.__help_menu_search_widget = Input(
1606 decoration_path="menu/input/decoration_search"
1607 )
1608 elif self.__help_menu_search:
1609 if e == KeyboardEvent(Key.ESCAPE) or (
1610 e == KeyboardEvent(Key.BACKSPACE)
1611 and not self.__help_menu_search_widget.text
1612 ):
1613 self.__help_menu_search = False
1614 self.__last_help_data = None
1615 del self.__help_menu_search_widget
1616 self.__help_menu_search_layout = 0, 0
1617 else:
1618 self.__help_menu_search_widget.event(e)
1619 self.__last_help_data = None
1620 self.__help_menu_line = min(
1621 max(-self.__menu_content_height + self.__height, self.__help_menu_line), 0
1622 )
1624 def __clear_layout_cache(self, rc: RenderContext, /) -> bool:
1625 if self.__width == rc.width and self.__last_help_data == self.help_data:
1626 return False
1628 if self.__width != rc.width:
1629 self.__help_menu_line = 0
1631 self.__width = rc.width
1632 self.__height = rc.height
1634 if self.__last_help_data != self.help_data:
1635 self.__last_help_data = self.help_data
1636 self.__prepared_groups = self.__prepare_groups(self.__last_help_data, rc)
1637 self.__prepared_inline_help = self.__prepare_inline_help(
1638 self.__last_help_data, rc
1639 )
1640 self.__has_help = bool(
1641 self.__last_help_data.inline_help or self.__last_help_data.groups
1642 )
1644 return True
1646 def __help_menu_layout(self, rc: RenderContext, /) -> tuple[int, int]:
1647 if self.__help_menu_search:
1648 self.__help_menu_search_layout = self.__help_menu_search_widget.layout(rc)
1650 if not self.__clear_layout_cache(rc):
1651 return rc.height, rc.height
1653 self.__key_width = 10
1654 formatter = yuio.md.MdFormatter(
1655 rc.make_repr_context(
1656 width=min(rc.width, 90) - self.__key_width - 2,
1657 ),
1658 allow_headings=False,
1659 )
1661 self.__wrapped_groups = []
1662 for title, actions in self.__prepared_groups.items():
1663 wrapped_actions: list[tuple[list[str], list[_ColorizedString], int]] = []
1664 for keys, _, msg, key_width in actions:
1665 wrapped_actions.append((keys, formatter.format(msg), key_width))
1666 self.__wrapped_groups.append((title, wrapped_actions))
1668 return rc.height, rc.height
1670 def __help_menu_draw(self, rc: RenderContext, /):
1671 y = self.__help_menu_line
1673 if not self.__wrapped_groups:
1674 rc.set_color_path("menu/decoration:help_menu")
1675 rc.write("No actions to display")
1676 y += 1
1678 for title, actions in self.__wrapped_groups:
1679 rc.set_pos(0, y)
1680 if title:
1681 rc.set_color_path("menu/text/heading:help_menu")
1682 rc.write(title)
1683 y += 2
1685 for keys, lines, key_width in actions:
1686 if key_width > self.__key_width:
1687 rc.set_pos(0, y)
1688 y += 1
1689 else:
1690 rc.set_pos(self.__key_width - key_width, y)
1691 sep = ""
1692 for key in keys:
1693 rc.set_color_path("menu/text/help_sep:help_menu")
1694 rc.write(sep)
1695 rc.set_color_path("menu/text/help_key:help_menu")
1696 rc.write(key)
1697 sep = "/"
1699 rc.set_pos(0 + self.__key_width + 2, y)
1700 rc.write_text(lines)
1701 y += len(lines)
1703 y += 2
1705 self.__menu_content_height = y - self.__help_menu_line
1707 with rc.frame(0, rc.height - max(self.__help_menu_search_layout[0], 1)):
1708 if self.__help_menu_search:
1709 rc.write(" " * rc.width)
1710 rc.set_pos(0, 0)
1711 self.__help_menu_search_widget.draw(rc)
1712 else:
1713 rc.set_color_path("menu/decoration:help_menu")
1714 rc.write(rc.get_msg_decoration("menu/help/decoration"))
1715 rc.reset_color()
1716 rc.write(" " * (rc.width - 1))
1717 rc.set_final_pos(1, 0)
1719 def __help_menu_layout_inline(self, rc: RenderContext, /) -> tuple[int, int]:
1720 if not self.__clear_layout_cache(rc):
1721 return (1, 1) if self.__has_help else (0, 0)
1723 if not self.__has_help:
1724 return 0, 0
1726 self.__colorized_inline_help = []
1727 for keys, title, _, key_width in self.__prepared_inline_help:
1728 if keys:
1729 title_color = "menu/text/help_msg:help"
1730 else:
1731 title_color = "menu/text/help_info:help"
1732 colorized_title = yuio.string.colorize(
1733 title,
1734 default_color=title_color,
1735 ctx=rc.make_repr_context(),
1736 )
1737 self.__colorized_inline_help.append((keys, colorized_title, key_width))
1739 return 1, 1
1741 def __help_menu_draw_inline(self, rc: RenderContext, /):
1742 if not self.__has_help:
1743 return
1745 used_width = _line_width(rc.get_msg_decoration("menu/help/key/f1")) + 5
1746 col_sep = ""
1748 for keys, title, keys_width in self.__colorized_inline_help:
1749 action_width = keys_width + bool(keys_width) + title.width + 3
1750 if used_width + action_width > rc.width:
1751 break
1753 rc.set_color_path("menu/text/help_sep:help")
1754 rc.write(col_sep)
1756 sep = ""
1757 for key in keys:
1758 rc.set_color_path("menu/text/help_sep:help")
1759 rc.write(sep)
1760 rc.set_color_path("menu/text/help_key:help")
1761 rc.write(key)
1762 sep = "/"
1764 if keys_width:
1765 rc.move_pos(1, 0)
1766 rc.write(title)
1768 col_sep = " • "
1770 rc.set_color_path("menu/text/help_sep:help")
1771 rc.write(col_sep)
1772 rc.set_color_path("menu/text/help_key:help")
1773 rc.write(rc.get_msg_decoration("menu/help/key/f1"))
1774 rc.move_pos(1, 0)
1775 rc.set_color_path("menu/text/help_msg:help")
1776 rc.write("help")
1778 def __prepare_inline_help(
1779 self, data: WidgetHelp, rc: RenderContext
1780 ) -> list[tuple[list[str], str, str, int]]:
1781 return [
1782 prepared_action
1783 for action in data.inline_help
1784 if (prepared_action := self.__prepare_action(action, rc))
1785 and prepared_action[1]
1786 ]
1788 def __prepare_groups(
1789 self, data: WidgetHelp, rc: RenderContext
1790 ) -> dict[str, list[tuple[list[str], str, str, int]]]:
1791 help_data = (
1792 data.with_action(
1793 rc.get_msg_decoration("menu/help/key/f1"),
1794 group="Other Actions",
1795 long_msg="toggle help menu",
1796 )
1797 .with_action(
1798 rc.get_msg_decoration("menu/help/key/ctrl") + "l",
1799 group="Other Actions",
1800 long_msg="refresh screen",
1801 )
1802 .with_action(
1803 rc.get_msg_decoration("menu/help/key/ctrl") + "c",
1804 group="Other Actions",
1805 long_msg="send interrupt signal",
1806 )
1807 .with_action(
1808 rc.get_msg_decoration("menu/help/key/ctrl") + "...",
1809 group="Legend",
1810 long_msg="means `Ctrl+...`",
1811 )
1812 .with_action(
1813 rc.get_msg_decoration("menu/help/key/alt") + "...",
1814 group="Legend",
1815 long_msg=(
1816 "means `Option+...`"
1817 if sys.platform == "darwin"
1818 else "means `Alt+...`"
1819 ),
1820 )
1821 .with_action(
1822 rc.get_msg_decoration("menu/help/key/shift") + "...",
1823 group="Legend",
1824 long_msg="means `Shift+...`",
1825 )
1826 .with_action(
1827 rc.get_msg_decoration("menu/help/key/enter"),
1828 group="Legend",
1829 long_msg="means `Return` or `Enter`",
1830 )
1831 .with_action(
1832 rc.get_msg_decoration("menu/help/key/backspace"),
1833 group="Legend",
1834 long_msg="means `Backspace`",
1835 )
1836 )
1838 # Make sure unsorted actions go first.
1839 groups = {"Input Format": [], "Actions": []}
1841 groups.update(
1842 {
1843 title: prepared_actions
1844 for title, actions in help_data.groups.items()
1845 if (
1846 prepared_actions := [
1847 prepared_action
1848 for action in actions
1849 if (prepared_action := self.__prepare_action(action, rc))
1850 and prepared_action[1]
1851 ]
1852 )
1853 }
1854 )
1856 if not groups["Input Format"]:
1857 del groups["Input Format"]
1858 if not groups["Actions"]:
1859 del groups["Actions"]
1861 # Make sure other actions go last.
1862 if "Other Actions" in groups:
1863 groups["Other Actions"] = groups.pop("Other Actions")
1864 if "Legend" in groups:
1865 groups["Legend"] = groups.pop("Legend")
1867 return groups
1869 def __prepare_action(
1870 self, action: Action, rc: RenderContext
1871 ) -> tuple[list[str], str, str, int] | None:
1872 if isinstance(action, tuple):
1873 action_keys, msg = action
1874 prepared_keys = self.__prepare_keys(action_keys, rc)
1875 else:
1876 prepared_keys = []
1877 msg = action
1879 if self.__help_menu_search:
1880 pattern = self.__help_menu_search_widget.text
1881 if not any(pattern in key for key in prepared_keys) and pattern not in msg:
1882 return None
1884 title = msg.split("\n\n", maxsplit=1)[0]
1885 return prepared_keys, title, msg, _line_width("/".join(prepared_keys))
1887 def __prepare_keys(self, action_keys: ActionKeys, rc: RenderContext) -> list[str]:
1888 if isinstance(action_keys, (str, Key, KeyboardEvent)):
1889 return [self.__prepare_key(action_keys, rc)]
1890 else:
1891 return [self.__prepare_key(action_key, rc) for action_key in action_keys]
1893 def __prepare_key(self, action_key: ActionKey, rc: RenderContext) -> str:
1894 if isinstance(action_key, str):
1895 return action_key
1896 elif isinstance(action_key, KeyboardEvent):
1897 ctrl, alt, shift, key = (
1898 action_key.ctrl,
1899 action_key.alt,
1900 action_key.shift,
1901 action_key.key,
1902 )
1903 else:
1904 ctrl, alt, shift, key = False, False, False, action_key
1906 symbol = ""
1908 if isinstance(key, str):
1909 if key.lower() != key:
1910 shift = True
1911 key = key.lower()
1912 elif key == " ":
1913 key = "space"
1914 else:
1915 key = key.name.lower()
1917 if shift:
1918 symbol += rc.get_msg_decoration("menu/help/key/shift")
1920 if ctrl:
1921 symbol += rc.get_msg_decoration("menu/help/key/ctrl")
1923 if alt:
1924 symbol += rc.get_msg_decoration("menu/help/key/alt")
1926 return symbol + (rc.get_msg_decoration(f"menu/help/key/{key}") or key)
1929Widget.__init_subclass__()
1932@dataclass(frozen=True, slots=True)
1933class _Binding:
1934 event: KeyboardEvent
1935 show_in_inline_help: bool
1936 show_in_detailed_help: bool
1938 def __call__(self, fn: T, /) -> T:
1939 if not hasattr(fn, "__yuio_keybindings__"):
1940 setattr(fn, "__yuio_keybindings__", [])
1941 getattr(fn, "__yuio_keybindings__").append(self)
1943 return fn
1946def bind(
1947 key: Key | str,
1948 *,
1949 ctrl: bool = False,
1950 alt: bool = False,
1951 shift: bool = False,
1952 show_in_inline_help: bool = False,
1953 show_in_detailed_help: bool = True,
1954) -> _Binding:
1955 """
1956 Register an event handler for a widget.
1958 Widget's methods can be registered as handlers for keyboard events.
1959 When a new event comes in, it is checked to match arguments of this decorator.
1960 If there is a match, the decorated method is called
1961 instead of the :meth:`Widget.default_event_handler`.
1963 .. note::
1965 :kbd:`Ctrl+L` and :kbd:`F1` are always reserved by the widget itself.
1967 If `show_in_help` is :data:`True`, this binding will be shown in the widget's
1968 inline help. If `show_in_detailed_help` is :data:`True`,
1969 this binding will be shown in the widget's help menu.
1971 Example::
1973 class MyWidget(Widget):
1974 @bind(Key.ENTER)
1975 def enter(self):
1976 # all `ENTER` events go here.
1977 ...
1979 def default_event_handler(self, e: KeyboardEvent):
1980 # all non-`ENTER` events go here (including `ALT+ENTER`).
1981 ...
1983 """
1985 e = KeyboardEvent(key=key, ctrl=ctrl, alt=alt, shift=shift)
1986 return _Binding(e, show_in_inline_help, show_in_detailed_help)
1989@dataclass(frozen=True, slots=True)
1990class _Help:
1991 group: str = "Actions"
1992 inline_msg: str | None = None
1993 long_msg: str | None = None
1995 def __call__(self, fn: T, /) -> T:
1996 h = dataclasses.replace(
1997 self,
1998 inline_msg=(
1999 self.inline_msg
2000 if self.inline_msg is not None
2001 else getattr(fn, "__doc__", None)
2002 ),
2003 long_msg=(
2004 self.long_msg
2005 if self.long_msg is not None
2006 else getattr(fn, "__doc__", None)
2007 ),
2008 )
2009 setattr(fn, "__yuio_help__", h)
2011 return fn
2014def help(
2015 *,
2016 group: str = "Actions",
2017 inline_msg: str | None = None,
2018 long_msg: str | None = None,
2019 msg: str | None = None,
2020) -> _Help:
2021 """
2022 Set options for how this callback should be displayed.
2024 This decorator controls automatic generation of help messages for a widget.
2026 :param group:
2027 title of a group that this action will appear in when the user opens
2028 a help menu. Groups appear in order of declaration of their first element.
2029 :param inline_msg:
2030 this parameter overrides a message in the inline help. By default,
2031 it will be taken from a docstring.
2032 :param long_msg:
2033 this parameter overrides a message in the help menu. By default,
2034 it will be taken from a docstring.
2035 :param msg:
2036 a shortcut parameter for setting both `inline_msg` and `long_msg`
2037 at the same time.
2039 Example::
2041 class MyWidget(Widget):
2042 NAVIGATE = "Navigate"
2044 @bind(Key.TAB)
2045 @help(group=NAVIGATE)
2046 def tab(self):
2047 \"""next item\"""
2048 ...
2050 @bind(Key.TAB, shift=True)
2051 @help(group=NAVIGATE)
2052 def shift_tab(self):
2053 \"""previous item\"""
2054 ...
2056 """
2058 if msg is not None and inline_msg is None:
2059 inline_msg = msg
2060 if msg is not None and long_msg is None:
2061 long_msg = msg
2063 return _Help(
2064 group,
2065 inline_msg,
2066 long_msg,
2067 )
2070ActionKey: _t.TypeAlias = Key | KeyboardEvent | str
2071"""
2072A single key associated with an action.
2073Can be either a hotkey or a string with an arbitrary description.
2075"""
2078ActionKeys: _t.TypeAlias = ActionKey | _t.Collection[ActionKey]
2079"""
2080A list of keys associated with an action.
2082"""
2085Action: _t.TypeAlias = str | tuple[ActionKeys, str]
2086"""
2087An action itself, i.e. a set of hotkeys and a description for them.
2089"""
2092@dataclass(frozen=True, slots=True)
2093class WidgetHelp:
2094 """
2095 Data for automatic help generation.
2097 .. warning::
2099 Do not modify contents of this class in-place. This might break layout
2100 caching in the widget rendering routine, which will cause displaying
2101 outdated help messages.
2103 Use the provided helpers to modify contents of this class.
2105 """
2107 inline_help: list[Action] = dataclasses.field(default_factory=list)
2108 """
2109 List of actions to show in the inline help.
2111 """
2113 groups: dict[str, list[Action]] = dataclasses.field(default_factory=dict)
2114 """
2115 Dict of group titles and actions to show in the help menu.
2117 """
2119 def with_action(
2120 self,
2121 *bindings: _Binding | ActionKey,
2122 group: str = "Actions",
2123 msg: str | None = None,
2124 inline_msg: str | None = None,
2125 long_msg: str | None = None,
2126 prepend: bool = False,
2127 prepend_group: bool = False,
2128 ) -> WidgetHelp:
2129 """
2130 Return a new :class:`WidgetHelp` that has an extra action.
2132 :param bindings:
2133 keys that trigger an action.
2134 :param group:
2135 title of a group that this action will appear in when the user opens
2136 a help menu. Groups appear in order of declaration of their first element.
2137 :param inline_msg:
2138 this parameter overrides a message in the inline help. By default,
2139 it will be taken from a docstring.
2140 :param long_msg:
2141 this parameter overrides a message in the help menu. By default,
2142 it will be taken from a docstring.
2143 :param msg:
2144 a shortcut parameter for setting both `inline_msg` and `long_msg`
2145 at the same time.
2146 :param prepend:
2147 if :data:`True`, action will be added to the beginning of its group.
2148 :param prepend_group:
2149 if :data:`True`, group will be added to the beginning of the help menu.
2151 """
2153 return WidgetHelp(self.inline_help.copy(), self.groups.copy()).__add_action(
2154 *bindings,
2155 group=group,
2156 inline_msg=inline_msg,
2157 long_msg=long_msg,
2158 prepend=prepend,
2159 prepend_group=prepend_group,
2160 msg=msg,
2161 )
2163 def merge(self, other: WidgetHelp, /) -> WidgetHelp:
2164 """
2165 Merge this help data with another one and return
2166 a new instance of :class:`WidgetHelp`.
2168 :param other:
2169 other :class:`WidgetHelp` for merging.
2171 """
2173 result = WidgetHelp(self.inline_help.copy(), self.groups.copy())
2174 result.inline_help.extend(other.inline_help)
2175 for title, actions in other.groups.items():
2176 result.groups[title] = result.groups.get(title, []) + actions
2177 return result
2179 def without_group(self, title: str, /) -> WidgetHelp:
2180 """
2181 Return a new :class:`WidgetHelp` that has a group with the given title removed.
2183 :param title:
2184 title to remove.
2186 """
2188 result = WidgetHelp(self.inline_help.copy(), self.groups.copy())
2189 result.groups.pop(title, None)
2190 return result
2192 def rename_group(self, title: str, new_title: str, /) -> WidgetHelp:
2193 """
2194 Return a new :class:`WidgetHelp` that has a group with the given title renamed.
2196 :param title:
2197 title to replace.
2198 :param new_title:
2199 new title.
2201 """
2203 result = WidgetHelp(self.inline_help.copy(), self.groups.copy())
2204 if group := result.groups.pop(title, None):
2205 result.groups[new_title] = result.groups.get(new_title, []) + group
2206 return result
2208 def __add_action(
2209 self,
2210 *bindings: _Binding | ActionKey,
2211 group: str,
2212 inline_msg: str | None,
2213 long_msg: str | None,
2214 prepend: bool,
2215 prepend_group: bool,
2216 msg: str | None,
2217 ) -> WidgetHelp:
2218 settings = help(
2219 group=group,
2220 inline_msg=inline_msg,
2221 long_msg=long_msg,
2222 msg=msg,
2223 )
2225 if settings.inline_msg:
2226 inline_keys: ActionKeys = [
2227 binding.event if isinstance(binding, _Binding) else binding
2228 for binding in bindings
2229 if not isinstance(binding, _Binding) or binding.show_in_inline_help
2230 ]
2231 if prepend:
2232 self.inline_help.insert(0, (inline_keys, settings.inline_msg))
2233 else:
2234 self.inline_help.append((inline_keys, settings.inline_msg))
2236 if settings.long_msg:
2237 menu_keys: ActionKeys = [
2238 binding.event if isinstance(binding, _Binding) else binding
2239 for binding in bindings
2240 if not isinstance(binding, _Binding) or binding.show_in_detailed_help
2241 ]
2242 if prepend_group and settings.group not in self.groups:
2243 # Re-create self.groups with a new group as a first element.
2244 groups = {settings.group: [], **self.groups}
2245 self.groups.clear()
2246 self.groups.update(groups)
2247 if prepend:
2248 self.groups[settings.group] = [
2249 (menu_keys, settings.long_msg)
2250 ] + self.groups.get(settings.group, [])
2251 else:
2252 self.groups[settings.group] = self.groups.get(settings.group, []) + [
2253 (menu_keys, settings.long_msg)
2254 ]
2256 return self
2259@_t.final
2260class VerticalLayoutBuilder(_t.Generic[T]):
2261 """
2262 Builder for :class:`VerticalLayout` that allows for precise control
2263 of keyboard events.
2265 By default, :class:`VerticalLayout` does not handle incoming keyboard events.
2266 However, you can create :class:`VerticalLayout` that forwards all keyboard events
2267 to a particular widget within the stack::
2269 widget = VerticalLayout.builder() \\
2270 .add(Line("Enter something:")) \\
2271 .add(Input(), receive_events=True) \\
2272 .build()
2274 result = widget.run(term, theme)
2276 """
2278 if TYPE_CHECKING:
2280 def __new__(cls) -> VerticalLayoutBuilder[_t.Never]: ...
2282 def __init__(self):
2283 self._widgets: list[Widget[_t.Any]] = []
2284 self._event_receiver: int | None = None
2286 @_t.overload
2287 def add(
2288 self, widget: Widget[_t.Any], /, *, receive_events: _t.Literal[False] = False
2289 ) -> VerticalLayoutBuilder[T]: ...
2291 @_t.overload
2292 def add(
2293 self, widget: Widget[U], /, *, receive_events: _t.Literal[True]
2294 ) -> VerticalLayoutBuilder[U]: ...
2296 def add(self, widget: Widget[_t.Any], /, *, receive_events=False) -> _t.Any:
2297 """
2298 Add a new widget to the bottom of the layout.
2300 If `receive_events` is `True`, all incoming events will be forwarded
2301 to the added widget. Only the latest widget added with ``receive_events=True``
2302 will receive events.
2304 This method does not mutate the builder, but instead returns a new one.
2305 Use it with method chaining.
2307 """
2309 other = VerticalLayoutBuilder()
2311 other._widgets = self._widgets.copy()
2312 other._event_receiver = self._event_receiver
2314 if isinstance(widget, VerticalLayout):
2315 if receive_events and widget._event_receiver is not None:
2316 other._event_receiver = len(other._widgets) + widget._event_receiver
2317 elif receive_events:
2318 other._event_receiver = None
2319 other._widgets.extend(widget._widgets)
2320 else:
2321 if receive_events:
2322 other._event_receiver = len(other._widgets)
2323 other._widgets.append(widget)
2325 return other
2327 def build(self) -> VerticalLayout[T]:
2328 layout = VerticalLayout()
2329 layout._widgets = self._widgets
2330 layout._event_receiver = self._event_receiver
2331 return _t.cast(VerticalLayout[T], layout)
2334class VerticalLayout(Widget[T], _t.Generic[T]):
2335 """
2336 Helper class for stacking widgets together.
2338 You can stack your widgets together, then calculate their layout
2339 and draw them all at once.
2341 You can use this class as a helper component inside your own widgets,
2342 or you can use it as a standalone widget. See :class:`~VerticalLayoutBuilder`
2343 for an example.
2345 .. automethod:: append
2347 .. automethod:: extend
2349 .. automethod:: event
2351 .. automethod:: layout
2353 .. automethod:: draw
2355 """
2357 if TYPE_CHECKING:
2359 def __new__(cls, *widgets: Widget[object]) -> VerticalLayout[_t.Never]: ...
2361 def __init__(self, *widgets: Widget[object]):
2362 self._widgets: list[Widget[object]] = list(widgets)
2363 self._event_receiver: int | None = None
2365 self.__layouts: list[tuple[int, int]] = []
2366 self.__min_h: int = 0
2367 self.__max_h: int = 0
2369 def append(self, widget: Widget[_t.Any], /):
2370 """
2371 Add a widget to the end of the stack.
2373 """
2375 if isinstance(widget, VerticalLayout):
2376 self._widgets.extend(widget._widgets)
2377 else:
2378 self._widgets.append(widget)
2380 def extend(self, widgets: _t.Iterable[Widget[_t.Any]], /):
2381 """
2382 Add multiple widgets to the end of the stack.
2384 """
2386 for widget in widgets:
2387 self.append(widget)
2389 def event(self, e: KeyboardEvent) -> Result[T] | None:
2390 """
2391 Dispatch event to the widget that was added with ``receive_events=True``.
2393 See :class:`~VerticalLayoutBuilder` for details.
2395 """
2397 if self._event_receiver is not None:
2398 return _t.cast(
2399 Result[T] | None, self._widgets[self._event_receiver].event(e)
2400 )
2402 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
2403 """
2404 Calculate layout of the entire stack.
2406 """
2408 self.__layouts = [widget.layout(rc) for widget in self._widgets]
2409 assert all(l[0] <= l[1] for l in self.__layouts), "incorrect layout"
2410 self.__min_h = sum(l[0] for l in self.__layouts)
2411 self.__max_h = sum(l[1] for l in self.__layouts)
2412 return self.__min_h, self.__max_h
2414 def draw(self, rc: RenderContext, /):
2415 """
2416 Draw the stack according to the calculated layout and available height.
2418 """
2420 assert len(self._widgets) == len(self.__layouts), (
2421 "you need to call `VerticalLayout.layout()` before `VerticalLayout.draw()`"
2422 )
2424 if rc.height <= self.__min_h:
2425 scale = 0.0
2426 elif rc.height >= self.__max_h:
2427 scale = 1.0
2428 else:
2429 scale = (rc.height - self.__min_h) / (self.__max_h - self.__min_h)
2431 y1 = 0.0
2432 for widget, (min_h, max_h) in zip(self._widgets, self.__layouts):
2433 y2 = y1 + min_h + scale * (max_h - min_h)
2435 iy1 = round(y1)
2436 iy2 = round(y2)
2438 with rc.frame(0, iy1, height=iy2 - iy1):
2439 widget.draw(rc)
2441 y1 = y2
2443 @property
2444 def help_data(self) -> WidgetHelp:
2445 if self._event_receiver is not None:
2446 return self._widgets[self._event_receiver].help_data
2447 else:
2448 return WidgetHelp()
2451class Line(Widget[_t.Never]):
2452 """
2453 A widget that prints a single line of text.
2455 """
2457 def __init__(
2458 self,
2459 text: yuio.string.Colorable,
2460 /,
2461 ):
2462 self.__text = text
2463 self.__colorized_text = None
2465 @property
2466 def text(self) -> yuio.string.Colorable:
2467 """
2468 Currently displayed text.
2470 """
2472 return self.__text
2474 @text.setter
2475 def text(self, text: yuio.string.Colorable, /):
2476 self.__text = text
2477 self.__colorized_text = None
2479 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
2480 return 1, 1
2482 def draw(self, rc: RenderContext, /):
2483 if self.__colorized_text is None:
2484 self.__colorized_text = rc.make_repr_context().str(self.__text)
2486 rc.write(self.__colorized_text)
2489class Text(Widget[_t.Never]):
2490 """
2491 A widget that prints wrapped text.
2493 """
2495 def __init__(
2496 self,
2497 text: yuio.string.Colorable,
2498 /,
2499 ):
2500 self.__text = text
2501 self.__wrapped_text: list[_ColorizedString] | None = None
2502 self.__wrapped_text_width: int = 0
2504 @property
2505 def text(self) -> yuio.string.Colorable:
2506 """
2507 Currently displayed text.
2509 """
2511 return self.__text
2513 @text.setter
2514 def text(self, text: yuio.string.Colorable, /):
2515 self.__text = text
2516 self.__wrapped_text = None
2517 self.__wrapped_text_width = 0
2519 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
2520 if self.__wrapped_text is None or self.__wrapped_text_width != rc.width:
2521 colorized_text = rc.make_repr_context().str(self.__text)
2522 self.__wrapped_text = colorized_text.wrap(
2523 rc.width,
2524 break_long_nowrap_words=True,
2525 )
2526 self.__wrapped_text_width = rc.width
2527 height = len(self.__wrapped_text)
2528 return height, height
2530 def draw(self, rc: RenderContext, /):
2531 assert self.__wrapped_text is not None
2532 rc.write_text(self.__wrapped_text)
2535_CHAR_NAMES = {
2536 "\u0000": "<NUL>",
2537 "\u0001": "<SOH>",
2538 "\u0002": "<STX>",
2539 "\u0003": "<ETX>",
2540 "\u0004": "<EOT>",
2541 "\u0005": "<ENQ>",
2542 "\u0006": "<ACK>",
2543 "\u0007": "\\a",
2544 "\u0008": "\\b",
2545 "\u0009": "\\t",
2546 "\u000b": "\\v",
2547 "\u000c": "\\f",
2548 "\u000d": "\\r",
2549 "\u000e": "<SO>",
2550 "\u000f": "<SI>",
2551 "\u0010": "<DLE>",
2552 "\u0011": "<DC1>",
2553 "\u0012": "<DC2>",
2554 "\u0013": "<DC3>",
2555 "\u0014": "<DC4>",
2556 "\u0015": "<NAK>",
2557 "\u0016": "<SYN>",
2558 "\u0017": "<ETB>",
2559 "\u0018": "<CAN>",
2560 "\u0019": "<EM>",
2561 "\u001a": "<SUB>",
2562 "\u001b": "<ESC>",
2563 "\u001c": "<FS>",
2564 "\u001d": "<GS>",
2565 "\u001e": "<RS>",
2566 "\u001f": "<US>",
2567 "\u007f": "<DEL>",
2568 "\u0080": "<PAD>",
2569 "\u0081": "<HOP>",
2570 "\u0082": "<BPH>",
2571 "\u0083": "<NBH>",
2572 "\u0084": "<IND>",
2573 "\u0085": "<NEL>",
2574 "\u0086": "<SSA>",
2575 "\u0087": "<ESA>",
2576 "\u0088": "<HTS>",
2577 "\u0089": "<HTJ>",
2578 "\u008a": "<VTS>",
2579 "\u008b": "<PLD>",
2580 "\u008c": "<PLU>",
2581 "\u008d": "<RI>",
2582 "\u008e": "<SS2>",
2583 "\u008f": "<SS3>",
2584 "\u0090": "<DCS>",
2585 "\u0091": "<PU1>",
2586 "\u0092": "<PU2>",
2587 "\u0093": "<STS>",
2588 "\u0094": "<CCH>",
2589 "\u0095": "<MW>",
2590 "\u0096": "<SPA>",
2591 "\u0097": "<EPA>",
2592 "\u0098": "<SOS>",
2593 "\u0099": "<SGCI>",
2594 "\u009a": "<SCI>",
2595 "\u009b": "<CSI>",
2596 "\u009c": "<ST>",
2597 "\u009d": "<OSC>",
2598 "\u009e": "<PM>",
2599 "\u009f": "<APC>",
2600 "\u00a0": "<NBSP>",
2601 "\u00ad": "<SHY>",
2602}
2604_ESC_RE = re.compile(r"([" + re.escape("".join(map(str, _CHAR_NAMES))) + "])")
2607def _replace_special_symbols(text: str, esc_color: _Color, n_color: _Color):
2608 raw: list[_Color | str] = [n_color]
2609 i = 0
2610 for match in _ESC_RE.finditer(text):
2611 if s := text[i : match.start()]:
2612 raw.append(s)
2613 raw.append(esc_color)
2614 raw.append(_Esc(_CHAR_NAMES[match.group(1)]))
2615 raw.append(n_color)
2616 i = match.end()
2617 if i < len(text):
2618 raw.append(text[i:])
2619 return raw
2622def _find_cursor_pos(text: list[_ColorizedString], text_width: int, offset: int):
2623 total_len = 0
2624 if not offset:
2625 return (0, 0)
2626 for y, line in enumerate(text):
2627 x = 0
2628 for part in line:
2629 if isinstance(part, _Esc):
2630 l = 1
2631 dx = len(part)
2632 elif isinstance(part, str):
2633 l = len(part)
2634 dx = _line_width(part)
2635 else:
2636 continue
2637 if total_len + l >= offset:
2638 if isinstance(part, _Esc):
2639 x += dx
2640 else:
2641 x += _line_width(part[: offset - total_len])
2642 if x >= text_width:
2643 return (0, y + 1)
2644 else:
2645 return (0 + x, y)
2646 break
2647 x += dx
2648 total_len += l
2649 total_len += len(line.explicit_newline)
2650 if total_len >= offset:
2651 return (0, y + 1)
2652 assert False
2655class Input(Widget[str]):
2656 """
2657 An input box.
2659 .. vhs:: /_tapes/widget_input.tape
2660 :alt: Demonstration of `Input` widget.
2661 :scale: 40%
2663 .. note::
2665 :class:`Input` is not optimized to handle long texts or long editing sessions.
2666 It's best used to get relatively short answers from users
2667 with :func:`yuio.io.ask`. If you need to edit large text, especially multiline,
2668 consider using :func:`yuio.io.edit` instead.
2670 :param text:
2671 initial text.
2672 :param pos:
2673 initial cursor position, calculated as an offset from beginning of the text.
2674 Should be ``0 <= pos <= len(text)``.
2675 :param placeholder:
2676 placeholder text, shown when input is empty.
2677 :param decoration_path:
2678 path that will be used to look up decoration printed before the input box.
2679 :param allow_multiline:
2680 if `True`, :kbd:`Enter` key makes a new line, otherwise it accepts input.
2681 In this mode, newlines in pasted text are also preserved.
2682 :param allow_special_characters:
2683 If `True`, special characters like tabs or escape symbols are preserved
2684 and not replaced with whitespaces.
2686 """
2688 # Characters that count as word separators, used when navigating input text
2689 # via hotkeys.
2690 _WORD_SEPARATORS = string.punctuation + string.whitespace
2692 # Character that replaces newlines and unprintable characters when
2693 # `allow_multiline`/`allow_special_characters` is `False`.
2694 _UNPRINTABLE_SUBSTITUTOR = " "
2696 class _CheckpointType(enum.Enum):
2697 """
2698 Types of entries in the history buffer.
2700 """
2702 USR = enum.auto()
2703 """
2704 User-initiated checkpoint.
2706 """
2708 SYM = enum.auto()
2709 """
2710 Checkpoint before a symbol was inserted.
2712 """
2714 SEP = enum.auto()
2715 """
2716 Checkpoint before a space was inserted.
2718 """
2720 DEL = enum.auto()
2721 """
2722 Checkpoint before something was deleted.
2724 """
2726 def __init__(
2727 self,
2728 *,
2729 text: str = "",
2730 pos: int | None = None,
2731 placeholder: str = "",
2732 decoration_path: str = "menu/input/decoration",
2733 allow_multiline: bool = False,
2734 allow_special_characters: bool = False,
2735 ):
2736 self.__text: str = text
2737 self.__pos: int = len(text) if pos is None else max(0, min(pos, len(text)))
2738 self.__placeholder: str = placeholder
2739 self.__decoration_path: str = decoration_path
2740 self.__allow_multiline: bool = allow_multiline
2741 self.__allow_special_characters: bool = allow_special_characters
2743 self.__wrapped_text_width: int = 0
2744 self.__wrapped_text: list[_ColorizedString] | None = None
2745 self.__pos_after_wrap: tuple[int, int] | None = None
2747 # We keep track of edit history by saving input text
2748 # and cursor position in this list.
2749 self.__history: list[tuple[str, int, Input._CheckpointType]] = [
2750 (self.__text, self.__pos, Input._CheckpointType.SYM)
2751 ]
2752 # Sometimes we don't record all actions. For example, entering multiple spaces
2753 # one after the other, or entering multiple symbols one after the other,
2754 # will only generate one checkpoint. We keep track of how many items
2755 # were skipped this way since the last checkpoint.
2756 self.__history_skipped_actions = 0
2757 # After we move a cursor, the logic with skipping checkpoints
2758 # should be momentarily disabled. This avoids inconsistencies in situations
2759 # where we've typed a word, moved the cursor, then typed another word.
2760 self.__require_checkpoint: bool = False
2762 # All delete operations save deleted text here. Pressing `C-y` pastes deleted
2763 # text at the position of the cursor.
2764 self.__yanked_text: str = ""
2766 self.__err_region: tuple[int, int] | None = None
2768 @property
2769 def text(self) -> str:
2770 """
2771 Current text in the input box.
2773 """
2774 return self.__text
2776 @text.setter
2777 def text(self, text: str, /):
2778 self.__text = text
2779 self.__wrapped_text = None
2780 if self.pos > len(text):
2781 self.pos = len(text)
2782 self.__err_region = None
2784 @property
2785 def pos(self) -> int:
2786 """
2787 Current cursor position, measured in code points before the cursor.
2789 That is, if the text is `"quick brown fox"` with cursor right before the word
2790 "brown", then :attr:`~Input.pos` is equal to `len("quick ")`.
2792 """
2793 return self.__pos
2795 @pos.setter
2796 def pos(self, pos: int, /):
2797 self.__pos = max(0, min(pos, len(self.__text)))
2798 self.__pos_after_wrap = None
2800 @property
2801 def err_region(self) -> tuple[int, int] | None:
2802 return self.__err_region
2804 @err_region.setter
2805 def err_region(self, err_region: tuple[int, int] | None, /):
2806 self.__err_region = err_region
2807 self.__wrapped_text = None
2809 def checkpoint(self):
2810 """
2811 Manually create an entry in the history buffer.
2813 """
2814 self.__history.append((self.text, self.pos, Input._CheckpointType.USR))
2815 self.__history_skipped_actions = 0
2817 def restore_checkpoint(self):
2818 """
2819 Restore the last manually created checkpoint.
2821 """
2822 if self.__history[-1][2] is Input._CheckpointType.USR:
2823 self.undo()
2825 def _internal_checkpoint(self, action: Input._CheckpointType, text: str, pos: int):
2826 prev_text, prev_pos, prev_action = self.__history[-1]
2828 if action == prev_action and not self.__require_checkpoint:
2829 # If we're repeating the same action, don't create a checkpoint.
2830 # I.e. if we're typing a word, we don't want to create checkpoints
2831 # for every letter.
2832 self.__history_skipped_actions += 1
2833 return
2835 prev_skipped_actions = self.__history_skipped_actions
2836 self.__history_skipped_actions = 0
2838 if (
2839 action == Input._CheckpointType.SYM
2840 and prev_action == Input._CheckpointType.SEP
2841 and prev_skipped_actions == 0
2842 and not self.__require_checkpoint
2843 ):
2844 # If we're inserting a symbol after we've typed a single space,
2845 # we only want one checkpoint for both space and symbols.
2846 # Thus, we simply change the type of the last checkpoint.
2847 self.__history[-1] = prev_text, prev_pos, action
2848 return
2850 if text == prev_text and pos == prev_pos:
2851 # This could happen when user presses backspace while the cursor
2852 # is at the text's beginning. We don't want to create
2853 # a checkpoint for this.
2854 return
2856 self.__history.append((text, pos, action))
2857 if len(self.__history) > 50:
2858 self.__history.pop(0)
2860 self.__require_checkpoint = False
2862 @bind(Key.ENTER)
2863 def enter(self) -> Result[str] | None:
2864 if self.__allow_multiline:
2865 self.insert("\n")
2866 else:
2867 return self.alt_enter()
2869 @bind(Key.ENTER, alt=True)
2870 @bind("d", ctrl=True)
2871 def alt_enter(self) -> Result[str] | None:
2872 return Result(self.text)
2874 _NAVIGATE = "Navigate"
2876 @bind(Key.ARROW_UP)
2877 @bind("p", ctrl=True)
2878 @help(group=_NAVIGATE)
2879 def up(self, /, *, checkpoint: bool = True):
2880 """up"""
2881 pos = self.pos
2882 self.home()
2883 if self.pos:
2884 width = _line_width(self.text[self.pos : pos])
2886 self.left()
2887 self.home()
2889 pos = self.pos
2890 text = self.text
2891 cur_width = 0
2892 while pos < len(text) and text[pos] != "\n":
2893 if cur_width >= width:
2894 break
2895 cur_width += _line_width(text[pos])
2896 pos += 1
2898 self.pos = pos
2900 self.__require_checkpoint |= checkpoint
2902 @bind(Key.ARROW_DOWN)
2903 @bind("n", ctrl=True)
2904 @help(group=_NAVIGATE)
2905 def down(self, /, *, checkpoint: bool = True):
2906 """down"""
2907 pos = self.pos
2908 self.home()
2909 width = _line_width(self.text[self.pos : pos])
2910 self.end()
2912 if self.pos < len(self.text):
2913 self.right()
2915 pos = self.pos
2916 text = self.text
2917 cur_width = 0
2918 while pos < len(text) and text[pos] != "\n":
2919 if cur_width >= width:
2920 break
2921 cur_width += _line_width(text[pos])
2922 pos += 1
2924 self.pos = pos
2926 self.__require_checkpoint |= checkpoint
2928 @bind(Key.ARROW_LEFT)
2929 @bind("b", ctrl=True)
2930 @help(group=_NAVIGATE)
2931 def left(self, /, *, checkpoint: bool = True):
2932 """left"""
2933 self.pos -= 1
2934 self.__require_checkpoint |= checkpoint
2936 @bind(Key.ARROW_RIGHT)
2937 @bind("f", ctrl=True)
2938 @help(group=_NAVIGATE)
2939 def right(self, /, *, checkpoint: bool = True):
2940 """right"""
2941 self.pos += 1
2942 self.__require_checkpoint |= checkpoint
2944 @bind(Key.ARROW_LEFT, alt=True)
2945 @bind("b", alt=True)
2946 @help(group=_NAVIGATE)
2947 def left_word(self, /, *, checkpoint: bool = True):
2948 """left one word"""
2949 pos = self.pos
2950 text = self.text
2951 if pos:
2952 pos -= 1
2953 while pos and text[pos] in self._WORD_SEPARATORS and text[pos - 1] != "\n":
2954 pos -= 1
2955 while pos and text[pos - 1] not in self._WORD_SEPARATORS:
2956 pos -= 1
2957 self.pos = pos
2958 self.__require_checkpoint |= checkpoint
2960 @bind(Key.ARROW_RIGHT, alt=True)
2961 @bind("f", alt=True)
2962 @help(group=_NAVIGATE)
2963 def right_word(self, /, *, checkpoint: bool = True):
2964 """right one word"""
2965 pos = self.pos
2966 text = self.text
2967 if pos < len(text) and text[pos] == "\n":
2968 pos += 1
2969 while (
2970 pos < len(text) and text[pos] in self._WORD_SEPARATORS and text[pos] != "\n"
2971 ):
2972 pos += 1
2973 while pos < len(text) and text[pos] not in self._WORD_SEPARATORS:
2974 pos += 1
2975 self.pos = pos
2976 self.__require_checkpoint |= checkpoint
2978 @bind(Key.HOME)
2979 @bind("a", ctrl=True)
2980 @help(group=_NAVIGATE)
2981 def home(self, /, *, checkpoint: bool = True):
2982 """to line start"""
2983 self.pos = self.text.rfind("\n", 0, self.pos) + 1
2984 self.__require_checkpoint |= checkpoint
2986 @bind(Key.END)
2987 @bind("e", ctrl=True)
2988 @help(group=_NAVIGATE)
2989 def end(self, /, *, checkpoint: bool = True):
2990 """to line end"""
2991 next_nl = self.text.find("\n", self.pos)
2992 if next_nl == -1:
2993 self.pos = len(self.text)
2994 else:
2995 self.pos = next_nl
2996 self.__require_checkpoint |= checkpoint
2998 @bind("g", ctrl=True)
2999 def go_to_err(self, /, *, checkpoint: bool = True):
3000 if not self.__err_region:
3001 return
3002 if self.pos == self.__err_region[1]:
3003 self.pos = self.__err_region[0]
3004 else:
3005 self.pos = self.__err_region[1]
3006 self.__require_checkpoint |= checkpoint
3008 _MODIFY = "Modify"
3010 @bind(Key.BACKSPACE)
3011 @bind("h", ctrl=True)
3012 @help(group=_MODIFY)
3013 def backspace(self):
3014 """backspace"""
3015 prev_pos = self.pos
3016 self.left(checkpoint=False)
3017 if prev_pos != self.pos:
3018 self._internal_checkpoint(Input._CheckpointType.DEL, self.text, prev_pos)
3019 self.text = self.text[: self.pos] + self.text[prev_pos:]
3020 else:
3021 self._bell()
3023 @bind(Key.DELETE)
3024 @help(group=_MODIFY)
3025 def delete(self):
3026 """delete"""
3027 prev_pos = self.pos
3028 self.right(checkpoint=False)
3029 if prev_pos != self.pos:
3030 self._internal_checkpoint(Input._CheckpointType.DEL, self.text, prev_pos)
3031 self.text = self.text[:prev_pos] + self.text[self.pos :]
3032 self.pos = prev_pos
3033 else:
3034 self._bell()
3036 @bind(Key.BACKSPACE, alt=True)
3037 @bind("w", ctrl=True)
3038 @help(group=_MODIFY)
3039 def backspace_word(self):
3040 """backspace one word"""
3041 prev_pos = self.pos
3042 self.left_word(checkpoint=False)
3043 if prev_pos != self.pos:
3044 self._internal_checkpoint(Input._CheckpointType.DEL, self.text, prev_pos)
3045 self.__yanked_text = self.text[self.pos : prev_pos]
3046 self.text = self.text[: self.pos] + self.text[prev_pos:]
3047 else:
3048 self._bell()
3050 @bind(Key.DELETE, alt=True)
3051 @bind("d", alt=True)
3052 @help(group=_MODIFY)
3053 def delete_word(self):
3054 """delete one word"""
3055 prev_pos = self.pos
3056 self.right_word(checkpoint=False)
3057 if prev_pos != self.pos:
3058 self._internal_checkpoint(Input._CheckpointType.DEL, self.text, prev_pos)
3059 self.__yanked_text = self.text[prev_pos : self.pos]
3060 self.text = self.text[:prev_pos] + self.text[self.pos :]
3061 self.pos = prev_pos
3062 else:
3063 self._bell()
3065 @bind("u", ctrl=True)
3066 @help(group=_MODIFY)
3067 def backspace_home(self):
3068 """backspace to the beginning of a line"""
3069 prev_pos = self.pos
3070 self.home(checkpoint=False)
3071 if prev_pos != self.pos:
3072 self._internal_checkpoint(Input._CheckpointType.DEL, self.text, prev_pos)
3073 self.__yanked_text = self.text[self.pos : prev_pos]
3074 self.text = self.text[: self.pos] + self.text[prev_pos:]
3075 else:
3076 self._bell()
3078 @bind("k", ctrl=True)
3079 @help(group=_MODIFY)
3080 def delete_end(self):
3081 """delete to the ending of a line"""
3082 prev_pos = self.pos
3083 self.end(checkpoint=False)
3084 if prev_pos != self.pos:
3085 self._internal_checkpoint(Input._CheckpointType.DEL, self.text, prev_pos)
3086 self.__yanked_text = self.text[prev_pos : self.pos]
3087 self.text = self.text[:prev_pos] + self.text[self.pos :]
3088 self.pos = prev_pos
3089 else:
3090 self._bell()
3092 @bind("y", ctrl=True)
3093 @help(group=_MODIFY)
3094 def yank(self):
3095 """yank (paste the last deleted text)"""
3096 if self.__yanked_text:
3097 self.__require_checkpoint = True
3098 self.insert(self.__yanked_text)
3099 else:
3100 self._bell()
3102 # the actual shortcut is `C-7`, the rest produce the same code...
3103 @bind("7", ctrl=True, show_in_detailed_help=False)
3104 @bind("-", ctrl=True, shift=True, show_in_detailed_help=False)
3105 @bind("?", ctrl=True, show_in_detailed_help=False)
3106 @bind("-", ctrl=True)
3107 @bind("z", ctrl=True)
3108 @help(group=_MODIFY)
3109 def undo(self):
3110 """undo"""
3111 self.text, self.pos, _ = self.__history[-1]
3112 if len(self.__history) > 1:
3113 self.__history.pop()
3114 else:
3115 self._bell()
3117 def default_event_handler(self, e: KeyboardEvent):
3118 if e.key is Key.PASTE:
3119 self.__require_checkpoint = True
3120 s = e.paste_str or ""
3121 if self.__allow_special_characters and self.__allow_multiline:
3122 pass
3123 elif self.__allow_multiline:
3124 s = re.sub(_UNPRINTABLE_RE_WITHOUT_NL, self._UNPRINTABLE_SUBSTITUTOR, s)
3125 elif self.__allow_special_characters:
3126 s = s.replace("\n", self._UNPRINTABLE_SUBSTITUTOR)
3127 else:
3128 s = re.sub(_UNPRINTABLE_RE, self._UNPRINTABLE_SUBSTITUTOR, s)
3129 self.insert(s)
3130 elif e.key is Key.TAB:
3131 if self.__allow_special_characters:
3132 self.insert("\t")
3133 else:
3134 self.insert(self._UNPRINTABLE_SUBSTITUTOR)
3135 elif isinstance(e.key, str) and not e.alt and not e.ctrl:
3136 self.insert(e.key)
3138 def insert(self, s: str):
3139 if not s:
3140 return
3142 self._internal_checkpoint(
3143 (
3144 Input._CheckpointType.SEP
3145 if s in self._WORD_SEPARATORS
3146 else Input._CheckpointType.SYM
3147 ),
3148 self.text,
3149 self.pos,
3150 )
3152 self.text = self.text[: self.pos] + s + self.text[self.pos :]
3153 self.pos += len(s)
3155 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
3156 decoration = rc.get_msg_decoration(self.__decoration_path)
3157 decoration_width = _line_width(decoration)
3158 text_width = rc.width - decoration_width
3159 if text_width < 2:
3160 self.__wrapped_text_width = max(text_width, 0)
3161 self.__wrapped_text = None
3162 self.__pos_after_wrap = None
3163 return 0, 0
3165 if self.__wrapped_text is None or self.__wrapped_text_width != text_width:
3166 self.__wrapped_text_width = text_width
3168 # Note: don't use wrap with overflow here
3169 # or we won't be able to find the cursor position!
3170 if self.__text:
3171 self.__wrapped_text = self._prepare_display_text(
3172 self.__text,
3173 rc.theme.get_color("menu/text/esc:input"),
3174 rc.theme.get_color("menu/text:input"),
3175 rc.theme.get_color("menu/text/error:input"),
3176 ).wrap(
3177 text_width,
3178 preserve_spaces=True,
3179 break_long_nowrap_words=True,
3180 )
3181 self.__pos_after_wrap = None
3182 else:
3183 self.__wrapped_text = _ColorizedString(
3184 [
3185 rc.theme.get_color("menu/text/placeholder:input"),
3186 self.__placeholder,
3187 ]
3188 ).wrap(
3189 text_width,
3190 preserve_newlines=False,
3191 break_long_nowrap_words=True,
3192 )
3193 self.__pos_after_wrap = (decoration_width, 0)
3195 if self.__pos_after_wrap is None:
3196 x, y = _find_cursor_pos(self.__wrapped_text, text_width, self.__pos)
3197 self.__pos_after_wrap = (decoration_width + x, y)
3199 height = max(len(self.__wrapped_text), self.__pos_after_wrap[1] + 1)
3200 return height, height
3202 def draw(self, rc: RenderContext, /):
3203 if decoration := rc.get_msg_decoration(self.__decoration_path):
3204 rc.set_color_path("menu/decoration:input")
3205 rc.write(decoration)
3207 if self.__wrapped_text is not None:
3208 rc.write_text(self.__wrapped_text)
3210 if self.__pos_after_wrap is not None:
3211 rc.set_final_pos(*self.__pos_after_wrap)
3213 def _prepare_display_text(
3214 self, text: str, esc_color: _Color, n_color: _Color, err_color: _Color
3215 ) -> _ColorizedString:
3216 res = _ColorizedString()
3217 if self.__err_region:
3218 start, end = self.__err_region
3219 res += _replace_special_symbols(text[:start], esc_color, n_color)
3220 res += _replace_special_symbols(text[start:end], esc_color, err_color)
3221 res += _replace_special_symbols(text[end:], esc_color, n_color)
3222 else:
3223 res += _replace_special_symbols(text, esc_color, n_color)
3224 return res
3226 @property
3227 def help_data(self) -> WidgetHelp:
3228 help_data = super().help_data
3230 if self.__allow_multiline:
3231 help_data = help_data.with_action(
3232 KeyboardEvent(Key.ENTER, alt=True),
3233 KeyboardEvent("d", ctrl=True),
3234 msg="accept",
3235 prepend=True,
3236 ).with_action(
3237 KeyboardEvent(Key.ENTER),
3238 group=self._MODIFY,
3239 long_msg="new line",
3240 prepend=True,
3241 )
3243 if self.__err_region:
3244 help_data = help_data.with_action(
3245 KeyboardEvent("g", ctrl=True),
3246 group=self._NAVIGATE,
3247 msg="go to error",
3248 prepend=True,
3249 )
3251 return help_data
3254class SecretInput(Input):
3255 """
3256 An input box that shows stars instead of entered symbols.
3258 :param text:
3259 initial text.
3260 :param pos:
3261 initial cursor position, calculated as an offset from beginning of the text.
3262 Should be ``0 <= pos <= len(text)``.
3263 :param placeholder:
3264 placeholder text, shown when input is empty.
3265 :param decoration:
3266 decoration printed before the input box.
3268 """
3270 _WORD_SEPARATORS = ""
3271 _UNPRINTABLE_SUBSTITUTOR = ""
3273 def __init__(
3274 self,
3275 *,
3276 text: str = "",
3277 pos: int | None = None,
3278 placeholder: str = "",
3279 decoration_path: str = "menu/input/decoration",
3280 ):
3281 super().__init__(
3282 text=text,
3283 pos=pos,
3284 placeholder=placeholder,
3285 decoration_path=decoration_path,
3286 allow_multiline=False,
3287 allow_special_characters=False,
3288 )
3290 def _prepare_display_text(
3291 self, text: str, esc_color: _Color, n_color: _Color, err_color: _Color
3292 ) -> _ColorizedString:
3293 return _ColorizedString("*" * len(text))
3296@dataclass(slots=True)
3297class Option(_t.Generic[T_co]):
3298 """
3299 An option for the :class:`Grid` and :class:`Choice` widgets.
3301 """
3303 def __post_init__(self):
3304 if self.color_tag is None:
3305 object.__setattr__(self, "color_tag", "none")
3307 value: T_co
3308 """
3309 Option's value that will be returned from widget.
3311 """
3313 display_text: str
3314 """
3315 What should be displayed in the autocomplete list.
3317 """
3319 display_text_prefix: str = dataclasses.field(default="", kw_only=True)
3320 """
3321 Prefix that will be displayed before :attr:`~Option.display_text`.
3323 """
3325 display_text_suffix: str = dataclasses.field(default="", kw_only=True)
3326 """
3327 Suffix that will be displayed after :attr:`~Option.display_text`.
3329 """
3331 comment: str | None = dataclasses.field(default=None, kw_only=True)
3332 """
3333 Option's short comment.
3335 """
3337 color_tag: str | None = dataclasses.field(default=None, kw_only=True)
3338 """
3339 Option's color tag.
3341 This color tag will be used to display option.
3342 Specifically, color for the option will be looked up py path
3343 :samp:``menu/{element}:choice/{status}/{color_tag}``.
3345 """
3347 selected: bool = dataclasses.field(default=False, kw_only=True)
3348 """
3349 For multi-choice widgets, whether this option is chosen or not.
3351 """
3354class Grid(Widget[_t.Never], _t.Generic[T]):
3355 """
3356 A helper widget that shows up in :class:`Choice` and :class:`InputWithCompletion`.
3358 .. note::
3360 On its own, :class:`Grid` doesn't return when you press :kbd:`Enter`
3361 or :kbd:`Ctrl+D`. It's meant to be used as part of another widget.
3363 :param options:
3364 list of options displayed in the grid.
3365 :param decoration:
3366 decoration printed before the selected option.
3367 :param default_index:
3368 index of the initially selected option.
3369 :param min_rows:
3370 minimum number of rows that the grid should occupy before it starts
3371 splitting options into columns. This option is ignored if there isn't enough
3372 space on the screen.
3374 """
3376 def __init__(
3377 self,
3378 options: list[Option[T]],
3379 /,
3380 *,
3381 active_item_decoration_path: str = "menu/choice/decoration/active_item",
3382 selected_item_decoration_path: str = "",
3383 deselected_item_decoration_path: str = "",
3384 default_index: int | None = 0,
3385 min_rows: int | None = 5,
3386 ):
3387 self.__options: list[Option[T]]
3388 self.__index: int | None
3389 self.__min_rows: int | None = min_rows
3390 self.__max_column_width: int | None
3391 self.__column_width: int
3392 self.__num_rows: int
3393 self.__num_columns: int
3395 self.__active_item_decoration_path = active_item_decoration_path
3396 self.__selected_item_decoration_path = selected_item_decoration_path
3397 self.__deselected_item_decoration_path = deselected_item_decoration_path
3399 self.set_options(options)
3400 self.index = default_index
3402 @property
3403 def _page_size(self) -> int:
3404 return self.__num_rows * self.__num_columns
3406 @property
3407 def index(self) -> int | None:
3408 """
3409 Index of the currently selected option.
3411 """
3413 return self.__index
3415 @index.setter
3416 def index(self, idx: int | None):
3417 if idx is None or not self.__options:
3418 self.__index = None
3419 elif self.__options:
3420 self.__index = idx % len(self.__options)
3422 def get_option(self) -> Option[T] | None:
3423 """
3424 Get the currently selected option,
3425 or `None` if there are no options selected.
3427 """
3429 if self.__options and self.__index is not None:
3430 return self.__options[self.__index]
3432 def has_options(self) -> bool:
3433 """
3434 Return :data:`True` if the options list is not empty.
3436 """
3438 return bool(self.__options)
3440 def get_options(self) -> _t.Sequence[Option[T]]:
3441 """
3442 Get all options.
3444 """
3446 return self.__options
3448 def set_options(
3449 self,
3450 options: list[Option[T]],
3451 /,
3452 default_index: int | None = 0,
3453 ):
3454 """
3455 Set a new list of options.
3457 """
3459 self.__options = options
3460 self.__max_column_width = None
3461 self.index = default_index
3463 _NAVIGATE = "Navigate"
3465 @bind(Key.ARROW_UP)
3466 @bind(Key.TAB, shift=True)
3467 @help(group=_NAVIGATE)
3468 def prev_item(self):
3469 """previous item"""
3470 if not self.__options:
3471 return
3473 if self.__index is None:
3474 self.__index = 0
3475 else:
3476 self.__index = (self.__index - 1) % len(self.__options)
3478 @bind(Key.ARROW_DOWN)
3479 @bind(Key.TAB)
3480 @help(group=_NAVIGATE)
3481 def next_item(self):
3482 """next item"""
3483 if not self.__options:
3484 return
3486 if self.__index is None:
3487 self.__index = 0
3488 else:
3489 self.__index = (self.__index + 1) % len(self.__options)
3491 @bind(Key.ARROW_LEFT)
3492 @help(group=_NAVIGATE)
3493 def prev_column(self):
3494 """previous column"""
3495 if not self.__options:
3496 return
3498 if self.__index is None:
3499 self.__index = 0
3500 else:
3501 total_grid_capacity = self.__num_rows * math.ceil(
3502 len(self.__options) / self.__num_rows
3503 )
3505 self.__index = (self.__index - self.__num_rows) % total_grid_capacity
3506 if self.__index >= len(self.__options):
3507 self.__index = len(self.__options) - 1
3509 @bind(Key.ARROW_RIGHT)
3510 @help(group=_NAVIGATE)
3511 def next_column(self):
3512 """next column"""
3513 if not self.__options:
3514 return
3516 if self.__index is None:
3517 self.__index = 0
3518 else:
3519 total_grid_capacity = self.__num_rows * math.ceil(
3520 len(self.__options) / self.__num_rows
3521 )
3523 self.__index = (self.__index + self.__num_rows) % total_grid_capacity
3524 if self.__index >= len(self.__options):
3525 self.__index = len(self.__options) - 1
3527 @bind(Key.PAGE_UP)
3528 @help(group=_NAVIGATE)
3529 def prev_page(self):
3530 """previous page"""
3531 if not self.__options:
3532 return
3534 if self.__index is None:
3535 self.__index = 0
3536 else:
3537 self.__index -= self.__index % self._page_size
3538 self.__index -= 1
3539 if self.__index < 0:
3540 self.__index = len(self.__options) - 1
3542 @bind(Key.PAGE_DOWN)
3543 @help(group=_NAVIGATE)
3544 def next_page(self):
3545 """next page"""
3546 if not self.__options:
3547 return
3549 if self.__index is None:
3550 self.__index = 0
3551 else:
3552 self.__index -= self.__index % self._page_size
3553 self.__index += self._page_size
3554 if self.__index > len(self.__options):
3555 self.__index = 0
3557 @bind(Key.HOME)
3558 @help(group=_NAVIGATE)
3559 def home(self):
3560 """first page"""
3561 if not self.__options:
3562 return
3564 if self.__index is None:
3565 self.__index = 0
3566 else:
3567 self.__index = 0
3569 @bind(Key.END)
3570 @help(group=_NAVIGATE)
3571 def end(self):
3572 """last page"""
3573 if not self.__options:
3574 return
3576 if self.__index is None:
3577 self.__index = 0
3578 else:
3579 self.__index = len(self.__options) - 1
3581 def default_event_handler(self, e: KeyboardEvent):
3582 if isinstance(e.key, str):
3583 key = e.key.casefold()
3584 if (
3585 self.__options
3586 and self.__index is not None
3587 and self.__options[self.__index].display_text.casefold().startswith(key)
3588 ):
3589 start = self.__index + 1
3590 else:
3591 start = 0
3592 for i in range(start, start + len(self.__options)):
3593 index = i % len(self.__options)
3594 if self.__options[index].display_text.casefold().startswith(key):
3595 self.__index = index
3596 break
3598 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
3599 active_item_decoration = rc.get_msg_decoration(
3600 self.__active_item_decoration_path
3601 )
3602 selected_item_decoration = rc.get_msg_decoration(
3603 self.__selected_item_decoration_path
3604 )
3605 deselected_item_decoration = rc.get_msg_decoration(
3606 self.__deselected_item_decoration_path
3607 )
3609 decoration_width = _line_width(active_item_decoration) + max(
3610 _line_width(selected_item_decoration),
3611 _line_width(deselected_item_decoration),
3612 )
3614 if self.__max_column_width is None:
3615 self.__max_column_width = max(
3616 0,
3617 _MIN_COLUMN_WIDTH,
3618 *(
3619 self._get_option_width(option, decoration_width)
3620 for option in self.__options
3621 ),
3622 )
3623 self.__column_width = max(1, min(self.__max_column_width, rc.width))
3624 self.__num_columns = num_columns = max(1, rc.width // self.__column_width)
3625 self.__num_rows = max(
3626 1,
3627 min(self.__min_rows or 1, len(self.__options)),
3628 min(math.ceil(len(self.__options) / num_columns), rc.height),
3629 )
3631 additional_space = 0
3632 pages = math.ceil(len(self.__options) / self._page_size)
3633 if pages > 1:
3634 additional_space = 1
3636 return 1 + additional_space, self.__num_rows + additional_space
3638 def draw(self, rc: RenderContext, /):
3639 if not self.__options:
3640 rc.set_color_path("menu/decoration:choice")
3641 rc.write("No options to display")
3642 return
3644 # Adjust for the actual available height.
3645 self.__num_rows = max(1, min(self.__num_rows, rc.height))
3646 pages = math.ceil(len(self.__options) / self._page_size)
3647 if pages > 1 and self.__num_rows > 1:
3648 self.__num_rows -= 1
3650 column_width = self.__column_width
3651 num_rows = self.__num_rows
3652 page_size = self._page_size
3654 page_start_index = 0
3655 if page_size and self.__index is not None:
3656 page_start_index = self.__index - self.__index % page_size
3657 page = self.__options[page_start_index : page_start_index + page_size]
3659 if self.__num_columns > 1:
3660 available_column_width = column_width - _SPACE_BETWEEN_COLUMNS
3661 else:
3662 available_column_width = column_width
3664 for i, option in enumerate(page):
3665 x = i // num_rows
3666 y = i % num_rows
3668 rc.set_pos(x * column_width, y)
3670 index = i + page_start_index
3671 is_current = index == self.__index
3672 self._render_option(rc, available_column_width, option, is_current)
3674 pages = math.ceil(len(self.__options) / self._page_size)
3675 if pages > 1:
3676 page = (self.index or 0) // self._page_size + 1
3677 rc.set_pos(0, num_rows)
3678 rc.set_color_path("menu/text:choice/status_line")
3679 rc.write("Page ")
3680 rc.set_color_path("menu/text:choice/status_line/number")
3681 rc.write(f"{page}")
3682 rc.set_color_path("menu/text:choice/status_line")
3683 rc.write(" of ")
3684 rc.set_color_path("menu/text:choice/status_line/number")
3685 rc.write(f"{pages}")
3687 def _get_option_width(self, option: Option[object], decoration_width: int):
3688 return (
3689 _SPACE_BETWEEN_COLUMNS
3690 + decoration_width
3691 + (_line_width(option.display_text_prefix))
3692 + (_line_width(option.display_text))
3693 + (_line_width(option.display_text_suffix))
3694 + (3 if option.comment else 0)
3695 + (_line_width(option.comment) if option.comment else 0)
3696 )
3698 def _render_option(
3699 self,
3700 rc: RenderContext,
3701 width: int,
3702 option: Option[object],
3703 is_active: bool,
3704 ):
3705 active_item_decoration = rc.get_msg_decoration(
3706 self.__active_item_decoration_path
3707 )
3708 active_item_decoration_width = _line_width(active_item_decoration)
3709 selected_item_decoration = rc.get_msg_decoration(
3710 self.__selected_item_decoration_path
3711 )
3712 selected_item_decoration_width = _line_width(selected_item_decoration)
3713 deselected_item_decoration = rc.get_msg_decoration(
3714 self.__deselected_item_decoration_path
3715 )
3716 deselected_item_decoration_width = _line_width(deselected_item_decoration)
3717 item_selection_decoration_width = max(
3718 selected_item_decoration_width, deselected_item_decoration_width
3719 )
3721 left_prefix_width = _line_width(option.display_text_prefix)
3722 left_main_width = _line_width(option.display_text)
3723 left_suffix_width = _line_width(option.display_text_suffix)
3724 left_width = left_prefix_width + left_main_width + left_suffix_width
3725 left_decoration_width = (
3726 active_item_decoration_width + item_selection_decoration_width
3727 )
3729 right = option.comment or ""
3730 right_width = _line_width(right)
3731 right_decoration_width = 3 if right else 0
3733 total_width = (
3734 left_decoration_width + left_width + right_decoration_width + right_width
3735 )
3737 if total_width > width:
3738 right_width = max(right_width - (total_width - width), 0)
3739 if right_width == 0:
3740 right = ""
3741 right_decoration_width = 0
3742 total_width = (
3743 left_decoration_width
3744 + left_width
3745 + right_decoration_width
3746 + right_width
3747 )
3749 if total_width > width:
3750 left_width = max(left_width - (total_width - width), 3)
3751 total_width = left_decoration_width + left_width
3753 if is_active:
3754 status_tag = "active"
3755 else:
3756 status_tag = "normal"
3758 if option.selected:
3759 color_tag = "selected"
3760 else:
3761 color_tag = option.color_tag
3763 if is_active:
3764 rc.set_color_path(f"menu/decoration:choice/{status_tag}/{color_tag}")
3765 rc.write(active_item_decoration)
3766 else:
3767 rc.set_color_path(f"menu/text:choice/{status_tag}/{color_tag}")
3768 rc.write(" " * active_item_decoration_width)
3770 if option.selected:
3771 rc.set_color_path(f"menu/decoration:choice/{status_tag}/{color_tag}")
3772 rc.write(selected_item_decoration)
3773 rc.write(
3774 " " * (item_selection_decoration_width - selected_item_decoration_width)
3775 )
3776 else:
3777 rc.set_color_path(f"menu/decoration:choice/{status_tag}/{color_tag}")
3778 rc.write(deselected_item_decoration)
3779 rc.write(
3780 " "
3781 * (item_selection_decoration_width - deselected_item_decoration_width)
3782 )
3784 rc.set_color_path(f"menu/text/prefix:choice/{status_tag}/{color_tag}")
3785 rc.write(option.display_text_prefix, max_width=left_width)
3786 rc.set_color_path(f"menu/text:choice/{status_tag}/{color_tag}")
3787 rc.write(option.display_text, max_width=left_width - left_prefix_width)
3788 rc.set_color_path(f"menu/text/suffix:choice/{status_tag}/{color_tag}")
3789 rc.write(
3790 option.display_text_suffix,
3791 max_width=left_width - left_prefix_width - left_main_width,
3792 )
3793 rc.set_color_path(f"menu/text:choice/{status_tag}/{color_tag}")
3794 rc.write(
3795 " "
3796 * (
3797 width
3798 - left_decoration_width
3799 - left_width
3800 - right_decoration_width
3801 - right_width
3802 )
3803 )
3805 if right:
3806 rc.set_color_path(
3807 f"menu/decoration/comment:choice/{status_tag}/{color_tag}"
3808 )
3809 rc.write(" [")
3810 rc.set_color_path(f"menu/text/comment:choice/{status_tag}/{color_tag}")
3811 rc.write(right, max_width=right_width)
3812 rc.set_color_path(
3813 f"menu/decoration/comment:choice/{status_tag}/{color_tag}"
3814 )
3815 rc.write("]")
3817 @property
3818 def help_data(self) -> WidgetHelp:
3819 return super().help_data.with_action(
3820 "1..9",
3821 "a..z",
3822 long_msg="quick select",
3823 )
3826class Choice(Widget[T], _t.Generic[T]):
3827 """
3828 Allows choosing from pre-defined options.
3830 .. vhs:: /_tapes/widget_choice.tape
3831 :alt: Demonstration of `Choice` widget.
3832 :scale: 40%
3834 :param options:
3835 list of choice options.
3836 :param mapper:
3837 maps option to a text that will be used for filtering. By default,
3838 uses :attr:`Option.display_text`. This argument is ignored
3839 if a custom `filter` is given.
3840 :param filter:
3841 customizes behavior of list filtering. The default filter extracts text
3842 from an option using the `mapper`, and checks if it starts with the search
3843 query.
3844 :param default_index:
3845 index of the initially selected option.
3847 """
3849 @_t.overload
3850 def __init__(
3851 self,
3852 options: list[Option[T]],
3853 /,
3854 *,
3855 mapper: _t.Callable[[Option[T]], str] = lambda x: (
3856 x.display_text or str(x.value)
3857 ),
3858 default_index: int = 0,
3859 search_bar_decoration_path: str = "menu/input/decoration_search",
3860 active_item_decoration_path: str = "menu/choice/decoration/active_item",
3861 ): ...
3863 @_t.overload
3864 def __init__(
3865 self,
3866 options: list[Option[T]],
3867 /,
3868 *,
3869 filter: _t.Callable[[Option[T], str], bool],
3870 default_index: int = 0,
3871 search_bar_decoration_path: str = "menu/input/decoration_search",
3872 active_item_decoration_path: str = "menu/choice/decoration/active_item",
3873 ): ...
3875 def __init__(
3876 self,
3877 options: list[Option[T]],
3878 /,
3879 *,
3880 mapper: _t.Callable[[Option[T]], str] = lambda x: x.display_text
3881 or str(x.value),
3882 filter: _t.Callable[[Option[T], str], bool] | None = None,
3883 default_index: int = 0,
3884 search_bar_decoration_path: str = "menu/input/decoration_search",
3885 active_item_decoration_path: str = "menu/choice/decoration/active_item",
3886 ):
3887 self.__options = options
3889 if filter is None:
3890 filter = lambda x, q: mapper(x).lstrip().startswith(q)
3892 self.__filter = filter
3894 self.__default_index = default_index
3896 self.__input = Input(
3897 placeholder="Filter options...", decoration_path=search_bar_decoration_path
3898 )
3899 self.__grid = Grid[T](
3900 [], active_item_decoration_path=active_item_decoration_path
3901 )
3903 self.__enable_search = False
3905 self.__layout: VerticalLayout[_t.Never]
3907 self.__update_completion()
3909 @bind("/")
3910 def search(self):
3911 """search"""
3912 if not self.__enable_search:
3913 self.__enable_search = True
3914 else:
3915 self.__input.event(KeyboardEvent("/"))
3916 self.__update_completion()
3918 @bind(Key.ENTER)
3919 @bind(Key.ENTER, alt=True, show_in_detailed_help=False)
3920 @bind("d", ctrl=True)
3921 def enter(self) -> Result[T] | None:
3922 """select"""
3923 option = self.__grid.get_option()
3924 if option is not None:
3925 return Result(option.value)
3926 else:
3927 self._bell()
3929 @bind(Key.ESCAPE)
3930 def esc(self):
3931 self.__input.text = ""
3932 self.__update_completion()
3933 self.__enable_search = False
3935 def default_event_handler(self, e: KeyboardEvent) -> Result[T] | None:
3936 if not self.__enable_search and e == KeyboardEvent(" "):
3937 return self.enter()
3938 if not self.__enable_search or e.key in (
3939 Key.ARROW_UP,
3940 Key.ARROW_DOWN,
3941 Key.TAB,
3942 Key.ARROW_LEFT,
3943 Key.ARROW_RIGHT,
3944 Key.PAGE_DOWN,
3945 Key.PAGE_UP,
3946 Key.HOME,
3947 Key.END,
3948 ):
3949 self.__grid.event(e)
3950 elif e == KeyboardEvent(Key.BACKSPACE) and not self.__input.text:
3951 self.__enable_search = False
3952 else:
3953 self.__input.event(e)
3954 self.__update_completion()
3956 def __update_completion(self):
3957 query = self.__input.text
3959 index = 0
3960 options = []
3961 cur_option = self.__grid.get_option()
3962 for i, option in enumerate(self.__options):
3963 if not query or self.__filter(option, query):
3964 if option is cur_option or (
3965 cur_option is None and i == self.__default_index
3966 ):
3967 index = len(options)
3968 options.append(option)
3970 self.__grid.set_options(options)
3971 self.__grid.index = index
3973 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
3974 self.__layout = VerticalLayout()
3975 self.__layout.append(self.__grid)
3977 if self.__enable_search:
3978 self.__layout.append(self.__input)
3980 return self.__layout.layout(rc)
3982 def draw(self, rc: RenderContext, /):
3983 self.__layout.draw(rc)
3985 @property
3986 def help_data(self) -> WidgetHelp:
3987 return super().help_data.merge(self.__grid.help_data)
3990class Multiselect(Widget[list[T]], _t.Generic[T]):
3991 """
3992 Like :class:`Choice`, but allows selecting multiple items.
3994 .. vhs:: /_tapes/widget_multiselect.tape
3995 :alt: Demonstration of `Multiselect` widget.
3996 :scale: 40%
3998 :param options:
3999 list of choice options.
4000 :param mapper:
4001 maps option to a text that will be used for filtering. By default,
4002 uses :attr:`Option.display_text`. This argument is ignored
4003 if a custom `filter` is given.
4004 :param filter:
4005 customizes behavior of list filtering. The default filter extracts text
4006 from an option using the `mapper`, and checks if it starts with the search
4007 query.
4008 :param default_index:
4009 index of the initially selected option.
4011 """
4013 @_t.overload
4014 def __init__(
4015 self,
4016 options: list[Option[T]],
4017 /,
4018 *,
4019 mapper: _t.Callable[[Option[T]], str] = lambda x: x.display_text
4020 or str(x.value),
4021 ): ...
4023 @_t.overload
4024 def __init__(
4025 self,
4026 options: list[Option[T]],
4027 /,
4028 *,
4029 filter: _t.Callable[[Option[T], str], bool],
4030 ): ...
4032 def __init__(
4033 self,
4034 options: list[Option[T]],
4035 /,
4036 *,
4037 mapper: _t.Callable[[Option[T]], str] = lambda x: x.display_text
4038 or str(x.value),
4039 filter: _t.Callable[[Option[T], str], bool] | None = None,
4040 search_bar_decoration_path: str = "menu/input/decoration_search",
4041 active_item_decoration_path: str = "menu/choice/decoration/active_item",
4042 selected_item_decoration_path: str = "menu/choice/decoration/selected_item",
4043 deselected_item_decoration_path: str = "menu/choice/decoration/deselected_item",
4044 ):
4045 self.__options = options
4047 if filter is None:
4048 filter = lambda x, q: mapper(x).lstrip().startswith(q)
4050 self.__filter = filter
4052 self.__input = Input(
4053 placeholder="Filter options...", decoration_path=search_bar_decoration_path
4054 )
4055 self.__grid = Grid[tuple[T, bool]](
4056 [],
4057 active_item_decoration_path=active_item_decoration_path,
4058 selected_item_decoration_path=selected_item_decoration_path,
4059 deselected_item_decoration_path=deselected_item_decoration_path,
4060 )
4062 self.__enable_search = False
4064 self.__layout: VerticalLayout[_t.Never]
4066 self.__update_completion()
4068 @bind(Key.ENTER)
4069 @bind(" ")
4070 def select(self):
4071 """select"""
4072 if self.__enable_search and self._cur_event == KeyboardEvent(" "):
4073 self.__input.event(KeyboardEvent(" "))
4074 self.__update_completion()
4075 return
4076 option = self.__grid.get_option()
4077 if option is not None:
4078 option.selected = not option.selected
4079 self.__update_completion()
4081 @bind(Key.ENTER, alt=True)
4082 @bind("d", ctrl=True, show_in_inline_help=True)
4083 def enter(self) -> Result[list[T]] | None:
4084 """accept"""
4085 return Result([option.value for option in self.__options if option.selected])
4087 @bind("/")
4088 def search(self):
4089 """search"""
4090 if not self.__enable_search:
4091 self.__enable_search = True
4092 else:
4093 self.__input.event(KeyboardEvent("/"))
4094 self.__update_completion()
4096 @bind(Key.ESCAPE)
4097 def esc(self):
4098 """exit search"""
4099 self.__input.text = ""
4100 self.__update_completion()
4101 self.__enable_search = False
4103 def default_event_handler(self, e: KeyboardEvent) -> Result[list[T]] | None:
4104 if not self.__enable_search or e.key in (
4105 Key.ARROW_UP,
4106 Key.ARROW_DOWN,
4107 Key.TAB,
4108 Key.ARROW_LEFT,
4109 Key.ARROW_RIGHT,
4110 Key.PAGE_DOWN,
4111 Key.PAGE_UP,
4112 Key.HOME,
4113 Key.END,
4114 ):
4115 self.__grid.event(e)
4116 elif e == KeyboardEvent(Key.BACKSPACE) and not self.__input.text:
4117 self.__enable_search = False
4118 else:
4119 self.__input.event(e)
4120 self.__update_completion()
4122 def __update_completion(self):
4123 query = self.__input.text
4125 index = 0
4126 options = []
4127 cur_option = self.__grid.get_option()
4128 for option in self.__options:
4129 if not query or self.__filter(option, query):
4130 if option is cur_option:
4131 index = len(options)
4132 options.append(option)
4134 self.__grid.set_options(options)
4135 self.__grid.index = index
4137 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
4138 self.__layout = VerticalLayout()
4139 self.__layout.append(self.__grid)
4141 if self.__enable_search:
4142 self.__layout.append(self.__input)
4144 return self.__layout.layout(rc)
4146 def draw(self, rc: RenderContext, /):
4147 self.__layout.draw(rc)
4149 @property
4150 def help_data(self) -> WidgetHelp:
4151 return super().help_data.merge(self.__grid.help_data)
4154class InputWithCompletion(Widget[str]):
4155 """
4156 An input box with tab completion.
4158 .. vhs:: /_tapes/widget_completion.tape
4159 :alt: Demonstration of `InputWithCompletion` widget.
4160 :scale: 40%
4162 """
4164 def __init__(
4165 self,
4166 completer: yuio.complete.Completer,
4167 /,
4168 *,
4169 placeholder: str = "",
4170 decoration_path: str = "menu/input/decoration",
4171 active_item_decoration_path: str = "menu/choice/decoration/active_item",
4172 ):
4173 self.__completer = completer
4175 self.__input = Input(placeholder=placeholder, decoration_path=decoration_path)
4176 self.__grid = Grid[yuio.complete.Completion](
4177 [], active_item_decoration_path=active_item_decoration_path, min_rows=None
4178 )
4179 self.__grid_active = False
4181 self.__layout: VerticalLayout[_t.Never]
4182 self.__rsuffix: yuio.complete.Completion | None = None
4184 @property
4185 def text(self) -> str:
4186 """
4187 Current text in the input box.
4189 """
4191 return self.__input.text
4193 @property
4194 def pos(self) -> int:
4195 """
4196 Current cursor position, measured in code points before the cursor.
4198 That is, if the text is `"quick brown fox"` with cursor right before the word
4199 "brown", then :attr:`~Input.pos` is equal to `len("quick ")`.
4201 """
4203 return self.__input.pos
4205 @property
4206 def err_region(self) -> tuple[int, int] | None:
4207 return self.__input.err_region
4209 @err_region.setter
4210 def err_region(self, err_region: tuple[int, int] | None, /):
4211 self.__input.err_region = err_region
4213 @bind(Key.ENTER)
4214 @bind("d", ctrl=True)
4215 @help(inline_msg="accept")
4216 def enter(self) -> Result[str] | None:
4217 """accept / select completion"""
4218 if self.__grid_active and (option := self.__grid.get_option()):
4219 self._set_input_state_from_completion(option.value)
4220 self._deactivate_completion()
4221 else:
4222 self._drop_rsuffix()
4223 return Result(self.__input.text)
4225 @bind(Key.TAB)
4226 def tab(self):
4227 """autocomplete"""
4228 if self.__grid_active:
4229 self.__grid.next_item()
4230 if option := self.__grid.get_option():
4231 self._set_input_state_from_completion(option.value)
4232 return
4234 completion = self.__completer.complete(self.__input.text, self.__input.pos)
4235 if len(completion) == 1:
4236 self.__input.checkpoint()
4237 self._set_input_state_from_completion(completion[0])
4238 elif completion:
4239 self.__input.checkpoint()
4240 self.__grid.set_options(
4241 [
4242 Option(
4243 c,
4244 c.completion,
4245 display_text_prefix=c.dprefix,
4246 display_text_suffix=c.dsuffix,
4247 comment=c.comment,
4248 color_tag=c.group_color_tag,
4249 )
4250 for c in completion
4251 ],
4252 default_index=None,
4253 )
4254 self._activate_completion()
4255 else:
4256 self._bell()
4258 @bind(Key.ESCAPE)
4259 def escape(self):
4260 """close autocomplete"""
4261 self._drop_rsuffix()
4262 if self.__grid_active:
4263 self.__input.restore_checkpoint()
4264 self._deactivate_completion()
4266 def default_event_handler(self, e: KeyboardEvent):
4267 if self.__grid_active and e.key in (
4268 Key.ARROW_UP,
4269 Key.ARROW_DOWN,
4270 Key.TAB,
4271 Key.PAGE_UP,
4272 Key.PAGE_DOWN,
4273 Key.HOME,
4274 Key.END,
4275 ):
4276 self._dispatch_completion_event(e)
4277 elif (
4278 self.__grid_active
4279 and self.__grid.index is not None
4280 and e.key in (Key.ARROW_RIGHT, Key.ARROW_LEFT)
4281 ):
4282 self._dispatch_completion_event(e)
4283 else:
4284 self._dispatch_input_event(e)
4286 def _activate_completion(self):
4287 self.__grid_active = True
4289 def _deactivate_completion(self):
4290 self.__grid_active = False
4292 def _set_input_state_from_completion(
4293 self, completion: yuio.complete.Completion, set_rsuffix: bool = True
4294 ):
4295 prefix = completion.iprefix + completion.completion
4296 if set_rsuffix:
4297 prefix += completion.rsuffix
4298 self.__rsuffix = completion
4299 else:
4300 self.__rsuffix = None
4301 self.__input.text = prefix + completion.isuffix
4302 self.__input.pos = len(prefix)
4304 def _dispatch_completion_event(self, e: KeyboardEvent):
4305 self.__rsuffix = None
4306 self.__grid.event(e)
4307 if option := self.__grid.get_option():
4308 self._set_input_state_from_completion(option.value)
4310 def _dispatch_input_event(self, e: KeyboardEvent):
4311 if self.__rsuffix:
4312 # We need to drop current rsuffix in some cases:
4313 if (not e.ctrl and not e.alt and isinstance(e.key, str)) or (
4314 e.key is Key.PASTE and e.paste_str
4315 ):
4316 text = e.key if e.key is not Key.PASTE else e.paste_str
4317 # When user prints something...
4318 if text and text[0] in self.__rsuffix.rsymbols:
4319 # ...that is in `rsymbols`...
4320 self._drop_rsuffix()
4321 elif e in [
4322 KeyboardEvent(Key.ARROW_UP),
4323 KeyboardEvent(Key.ARROW_DOWN),
4324 KeyboardEvent(Key.ARROW_LEFT),
4325 KeyboardEvent("b", ctrl=True),
4326 KeyboardEvent(Key.ARROW_RIGHT),
4327 KeyboardEvent("f", ctrl=True),
4328 KeyboardEvent(Key.ARROW_LEFT, alt=True),
4329 KeyboardEvent("b", alt=True),
4330 KeyboardEvent(Key.ARROW_RIGHT, alt=True),
4331 KeyboardEvent("f", alt=True),
4332 KeyboardEvent(Key.HOME),
4333 KeyboardEvent("a", ctrl=True),
4334 KeyboardEvent(Key.END),
4335 KeyboardEvent("e", ctrl=True),
4336 ]:
4337 # ...or when user moves cursor.
4338 self._drop_rsuffix()
4339 self.__rsuffix = None
4340 self.__input.event(e)
4341 self._deactivate_completion()
4343 def _drop_rsuffix(self):
4344 if self.__rsuffix:
4345 rsuffix = self.__rsuffix.rsuffix
4346 if self.__input.text[: self.__input.pos].endswith(rsuffix):
4347 self._set_input_state_from_completion(self.__rsuffix, set_rsuffix=False)
4349 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
4350 self.__layout = VerticalLayout()
4351 self.__layout.append(self.__input)
4352 if self.__grid_active:
4353 self.__layout.append(self.__grid)
4354 return self.__layout.layout(rc)
4356 def draw(self, rc: RenderContext, /):
4357 self.__layout.draw(rc)
4359 @property
4360 def help_data(self) -> WidgetHelp:
4361 return (
4362 (super().help_data)
4363 .merge(
4364 (self.__grid.help_data)
4365 .without_group("Actions")
4366 .rename_group(Grid._NAVIGATE, "Navigate Completions")
4367 )
4368 .merge(
4369 (self.__input.help_data)
4370 .without_group("Actions")
4371 .rename_group(Input._NAVIGATE, "Navigate Input")
4372 .rename_group(Input._MODIFY, "Modify Input")
4373 )
4374 )
4377class Map(Widget[T], _t.Generic[T, U]):
4378 """
4379 A wrapper that maps result of the given widget using the given function.
4381 ..
4382 >>> class Input(Widget):
4383 ... def event(self, e):
4384 ... return Result("10")
4385 ...
4386 ... def layout(self, rc):
4387 ... return 0, 0
4388 ...
4389 ... def draw(self, rc):
4390 ... pass
4391 >>> class Map(Map):
4392 ... def run(self, term, theme):
4393 ... return self.event(None).value
4394 >>> term, theme = None, None
4396 Example::
4398 >>> # Run `Input` widget, then parse user input as `int`.
4399 >>> int_input = Map(Input(), int)
4400 >>> int_input.run(term, theme)
4401 10
4403 """
4405 def __init__(self, inner: Widget[U], fn: _t.Callable[[U], T], /):
4406 self._inner = inner
4407 self._fn = fn
4409 def event(self, e: KeyboardEvent, /) -> Result[T] | None:
4410 if result := self._inner.event(e):
4411 return Result(self._fn(result.value))
4413 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
4414 return self._inner.layout(rc)
4416 def draw(self, rc: RenderContext, /):
4417 self._inner.draw(rc)
4419 @property
4420 def help_data(self) -> WidgetHelp:
4421 return self._inner.help_data
4424class Apply(Map[T, T], _t.Generic[T]):
4425 """
4426 A wrapper that applies the given function to the result of a wrapped widget.
4428 ..
4429 >>> class Input(Widget):
4430 ... def event(self, e):
4431 ... return Result("foobar!")
4432 ...
4433 ... def layout(self, rc):
4434 ... return 0, 0
4435 ...
4436 ... def draw(self, rc):
4437 ... pass
4438 >>> class Apply(Apply):
4439 ... def run(self, term, theme):
4440 ... return self.event(None).value
4441 >>> term, theme = None, None
4443 Example::
4445 >>> # Run `Input` widget, then print its output before returning
4446 >>> print_output = Apply(Input(), print)
4447 >>> result = print_output.run(term, theme)
4448 foobar!
4449 >>> result
4450 'foobar!'
4452 """
4454 def __init__(self, inner: Widget[T], fn: _t.Callable[[T], None], /):
4455 def mapper(x: T) -> T:
4456 fn(x)
4457 return x
4459 super().__init__(inner, mapper)
4462@dataclass(slots=True)
4463class _EventStreamState:
4464 ostream: _t.TextIO
4465 istream: _t.TextIO
4466 key: str = ""
4467 index: int = 0
4469 def load(self):
4470 key = ""
4471 while not key:
4472 key = yuio.term._read_keycode(self.ostream, self.istream)
4473 self.key = key
4474 self.index = 0
4476 def next(self):
4477 ch = self.peek()
4478 self.index += 1
4479 return ch
4481 def peek(self):
4482 if self.index >= len(self.key):
4483 return ""
4484 else:
4485 return self.key[self.index]
4487 def tail(self):
4488 return self.key[self.index :]
4491def _event_stream(ostream: _t.TextIO, istream: _t.TextIO) -> _t.Iterator[KeyboardEvent]:
4492 # Implementation is heavily inspired by libtermkey by Paul Evans, MIT license,
4493 # with some additions for modern protocols.
4494 # See https://sw.kovidgoyal.net/kitty/keyboard-protocol/.
4496 state = _EventStreamState(ostream, istream)
4497 while True:
4498 ch = state.next()
4499 if not ch:
4500 state.load()
4501 ch = state.next()
4502 if ch == "\x1b":
4503 alt = False
4504 ch = state.next()
4505 while ch == "\x1b":
4506 alt = True
4507 ch = state.next()
4508 if not ch:
4509 yield KeyboardEvent(Key.ESCAPE, alt=alt)
4510 elif ch == "[":
4511 yield from _parse_csi(state, alt)
4512 elif ch in "N]":
4513 _parse_dcs(state)
4514 elif ch == "O":
4515 yield from _parse_ss3(state, alt)
4516 else:
4517 yield from _parse_char(ch, alt=True)
4518 elif ch == "\x9b":
4519 # CSI
4520 yield from _parse_csi(state, False)
4521 elif ch in "\x90\x9d":
4522 # DCS or SS2
4523 _parse_dcs(state)
4524 elif ch == "\x8f":
4525 # SS3
4526 yield from _parse_ss3(state, False)
4527 else:
4528 # Char
4529 yield from _parse_char(ch)
4532def _parse_ss3(state: _EventStreamState, alt: bool = False):
4533 ch = state.next()
4534 if not ch:
4535 yield KeyboardEvent("O", alt=True)
4536 else:
4537 yield from _parse_ss3_key(ch, alt=alt)
4540def _parse_dcs(state: _EventStreamState):
4541 while True:
4542 ch = state.next()
4543 if ch == "\x9c":
4544 break
4545 elif ch == "\x1b" and state.peek() == "\\":
4546 state.next()
4547 break
4548 elif not ch:
4549 state.load()
4552def _parse_csi(state: _EventStreamState, alt: bool = False):
4553 buffer = ""
4554 while state.peek() and not (0x40 <= ord(state.peek()) <= 0x80):
4555 buffer += state.next()
4556 cmd = state.next()
4557 if not cmd:
4558 yield KeyboardEvent("[", alt=True)
4559 return
4560 if buffer.startswith(("?", "<", ">", "=")):
4561 # Some command response, ignore.
4562 return # pragma: no cover
4563 args = buffer.split(";")
4565 shift = ctrl = False
4566 if len(args) > 1:
4567 try:
4568 modifiers = int(args[1]) - 1
4569 except ValueError: # pragma: no cover
4570 pass
4571 else:
4572 shift = bool(modifiers & 1)
4573 alt |= bool(modifiers & 2)
4574 ctrl = bool(modifiers & 4)
4576 if cmd == "~":
4577 if args[0] == "27":
4578 try:
4579 ch = chr(int(args[2]))
4580 except (ValueError, KeyError): # pragma: no cover
4581 pass
4582 else:
4583 yield from _parse_char(ch, ctrl=ctrl, alt=alt, shift=shift)
4584 elif args[0] == "200":
4585 yield KeyboardEvent(Key.PASTE, paste_str=_read_pasted_content(state))
4586 elif key := _CSI_CODES.get(args[0]):
4587 yield KeyboardEvent(key, ctrl=ctrl, alt=alt, shift=shift)
4588 elif cmd == "u":
4589 try:
4590 ch = chr(int(args[0]))
4591 except ValueError: # pragma: no cover
4592 pass
4593 else:
4594 yield from _parse_char(ch, ctrl=ctrl, alt=alt, shift=shift)
4595 elif cmd in "mMyR":
4596 # Some command response, ignore.
4597 pass # pragma: no cover
4598 else:
4599 yield from _parse_ss3_key(cmd, ctrl=ctrl, alt=alt, shift=shift)
4602def _parse_ss3_key(
4603 cmd: str, ctrl: bool = False, alt: bool = False, shift: bool = False
4604):
4605 if key := _SS3_CODES.get(cmd):
4606 if cmd == "Z":
4607 shift = True
4608 yield KeyboardEvent(key, ctrl=ctrl, alt=alt, shift=shift)
4611_SS3_CODES = {
4612 "A": Key.ARROW_UP,
4613 "B": Key.ARROW_DOWN,
4614 "C": Key.ARROW_RIGHT,
4615 "D": Key.ARROW_LEFT,
4616 "E": Key.HOME,
4617 "F": Key.END,
4618 "H": Key.HOME,
4619 "Z": Key.TAB,
4620 "P": Key.F1,
4621 "Q": Key.F2,
4622 "R": Key.F3,
4623 "S": Key.F4,
4624 "M": Key.ENTER,
4625 " ": " ",
4626 "I": Key.TAB,
4627 "X": "=",
4628 "j": "*",
4629 "k": "+",
4630 "l": ",",
4631 "m": "-",
4632 "n": ".",
4633 "o": "/",
4634 "p": "0",
4635 "q": "1",
4636 "r": "2",
4637 "s": "3",
4638 "t": "4",
4639 "u": "5",
4640 "v": "6",
4641 "w": "7",
4642 "x": "8",
4643 "y": "9",
4644}
4647_CSI_CODES = {
4648 "1": Key.HOME,
4649 "2": Key.INSERT,
4650 "3": Key.DELETE,
4651 "4": Key.END,
4652 "5": Key.PAGE_UP,
4653 "6": Key.PAGE_DOWN,
4654 "7": Key.HOME,
4655 "8": Key.END,
4656 "11": Key.F1,
4657 "12": Key.F2,
4658 "13": Key.F3,
4659 "14": Key.F4,
4660 "15": Key.F5,
4661 "17": Key.F6,
4662 "18": Key.F7,
4663 "19": Key.F8,
4664 "20": Key.F9,
4665 "21": Key.F10,
4666 "23": Key.F11,
4667 "24": Key.F12,
4668 "200": Key.PASTE,
4669}
4672def _parse_char(
4673 ch: str, ctrl: bool = False, alt: bool = False, shift: bool = False
4674) -> _t.Iterable[KeyboardEvent]:
4675 if ch == "\t":
4676 yield KeyboardEvent(Key.TAB, ctrl, alt, shift)
4677 elif ch in "\r\n":
4678 yield KeyboardEvent(Key.ENTER, ctrl, alt, shift)
4679 elif ch == "\x08":
4680 yield KeyboardEvent(Key.BACKSPACE, ctrl, alt, shift)
4681 elif ch == "\x1b":
4682 yield KeyboardEvent(Key.ESCAPE, ctrl, alt, shift)
4683 elif ch == "\x7f":
4684 yield KeyboardEvent(Key.BACKSPACE, ctrl, alt, shift)
4685 elif "\x00" <= ch <= "\x1a":
4686 yield KeyboardEvent(chr(ord(ch) + ord("a") - 0x01), True, alt, shift)
4687 elif "\x1c" <= ch <= "\x1f":
4688 yield KeyboardEvent(chr(ord(ch) + ord("4") - 0x1C), True, alt, shift)
4689 elif ch in string.printable or ord(ch) >= 160:
4690 yield KeyboardEvent(ch, ctrl, alt, shift)
4693def _read_pasted_content(state: _EventStreamState) -> str:
4694 buf = ""
4695 while True:
4696 index = state.tail().find("\x1b[201~")
4697 if index == -1:
4698 buf += state.tail()
4699 else:
4700 buf += state.tail()[:index]
4701 state.index += index
4702 return buf
4703 state.load()