Coverage for yuio / widget.py: 95%
2162 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-03 15:42 +0000
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-03 15:42 +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.. autoclass:: Task
125 :members:
126 :private-members:
128"""
130# ruff: noqa: RET503
132from __future__ import annotations
134import abc
135import contextlib
136import dataclasses
137import enum
138import functools
139import math
140import re
141import string
142import sys
143import time
144from dataclasses import dataclass
146import yuio.color
147import yuio.complete
148import yuio.string
149import yuio.term
150from yuio.color import Color as _Color
151from yuio.string import ColorizedString as _ColorizedString
152from yuio.string import Esc as _Esc
153from yuio.string import line_width as _line_width
154from yuio.term import Term as _Term
155from yuio.theme import Theme as _Theme
156from yuio.util import _UNPRINTABLE_RE, _UNPRINTABLE_RE_WITHOUT_NL, _UNPRINTABLE_TRANS
158import typing
159from typing import TYPE_CHECKING
161if TYPE_CHECKING:
162 import typing_extensions as _t
163else:
164 from yuio import _typing as _t
166__all__ = [
167 "Action",
168 "ActionKey",
169 "ActionKeys",
170 "Apply",
171 "Choice",
172 "Empty",
173 "Grid",
174 "Input",
175 "InputWithCompletion",
176 "Key",
177 "KeyboardEvent",
178 "Line",
179 "Map",
180 "Multiselect",
181 "Option",
182 "RenderContext",
183 "Result",
184 "SecretInput",
185 "Task",
186 "Text",
187 "VerticalLayout",
188 "VerticalLayoutBuilder",
189 "Widget",
190 "WidgetHelp",
191 "bind",
192 "help",
193]
195_SPACE_BETWEEN_COLUMNS = 2
196_MIN_COLUMN_WIDTH = 10
199T = _t.TypeVar("T")
200U = _t.TypeVar("U")
201T_co = _t.TypeVar("T_co", covariant=True)
204class Key(enum.Enum):
205 """
206 Non-character keys.
208 """
210 ENTER = enum.auto()
211 """
212 :kbd:`Enter` key.
214 """
216 ESCAPE = enum.auto()
217 """
218 :kbd:`Escape` key.
220 """
222 INSERT = enum.auto()
223 """
224 :kbd:`Insert` key.
226 """
228 DELETE = enum.auto()
229 """
230 :kbd:`Delete` key.
232 """
234 BACKSPACE = enum.auto()
235 """
236 :kbd:`Backspace` key.
238 """
240 TAB = enum.auto()
241 """
242 :kbd:`Tab` key.
244 """
246 HOME = enum.auto()
247 """
248 :kbd:`Home` key.
250 """
252 END = enum.auto()
253 """
254 :kbd:`End` key.
256 """
258 PAGE_UP = enum.auto()
259 """
260 :kbd:`PageUp` key.
262 """
264 PAGE_DOWN = enum.auto()
265 """
266 :kbd:`PageDown` key.
268 """
270 ARROW_UP = enum.auto()
271 """
272 :kbd:`ArrowUp` key.
274 """
276 ARROW_DOWN = enum.auto()
277 """
278 :kbd:`ArrowDown` key.
280 """
282 ARROW_LEFT = enum.auto()
283 """
284 :kbd:`ArrowLeft` key.
286 """
288 ARROW_RIGHT = enum.auto()
289 """
290 :kbd:`ArrowRight` key.
292 """
294 F1 = enum.auto()
295 """
296 :kbd:`F1` key.
298 """
300 F2 = enum.auto()
301 """
302 :kbd:`F2` key.
304 """
306 F3 = enum.auto()
307 """
308 :kbd:`F3` key.
310 """
312 F4 = enum.auto()
313 """
314 :kbd:`F4` key.
316 """
318 F5 = enum.auto()
319 """
320 :kbd:`F5` key.
322 """
324 F6 = enum.auto()
325 """
326 :kbd:`F6` key.
328 """
330 F7 = enum.auto()
331 """
332 :kbd:`F7` key.
334 """
336 F8 = enum.auto()
337 """
338 :kbd:`F8` key.
340 """
342 F9 = enum.auto()
343 """
344 :kbd:`F9` key.
346 """
348 F10 = enum.auto()
349 """
350 :kbd:`F10` key.
352 """
354 F11 = enum.auto()
355 """
356 :kbd:`F11` key.
358 """
360 F12 = enum.auto()
361 """
362 :kbd:`F12` key.
364 """
366 PASTE = enum.auto()
367 """
368 Triggered when a text is pasted into a terminal.
370 """
372 def __str__(self) -> str:
373 return self.name.replace("_", " ").title()
376@dataclass(frozen=True, slots=True)
377class KeyboardEvent:
378 """
379 A single keyboard event.
381 .. warning::
383 Protocol for interacting with terminals is quite old, and not all terminals
384 support all keystroke combinations.
386 Use :flag:`python -m yuio.scripts.showkey` to check how your terminal reports
387 keystrokes, and how Yuio interprets them.
389 """
391 key: Key | str
392 """
393 Which key was pressed? Can be a single character,
394 or a :class:`Key` for non-character keys.
396 """
398 ctrl: bool = False
399 """
400 Whether a :kbd:`Ctrl` modifier was pressed with keystroke.
402 For letter keys modified with control, the letter is always lowercase; if terminal
403 supports reporting :kbd:`Shift` being pressed, the :attr:`~KeyboardEvent.shift`
404 attribute will be set. This does not affect punctuation keys, though:
406 .. skip-next:
408 .. code-block:: python
410 # `Ctrl+X` was pressed.
411 KeyboardEvent("x", ctrl=True)
413 # `Ctrl+Shift+X` was pressed. Not all terminals are able
414 # to report this correctly, though.
415 KeyboardEvent("x", ctrl=True, shift=True)
417 # This can't happen.
418 KeyboardEvent("X", ctrl=True)
420 # `Ctrl+_` was pressed. On most keyboards, the actual keystroke
421 # is `Ctrl+Shift+-`, but most terminals can't properly report this.
422 KeyboardEvent("_", ctrl=True)
424 """
426 alt: bool = False
427 """
428 Whether an :kbd:`Alt` (:kbd:`Option` on macs) modifier was pressed with keystroke.
430 """
432 shift: bool = False
433 """
434 Whether a :kbd:`Shift` modifier was pressed with keystroke.
436 Note that, when letters are typed with shift, they will not have this flag.
437 Instead, their upper case version will be set as :attr:`~KeyboardEvent.key`:
439 .. skip-next:
441 .. code-block:: python
443 KeyboardEvent("x") # `X` was pressed.
444 KeyboardEvent("X") # `Shift+X` was pressed.
446 .. warning::
448 Only :kbd:`Shift+Tab` can be reliably reported by all terminals.
450 """
452 paste_str: str | None = dataclasses.field(default=None, compare=False, kw_only=True)
453 """
454 If `key` is :attr:`Key.PASTE`, this attribute will contain pasted string.
456 """
459@_t.final
460class RenderContext:
461 """
462 A canvas onto which widgets render themselves.
464 This class represents a canvas with size equal to the available space on the terminal.
465 Like a real terminal, it has a character grid and a virtual cursor that can be moved
466 around freely.
468 Before each render, context's canvas is cleared, and then widgets print themselves onto it.
469 When render ends, context compares new canvas with what's been rendered previously,
470 and then updates those parts of the real terminal's grid that changed between renders.
472 This approach allows simplifying widgets (they don't have to track changes and do conditional
473 screen updates themselves), while still minimizing the amount of data that's sent between
474 the program and the terminal. It is especially helpful with rendering larger widgets over ssh.
476 """
478 # For tests.
479 _override_wh: tuple[int, int] | None = None
481 def __init__(self, term: _Term, theme: _Theme, /):
482 self._term: _Term = term
483 self._theme: _Theme = theme
485 # We have three levels of abstraction here.
486 #
487 # First, we have the TTY which our process attached to.
488 # This TTY has cursor, current color,
489 # and different drawing capabilities.
490 #
491 # Second, we have the canvas. This canvas has same dimensions
492 # as the underlying TTY. Canvas' contents and actual TTY contents
493 # are synced in `render` function.
494 #
495 # Finally, we have virtual cursor,
496 # and a drawing frame which clips dimensions of a widget.
497 #
498 #
499 # Drawing frame
500 # ...................
501 # . ┌────────┐ .
502 # . │ hello │ .
503 # . │ world │ .
504 # . └────────┘ .
505 # ...................
506 # ↓
507 # Canvas
508 # ┌─────────────────┐
509 # │ > hello │
510 # │ world │
511 # │ │
512 # └─────────────────┘
513 # ↓
514 # Real terminal
515 # ┏━━━━━━━━━━━━━━━━━┯━━━┓
516 # ┃ > hello │ ┃
517 # ┃ world │ ┃
518 # ┃ │ ┃
519 # ┠───────────VT100─┤◆◆◆┃
520 # ┗█▇█▇█▇█▇█▇█▇█▇█▇█▇█▇█┛
522 # Drawing frame and virtual cursor
523 self._frame_x: int = 0
524 self._frame_y: int = 0
525 self._frame_w: int = 0
526 self._frame_h: int = 0
527 self._frame_cursor_x: int = 0 # relative to _frame_x
528 self._frame_cursor_y: int = 0 # relative to _frame_y
529 self._frame_cursor_color: str = ""
531 # Canvas
532 self._width: int = 0
533 self._height: int = 0
534 self._final_x: int = 0
535 self._final_y: int = 0
536 self._lines: list[list[str]] = []
537 self._colors: list[list[str]] = []
538 self._prev_lines: list[list[str]] = []
539 self._prev_colors: list[list[str]] = []
540 self._prev_urls: list[list[str]] = []
542 # Rendering status
543 self._full_redraw: bool = False
544 self._term_x: int = 0
545 self._term_y: int = 0
546 self._term_color: str = ""
547 self._max_term_y: int = 0
548 self._out: list[str] = []
549 self._bell: bool = False
550 self._in_alternative_buffer: bool = False
551 self._normal_buffer_term_x: int = 0
552 self._normal_buffer_term_y: int = 0
553 self._spinner_state: int = 0
555 # Helpers
556 self._none_color: str = _Color.NONE.as_code(term.color_support)
558 # Used for tests and debug
559 self._renders: int = 0
560 self._bytes_rendered: int = 0
561 self._total_bytes_rendered: int = 0
563 @property
564 def term(self) -> _Term:
565 """
566 Terminal where we render the widgets.
568 """
570 return self._term
572 @property
573 def theme(self) -> _Theme:
574 """
575 Current color theme.
577 """
579 return self._theme
581 @property
582 def spinner_state(self) -> int:
583 """
584 A timer that ticks once every
585 :attr:`Theme.spinner_update_rate_ms <yuio.theme.Theme.spinner_update_rate_ms>`.
587 """
589 return self._spinner_state
591 @contextlib.contextmanager
592 def frame(
593 self,
594 x: int,
595 y: int,
596 /,
597 *,
598 width: int | None = None,
599 height: int | None = None,
600 ):
601 """
602 Override drawing frame.
604 Widgets are always drawn in the frame's top-left corner,
605 and they can take the entire frame size.
607 The idea is that, if you want to draw a widget at specific coordinates,
608 you make a frame and draw the widget inside said frame.
610 When new frame is created, cursor's position and color are reset.
611 When frame is dropped, they are restored.
612 Therefore, drawing widgets in a frame will not affect current drawing state.
614 ..
615 >>> term = _Term(sys.stdout, sys.stdin)
616 >>> theme = _Theme()
617 >>> rc = RenderContext(term, theme)
618 >>> rc._override_wh = (20, 5)
620 Example::
622 >>> rc = RenderContext(term, theme) # doctest: +SKIP
623 >>> rc.prepare()
625 >>> # By default, our frame is located at (0, 0)...
626 >>> rc.write("+")
628 >>> # ...and spans the entire canvas.
629 >>> print(rc.width, rc.height)
630 20 5
632 >>> # Let's write something at (4, 0).
633 >>> rc.set_pos(4, 0)
634 >>> rc.write("Hello, world!")
636 >>> # Now we set our drawing frame to be at (2, 2).
637 >>> with rc.frame(2, 2):
638 ... # Out current pos was reset to the frame's top-left corner,
639 ... # which is now (2, 2).
640 ... rc.write("+")
641 ...
642 ... # Frame dimensions were automatically reduced.
643 ... print(rc.width, rc.height)
644 ...
645 ... # Set pos and all other functions work relative
646 ... # to the current frame, so writing at (4, 0)
647 ... # in the current frame will result in text at (6, 2).
648 ... rc.set_pos(4, 0)
649 ... rc.write("Hello, world!")
650 18 3
652 >>> rc.render() # doctest: +NORMALIZE_WHITESPACE
653 + Hello, world!
654 <BLANKLINE>
655 + Hello, world!
656 <BLANKLINE>
657 <BLANKLINE>
659 Usually you don't have to think about frames. If you want to stack
660 multiple widgets one on top of another, simply use :class:`VerticalLayout`.
661 In cases where it's not enough though, you'll have to call
662 :meth:`~Widget.layout` for each of the nested widgets, and then manually
663 create frames and execute :meth:`~Widget.draw` methods::
665 class MyWidget(Widget):
666 # Let's say we want to print a text indented by four spaces,
667 # and limit its with by 15. And we also want to print a small
668 # un-indented heading before it.
670 def __init__(self):
671 # This is the text we'll print.
672 self._nested_widget = Text(
673 "very long paragraph which potentially can span multiple lines"
674 )
676 def layout(self, rc: RenderContext) -> tuple[int, int]:
677 # The text will be placed at (4, 1), and we'll also limit
678 # its width. So we'll reflect those constrains
679 # by arranging a drawing frame.
680 with rc.frame(4, 1, width=min(rc.width - 4, 15)):
681 min_h, max_h = self._nested_widget.layout(rc)
683 # Our own widget will take as much space as the nested text,
684 # plus one line for our heading.
685 return min_h + 1, max_h + 1
687 def draw(self, rc: RenderContext):
688 # Print a small heading.
689 rc.set_color_path("bold")
690 rc.write("Small heading")
692 # And draw our nested widget, controlling its position
693 # via a frame.
694 with rc.frame(4, 1, width=min(rc.width - 4, 15)):
695 self._nested_widget.draw(rc)
697 """
699 prev_frame_x = self._frame_x
700 prev_frame_y = self._frame_y
701 prev_frame_w = self._frame_w
702 prev_frame_h = self._frame_h
703 prev_frame_cursor_x = self._frame_cursor_x
704 prev_frame_cursor_y = self._frame_cursor_y
705 prev_frame_cursor_color = self._frame_cursor_color
707 self._frame_x += x
708 self._frame_y += y
710 if width is not None:
711 self._frame_w = width
712 else:
713 self._frame_w -= x
714 if self._frame_w < 0:
715 self._frame_w = 0
717 if height is not None:
718 self._frame_h = height
719 else:
720 self._frame_h -= y
721 if self._frame_h < 0:
722 self._frame_h = 0
724 self._frame_cursor_x = 0
725 self._frame_cursor_y = 0
726 self._frame_cursor_color = self._none_color
728 try:
729 yield
730 finally:
731 self._frame_x = prev_frame_x
732 self._frame_y = prev_frame_y
733 self._frame_w = prev_frame_w
734 self._frame_h = prev_frame_h
735 self._frame_cursor_x = prev_frame_cursor_x
736 self._frame_cursor_y = prev_frame_cursor_y
737 self._frame_cursor_color = prev_frame_cursor_color
739 @property
740 def width(self) -> int:
741 """
742 Get width of the current frame.
744 """
746 return self._frame_w
748 @property
749 def height(self) -> int:
750 """
751 Get height of the current frame.
753 """
755 return self._frame_h
757 @property
758 def canvas_width(self) -> int:
759 """
760 Get width of the terminal.
762 """
764 return self._width
766 @property
767 def canvas_height(self) -> int:
768 """
769 Get height of the terminal.
771 """
773 return self._height
775 def set_pos(self, x: int, y: int, /):
776 """
777 Set current cursor position within the frame.
779 """
781 self._frame_cursor_x = x
782 self._frame_cursor_y = y
784 def move_pos(self, dx: int, dy: int, /):
785 """
786 Move current cursor position by the given amount.
788 """
790 self._frame_cursor_x += dx
791 self._frame_cursor_y += dy
793 def new_line(self):
794 """
795 Move cursor to new line within the current frame.
797 """
799 self._frame_cursor_x = 0
800 self._frame_cursor_y += 1
802 def set_final_pos(self, x: int, y: int, /):
803 """
804 Set position where the cursor should end up
805 after everything has been rendered.
807 By default, cursor will end up at the beginning of the last line.
808 Components such as :class:`Input` can modify this behavior
809 and move the cursor into the correct position.
811 """
813 self._final_x = x + self._frame_x
814 self._final_y = y + self._frame_y
816 def set_color_path(self, path: str, /):
817 """
818 Set current color by fetching it from the theme by path.
820 """
822 self._frame_cursor_color = self._theme.get_color(path).as_code(
823 self._term.color_support
824 )
826 def set_color(self, color: _Color, /):
827 """
828 Set current color.
830 """
832 self._frame_cursor_color = color.as_code(self._term.color_support)
834 def reset_color(self):
835 """
836 Set current color to the default color of the terminal.
838 """
840 self._frame_cursor_color = self._none_color
842 def get_msg_decoration(self, name: str, /) -> str:
843 """
844 Get message decoration by name.
846 """
848 return self.theme.get_msg_decoration(name, is_unicode=self.term.is_unicode)
850 def write(self, text: yuio.string.AnyString, /, *, max_width: int | None = None):
851 """
852 Write string at the current position using the current color.
853 Move cursor while printing.
855 While the displayed text will not be clipped at frame's borders,
856 its width can be limited by passing `max_width`. Note that
857 ``rc.write(text, max_width)`` is not the same
858 as ``rc.write(text[:max_width])``, because the later case
859 doesn't account for double-width characters.
861 All whitespace characters in the text, including tabs and newlines,
862 will be treated as single spaces. If you need to print multiline text,
863 use :meth:`yuio.string.ColorizedString.wrap` and :meth:`~RenderContext.write_text`.
865 ..
866 >>> term = _Term(sys.stdout, sys.stdin)
867 >>> theme = _Theme()
868 >>> rc = RenderContext(term, theme)
869 >>> rc._override_wh = (20, 5)
871 Example::
873 >>> rc = RenderContext(term, theme) # doctest: +SKIP
874 >>> rc.prepare()
876 >>> rc.write("Hello, world!")
877 >>> rc.new_line()
878 >>> rc.write("Hello,\\nworld!")
879 >>> rc.new_line()
880 >>> rc.write(
881 ... "Hello, 🌍!<this text will be clipped>",
882 ... max_width=10
883 ... )
884 >>> rc.new_line()
885 >>> rc.write(
886 ... "Hello, 🌍!<this text will be clipped>"[:10]
887 ... )
888 >>> rc.new_line()
890 >>> rc.render()
891 Hello, world!
892 Hello, world!
893 Hello, 🌍!
894 Hello, 🌍!<
895 <BLANKLINE>
897 Notice that ``"\\n"`` on the second line was replaced with a space.
898 Notice also that the last line wasn't properly clipped.
900 """
902 if not isinstance(text, _ColorizedString):
903 text = _ColorizedString(text, _isolate_colors=False)
905 x = self._frame_x + self._frame_cursor_x
906 y = self._frame_y + self._frame_cursor_y
908 max_x = self._width
909 if max_width is not None:
910 max_x = min(max_x, x + max_width)
911 self._frame_cursor_x = min(self._frame_cursor_x + text.width, x + max_width)
912 else:
913 self._frame_cursor_x = self._frame_cursor_x + text.width
915 if not 0 <= y < self._height:
916 for s in text:
917 if isinstance(s, _Color):
918 self._frame_cursor_color = s.as_code(self._term.color_support)
919 return
921 ll = self._lines[y]
922 cc = self._colors[y]
923 uu = self._urls[y]
925 url = ""
927 for s in text:
928 if isinstance(s, _Color):
929 self._frame_cursor_color = s.as_code(self._term.color_support)
930 continue
931 elif s in (yuio.string.NO_WRAP_START, yuio.string.NO_WRAP_END):
932 continue
933 elif isinstance(s, yuio.string.LinkMarker):
934 url = s.url or ""
935 continue
937 s = s.translate(_UNPRINTABLE_TRANS)
939 if s.isascii():
940 # Fast track.
941 if x + len(s) <= 0:
942 # We're beyond the left terminal border.
943 x += len(s)
944 continue
946 slice_begin = 0
947 if x < 0:
948 # We're partially beyond the left terminal border.
949 slice_begin = -x
950 x = 0
952 if x >= max_x:
953 # We're beyond the right terminal border.
954 x += len(s) - slice_begin
955 continue
957 slice_end = len(s)
958 if x + len(s) - slice_begin > max_x:
959 # We're partially beyond the right terminal border.
960 slice_end = slice_begin + max_x - x
962 l = slice_end - slice_begin
963 ll[x : x + l] = s[slice_begin:slice_end]
964 cc[x : x + l] = [self._frame_cursor_color] * l
965 uu[x : x + l] = [url] * l
966 x += l
967 continue
969 for c in s:
970 cw = _line_width(c)
971 if x + cw <= 0:
972 # We're beyond the left terminal border.
973 x += cw
974 continue
975 elif x < 0:
976 # This character was split in half by the terminal border.
977 ll[: x + cw] = [" "] * (x + cw)
978 cc[: x + cw] = [self._none_color] * (x + cw)
979 uu[: x + cw] = [url] * (x + cw)
980 x += cw
981 continue
982 elif cw > 0 and x >= max_x:
983 # We're beyond the right terminal border.
984 x += cw
985 break
986 elif x + cw > max_x:
987 # This character was split in half by the terminal border.
988 ll[x:max_x] = " " * (max_x - x)
989 cc[x:max_x] = [self._frame_cursor_color] * (max_x - x)
990 uu[x:max_x] = [url] * (max_x - x)
991 x += cw
992 break
994 if cw == 0:
995 # This is a zero-width character.
996 # We'll append it to the previous cell.
997 if x > 0:
998 ll[x - 1] += c
999 continue
1001 ll[x] = c
1002 cc[x] = self._frame_cursor_color
1003 uu[x] = url
1005 x += 1
1006 cw -= 1
1007 if cw:
1008 ll[x : x + cw] = [""] * cw
1009 cc[x : x + cw] = [self._frame_cursor_color] * cw
1010 uu[x : x + cw] = [url] * cw
1011 x += cw
1013 def write_text(
1014 self,
1015 lines: _t.Iterable[yuio.string.AnyString],
1016 /,
1017 *,
1018 max_width: int | None = None,
1019 ):
1020 """
1021 Write multiple lines.
1023 Each line is printed using :meth:`~RenderContext.write`,
1024 so newline characters and tabs within each line are replaced with spaces.
1025 Use :meth:`yuio.string.ColorizedString.wrap` to properly handle them.
1027 After each line, the cursor is moved one line down,
1028 and back to its original horizontal position.
1030 ..
1031 >>> term = _Term(sys.stdout, sys.stdin)
1032 >>> theme = _Theme()
1033 >>> rc = RenderContext(term, theme)
1034 >>> rc._override_wh = (20, 5)
1036 Example::
1038 >>> rc = RenderContext(term, theme) # doctest: +SKIP
1039 >>> rc.prepare()
1041 >>> # Cursor is at (0, 0).
1042 >>> rc.write("+ > ")
1044 >>> # First line is printed at the cursor's position.
1045 >>> # All consequent lines are horizontally aligned with first line.
1046 >>> rc.write_text(["Hello,", "world!"])
1048 >>> # Cursor is at the last line.
1049 >>> rc.write("+")
1051 >>> rc.render() # doctest: +NORMALIZE_WHITESPACE
1052 + > Hello,
1053 world!+
1054 <BLANKLINE>
1055 <BLANKLINE>
1056 <BLANKLINE>
1058 """
1060 x = self._frame_cursor_x
1062 for i, line in enumerate(lines):
1063 if i > 0:
1064 self._frame_cursor_x = x
1065 self._frame_cursor_y += 1
1067 self.write(line, max_width=max_width)
1069 def bell(self):
1070 """
1071 Ring a terminal bell.
1073 """
1075 self._bell = True
1077 def make_repr_context(
1078 self,
1079 *,
1080 multiline: bool | None = None,
1081 highlighted: bool | None = None,
1082 max_depth: int | None = None,
1083 width: int | None = None,
1084 ) -> yuio.string.ReprContext:
1085 """
1086 Create a new :class:`~yuio.string.ReprContext` for rendering colorized strings
1087 inside widgets.
1089 :param multiline:
1090 sets initial value for
1091 :attr:`ReprContext.multiline <yuio.string.ReprContext.multiline>`.
1092 :param highlighted:
1093 sets initial value for
1094 :attr:`ReprContext.highlighted <yuio.string.ReprContext.highlighted>`.
1095 :param max_depth:
1096 sets initial value for
1097 :attr:`ReprContext.max_depth <yuio.string.ReprContext.max_depth>`.
1098 :param width:
1099 sets initial value for
1100 :attr:`ReprContext.width <yuio.string.ReprContext.width>`.
1101 If not given, uses current frame's width.
1102 :returns:
1103 a new repr context suitable for rendering colorized strings.
1105 """
1107 if width is None:
1108 width = self._frame_w
1109 return yuio.string.ReprContext(
1110 term=self._term,
1111 theme=self._theme,
1112 multiline=multiline,
1113 highlighted=highlighted,
1114 max_depth=max_depth,
1115 width=width,
1116 )
1118 @functools.cached_property
1119 def _update_rate_us(self) -> int:
1120 update_rate_ms = max(self._theme.spinner_update_rate_ms, 1)
1121 while update_rate_ms < 50:
1122 update_rate_ms *= 2
1123 while update_rate_ms > 250:
1124 update_rate_ms //= 2
1125 return int(update_rate_ms * 1000)
1127 def prepare(
1128 self,
1129 *,
1130 full_redraw: bool = False,
1131 alternative_buffer: bool = False,
1132 reset_term_pos: bool = False,
1133 ):
1134 """
1135 Reset output canvas and prepare context for a new round of widget formatting.
1137 """
1139 if self._override_wh:
1140 width, height = self._override_wh
1141 else:
1142 size = yuio.term.get_tty_size(fallback=(self._theme.fallback_width, 24))
1143 width = size.columns
1144 height = size.lines
1146 full_redraw = full_redraw or self._width != width or self._height != height
1148 if self._in_alternative_buffer != alternative_buffer:
1149 full_redraw = True
1150 self._in_alternative_buffer = alternative_buffer
1151 if alternative_buffer:
1152 self._out.append("\x1b[<u\x1b[?1049h\x1b[m\x1b[2J\x1b[H\x1b[>1u")
1153 self._normal_buffer_term_x = self._term_x
1154 self._normal_buffer_term_y = self._term_y
1155 self._term_x, self._term_y = 0, 0
1156 self._term_color = self._none_color
1157 else:
1158 self._out.append("\x1b[<u\x1b[?1049l\x1b[m\x1b[>1u")
1159 self._term_x = self._normal_buffer_term_x
1160 self._term_y = self._normal_buffer_term_y
1161 self._term_color = self._none_color
1163 if reset_term_pos:
1164 self._term_x, self._term_y = 0, 0
1165 full_redraw = True
1167 # Drawing frame and virtual cursor
1168 self._frame_x = 0
1169 self._frame_y = 0
1170 self._frame_w = width
1171 self._frame_h = height
1172 self._frame_cursor_x = 0
1173 self._frame_cursor_y = 0
1174 self._frame_cursor_color = self._none_color
1176 # Canvas
1177 self._width = width
1178 self._height = height
1179 self._final_x = 0
1180 self._final_y = 0
1181 if full_redraw:
1182 self._max_term_y = 0
1183 self._prev_lines, self._prev_colors, self._prev_urls = (
1184 self._make_empty_canvas()
1185 )
1186 else:
1187 self._prev_lines = self._lines
1188 self._prev_colors = self._colors
1189 self._prev_urls = self._urls
1190 self._lines, self._colors, self._urls = self._make_empty_canvas()
1192 # Rendering status
1193 self._full_redraw = full_redraw
1195 start_ns = time.monotonic_ns()
1196 now_us = start_ns // 1000
1197 now_us -= now_us % self._update_rate_us
1198 self._spinner_state = now_us // self.theme.spinner_update_rate_ms // 1000
1200 def clear_screen(self):
1201 """
1202 Clear screen and prepare for a full redraw.
1204 """
1206 self._out.append("\x1b[2J\x1b[1H")
1207 self._term_x, self._term_y = 0, 0
1208 self.prepare(full_redraw=True, alternative_buffer=self._in_alternative_buffer)
1210 def _make_empty_canvas(
1211 self,
1212 ) -> tuple[list[list[str]], list[list[str]], list[list[str]]]:
1213 lines = [l[:] for l in [[" "] * self._width] * self._height]
1214 colors = [
1215 c[:] for c in [[self._frame_cursor_color] * self._width] * self._height
1216 ]
1217 urls = [l[:] for l in [[""] * self._width] * self._height]
1218 return lines, colors, urls
1220 def render(self):
1221 """
1222 Render current canvas onto the terminal.
1224 """
1226 if not self.term.ostream_is_tty:
1227 # For tests. Widgets can't work with dumb terminals
1228 self._render_dumb()
1229 return
1231 if self._bell:
1232 self._out.append("\a")
1233 self._bell = False
1235 if self._full_redraw:
1236 self._move_term_cursor(0, 0)
1237 self._out.append("\x1b[J")
1239 term_url = ""
1241 for y in range(self._height):
1242 line = self._lines[y]
1244 for x in range(self._width):
1245 prev_color = self._prev_colors[y][x]
1246 color = self._colors[y][x]
1247 url = self._urls[y][x]
1249 if (
1250 color != prev_color
1251 or line[x] != self._prev_lines[y][x]
1252 or url != self._prev_urls[y][x]
1253 ):
1254 self._move_term_cursor(x, y)
1256 if color != self._term_color:
1257 self._out.append(color)
1258 self._term_color = color
1260 if url != term_url:
1261 self._out.append("\x1b]8;;")
1262 self._out.append(url)
1263 self._out.append("\x1b\\")
1264 term_url = url
1266 self._out.append(line[x])
1267 self._term_x += 1
1269 if term_url:
1270 self._out.append("\x1b]8;;\x1b\\")
1272 final_x = max(0, min(self._width - 1, self._final_x))
1273 final_y = max(0, min(self._height - 1, self._final_y))
1274 self._move_term_cursor(final_x, final_y)
1276 rendered = "".join(self._out)
1277 self._term.ostream.write(rendered)
1278 self._term.ostream.flush()
1279 self._out.clear()
1281 if yuio._debug:
1282 self._renders += 1
1283 self._bytes_rendered = len(rendered.encode())
1284 self._total_bytes_rendered += self._bytes_rendered
1286 debug_msg = f"n={self._renders:>04},r={self._bytes_rendered:>04},t={self._total_bytes_rendered:>04}"
1287 term_x, term_y = self._term_x, self._term_y
1288 self._move_term_cursor(self._width - len(debug_msg), 0)
1289 color = yuio.color.Color.STYLE_INVERSE | yuio.color.Color.FORE_CYAN
1290 self._out.append(color.as_code(self._term.color_support))
1291 self._out.append(debug_msg)
1292 self._out.append(self._term_color)
1293 self._move_term_cursor(term_x, term_y)
1295 self._term.ostream.write("".join(self._out))
1296 self._term.ostream.flush()
1297 self._out.clear()
1299 def finalize(self):
1300 """
1301 Erase any rendered widget and move cursor to the initial position.
1303 """
1305 self.prepare(full_redraw=True)
1307 self._move_term_cursor(0, 0)
1308 self._out.append("\x1b[J")
1309 self._out.append(self._none_color)
1310 self._term.ostream.write("".join(self._out))
1311 self._term.ostream.flush()
1312 self._out.clear()
1313 self._term_color = self._none_color
1315 def _move_term_cursor(self, x: int, y: int):
1316 dy = y - self._term_y
1317 if y > self._max_term_y:
1318 self._out.append("\n" * dy)
1319 self._term_x = 0
1320 elif dy > 0:
1321 self._out.append(f"\x1b[{dy}B")
1322 elif dy < 0:
1323 self._out.append(f"\x1b[{-dy}A")
1324 self._term_y = y
1325 self._max_term_y = max(self._max_term_y, y)
1327 if x != self._term_x:
1328 self._out.append(f"\x1b[{x + 1}G")
1329 self._term_x = x
1331 def _render_dumb(self):
1332 prev_printed_color = self._none_color
1334 for line, colors in zip(self._lines, self._colors):
1335 for ch, color in zip(line, colors):
1336 if prev_printed_color != color:
1337 self._out.append(color)
1338 prev_printed_color = color
1339 self._out.append(ch)
1340 self._out.append("\n")
1342 self._term.ostream.writelines(
1343 # Trim trailing spaces for doctests.
1344 re.sub(r" +$", "\n", line, flags=re.MULTILINE)
1345 for line in "".join(self._out).splitlines()
1346 )
1349@dataclass(frozen=True, slots=True)
1350class Result(_t.Generic[T_co]):
1351 """
1352 Result of a widget run.
1354 We have to wrap the return value of event processors into this class.
1355 Otherwise we won't be able to distinguish between returning `None`
1356 as result of a ``Widget[None]``, and not returning anything.
1358 """
1360 value: T_co
1361 """
1362 Result of a widget run.
1364 """
1367class Widget(abc.ABC, _t.Generic[T_co]):
1368 """
1369 Base class for all interactive console elements.
1371 Widgets are displayed with their :meth:`~Widget.run` method.
1372 They always go through the same event loop:
1374 .. raw:: html
1376 <p>
1377 <pre class="mermaid">
1378 flowchart TD
1379 Start([Start]) --> Layout["`layout()`"]
1380 Layout --> Draw["`draw()`"]
1381 Draw -->|Wait for keyboard event| Event["`Event()`"]
1382 Event --> Result{{Returned result?}}
1383 Result -->|no| Layout
1384 Result -->|yes| Finish([Finish])
1385 </pre>
1386 </p>
1388 Widgets run indefinitely until they stop themselves and return a value.
1389 For example, :class:`Input` will return when user presses :kbd:`Enter`.
1390 When widget needs to stop, it can return the :meth:`Result` class
1391 from its event handler.
1393 For typing purposes, :class:`Widget` is generic. That is, ``Widget[T]``
1394 returns ``T`` from its :meth:`~Widget.run` method. So, :class:`Input`,
1395 for example, is ``Widget[str]``.
1397 Some widgets are ``Widget[Never]`` (see :class:`typing.Never`), indicating that
1398 they don't ever stop. Others are ``Widget[None]``, indicating that they stop,
1399 but don't return a value.
1401 """
1403 __bindings: typing.ClassVar[dict[KeyboardEvent, _t.Callable[[_t.Any], _t.Any]]]
1404 __callbacks: typing.ClassVar[list[object]]
1406 __in_help_menu: bool = False
1407 __bell: bool = False
1409 _cur_event: KeyboardEvent | None = None
1410 """
1411 Current event that is being processed.
1412 Guaranteed to be not :data:`None` inside event handlers.
1414 """
1416 def __init_subclass__(cls, **kwargs):
1417 super().__init_subclass__(**kwargs)
1419 cls.__bindings = {}
1420 cls.__callbacks = []
1422 event_handler_names = []
1423 for base in reversed(cls.__mro__):
1424 for name, cb in base.__dict__.items():
1425 if (
1426 hasattr(cb, "__yuio_keybindings__")
1427 and name not in event_handler_names
1428 ):
1429 event_handler_names.append(name)
1431 for name in event_handler_names:
1432 cb = getattr(cls, name, None)
1433 if cb is not None and hasattr(cb, "__yuio_keybindings__"):
1434 bindings: list[_Binding] = cb.__yuio_keybindings__
1435 cls.__bindings.update((binding.event, cb) for binding in bindings)
1436 cls.__callbacks.append(cb)
1438 def event(self, e: KeyboardEvent, /) -> Result[T_co] | None:
1439 """
1440 Handle incoming keyboard event.
1442 By default, this function dispatches event to handlers registered
1443 via :func:`bind`. If no handler is found,
1444 it calls :meth:`~Widget.default_event_handler`.
1446 """
1448 self._cur_event = e
1449 if handler := self.__bindings.get(e):
1450 return handler(self)
1451 else:
1452 return self.default_event_handler(e)
1454 def default_event_handler(self, e: KeyboardEvent, /) -> Result[T_co] | None:
1455 """
1456 Process any event that wasn't caught by other event handlers.
1458 """
1460 @abc.abstractmethod
1461 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
1462 """
1463 Prepare widget for drawing, and recalculate its dimensions
1464 according to new frame dimensions.
1466 Yuio's widgets always take all available width. They should return
1467 their minimum height that they will definitely take, and their maximum
1468 height that they can potentially take.
1470 """
1472 raise NotImplementedError()
1474 @abc.abstractmethod
1475 def draw(self, rc: RenderContext, /):
1476 """
1477 Draw the widget.
1479 Render context's drawing frame dimensions are guaranteed to be between
1480 the minimum and the maximum height returned from the last call
1481 to :meth:`~Widget.layout`.
1483 """
1485 raise NotImplementedError()
1487 @_t.final
1488 def run(self, term: _Term, theme: _Theme, /) -> T_co:
1489 """
1490 Read user input and run the widget.
1492 """
1494 if not term.can_run_widgets:
1495 raise RuntimeError("terminal doesn't support rendering widgets")
1497 with yuio.term._enter_raw_mode(
1498 term.ostream, term.istream, bracketed_paste=True, modify_keyboard=True
1499 ):
1500 rc = RenderContext(term, theme)
1502 events = _event_stream(term.ostream, term.istream)
1504 try:
1505 while True:
1506 rc.prepare(alternative_buffer=self.__in_help_menu)
1508 height = rc.height
1509 if self.__in_help_menu:
1510 min_h, max_h = self.__help_menu_layout(rc)
1511 inline_help_height = 0
1512 else:
1513 with rc.frame(0, 0):
1514 inline_help_height = self.__help_menu_layout_inline(rc)[0]
1515 if height > inline_help_height:
1516 height -= inline_help_height
1517 with rc.frame(0, 0, height=height):
1518 min_h, max_h = self.layout(rc)
1519 max_h = max(min_h, min(max_h, height))
1520 rc.set_final_pos(0, max_h + inline_help_height)
1521 if self.__in_help_menu:
1522 self.__help_menu_draw(rc)
1523 else:
1524 with rc.frame(0, 0, height=max_h):
1525 self.draw(rc)
1526 if max_h < rc.height:
1527 with rc.frame(0, max_h, height=rc.height - max_h):
1528 self.__help_menu_draw_inline(rc)
1530 if self.__bell:
1531 rc.bell()
1532 self.__bell = False
1533 rc.render()
1535 try:
1536 event = next(events)
1537 except StopIteration:
1538 assert False, "_event_stream supposed to be infinite"
1540 if event == KeyboardEvent("c", ctrl=True):
1541 raise KeyboardInterrupt()
1542 elif event == KeyboardEvent("l", ctrl=True):
1543 rc.clear_screen()
1544 elif event == KeyboardEvent(Key.F1) and not self.__in_help_menu:
1545 self.__in_help_menu = True
1546 self.__help_menu_line = 0
1547 self.__last_help_data = None
1548 elif self.__in_help_menu:
1549 self.__help_menu_event(event)
1550 elif result := self.event(event):
1551 return result.value
1552 finally:
1553 rc.finalize()
1555 def _bell(self):
1556 self.__bell = True
1558 @property
1559 def help_data(self) -> WidgetHelp:
1560 """
1561 Data for displaying help messages.
1563 See :func:`help` for more info.
1565 """
1567 return self.__help_columns
1569 @functools.cached_property
1570 def __help_columns(self) -> WidgetHelp:
1571 inline_help: list[Action] = []
1572 groups: dict[str, list[Action]] = {}
1574 for cb in self.__callbacks:
1575 bindings: list[_Binding] = getattr(cb, "__yuio_keybindings__", [])
1576 help: _Help | None = getattr(cb, "__yuio_help__", None)
1577 if not bindings:
1578 continue
1579 if help is None:
1580 help = _Help(
1581 "Actions",
1582 getattr(cb, "__doc__", None),
1583 getattr(cb, "__doc__", None),
1584 )
1585 if not help.inline_msg and not help.long_msg:
1586 continue
1588 if help.inline_msg:
1589 inline_bindings = [
1590 binding.event
1591 for binding in reversed(bindings)
1592 if binding.show_in_inline_help
1593 ]
1594 if inline_bindings:
1595 inline_help.append((inline_bindings, help.inline_msg))
1597 if help.long_msg:
1598 menu_bindings = [
1599 binding.event
1600 for binding in reversed(bindings)
1601 if binding.show_in_detailed_help
1602 ]
1603 if menu_bindings:
1604 groups.setdefault(help.group, []).append(
1605 (menu_bindings, help.long_msg)
1606 )
1608 return WidgetHelp(inline_help, groups)
1610 __last_help_data: WidgetHelp | None = None
1611 __prepared_inline_help: list[tuple[list[str], str, str, int]]
1612 __prepared_groups: dict[str, list[tuple[list[str], str, str, int]]]
1613 __has_help: bool = True
1614 __width: int = 0
1615 __height: int = 0
1616 __menu_content_height: int = 0
1617 __help_menu_line: int = 0
1618 __help_menu_search: bool = False
1619 __help_menu_search_widget: Input
1620 __help_menu_search_layout: tuple[int, int] = 0, 0
1621 __key_width: int = 0
1622 __wrapped_groups: list[
1623 tuple[
1624 str, # Title
1625 list[ # Actions
1626 tuple[ # Action
1627 list[str], # Keys
1628 list[_ColorizedString], # Wrapped msg
1629 int, # Keys width
1630 ],
1631 ],
1632 ] # FML this type hint -___-
1633 ]
1634 __colorized_inline_help: list[
1635 tuple[ # Action
1636 list[str], # Keys
1637 _ColorizedString, # Title
1638 int, # Keys width
1639 ]
1640 ]
1642 def __help_menu_event(self, e: KeyboardEvent, /) -> Result[T_co] | None:
1643 if not self.__help_menu_search and e in [
1644 KeyboardEvent(Key.F1),
1645 KeyboardEvent(Key.ESCAPE),
1646 KeyboardEvent(Key.ENTER),
1647 KeyboardEvent("q"),
1648 KeyboardEvent("q", ctrl=True),
1649 ]:
1650 self.__in_help_menu = False
1651 self.__help_menu_line = 0
1652 self.__last_help_data = None
1653 elif e == KeyboardEvent(Key.ARROW_UP):
1654 self.__help_menu_line += 1
1655 elif e == KeyboardEvent(Key.HOME):
1656 self.__help_menu_line = 0
1657 elif e == KeyboardEvent(Key.PAGE_UP):
1658 self.__help_menu_line += self.__height
1659 elif e == KeyboardEvent(Key.END):
1660 self.__help_menu_line = -self.__menu_content_height
1661 elif e == KeyboardEvent(Key.ARROW_DOWN):
1662 self.__help_menu_line -= 1
1663 elif e == KeyboardEvent(Key.PAGE_DOWN):
1664 self.__help_menu_line -= self.__height
1665 elif not self.__help_menu_search and e == KeyboardEvent(" "):
1666 self.__help_menu_line -= self.__height
1667 elif not self.__help_menu_search and e == KeyboardEvent("/"):
1668 self.__help_menu_search = True
1669 self.__help_menu_search_widget = Input(
1670 decoration_path="menu/input/decoration_search"
1671 )
1672 elif self.__help_menu_search:
1673 if e == KeyboardEvent(Key.ESCAPE) or (
1674 e == KeyboardEvent(Key.BACKSPACE)
1675 and not self.__help_menu_search_widget.text
1676 ):
1677 self.__help_menu_search = False
1678 self.__last_help_data = None
1679 del self.__help_menu_search_widget
1680 self.__help_menu_search_layout = 0, 0
1681 else:
1682 self.__help_menu_search_widget.event(e)
1683 self.__last_help_data = None
1684 self.__help_menu_line = min(
1685 max(-self.__menu_content_height + self.__height, self.__help_menu_line), 0
1686 )
1688 def __clear_layout_cache(self, rc: RenderContext, /) -> bool:
1689 if self.__width == rc.width and self.__last_help_data == self.help_data:
1690 return False
1692 if self.__width != rc.width:
1693 self.__help_menu_line = 0
1695 self.__width = rc.width
1696 self.__height = rc.height
1698 if self.__last_help_data != self.help_data:
1699 self.__last_help_data = self.help_data
1700 self.__prepared_groups = self.__prepare_groups(self.__last_help_data, rc)
1701 self.__prepared_inline_help = self.__prepare_inline_help(
1702 self.__last_help_data, rc
1703 )
1704 self.__has_help = bool(
1705 self.__last_help_data.inline_help or self.__last_help_data.groups
1706 )
1708 return True
1710 def __help_menu_layout(self, rc: RenderContext, /) -> tuple[int, int]:
1711 if self.__help_menu_search:
1712 self.__help_menu_search_layout = self.__help_menu_search_widget.layout(rc)
1714 if not self.__clear_layout_cache(rc):
1715 return rc.height, rc.height
1717 self.__key_width = 10
1718 ctx = rc.make_repr_context(
1719 width=min(rc.width, 90) - self.__key_width - 2,
1720 )
1722 self.__wrapped_groups = []
1723 for title, actions in self.__prepared_groups.items():
1724 wrapped_actions: list[tuple[list[str], list[_ColorizedString], int]] = []
1725 for keys, _, msg, key_width in actions:
1726 lines = yuio.string.colorize(msg, ctx=ctx).wrap(ctx.width)
1727 wrapped_actions.append((keys, lines, key_width))
1728 self.__wrapped_groups.append((title, wrapped_actions))
1730 return rc.height, rc.height
1732 def __help_menu_draw(self, rc: RenderContext, /):
1733 y = self.__help_menu_line
1735 if not self.__wrapped_groups:
1736 rc.set_color_path("menu/decoration:help_menu")
1737 rc.write("No actions to display")
1738 y += 1
1740 for title, actions in self.__wrapped_groups:
1741 rc.set_pos(0, y)
1742 if title:
1743 rc.set_color_path("menu/text/heading:help_menu")
1744 rc.write(title)
1745 y += 2
1747 for keys, lines, key_width in actions:
1748 if key_width > self.__key_width:
1749 rc.set_pos(0, y)
1750 y += 1
1751 else:
1752 rc.set_pos(self.__key_width - key_width, y)
1753 sep = ""
1754 for key in keys:
1755 rc.set_color_path("menu/text/help_sep:help_menu")
1756 rc.write(sep)
1757 rc.set_color_path("menu/text/help_key:help_menu")
1758 rc.write(key)
1759 sep = "/"
1761 rc.set_pos(0 + self.__key_width + 2, y)
1762 rc.write_text(lines)
1763 y += len(lines)
1765 y += 2
1767 self.__menu_content_height = y - self.__help_menu_line
1769 with rc.frame(0, rc.height - max(self.__help_menu_search_layout[0], 1)):
1770 if self.__help_menu_search:
1771 rc.write(" " * rc.width)
1772 rc.set_pos(0, 0)
1773 self.__help_menu_search_widget.draw(rc)
1774 else:
1775 rc.set_color_path("menu/decoration:help_menu")
1776 rc.write(rc.get_msg_decoration("menu/help/decoration"))
1777 rc.reset_color()
1778 rc.write(" " * (rc.width - 1))
1779 rc.set_final_pos(1, 0)
1781 def __help_menu_layout_inline(self, rc: RenderContext, /) -> tuple[int, int]:
1782 if not self.__clear_layout_cache(rc):
1783 return (1, 1) if self.__has_help else (0, 0)
1785 if not self.__has_help:
1786 return 0, 0
1788 self.__colorized_inline_help = []
1789 for keys, title, _, key_width in self.__prepared_inline_help:
1790 if keys:
1791 title_color = "menu/text/help_msg:help"
1792 else:
1793 title_color = "menu/text/help_info:help"
1794 colorized_title = yuio.string.colorize(
1795 title,
1796 default_color=title_color,
1797 ctx=rc.make_repr_context(),
1798 )
1799 self.__colorized_inline_help.append((keys, colorized_title, key_width))
1801 return 1, 1
1803 def __help_menu_draw_inline(self, rc: RenderContext, /):
1804 if not self.__has_help:
1805 return
1807 used_width = _line_width(rc.get_msg_decoration("menu/help/key/f1")) + 5
1808 col_sep = ""
1810 for keys, title, keys_width in self.__colorized_inline_help:
1811 action_width = keys_width + bool(keys_width) + title.width + 3
1812 if used_width + action_width > rc.width:
1813 break
1815 rc.set_color_path("menu/text/help_sep:help")
1816 rc.write(col_sep)
1818 sep = ""
1819 for key in keys:
1820 rc.set_color_path("menu/text/help_sep:help")
1821 rc.write(sep)
1822 rc.set_color_path("menu/text/help_key:help")
1823 rc.write(key)
1824 sep = "/"
1826 if keys_width:
1827 rc.move_pos(1, 0)
1828 rc.write(title)
1830 col_sep = " • "
1832 rc.set_color_path("menu/text/help_sep:help")
1833 rc.write(col_sep)
1834 rc.set_color_path("menu/text/help_key:help")
1835 rc.write(rc.get_msg_decoration("menu/help/key/f1"))
1836 rc.move_pos(1, 0)
1837 rc.set_color_path("menu/text/help_msg:help")
1838 rc.write("help")
1840 def __prepare_inline_help(
1841 self, data: WidgetHelp, rc: RenderContext
1842 ) -> list[tuple[list[str], str, str, int]]:
1843 return [
1844 prepared_action
1845 for action in data.inline_help
1846 if (prepared_action := self.__prepare_action(action, rc))
1847 and prepared_action[1]
1848 ]
1850 def __prepare_groups(
1851 self, data: WidgetHelp, rc: RenderContext
1852 ) -> dict[str, list[tuple[list[str], str, str, int]]]:
1853 help_data = (
1854 data.with_action(
1855 rc.get_msg_decoration("menu/help/key/f1"),
1856 group="Other Actions",
1857 long_msg="toggle help menu",
1858 )
1859 .with_action(
1860 rc.get_msg_decoration("menu/help/key/ctrl") + "l",
1861 group="Other Actions",
1862 long_msg="refresh screen",
1863 )
1864 .with_action(
1865 rc.get_msg_decoration("menu/help/key/ctrl") + "c",
1866 group="Other Actions",
1867 long_msg="send interrupt signal",
1868 )
1869 .with_action(
1870 rc.get_msg_decoration("menu/help/key/ctrl") + "...",
1871 group="Legend",
1872 long_msg="means `Ctrl+...`",
1873 )
1874 .with_action(
1875 rc.get_msg_decoration("menu/help/key/alt") + "...",
1876 group="Legend",
1877 long_msg=(
1878 "means `Option+...`"
1879 if sys.platform == "darwin"
1880 else "means `Alt+...`"
1881 ),
1882 )
1883 .with_action(
1884 rc.get_msg_decoration("menu/help/key/shift") + "...",
1885 group="Legend",
1886 long_msg="means `Shift+...`",
1887 )
1888 .with_action(
1889 rc.get_msg_decoration("menu/help/key/enter"),
1890 group="Legend",
1891 long_msg="means `Return` or `Enter`",
1892 )
1893 .with_action(
1894 rc.get_msg_decoration("menu/help/key/backspace"),
1895 group="Legend",
1896 long_msg="means `Backspace`",
1897 )
1898 )
1900 # Make sure unsorted actions go first.
1901 groups = {"Input Format": [], "Actions": []}
1903 groups.update(
1904 {
1905 title: prepared_actions
1906 for title, actions in help_data.groups.items()
1907 if (
1908 prepared_actions := [
1909 prepared_action
1910 for action in actions
1911 if (prepared_action := self.__prepare_action(action, rc))
1912 and prepared_action[1]
1913 ]
1914 )
1915 }
1916 )
1918 if not groups["Input Format"]:
1919 del groups["Input Format"]
1920 if not groups["Actions"]:
1921 del groups["Actions"]
1923 # Make sure other actions go last.
1924 if "Other Actions" in groups:
1925 groups["Other Actions"] = groups.pop("Other Actions")
1926 if "Legend" in groups:
1927 groups["Legend"] = groups.pop("Legend")
1929 return groups
1931 def __prepare_action(
1932 self, action: Action, rc: RenderContext
1933 ) -> tuple[list[str], str, str, int] | None:
1934 if isinstance(action, tuple):
1935 action_keys, msg = action
1936 prepared_keys = self.__prepare_keys(action_keys, rc)
1937 else:
1938 prepared_keys = []
1939 msg = action
1941 if self.__help_menu_search:
1942 pattern = self.__help_menu_search_widget.text
1943 if not any(pattern in key for key in prepared_keys) and pattern not in msg:
1944 return None
1946 title = msg.split("\n\n", maxsplit=1)[0]
1947 return prepared_keys, title, msg, _line_width("/".join(prepared_keys))
1949 def __prepare_keys(self, action_keys: ActionKeys, rc: RenderContext) -> list[str]:
1950 if isinstance(action_keys, (str, Key, KeyboardEvent)):
1951 return [self.__prepare_key(action_keys, rc)]
1952 else:
1953 return [self.__prepare_key(action_key, rc) for action_key in action_keys]
1955 def __prepare_key(self, action_key: ActionKey, rc: RenderContext) -> str:
1956 if isinstance(action_key, str):
1957 return action_key
1958 elif isinstance(action_key, KeyboardEvent):
1959 ctrl, alt, shift, key = (
1960 action_key.ctrl,
1961 action_key.alt,
1962 action_key.shift,
1963 action_key.key,
1964 )
1965 else:
1966 ctrl, alt, shift, key = False, False, False, action_key
1968 symbol = ""
1970 if isinstance(key, str):
1971 if key.lower() != key:
1972 shift = True
1973 key = key.lower()
1974 elif key == " ":
1975 key = "space"
1976 else:
1977 key = key.name.lower()
1979 if shift:
1980 symbol += rc.get_msg_decoration("menu/help/key/shift")
1982 if ctrl:
1983 symbol += rc.get_msg_decoration("menu/help/key/ctrl")
1985 if alt:
1986 symbol += rc.get_msg_decoration("menu/help/key/alt")
1988 return symbol + (rc.get_msg_decoration(f"menu/help/key/{key}") or key)
1991Widget.__init_subclass__()
1994@dataclass(frozen=True, slots=True)
1995class _Binding:
1996 event: KeyboardEvent
1997 show_in_inline_help: bool
1998 show_in_detailed_help: bool
2000 def __call__(self, fn: T, /) -> T:
2001 if not hasattr(fn, "__yuio_keybindings__"):
2002 setattr(fn, "__yuio_keybindings__", [])
2003 getattr(fn, "__yuio_keybindings__").append(self)
2005 return fn
2008def bind(
2009 key: Key | str,
2010 *,
2011 ctrl: bool = False,
2012 alt: bool = False,
2013 shift: bool = False,
2014 show_in_inline_help: bool = False,
2015 show_in_detailed_help: bool = True,
2016) -> _Binding:
2017 """
2018 Register an event handler for a widget.
2020 Widget's methods can be registered as handlers for keyboard events.
2021 When a new event comes in, it is checked to match arguments of this decorator.
2022 If there is a match, the decorated method is called
2023 instead of the :meth:`Widget.default_event_handler`.
2025 .. note::
2027 :kbd:`Ctrl+L` and :kbd:`F1` are always reserved by the widget itself.
2029 If `show_in_help` is :data:`True`, this binding will be shown in the widget's
2030 inline help. If `show_in_detailed_help` is :data:`True`,
2031 this binding will be shown in the widget's help menu.
2033 Example::
2035 class MyWidget(Widget):
2036 @bind(Key.ENTER)
2037 def enter(self):
2038 # all `ENTER` events go here.
2039 ...
2041 def default_event_handler(self, e: KeyboardEvent):
2042 # all non-`ENTER` events go here (including `ALT+ENTER`).
2043 ...
2045 """
2047 e = KeyboardEvent(key=key, ctrl=ctrl, alt=alt, shift=shift)
2048 return _Binding(e, show_in_inline_help, show_in_detailed_help)
2051@dataclass(frozen=True, slots=True)
2052class _Help:
2053 group: str = "Actions"
2054 inline_msg: str | None = None
2055 long_msg: str | None = None
2057 def __call__(self, fn: T, /) -> T:
2058 h = dataclasses.replace(
2059 self,
2060 inline_msg=(
2061 self.inline_msg
2062 if self.inline_msg is not None
2063 else getattr(fn, "__doc__", None)
2064 ),
2065 long_msg=(
2066 self.long_msg
2067 if self.long_msg is not None
2068 else getattr(fn, "__doc__", None)
2069 ),
2070 )
2071 setattr(fn, "__yuio_help__", h)
2073 return fn
2076def help(
2077 *,
2078 group: str = "Actions",
2079 inline_msg: str | None = None,
2080 long_msg: str | None = None,
2081 msg: str | None = None,
2082) -> _Help:
2083 """
2084 Set options for how this callback should be displayed.
2086 This decorator controls automatic generation of help messages for a widget.
2088 :param group:
2089 title of a group that this action will appear in when the user opens
2090 a help menu. Groups appear in order of declaration of their first element.
2091 :param inline_msg:
2092 this parameter overrides a message in the inline help. By default,
2093 it will be taken from a docstring.
2094 :param long_msg:
2095 this parameter overrides a message in the help menu. By default,
2096 it will be taken from a docstring.
2097 :param msg:
2098 a shortcut parameter for setting both `inline_msg` and `long_msg`
2099 at the same time.
2101 Example::
2103 class MyWidget(Widget):
2104 NAVIGATE = "Navigate"
2106 @bind(Key.TAB)
2107 @help(group=NAVIGATE)
2108 def tab(self):
2109 \"""next item\"""
2110 ...
2112 @bind(Key.TAB, shift=True)
2113 @help(group=NAVIGATE)
2114 def shift_tab(self):
2115 \"""previous item\"""
2116 ...
2118 """
2120 if msg is not None and inline_msg is None:
2121 inline_msg = msg
2122 if msg is not None and long_msg is None:
2123 long_msg = msg
2125 return _Help(
2126 group,
2127 inline_msg,
2128 long_msg,
2129 )
2132ActionKey: _t.TypeAlias = Key | KeyboardEvent | str
2133"""
2134A single key associated with an action.
2135Can be either a hotkey or a string with an arbitrary description.
2136/
2137"""
2140ActionKeys: _t.TypeAlias = ActionKey | _t.Collection[ActionKey]
2141"""
2142A list of keys associated with an action.
2144"""
2147Action: _t.TypeAlias = str | tuple[ActionKeys, str]
2148"""
2149An action itself, i.e. a set of hotkeys and a description for them.
2151"""
2154@dataclass(frozen=True, slots=True)
2155class WidgetHelp:
2156 """
2157 Data for automatic help generation.
2159 .. warning::
2161 Do not modify contents of this class in-place. This might break layout
2162 caching in the widget rendering routine, which will cause displaying
2163 outdated help messages.
2165 Use the provided helpers to modify contents of this class.
2167 """
2169 inline_help: list[Action] = dataclasses.field(default_factory=list)
2170 """
2171 List of actions to show in the inline help.
2173 """
2175 groups: dict[str, list[Action]] = dataclasses.field(default_factory=dict)
2176 """
2177 Dict of group titles and actions to show in the help menu.
2179 """
2181 def with_action(
2182 self,
2183 *bindings: _Binding | ActionKey,
2184 group: str = "Actions",
2185 msg: str | None = None,
2186 inline_msg: str | None = None,
2187 long_msg: str | None = None,
2188 prepend: bool = False,
2189 prepend_group: bool = False,
2190 ) -> WidgetHelp:
2191 """
2192 Return a new :class:`WidgetHelp` that has an extra action.
2194 :param bindings:
2195 keys that trigger an action.
2196 :param group:
2197 title of a group that this action will appear in when the user opens
2198 a help menu. Groups appear in order of declaration of their first element.
2199 :param inline_msg:
2200 this parameter overrides a message in the inline help. By default,
2201 it will be taken from a docstring.
2202 :param long_msg:
2203 this parameter overrides a message in the help menu. By default,
2204 it will be taken from a docstring.
2205 :param msg:
2206 a shortcut parameter for setting both `inline_msg` and `long_msg`
2207 at the same time.
2208 :param prepend:
2209 if :data:`True`, action will be added to the beginning of its group.
2210 :param prepend_group:
2211 if :data:`True`, group will be added to the beginning of the help menu.
2213 """
2215 return WidgetHelp(self.inline_help.copy(), self.groups.copy()).__add_action(
2216 *bindings,
2217 group=group,
2218 inline_msg=inline_msg,
2219 long_msg=long_msg,
2220 prepend=prepend,
2221 prepend_group=prepend_group,
2222 msg=msg,
2223 )
2225 def merge(self, other: WidgetHelp, /) -> WidgetHelp:
2226 """
2227 Merge this help data with another one and return
2228 a new instance of :class:`WidgetHelp`.
2230 :param other:
2231 other :class:`WidgetHelp` for merging.
2233 """
2235 result = WidgetHelp(self.inline_help.copy(), self.groups.copy())
2236 result.inline_help.extend(other.inline_help)
2237 for title, actions in other.groups.items():
2238 result.groups[title] = result.groups.get(title, []) + actions
2239 return result
2241 def without_group(self, title: str, /) -> WidgetHelp:
2242 """
2243 Return a new :class:`WidgetHelp` that has a group with the given title removed.
2245 :param title:
2246 title to remove.
2248 """
2250 result = WidgetHelp(self.inline_help.copy(), self.groups.copy())
2251 result.groups.pop(title, None)
2252 return result
2254 def rename_group(self, title: str, new_title: str, /) -> WidgetHelp:
2255 """
2256 Return a new :class:`WidgetHelp` that has a group with the given title renamed.
2258 :param title:
2259 title to replace.
2260 :param new_title:
2261 new title.
2263 """
2265 result = WidgetHelp(self.inline_help.copy(), self.groups.copy())
2266 if group := result.groups.pop(title, None):
2267 result.groups[new_title] = result.groups.get(new_title, []) + group
2268 return result
2270 def __add_action(
2271 self,
2272 *bindings: _Binding | ActionKey,
2273 group: str,
2274 inline_msg: str | None,
2275 long_msg: str | None,
2276 prepend: bool,
2277 prepend_group: bool,
2278 msg: str | None,
2279 ) -> WidgetHelp:
2280 settings = help(
2281 group=group,
2282 inline_msg=inline_msg,
2283 long_msg=long_msg,
2284 msg=msg,
2285 )
2287 if settings.inline_msg:
2288 inline_keys: ActionKeys = [
2289 binding.event if isinstance(binding, _Binding) else binding
2290 for binding in bindings
2291 if not isinstance(binding, _Binding) or binding.show_in_inline_help
2292 ]
2293 if prepend:
2294 self.inline_help.insert(0, (inline_keys, settings.inline_msg))
2295 else:
2296 self.inline_help.append((inline_keys, settings.inline_msg))
2298 if settings.long_msg:
2299 menu_keys: ActionKeys = [
2300 binding.event if isinstance(binding, _Binding) else binding
2301 for binding in bindings
2302 if not isinstance(binding, _Binding) or binding.show_in_detailed_help
2303 ]
2304 if prepend_group and settings.group not in self.groups:
2305 # Re-create self.groups with a new group as a first element.
2306 groups = {settings.group: [], **self.groups}
2307 self.groups.clear()
2308 self.groups.update(groups)
2309 if prepend:
2310 self.groups[settings.group] = [
2311 (menu_keys, settings.long_msg)
2312 ] + self.groups.get(settings.group, [])
2313 else:
2314 self.groups[settings.group] = self.groups.get(settings.group, []) + [
2315 (menu_keys, settings.long_msg)
2316 ]
2318 return self
2321@_t.final
2322class VerticalLayoutBuilder(_t.Generic[T]):
2323 """
2324 Builder for :class:`VerticalLayout` that allows for precise control
2325 of keyboard events.
2327 By default, :class:`VerticalLayout` does not handle incoming keyboard events.
2328 However, you can create :class:`VerticalLayout` that forwards all keyboard events
2329 to a particular widget within the stack::
2331 widget = VerticalLayout.builder() \\
2332 .add(Line("Enter something:")) \\
2333 .add(Input(), receive_events=True) \\
2334 .build()
2336 result = widget.run(term, theme)
2338 """
2340 if TYPE_CHECKING:
2342 def __new__(cls) -> VerticalLayoutBuilder[_t.Never]: ...
2344 def __init__(self):
2345 self._widgets: list[Widget[_t.Any]] = []
2346 self._event_receiver: int | None = None
2348 @_t.overload
2349 def add(
2350 self, widget: Widget[_t.Any], /, *, receive_events: _t.Literal[False] = False
2351 ) -> VerticalLayoutBuilder[T]: ...
2353 @_t.overload
2354 def add(
2355 self, widget: Widget[U], /, *, receive_events: _t.Literal[True]
2356 ) -> VerticalLayoutBuilder[U]: ...
2358 def add(self, widget: Widget[_t.Any], /, *, receive_events=False) -> _t.Any:
2359 """
2360 Add a new widget to the bottom of the layout.
2362 If `receive_events` is `True`, all incoming events will be forwarded
2363 to the added widget. Only the latest widget added with ``receive_events=True``
2364 will receive events.
2366 This method does not mutate the builder, but instead returns a new one.
2367 Use it with method chaining.
2369 """
2371 other = VerticalLayoutBuilder()
2373 other._widgets = self._widgets.copy()
2374 other._event_receiver = self._event_receiver
2376 if isinstance(widget, VerticalLayout):
2377 if receive_events and widget._event_receiver is not None:
2378 other._event_receiver = len(other._widgets) + widget._event_receiver
2379 elif receive_events:
2380 other._event_receiver = None
2381 other._widgets.extend(widget._widgets)
2382 else:
2383 if receive_events:
2384 other._event_receiver = len(other._widgets)
2385 other._widgets.append(widget)
2387 return other
2389 def build(self) -> VerticalLayout[T]:
2390 layout = VerticalLayout()
2391 layout._widgets = self._widgets
2392 layout._event_receiver = self._event_receiver
2393 return _t.cast(VerticalLayout[T], layout)
2396class VerticalLayout(Widget[T], _t.Generic[T]):
2397 """
2398 Helper class for stacking widgets together.
2400 You can stack your widgets together, then calculate their layout
2401 and draw them all at once.
2403 You can use this class as a helper component inside your own widgets,
2404 or you can use it as a standalone widget. See :class:`~VerticalLayoutBuilder`
2405 for an example.
2407 .. automethod:: append
2409 .. automethod:: extend
2411 .. automethod:: event
2413 .. automethod:: layout
2415 .. automethod:: draw
2417 """
2419 if TYPE_CHECKING:
2421 def __new__(cls, *widgets: Widget[object]) -> VerticalLayout[_t.Never]: ...
2423 def __init__(self, *widgets: Widget[object]):
2424 self._widgets: list[Widget[object]] = list(widgets)
2425 self._event_receiver: int | None = None
2427 self.__layouts: list[tuple[int, int]] = []
2428 self.__min_h: int = 0
2429 self.__max_h: int = 0
2431 def append(self, widget: Widget[_t.Any], /):
2432 """
2433 Add a widget to the end of the stack.
2435 """
2437 if isinstance(widget, VerticalLayout):
2438 self._widgets.extend(widget._widgets)
2439 else:
2440 self._widgets.append(widget)
2442 def extend(self, widgets: _t.Iterable[Widget[_t.Any]], /):
2443 """
2444 Add multiple widgets to the end of the stack.
2446 """
2448 for widget in widgets:
2449 self.append(widget)
2451 def event(self, e: KeyboardEvent) -> Result[T] | None:
2452 """
2453 Dispatch event to the widget that was added with ``receive_events=True``.
2455 See :class:`~VerticalLayoutBuilder` for details.
2457 """
2459 if self._event_receiver is not None:
2460 return _t.cast(
2461 Result[T] | None, self._widgets[self._event_receiver].event(e)
2462 )
2464 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
2465 """
2466 Calculate layout of the entire stack.
2468 """
2470 self.__layouts = [widget.layout(rc) for widget in self._widgets]
2471 assert all(l[0] <= l[1] for l in self.__layouts), "incorrect layout"
2472 self.__min_h = sum(l[0] for l in self.__layouts)
2473 self.__max_h = sum(l[1] for l in self.__layouts)
2474 return self.__min_h, self.__max_h
2476 def draw(self, rc: RenderContext, /):
2477 """
2478 Draw the stack according to the calculated layout and available height.
2480 """
2482 assert len(self._widgets) == len(self.__layouts), (
2483 "you need to call `VerticalLayout.layout()` before `VerticalLayout.draw()`"
2484 )
2486 if rc.height <= self.__min_h:
2487 scale = 0.0
2488 elif rc.height >= self.__max_h:
2489 scale = 1.0
2490 else:
2491 scale = (rc.height - self.__min_h) / (self.__max_h - self.__min_h)
2493 y1 = 0.0
2494 for widget, (min_h, max_h) in zip(self._widgets, self.__layouts):
2495 y2 = y1 + min_h + scale * (max_h - min_h)
2497 iy1 = round(y1)
2498 iy2 = round(y2)
2500 with rc.frame(0, iy1, height=iy2 - iy1):
2501 widget.draw(rc)
2503 y1 = y2
2505 @property
2506 def help_data(self) -> WidgetHelp:
2507 if self._event_receiver is not None:
2508 return self._widgets[self._event_receiver].help_data
2509 else:
2510 return WidgetHelp()
2513class Empty(Widget[_t.Never]):
2514 """
2515 An empty widget with no size.
2517 """
2519 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
2520 return 0, 0
2522 def draw(self, rc: RenderContext, /):
2523 pass
2526class Line(Widget[_t.Never]):
2527 """
2528 A widget that prints a single line of text.
2530 """
2532 def __init__(
2533 self,
2534 text: yuio.string.Colorable,
2535 /,
2536 ):
2537 self.__text = text
2538 self.__colorized_text = None
2540 @property
2541 def text(self) -> yuio.string.Colorable:
2542 """
2543 Currently displayed text.
2545 """
2547 return self.__text
2549 @text.setter
2550 def text(self, text: yuio.string.Colorable, /):
2551 self.__text = text
2552 self.__colorized_text = None
2554 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
2555 return 1, 1
2557 def draw(self, rc: RenderContext, /):
2558 if self.__colorized_text is None:
2559 self.__colorized_text = rc.make_repr_context().str(self.__text)
2561 rc.write(self.__colorized_text)
2564class Text(Widget[_t.Never]):
2565 """
2566 A widget that prints wrapped text.
2568 """
2570 def __init__(
2571 self,
2572 text: yuio.string.Colorable,
2573 /,
2574 ):
2575 self.__text = text
2576 self.__wrapped_text: list[_ColorizedString] | None = None
2577 self.__wrapped_text_width: int = 0
2579 @property
2580 def text(self) -> yuio.string.Colorable:
2581 """
2582 Currently displayed text.
2584 """
2586 return self.__text
2588 @text.setter
2589 def text(self, text: yuio.string.Colorable, /):
2590 self.__text = text
2591 self.__wrapped_text = None
2592 self.__wrapped_text_width = 0
2594 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
2595 if self.__wrapped_text is None or self.__wrapped_text_width != rc.width:
2596 colorized_text = rc.make_repr_context().str(self.__text)
2597 self.__wrapped_text = colorized_text.wrap(
2598 rc.width,
2599 break_long_nowrap_words=True,
2600 )
2601 self.__wrapped_text_width = rc.width
2602 height = len(self.__wrapped_text)
2603 return height, height
2605 def draw(self, rc: RenderContext, /):
2606 assert self.__wrapped_text is not None
2607 rc.write_text(self.__wrapped_text)
2610_CHAR_NAMES = {
2611 "\u0000": "<NUL>",
2612 "\u0001": "<SOH>",
2613 "\u0002": "<STX>",
2614 "\u0003": "<ETX>",
2615 "\u0004": "<EOT>",
2616 "\u0005": "<ENQ>",
2617 "\u0006": "<ACK>",
2618 "\u0007": "\\a",
2619 "\u0008": "\\b",
2620 "\u0009": "\\t",
2621 "\u000b": "\\v",
2622 "\u000c": "\\f",
2623 "\u000d": "\\r",
2624 "\u000e": "<SO>",
2625 "\u000f": "<SI>",
2626 "\u0010": "<DLE>",
2627 "\u0011": "<DC1>",
2628 "\u0012": "<DC2>",
2629 "\u0013": "<DC3>",
2630 "\u0014": "<DC4>",
2631 "\u0015": "<NAK>",
2632 "\u0016": "<SYN>",
2633 "\u0017": "<ETB>",
2634 "\u0018": "<CAN>",
2635 "\u0019": "<EM>",
2636 "\u001a": "<SUB>",
2637 "\u001b": "<ESC>",
2638 "\u001c": "<FS>",
2639 "\u001d": "<GS>",
2640 "\u001e": "<RS>",
2641 "\u001f": "<US>",
2642 "\u007f": "<DEL>",
2643 "\u0080": "<PAD>",
2644 "\u0081": "<HOP>",
2645 "\u0082": "<BPH>",
2646 "\u0083": "<NBH>",
2647 "\u0084": "<IND>",
2648 "\u0085": "<NEL>",
2649 "\u0086": "<SSA>",
2650 "\u0087": "<ESA>",
2651 "\u0088": "<HTS>",
2652 "\u0089": "<HTJ>",
2653 "\u008a": "<VTS>",
2654 "\u008b": "<PLD>",
2655 "\u008c": "<PLU>",
2656 "\u008d": "<RI>",
2657 "\u008e": "<SS2>",
2658 "\u008f": "<SS3>",
2659 "\u0090": "<DCS>",
2660 "\u0091": "<PU1>",
2661 "\u0092": "<PU2>",
2662 "\u0093": "<STS>",
2663 "\u0094": "<CCH>",
2664 "\u0095": "<MW>",
2665 "\u0096": "<SPA>",
2666 "\u0097": "<EPA>",
2667 "\u0098": "<SOS>",
2668 "\u0099": "<SGCI>",
2669 "\u009a": "<SCI>",
2670 "\u009b": "<CSI>",
2671 "\u009c": "<ST>",
2672 "\u009d": "<OSC>",
2673 "\u009e": "<PM>",
2674 "\u009f": "<APC>",
2675 "\u00a0": "<NBSP>",
2676 "\u00ad": "<SHY>",
2677}
2679_ESC_RE = re.compile(r"([" + re.escape("".join(map(str, _CHAR_NAMES))) + "])")
2682def _replace_special_symbols(text: str, esc_color: _Color, n_color: _Color):
2683 raw: list[_Color | str] = [n_color]
2684 i = 0
2685 for match in _ESC_RE.finditer(text):
2686 if s := text[i : match.start()]:
2687 raw.append(s)
2688 raw.append(esc_color)
2689 raw.append(_Esc(_CHAR_NAMES[match.group(1)]))
2690 raw.append(n_color)
2691 i = match.end()
2692 if i < len(text):
2693 raw.append(text[i:])
2694 return raw
2697def _find_cursor_pos(text: list[_ColorizedString], text_width: int, offset: int):
2698 total_len = 0
2699 if not offset:
2700 return (0, 0)
2701 for y, line in enumerate(text):
2702 x = 0
2703 for part in line:
2704 if isinstance(part, _Esc):
2705 l = 1
2706 dx = len(part)
2707 elif isinstance(part, str):
2708 l = len(part)
2709 dx = _line_width(part)
2710 else:
2711 continue
2712 if total_len + l >= offset:
2713 if isinstance(part, _Esc):
2714 x += dx
2715 else:
2716 x += _line_width(part[: offset - total_len])
2717 if x >= text_width:
2718 return (0, y + 1)
2719 else:
2720 return (0 + x, y)
2721 break
2722 x += dx
2723 total_len += l
2724 total_len += len(line.explicit_newline)
2725 if total_len >= offset:
2726 return (0, y + 1)
2727 assert False
2730class Input(Widget[str]):
2731 """
2732 An input box.
2734 .. vhs:: /_tapes/widget_input.tape
2735 :alt: Demonstration of `Input` widget.
2736 :width: 480
2737 :height: 240
2739 .. note::
2741 :class:`Input` is not optimized to handle long texts or long editing sessions.
2742 It's best used to get relatively short answers from users
2743 with :func:`yuio.io.ask`. If you need to edit large text, especially multiline,
2744 consider using :func:`yuio.io.edit` instead.
2746 :param text:
2747 initial text.
2748 :param pos:
2749 initial cursor position, calculated as an offset from beginning of the text.
2750 Should be ``0 <= pos <= len(text)``.
2751 :param placeholder:
2752 placeholder text, shown when input is empty.
2753 :param decoration_path:
2754 path that will be used to look up decoration printed before the input box.
2755 :param allow_multiline:
2756 if `True`, :kbd:`Enter` key makes a new line, otherwise it accepts input.
2757 In this mode, newlines in pasted text are also preserved.
2758 :param allow_special_characters:
2759 If `True`, special characters like tabs or escape symbols are preserved
2760 and not replaced with whitespaces.
2762 """
2764 # Characters that count as word separators, used when navigating input text
2765 # via hotkeys.
2766 _WORD_SEPARATORS = string.punctuation + string.whitespace
2768 # Character that replaces newlines and unprintable characters when
2769 # `allow_multiline`/`allow_special_characters` is `False`.
2770 _UNPRINTABLE_SUBSTITUTOR = " "
2772 class _CheckpointType(enum.Enum):
2773 """
2774 Types of entries in the history buffer.
2776 """
2778 USR = enum.auto()
2779 """
2780 User-initiated checkpoint.
2782 """
2784 SYM = enum.auto()
2785 """
2786 Checkpoint before a symbol was inserted.
2788 """
2790 SEP = enum.auto()
2791 """
2792 Checkpoint before a space was inserted.
2794 """
2796 DEL = enum.auto()
2797 """
2798 Checkpoint before something was deleted.
2800 """
2802 def __init__(
2803 self,
2804 *,
2805 text: str = "",
2806 pos: int | None = None,
2807 placeholder: str = "",
2808 decoration_path: str = "menu/input/decoration",
2809 allow_multiline: bool = False,
2810 allow_special_characters: bool = False,
2811 ):
2812 self.__text: str = text
2813 self.__pos: int = len(text) if pos is None else max(0, min(pos, len(text)))
2814 self.__placeholder: str = placeholder
2815 self.__decoration_path: str = decoration_path
2816 self.__allow_multiline: bool = allow_multiline
2817 self.__allow_special_characters: bool = allow_special_characters
2819 self.__wrapped_text_width: int = 0
2820 self.__wrapped_text: list[_ColorizedString] | None = None
2821 self.__pos_after_wrap: tuple[int, int] | None = None
2823 # We keep track of edit history by saving input text
2824 # and cursor position in this list.
2825 self.__history: list[tuple[str, int, Input._CheckpointType]] = [
2826 (self.__text, self.__pos, Input._CheckpointType.SYM)
2827 ]
2828 # Sometimes we don't record all actions. For example, entering multiple spaces
2829 # one after the other, or entering multiple symbols one after the other,
2830 # will only generate one checkpoint. We keep track of how many items
2831 # were skipped this way since the last checkpoint.
2832 self.__history_skipped_actions = 0
2833 # After we move a cursor, the logic with skipping checkpoints
2834 # should be momentarily disabled. This avoids inconsistencies in situations
2835 # where we've typed a word, moved the cursor, then typed another word.
2836 self.__require_checkpoint: bool = False
2838 # All delete operations save deleted text here. Pressing `C-y` pastes deleted
2839 # text at the position of the cursor.
2840 self.__yanked_text: str = ""
2842 self.__err_region: tuple[int, int] | None = None
2844 @property
2845 def text(self) -> str:
2846 """
2847 Current text in the input box.
2849 """
2850 return self.__text
2852 @text.setter
2853 def text(self, text: str, /):
2854 self.__text = text
2855 self.__wrapped_text = None
2856 if self.pos > len(text):
2857 self.pos = len(text)
2858 self.__err_region = None
2860 @property
2861 def pos(self) -> int:
2862 """
2863 Current cursor position, measured in code points before the cursor.
2865 That is, if the text is `"quick brown fox"` with cursor right before the word
2866 "brown", then :attr:`~Input.pos` is equal to `len("quick ")`.
2868 """
2869 return self.__pos
2871 @pos.setter
2872 def pos(self, pos: int, /):
2873 self.__pos = max(0, min(pos, len(self.__text)))
2874 self.__pos_after_wrap = None
2876 @property
2877 def err_region(self) -> tuple[int, int] | None:
2878 return self.__err_region
2880 @err_region.setter
2881 def err_region(self, err_region: tuple[int, int] | None, /):
2882 self.__err_region = err_region
2883 self.__wrapped_text = None
2885 def checkpoint(self):
2886 """
2887 Manually create an entry in the history buffer.
2889 """
2890 self.__history.append((self.text, self.pos, Input._CheckpointType.USR))
2891 self.__history_skipped_actions = 0
2893 def restore_checkpoint(self):
2894 """
2895 Restore the last manually created checkpoint.
2897 """
2898 if self.__history[-1][2] is Input._CheckpointType.USR:
2899 self.undo()
2901 def _internal_checkpoint(self, action: Input._CheckpointType, text: str, pos: int):
2902 prev_text, prev_pos, prev_action = self.__history[-1]
2904 if action == prev_action and not self.__require_checkpoint:
2905 # If we're repeating the same action, don't create a checkpoint.
2906 # I.e. if we're typing a word, we don't want to create checkpoints
2907 # for every letter.
2908 self.__history_skipped_actions += 1
2909 return
2911 prev_skipped_actions = self.__history_skipped_actions
2912 self.__history_skipped_actions = 0
2914 if (
2915 action == Input._CheckpointType.SYM
2916 and prev_action == Input._CheckpointType.SEP
2917 and prev_skipped_actions == 0
2918 and not self.__require_checkpoint
2919 ):
2920 # If we're inserting a symbol after we've typed a single space,
2921 # we only want one checkpoint for both space and symbols.
2922 # Thus, we simply change the type of the last checkpoint.
2923 self.__history[-1] = prev_text, prev_pos, action
2924 return
2926 if text == prev_text and pos == prev_pos:
2927 # This could happen when user presses backspace while the cursor
2928 # is at the text's beginning. We don't want to create
2929 # a checkpoint for this.
2930 return
2932 self.__history.append((text, pos, action))
2933 if len(self.__history) > 50:
2934 self.__history.pop(0)
2936 self.__require_checkpoint = False
2938 @bind(Key.ENTER)
2939 def enter(self) -> Result[str] | None:
2940 if self.__allow_multiline:
2941 self.insert("\n")
2942 else:
2943 return self.alt_enter()
2945 @bind(Key.ENTER, alt=True)
2946 @bind("d", ctrl=True)
2947 def alt_enter(self) -> Result[str] | None:
2948 return Result(self.text)
2950 _NAVIGATE = "Navigate"
2952 @bind(Key.ARROW_UP)
2953 @bind("p", ctrl=True)
2954 @help(group=_NAVIGATE)
2955 def up(self, /, *, checkpoint: bool = True):
2956 """up"""
2957 pos = self.pos
2958 self.home()
2959 if self.pos:
2960 width = _line_width(self.text[self.pos : pos])
2962 self.left()
2963 self.home()
2965 pos = self.pos
2966 text = self.text
2967 cur_width = 0
2968 while pos < len(text) and text[pos] != "\n":
2969 if cur_width >= width:
2970 break
2971 cur_width += _line_width(text[pos])
2972 pos += 1
2974 self.pos = pos
2976 self.__require_checkpoint |= checkpoint
2978 @bind(Key.ARROW_DOWN)
2979 @bind("n", ctrl=True)
2980 @help(group=_NAVIGATE)
2981 def down(self, /, *, checkpoint: bool = True):
2982 """down"""
2983 pos = self.pos
2984 self.home()
2985 width = _line_width(self.text[self.pos : pos])
2986 self.end()
2988 if self.pos < len(self.text):
2989 self.right()
2991 pos = self.pos
2992 text = self.text
2993 cur_width = 0
2994 while pos < len(text) and text[pos] != "\n":
2995 if cur_width >= width:
2996 break
2997 cur_width += _line_width(text[pos])
2998 pos += 1
3000 self.pos = pos
3002 self.__require_checkpoint |= checkpoint
3004 @bind(Key.ARROW_LEFT)
3005 @bind("b", ctrl=True)
3006 @help(group=_NAVIGATE)
3007 def left(self, /, *, checkpoint: bool = True):
3008 """left"""
3009 self.pos -= 1
3010 self.__require_checkpoint |= checkpoint
3012 @bind(Key.ARROW_RIGHT)
3013 @bind("f", ctrl=True)
3014 @help(group=_NAVIGATE)
3015 def right(self, /, *, checkpoint: bool = True):
3016 """right"""
3017 self.pos += 1
3018 self.__require_checkpoint |= checkpoint
3020 @bind(Key.ARROW_LEFT, alt=True)
3021 @bind("b", alt=True)
3022 @help(group=_NAVIGATE)
3023 def left_word(self, /, *, checkpoint: bool = True):
3024 """left one word"""
3025 pos = self.pos
3026 text = self.text
3027 if pos:
3028 pos -= 1
3029 while pos and text[pos] in self._WORD_SEPARATORS and text[pos - 1] != "\n":
3030 pos -= 1
3031 while pos and text[pos - 1] not in self._WORD_SEPARATORS:
3032 pos -= 1
3033 self.pos = pos
3034 self.__require_checkpoint |= checkpoint
3036 @bind(Key.ARROW_RIGHT, alt=True)
3037 @bind("f", alt=True)
3038 @help(group=_NAVIGATE)
3039 def right_word(self, /, *, checkpoint: bool = True):
3040 """right one word"""
3041 pos = self.pos
3042 text = self.text
3043 if pos < len(text) and text[pos] == "\n":
3044 pos += 1
3045 while (
3046 pos < len(text) and text[pos] in self._WORD_SEPARATORS and text[pos] != "\n"
3047 ):
3048 pos += 1
3049 while pos < len(text) and text[pos] not in self._WORD_SEPARATORS:
3050 pos += 1
3051 self.pos = pos
3052 self.__require_checkpoint |= checkpoint
3054 @bind(Key.HOME)
3055 @bind("a", ctrl=True)
3056 @help(group=_NAVIGATE)
3057 def home(self, /, *, checkpoint: bool = True):
3058 """to line start"""
3059 self.pos = self.text.rfind("\n", 0, self.pos) + 1
3060 self.__require_checkpoint |= checkpoint
3062 @bind(Key.END)
3063 @bind("e", ctrl=True)
3064 @help(group=_NAVIGATE)
3065 def end(self, /, *, checkpoint: bool = True):
3066 """to line end"""
3067 next_nl = self.text.find("\n", self.pos)
3068 if next_nl == -1:
3069 self.pos = len(self.text)
3070 else:
3071 self.pos = next_nl
3072 self.__require_checkpoint |= checkpoint
3074 @bind("g", ctrl=True)
3075 def go_to_err(self, /, *, checkpoint: bool = True):
3076 if not self.__err_region:
3077 return
3078 if self.pos == self.__err_region[1]:
3079 self.pos = self.__err_region[0]
3080 else:
3081 self.pos = self.__err_region[1]
3082 self.__require_checkpoint |= checkpoint
3084 _MODIFY = "Modify"
3086 @bind(Key.BACKSPACE)
3087 @bind("h", ctrl=True)
3088 @help(group=_MODIFY)
3089 def backspace(self):
3090 """backspace"""
3091 prev_pos = self.pos
3092 self.left(checkpoint=False)
3093 if prev_pos != self.pos:
3094 self._internal_checkpoint(Input._CheckpointType.DEL, self.text, prev_pos)
3095 self.text = self.text[: self.pos] + self.text[prev_pos:]
3096 else:
3097 self._bell()
3099 @bind(Key.DELETE)
3100 @help(group=_MODIFY)
3101 def delete(self):
3102 """delete"""
3103 prev_pos = self.pos
3104 self.right(checkpoint=False)
3105 if prev_pos != self.pos:
3106 self._internal_checkpoint(Input._CheckpointType.DEL, self.text, prev_pos)
3107 self.text = self.text[:prev_pos] + self.text[self.pos :]
3108 self.pos = prev_pos
3109 else:
3110 self._bell()
3112 @bind(Key.BACKSPACE, alt=True)
3113 @bind("w", ctrl=True)
3114 @help(group=_MODIFY)
3115 def backspace_word(self):
3116 """backspace one word"""
3117 prev_pos = self.pos
3118 self.left_word(checkpoint=False)
3119 if prev_pos != self.pos:
3120 self._internal_checkpoint(Input._CheckpointType.DEL, self.text, prev_pos)
3121 self.__yanked_text = self.text[self.pos : prev_pos]
3122 self.text = self.text[: self.pos] + self.text[prev_pos:]
3123 else:
3124 self._bell()
3126 @bind(Key.DELETE, alt=True)
3127 @bind("d", alt=True)
3128 @help(group=_MODIFY)
3129 def delete_word(self):
3130 """delete one word"""
3131 prev_pos = self.pos
3132 self.right_word(checkpoint=False)
3133 if prev_pos != self.pos:
3134 self._internal_checkpoint(Input._CheckpointType.DEL, self.text, prev_pos)
3135 self.__yanked_text = self.text[prev_pos : self.pos]
3136 self.text = self.text[:prev_pos] + self.text[self.pos :]
3137 self.pos = prev_pos
3138 else:
3139 self._bell()
3141 @bind("u", ctrl=True)
3142 @help(group=_MODIFY)
3143 def backspace_home(self):
3144 """backspace to the beginning of a line"""
3145 prev_pos = self.pos
3146 self.home(checkpoint=False)
3147 if prev_pos != self.pos:
3148 self._internal_checkpoint(Input._CheckpointType.DEL, self.text, prev_pos)
3149 self.__yanked_text = self.text[self.pos : prev_pos]
3150 self.text = self.text[: self.pos] + self.text[prev_pos:]
3151 else:
3152 self._bell()
3154 @bind("k", ctrl=True)
3155 @help(group=_MODIFY)
3156 def delete_end(self):
3157 """delete to the ending of a line"""
3158 prev_pos = self.pos
3159 self.end(checkpoint=False)
3160 if prev_pos != self.pos:
3161 self._internal_checkpoint(Input._CheckpointType.DEL, self.text, prev_pos)
3162 self.__yanked_text = self.text[prev_pos : self.pos]
3163 self.text = self.text[:prev_pos] + self.text[self.pos :]
3164 self.pos = prev_pos
3165 else:
3166 self._bell()
3168 @bind("y", ctrl=True)
3169 @help(group=_MODIFY)
3170 def yank(self):
3171 """yank (paste the last deleted text)"""
3172 if self.__yanked_text:
3173 self.__require_checkpoint = True
3174 self.insert(self.__yanked_text)
3175 else:
3176 self._bell()
3178 # the actual shortcut is `C-7`, the rest produce the same code...
3179 @bind("7", ctrl=True, show_in_detailed_help=False)
3180 @bind("-", ctrl=True, shift=True, show_in_detailed_help=False)
3181 @bind("?", ctrl=True, show_in_detailed_help=False)
3182 @bind("-", ctrl=True)
3183 @bind("z", ctrl=True)
3184 @help(group=_MODIFY)
3185 def undo(self):
3186 """undo"""
3187 self.text, self.pos, _ = self.__history[-1]
3188 if len(self.__history) > 1:
3189 self.__history.pop()
3190 else:
3191 self._bell()
3193 def default_event_handler(self, e: KeyboardEvent):
3194 if e.key is Key.PASTE:
3195 self.__require_checkpoint = True
3196 s = e.paste_str or ""
3197 if self.__allow_special_characters and self.__allow_multiline:
3198 pass
3199 elif self.__allow_multiline:
3200 s = re.sub(_UNPRINTABLE_RE_WITHOUT_NL, self._UNPRINTABLE_SUBSTITUTOR, s)
3201 elif self.__allow_special_characters:
3202 s = s.replace("\n", self._UNPRINTABLE_SUBSTITUTOR)
3203 else:
3204 s = re.sub(_UNPRINTABLE_RE, self._UNPRINTABLE_SUBSTITUTOR, s)
3205 self.insert(s)
3206 elif e.key is Key.TAB:
3207 if self.__allow_special_characters:
3208 self.insert("\t")
3209 else:
3210 self.insert(self._UNPRINTABLE_SUBSTITUTOR)
3211 elif isinstance(e.key, str) and not e.alt and not e.ctrl:
3212 self.insert(e.key)
3214 def insert(self, s: str):
3215 if not s:
3216 return
3218 self._internal_checkpoint(
3219 (
3220 Input._CheckpointType.SEP
3221 if s in self._WORD_SEPARATORS
3222 else Input._CheckpointType.SYM
3223 ),
3224 self.text,
3225 self.pos,
3226 )
3228 self.text = self.text[: self.pos] + s + self.text[self.pos :]
3229 self.pos += len(s)
3231 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
3232 decoration = rc.get_msg_decoration(self.__decoration_path)
3233 decoration_width = _line_width(decoration)
3234 text_width = rc.width - decoration_width
3235 if text_width < 2:
3236 self.__wrapped_text_width = max(text_width, 0)
3237 self.__wrapped_text = None
3238 self.__pos_after_wrap = None
3239 return 0, 0
3241 if self.__wrapped_text is None or self.__wrapped_text_width != text_width:
3242 self.__wrapped_text_width = text_width
3244 # Note: don't use wrap with overflow here
3245 # or we won't be able to find the cursor position!
3246 if self.__text:
3247 self.__wrapped_text = self._prepare_display_text(
3248 self.__text,
3249 rc.theme.get_color("menu/text/esc:input"),
3250 rc.theme.get_color("menu/text:input"),
3251 rc.theme.get_color("menu/text/error:input"),
3252 ).wrap(
3253 text_width,
3254 preserve_spaces=True,
3255 break_long_nowrap_words=True,
3256 )
3257 self.__pos_after_wrap = None
3258 else:
3259 self.__wrapped_text = _ColorizedString(
3260 rc.theme.get_color("menu/text/placeholder:input"),
3261 self.__placeholder,
3262 ).wrap(
3263 text_width,
3264 preserve_newlines=False,
3265 break_long_nowrap_words=True,
3266 )
3267 self.__pos_after_wrap = (decoration_width, 0)
3269 if self.__pos_after_wrap is None:
3270 x, y = _find_cursor_pos(self.__wrapped_text, text_width, self.__pos)
3271 self.__pos_after_wrap = (decoration_width + x, y)
3273 height = max(len(self.__wrapped_text), self.__pos_after_wrap[1] + 1)
3274 return height, height
3276 def draw(self, rc: RenderContext, /):
3277 if decoration := rc.get_msg_decoration(self.__decoration_path):
3278 rc.set_color_path("menu/decoration:input")
3279 rc.write(decoration)
3281 if self.__wrapped_text is not None:
3282 rc.write_text(self.__wrapped_text)
3284 if self.__pos_after_wrap is not None:
3285 rc.set_final_pos(*self.__pos_after_wrap)
3287 def _prepare_display_text(
3288 self, text: str, esc_color: _Color, n_color: _Color, err_color: _Color
3289 ) -> _ColorizedString:
3290 res = _ColorizedString()
3291 if self.__err_region:
3292 start, end = self.__err_region
3293 res += _replace_special_symbols(text[:start], esc_color, n_color)
3294 res += _replace_special_symbols(text[start:end], esc_color, err_color)
3295 res += _replace_special_symbols(text[end:], esc_color, n_color)
3296 else:
3297 res += _replace_special_symbols(text, esc_color, n_color)
3298 return res
3300 @property
3301 def help_data(self) -> WidgetHelp:
3302 help_data = super().help_data
3304 if self.__allow_multiline:
3305 help_data = help_data.with_action(
3306 KeyboardEvent(Key.ENTER, alt=True),
3307 KeyboardEvent("d", ctrl=True),
3308 msg="accept",
3309 prepend=True,
3310 ).with_action(
3311 KeyboardEvent(Key.ENTER),
3312 group=self._MODIFY,
3313 long_msg="new line",
3314 prepend=True,
3315 )
3317 if self.__err_region:
3318 help_data = help_data.with_action(
3319 KeyboardEvent("g", ctrl=True),
3320 group=self._NAVIGATE,
3321 msg="go to error",
3322 prepend=True,
3323 )
3325 return help_data
3328class SecretInput(Input):
3329 """
3330 An input box that shows stars instead of entered symbols.
3332 :param text:
3333 initial text.
3334 :param pos:
3335 initial cursor position, calculated as an offset from beginning of the text.
3336 Should be ``0 <= pos <= len(text)``.
3337 :param placeholder:
3338 placeholder text, shown when input is empty.
3339 :param decoration:
3340 decoration printed before the input box.
3342 """
3344 _WORD_SEPARATORS = ""
3345 _UNPRINTABLE_SUBSTITUTOR = ""
3347 def __init__(
3348 self,
3349 *,
3350 text: str = "",
3351 pos: int | None = None,
3352 placeholder: str = "",
3353 decoration_path: str = "menu/input/decoration",
3354 ):
3355 super().__init__(
3356 text=text,
3357 pos=pos,
3358 placeholder=placeholder,
3359 decoration_path=decoration_path,
3360 allow_multiline=False,
3361 allow_special_characters=False,
3362 )
3364 def _prepare_display_text(
3365 self, text: str, esc_color: _Color, n_color: _Color, err_color: _Color
3366 ) -> _ColorizedString:
3367 return _ColorizedString("*" * len(text))
3370@dataclass(slots=True)
3371class Option(_t.Generic[T_co]):
3372 """
3373 An option for the :class:`Grid` and :class:`Choice` widgets.
3375 """
3377 def __post_init__(self):
3378 if self.color_tag is None:
3379 object.__setattr__(self, "color_tag", "none")
3381 value: T_co
3382 """
3383 Option's value that will be returned from widget.
3385 """
3387 display_text: str
3388 """
3389 What should be displayed in the autocomplete list.
3391 """
3393 display_text_prefix: str = dataclasses.field(default="", kw_only=True)
3394 """
3395 Prefix that will be displayed before :attr:`~Option.display_text`.
3397 """
3399 display_text_suffix: str = dataclasses.field(default="", kw_only=True)
3400 """
3401 Suffix that will be displayed after :attr:`~Option.display_text`.
3403 """
3405 comment: str | None = dataclasses.field(default=None, kw_only=True)
3406 """
3407 Option's short comment.
3409 """
3411 color_tag: str | None = dataclasses.field(default=None, kw_only=True)
3412 """
3413 Option's color tag.
3415 This color tag will be used to display option.
3416 Specifically, color for the option will be looked up py path
3417 :samp:``menu/{element}:choice/{status}/{color_tag}``.
3419 """
3421 selected: bool = dataclasses.field(default=False, kw_only=True)
3422 """
3423 For multi-choice widgets, whether this option is chosen or not.
3425 """
3428class Grid(Widget[_t.Never], _t.Generic[T]):
3429 """
3430 A helper widget that shows up in :class:`Choice` and :class:`InputWithCompletion`.
3432 .. note::
3434 On its own, :class:`Grid` doesn't return when you press :kbd:`Enter`
3435 or :kbd:`Ctrl+D`. It's meant to be used as part of another widget.
3437 :param options:
3438 list of options displayed in the grid.
3439 :param decoration:
3440 decoration printed before the selected option.
3441 :param default_index:
3442 index of the initially selected option.
3443 :param min_rows:
3444 minimum number of rows that the grid should occupy before it starts
3445 splitting options into columns. This option is ignored if there isn't enough
3446 space on the screen.
3448 """
3450 def __init__(
3451 self,
3452 options: list[Option[T]],
3453 /,
3454 *,
3455 active_item_decoration_path: str = "menu/choice/decoration/active_item",
3456 selected_item_decoration_path: str = "",
3457 deselected_item_decoration_path: str = "",
3458 default_index: int | None = 0,
3459 min_rows: int | None = 5,
3460 ):
3461 self.__options: list[Option[T]]
3462 self.__index: int | None
3463 self.__min_rows: int | None = min_rows
3464 self.__max_column_width: int | None
3465 self.__column_width: int
3466 self.__num_rows: int
3467 self.__num_columns: int
3469 self.__active_item_decoration_path = active_item_decoration_path
3470 self.__selected_item_decoration_path = selected_item_decoration_path
3471 self.__deselected_item_decoration_path = deselected_item_decoration_path
3473 self.set_options(options)
3474 self.index = default_index
3476 @property
3477 def _page_size(self) -> int:
3478 return self.__num_rows * self.__num_columns
3480 @property
3481 def index(self) -> int | None:
3482 """
3483 Index of the currently selected option.
3485 """
3487 return self.__index
3489 @index.setter
3490 def index(self, idx: int | None):
3491 if idx is None or not self.__options:
3492 self.__index = None
3493 elif self.__options:
3494 self.__index = idx % len(self.__options)
3496 def get_option(self) -> Option[T] | None:
3497 """
3498 Get the currently selected option,
3499 or `None` if there are no options selected.
3501 """
3503 if self.__options and self.__index is not None:
3504 return self.__options[self.__index]
3506 def has_options(self) -> bool:
3507 """
3508 Return :data:`True` if the options list is not empty.
3510 """
3512 return bool(self.__options)
3514 def get_options(self) -> _t.Sequence[Option[T]]:
3515 """
3516 Get all options.
3518 """
3520 return self.__options
3522 def set_options(
3523 self,
3524 options: list[Option[T]],
3525 /,
3526 default_index: int | None = 0,
3527 ):
3528 """
3529 Set a new list of options.
3531 """
3533 self.__options = options
3534 self.__max_column_width = None
3535 self.index = default_index
3537 _NAVIGATE = "Navigate"
3539 @bind(Key.ARROW_UP)
3540 @bind(Key.TAB, shift=True)
3541 @help(group=_NAVIGATE)
3542 def prev_item(self):
3543 """previous item"""
3544 if not self.__options:
3545 return
3547 if self.__index is None:
3548 self.__index = 0
3549 else:
3550 self.__index = (self.__index - 1) % len(self.__options)
3552 @bind(Key.ARROW_DOWN)
3553 @bind(Key.TAB)
3554 @help(group=_NAVIGATE)
3555 def next_item(self):
3556 """next item"""
3557 if not self.__options:
3558 return
3560 if self.__index is None:
3561 self.__index = 0
3562 else:
3563 self.__index = (self.__index + 1) % len(self.__options)
3565 @bind(Key.ARROW_LEFT)
3566 @help(group=_NAVIGATE)
3567 def prev_column(self):
3568 """previous column"""
3569 if not self.__options:
3570 return
3572 if self.__index is None:
3573 self.__index = 0
3574 else:
3575 total_grid_capacity = self.__num_rows * math.ceil(
3576 len(self.__options) / self.__num_rows
3577 )
3579 self.__index = (self.__index - self.__num_rows) % total_grid_capacity
3580 if self.__index >= len(self.__options):
3581 self.__index = len(self.__options) - 1
3583 @bind(Key.ARROW_RIGHT)
3584 @help(group=_NAVIGATE)
3585 def next_column(self):
3586 """next column"""
3587 if not self.__options:
3588 return
3590 if self.__index is None:
3591 self.__index = 0
3592 else:
3593 total_grid_capacity = self.__num_rows * math.ceil(
3594 len(self.__options) / self.__num_rows
3595 )
3597 self.__index = (self.__index + self.__num_rows) % total_grid_capacity
3598 if self.__index >= len(self.__options):
3599 self.__index = len(self.__options) - 1
3601 @bind(Key.PAGE_UP)
3602 @help(group=_NAVIGATE)
3603 def prev_page(self):
3604 """previous page"""
3605 if not self.__options:
3606 return
3608 if self.__index is None:
3609 self.__index = 0
3610 else:
3611 self.__index -= self.__index % self._page_size
3612 self.__index -= 1
3613 if self.__index < 0:
3614 self.__index = len(self.__options) - 1
3616 @bind(Key.PAGE_DOWN)
3617 @help(group=_NAVIGATE)
3618 def next_page(self):
3619 """next page"""
3620 if not self.__options:
3621 return
3623 if self.__index is None:
3624 self.__index = 0
3625 else:
3626 self.__index -= self.__index % self._page_size
3627 self.__index += self._page_size
3628 if self.__index > len(self.__options):
3629 self.__index = 0
3631 @bind(Key.HOME)
3632 @help(group=_NAVIGATE)
3633 def home(self):
3634 """first page"""
3635 if not self.__options:
3636 return
3638 if self.__index is None:
3639 self.__index = 0
3640 else:
3641 self.__index = 0
3643 @bind(Key.END)
3644 @help(group=_NAVIGATE)
3645 def end(self):
3646 """last page"""
3647 if not self.__options:
3648 return
3650 if self.__index is None:
3651 self.__index = 0
3652 else:
3653 self.__index = len(self.__options) - 1
3655 def default_event_handler(self, e: KeyboardEvent):
3656 if isinstance(e.key, str):
3657 key = e.key.casefold()
3658 if (
3659 self.__options
3660 and self.__index is not None
3661 and self.__options[self.__index].display_text.casefold().startswith(key)
3662 ):
3663 start = self.__index + 1
3664 else:
3665 start = 0
3666 for i in range(start, start + len(self.__options)):
3667 index = i % len(self.__options)
3668 if self.__options[index].display_text.casefold().startswith(key):
3669 self.__index = index
3670 break
3672 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
3673 active_item_decoration = rc.get_msg_decoration(
3674 self.__active_item_decoration_path
3675 )
3676 selected_item_decoration = rc.get_msg_decoration(
3677 self.__selected_item_decoration_path
3678 )
3679 deselected_item_decoration = rc.get_msg_decoration(
3680 self.__deselected_item_decoration_path
3681 )
3683 decoration_width = _line_width(active_item_decoration) + max(
3684 _line_width(selected_item_decoration),
3685 _line_width(deselected_item_decoration),
3686 )
3688 if self.__max_column_width is None:
3689 self.__max_column_width = max(
3690 0,
3691 _MIN_COLUMN_WIDTH,
3692 *(
3693 self._get_option_width(option, decoration_width)
3694 for option in self.__options
3695 ),
3696 )
3697 self.__column_width = max(1, min(self.__max_column_width, rc.width))
3698 self.__num_columns = num_columns = max(1, rc.width // self.__column_width)
3699 self.__num_rows = max(
3700 1,
3701 min(self.__min_rows or 1, len(self.__options)),
3702 min(math.ceil(len(self.__options) / num_columns), rc.height),
3703 )
3705 additional_space = 0
3706 pages = math.ceil(len(self.__options) / self._page_size)
3707 if pages > 1:
3708 additional_space = 1
3710 return 1 + additional_space, self.__num_rows + additional_space
3712 def draw(self, rc: RenderContext, /):
3713 if not self.__options:
3714 rc.set_color_path("menu/decoration:choice")
3715 rc.write("No options to display")
3716 return
3718 # Adjust for the actual available height.
3719 self.__num_rows = max(1, min(self.__num_rows, rc.height))
3720 pages = math.ceil(len(self.__options) / self._page_size)
3721 if pages > 1 and self.__num_rows > 1:
3722 self.__num_rows -= 1
3724 column_width = self.__column_width
3725 num_rows = self.__num_rows
3726 page_size = self._page_size
3728 page_start_index = 0
3729 if page_size and self.__index is not None:
3730 page_start_index = self.__index - self.__index % page_size
3731 page = self.__options[page_start_index : page_start_index + page_size]
3733 if self.__num_columns > 1:
3734 available_column_width = column_width - _SPACE_BETWEEN_COLUMNS
3735 else:
3736 available_column_width = column_width
3738 for i, option in enumerate(page):
3739 x = i // num_rows
3740 y = i % num_rows
3742 rc.set_pos(x * column_width, y)
3744 index = i + page_start_index
3745 is_current = index == self.__index
3746 self._render_option(rc, available_column_width, option, is_current)
3748 pages = math.ceil(len(self.__options) / self._page_size)
3749 if pages > 1:
3750 page = (self.index or 0) // self._page_size + 1
3751 rc.set_pos(0, num_rows)
3752 rc.set_color_path("menu/text:choice/status_line")
3753 rc.write("Page ")
3754 rc.set_color_path("menu/text:choice/status_line/number")
3755 rc.write(f"{page}")
3756 rc.set_color_path("menu/text:choice/status_line")
3757 rc.write(" of ")
3758 rc.set_color_path("menu/text:choice/status_line/number")
3759 rc.write(f"{pages}")
3761 def _get_option_width(self, option: Option[object], decoration_width: int):
3762 return (
3763 _SPACE_BETWEEN_COLUMNS
3764 + decoration_width
3765 + (_line_width(option.display_text_prefix))
3766 + (_line_width(option.display_text))
3767 + (_line_width(option.display_text_suffix))
3768 + (3 if option.comment else 0)
3769 + (_line_width(option.comment) if option.comment else 0)
3770 )
3772 def _render_option(
3773 self,
3774 rc: RenderContext,
3775 width: int,
3776 option: Option[object],
3777 is_active: bool,
3778 ):
3779 active_item_decoration = rc.get_msg_decoration(
3780 self.__active_item_decoration_path
3781 )
3782 active_item_decoration_width = _line_width(active_item_decoration)
3783 selected_item_decoration = rc.get_msg_decoration(
3784 self.__selected_item_decoration_path
3785 )
3786 selected_item_decoration_width = _line_width(selected_item_decoration)
3787 deselected_item_decoration = rc.get_msg_decoration(
3788 self.__deselected_item_decoration_path
3789 )
3790 deselected_item_decoration_width = _line_width(deselected_item_decoration)
3791 item_selection_decoration_width = max(
3792 selected_item_decoration_width, deselected_item_decoration_width
3793 )
3795 left_prefix_width = _line_width(option.display_text_prefix)
3796 left_main_width = _line_width(option.display_text)
3797 left_suffix_width = _line_width(option.display_text_suffix)
3798 left_width = left_prefix_width + left_main_width + left_suffix_width
3799 left_decoration_width = (
3800 active_item_decoration_width + item_selection_decoration_width
3801 )
3803 right = option.comment or ""
3804 right_width = _line_width(right)
3805 right_decoration_width = 3 if right else 0
3807 total_width = (
3808 left_decoration_width + left_width + right_decoration_width + right_width
3809 )
3811 if total_width > width:
3812 right_width = max(right_width - (total_width - width), 0)
3813 if right_width == 0:
3814 right = ""
3815 right_decoration_width = 0
3816 total_width = (
3817 left_decoration_width
3818 + left_width
3819 + right_decoration_width
3820 + right_width
3821 )
3823 if total_width > width:
3824 left_width = max(left_width - (total_width - width), 3)
3825 total_width = left_decoration_width + left_width
3827 if is_active:
3828 status_tag = "active"
3829 else:
3830 status_tag = "normal"
3832 if option.selected:
3833 color_tag = "selected"
3834 else:
3835 color_tag = option.color_tag
3837 if is_active:
3838 rc.set_color_path(f"menu/decoration:choice/{status_tag}/{color_tag}")
3839 rc.write(active_item_decoration)
3840 else:
3841 rc.set_color_path(f"menu/text:choice/{status_tag}/{color_tag}")
3842 rc.write(" " * active_item_decoration_width)
3844 if option.selected:
3845 rc.set_color_path(f"menu/decoration:choice/{status_tag}/{color_tag}")
3846 rc.write(selected_item_decoration)
3847 rc.write(
3848 " " * (item_selection_decoration_width - selected_item_decoration_width)
3849 )
3850 else:
3851 rc.set_color_path(f"menu/decoration:choice/{status_tag}/{color_tag}")
3852 rc.write(deselected_item_decoration)
3853 rc.write(
3854 " "
3855 * (item_selection_decoration_width - deselected_item_decoration_width)
3856 )
3858 rc.set_color_path(f"menu/text/prefix:choice/{status_tag}/{color_tag}")
3859 rc.write(option.display_text_prefix, max_width=left_width)
3860 rc.set_color_path(f"menu/text:choice/{status_tag}/{color_tag}")
3861 rc.write(option.display_text, max_width=left_width - left_prefix_width)
3862 rc.set_color_path(f"menu/text/suffix:choice/{status_tag}/{color_tag}")
3863 rc.write(
3864 option.display_text_suffix,
3865 max_width=left_width - left_prefix_width - left_main_width,
3866 )
3867 rc.set_color_path(f"menu/text:choice/{status_tag}/{color_tag}")
3868 rc.write(
3869 " "
3870 * (
3871 width
3872 - left_decoration_width
3873 - left_width
3874 - right_decoration_width
3875 - right_width
3876 )
3877 )
3879 if right:
3880 rc.set_color_path(
3881 f"menu/decoration/comment:choice/{status_tag}/{color_tag}"
3882 )
3883 rc.write(" [")
3884 rc.set_color_path(f"menu/text/comment:choice/{status_tag}/{color_tag}")
3885 rc.write(right, max_width=right_width)
3886 rc.set_color_path(
3887 f"menu/decoration/comment:choice/{status_tag}/{color_tag}"
3888 )
3889 rc.write("]")
3891 @property
3892 def help_data(self) -> WidgetHelp:
3893 return super().help_data.with_action(
3894 "1..9",
3895 "a..z",
3896 long_msg="quick select",
3897 )
3900class Choice(Widget[T], _t.Generic[T]):
3901 """
3902 Allows choosing from pre-defined options.
3904 .. vhs:: /_tapes/widget_choice.tape
3905 :alt: Demonstration of `Choice` widget.
3906 :width: 480
3907 :height: 240
3909 :param options:
3910 list of choice options.
3911 :param mapper:
3912 maps option to a text that will be used for filtering. By default,
3913 uses :attr:`Option.display_text`. This argument is ignored
3914 if a custom `filter` is given.
3915 :param filter:
3916 customizes behavior of list filtering. The default filter extracts text
3917 from an option using the `mapper`, and checks if it starts with the search
3918 query.
3919 :param default_index:
3920 index of the initially selected option.
3922 """
3924 @_t.overload
3925 def __init__(
3926 self,
3927 options: list[Option[T]],
3928 /,
3929 *,
3930 mapper: _t.Callable[[Option[T]], str] = lambda x: (
3931 x.display_text or str(x.value)
3932 ),
3933 default_index: int = 0,
3934 search_bar_decoration_path: str = "menu/input/decoration_search",
3935 active_item_decoration_path: str = "menu/choice/decoration/active_item",
3936 ): ...
3938 @_t.overload
3939 def __init__(
3940 self,
3941 options: list[Option[T]],
3942 /,
3943 *,
3944 filter: _t.Callable[[Option[T], str], bool],
3945 default_index: int = 0,
3946 search_bar_decoration_path: str = "menu/input/decoration_search",
3947 active_item_decoration_path: str = "menu/choice/decoration/active_item",
3948 ): ...
3950 def __init__(
3951 self,
3952 options: list[Option[T]],
3953 /,
3954 *,
3955 mapper: _t.Callable[[Option[T]], str] = lambda x: x.display_text
3956 or str(x.value),
3957 filter: _t.Callable[[Option[T], str], bool] | None = None,
3958 default_index: int = 0,
3959 search_bar_decoration_path: str = "menu/input/decoration_search",
3960 active_item_decoration_path: str = "menu/choice/decoration/active_item",
3961 ):
3962 self.__options = options
3964 if filter is None:
3965 filter = lambda x, q: mapper(x).lstrip().startswith(q)
3967 self.__filter = filter
3969 self.__default_index = default_index
3971 self.__input = Input(
3972 placeholder="Filter options...", decoration_path=search_bar_decoration_path
3973 )
3974 self.__grid = Grid[T](
3975 [], active_item_decoration_path=active_item_decoration_path
3976 )
3978 self.__enable_search = False
3980 self.__layout: VerticalLayout[_t.Never]
3982 self.__update_completion()
3984 @bind("/")
3985 def search(self):
3986 """search"""
3987 if not self.__enable_search:
3988 self.__enable_search = True
3989 else:
3990 self.__input.event(KeyboardEvent("/"))
3991 self.__update_completion()
3993 @bind(Key.ENTER)
3994 @bind(Key.ENTER, alt=True, show_in_detailed_help=False)
3995 @bind("d", ctrl=True)
3996 def enter(self) -> Result[T] | None:
3997 """select"""
3998 option = self.__grid.get_option()
3999 if option is not None:
4000 return Result(option.value)
4001 else:
4002 self._bell()
4004 @bind(Key.ESCAPE)
4005 def esc(self):
4006 self.__input.text = ""
4007 self.__update_completion()
4008 self.__enable_search = False
4010 def default_event_handler(self, e: KeyboardEvent) -> Result[T] | None:
4011 if not self.__enable_search and e == KeyboardEvent(" "):
4012 return self.enter()
4013 if not self.__enable_search or e.key in (
4014 Key.ARROW_UP,
4015 Key.ARROW_DOWN,
4016 Key.TAB,
4017 Key.ARROW_LEFT,
4018 Key.ARROW_RIGHT,
4019 Key.PAGE_DOWN,
4020 Key.PAGE_UP,
4021 Key.HOME,
4022 Key.END,
4023 ):
4024 self.__grid.event(e)
4025 elif e == KeyboardEvent(Key.BACKSPACE) and not self.__input.text:
4026 self.__enable_search = False
4027 else:
4028 self.__input.event(e)
4029 self.__update_completion()
4031 def __update_completion(self):
4032 query = self.__input.text
4034 index = 0
4035 options = []
4036 cur_option = self.__grid.get_option()
4037 for i, option in enumerate(self.__options):
4038 if not query or self.__filter(option, query):
4039 if option is cur_option or (
4040 cur_option is None and i == self.__default_index
4041 ):
4042 index = len(options)
4043 options.append(option)
4045 self.__grid.set_options(options)
4046 self.__grid.index = index
4048 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
4049 self.__layout = VerticalLayout()
4050 self.__layout.append(self.__grid)
4052 if self.__enable_search:
4053 self.__layout.append(self.__input)
4055 return self.__layout.layout(rc)
4057 def draw(self, rc: RenderContext, /):
4058 self.__layout.draw(rc)
4060 @property
4061 def help_data(self) -> WidgetHelp:
4062 return super().help_data.merge(self.__grid.help_data)
4065class Multiselect(Widget[list[T]], _t.Generic[T]):
4066 """
4067 Like :class:`Choice`, but allows selecting multiple items.
4069 .. vhs:: /_tapes/widget_multiselect.tape
4070 :alt: Demonstration of `Multiselect` widget.
4071 :width: 480
4072 :height: 240
4074 :param options:
4075 list of choice options.
4076 :param mapper:
4077 maps option to a text that will be used for filtering. By default,
4078 uses :attr:`Option.display_text`. This argument is ignored
4079 if a custom `filter` is given.
4080 :param filter:
4081 customizes behavior of list filtering. The default filter extracts text
4082 from an option using the `mapper`, and checks if it starts with the search
4083 query.
4084 :param default_index:
4085 index of the initially selected option.
4087 """
4089 @_t.overload
4090 def __init__(
4091 self,
4092 options: list[Option[T]],
4093 /,
4094 *,
4095 mapper: _t.Callable[[Option[T]], str] = lambda x: x.display_text
4096 or str(x.value),
4097 ): ...
4099 @_t.overload
4100 def __init__(
4101 self,
4102 options: list[Option[T]],
4103 /,
4104 *,
4105 filter: _t.Callable[[Option[T], str], bool],
4106 ): ...
4108 def __init__(
4109 self,
4110 options: list[Option[T]],
4111 /,
4112 *,
4113 mapper: _t.Callable[[Option[T]], str] = lambda x: x.display_text
4114 or str(x.value),
4115 filter: _t.Callable[[Option[T], str], bool] | None = None,
4116 search_bar_decoration_path: str = "menu/input/decoration_search",
4117 active_item_decoration_path: str = "menu/choice/decoration/active_item",
4118 selected_item_decoration_path: str = "menu/choice/decoration/selected_item",
4119 deselected_item_decoration_path: str = "menu/choice/decoration/deselected_item",
4120 ):
4121 self.__options = options
4123 if filter is None:
4124 filter = lambda x, q: mapper(x).lstrip().startswith(q)
4126 self.__filter = filter
4128 self.__input = Input(
4129 placeholder="Filter options...", decoration_path=search_bar_decoration_path
4130 )
4131 self.__grid = Grid[tuple[T, bool]](
4132 [],
4133 active_item_decoration_path=active_item_decoration_path,
4134 selected_item_decoration_path=selected_item_decoration_path,
4135 deselected_item_decoration_path=deselected_item_decoration_path,
4136 )
4138 self.__enable_search = False
4140 self.__layout: VerticalLayout[_t.Never]
4142 self.__update_completion()
4144 @bind(Key.ENTER)
4145 @bind(" ")
4146 def select(self):
4147 """select"""
4148 if self.__enable_search and self._cur_event == KeyboardEvent(" "):
4149 self.__input.event(KeyboardEvent(" "))
4150 self.__update_completion()
4151 return
4152 option = self.__grid.get_option()
4153 if option is not None:
4154 option.selected = not option.selected
4155 self.__update_completion()
4157 @bind(Key.ENTER, alt=True)
4158 @bind("d", ctrl=True, show_in_inline_help=True)
4159 def enter(self) -> Result[list[T]] | None:
4160 """accept"""
4161 return Result([option.value for option in self.__options if option.selected])
4163 @bind("/")
4164 def search(self):
4165 """search"""
4166 if not self.__enable_search:
4167 self.__enable_search = True
4168 else:
4169 self.__input.event(KeyboardEvent("/"))
4170 self.__update_completion()
4172 @bind(Key.ESCAPE)
4173 def esc(self):
4174 """exit search"""
4175 self.__input.text = ""
4176 self.__update_completion()
4177 self.__enable_search = False
4179 def default_event_handler(self, e: KeyboardEvent) -> Result[list[T]] | None:
4180 if not self.__enable_search or e.key in (
4181 Key.ARROW_UP,
4182 Key.ARROW_DOWN,
4183 Key.TAB,
4184 Key.ARROW_LEFT,
4185 Key.ARROW_RIGHT,
4186 Key.PAGE_DOWN,
4187 Key.PAGE_UP,
4188 Key.HOME,
4189 Key.END,
4190 ):
4191 self.__grid.event(e)
4192 elif e == KeyboardEvent(Key.BACKSPACE) and not self.__input.text:
4193 self.__enable_search = False
4194 else:
4195 self.__input.event(e)
4196 self.__update_completion()
4198 def __update_completion(self):
4199 query = self.__input.text
4201 index = 0
4202 options = []
4203 cur_option = self.__grid.get_option()
4204 for option in self.__options:
4205 if not query or self.__filter(option, query):
4206 if option is cur_option:
4207 index = len(options)
4208 options.append(option)
4210 self.__grid.set_options(options)
4211 self.__grid.index = index
4213 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
4214 self.__layout = VerticalLayout()
4215 self.__layout.append(self.__grid)
4217 if self.__enable_search:
4218 self.__layout.append(self.__input)
4220 return self.__layout.layout(rc)
4222 def draw(self, rc: RenderContext, /):
4223 self.__layout.draw(rc)
4225 @property
4226 def help_data(self) -> WidgetHelp:
4227 return super().help_data.merge(self.__grid.help_data)
4230class InputWithCompletion(Widget[str]):
4231 """
4232 An input box with tab completion.
4234 .. vhs:: /_tapes/widget_completion.tape
4235 :alt: Demonstration of `InputWithCompletion` widget.
4236 :width: 480
4237 :height: 240
4239 """
4241 def __init__(
4242 self,
4243 completer: yuio.complete.Completer,
4244 /,
4245 *,
4246 placeholder: str = "",
4247 decoration_path: str = "menu/input/decoration",
4248 active_item_decoration_path: str = "menu/choice/decoration/active_item",
4249 ):
4250 self.__completer = completer
4252 self.__input = Input(placeholder=placeholder, decoration_path=decoration_path)
4253 self.__grid = Grid[yuio.complete.Completion](
4254 [], active_item_decoration_path=active_item_decoration_path, min_rows=None
4255 )
4256 self.__grid_active = False
4258 self.__layout: VerticalLayout[_t.Never]
4259 self.__rsuffix: yuio.complete.Completion | None = None
4261 @property
4262 def text(self) -> str:
4263 """
4264 Current text in the input box.
4266 """
4268 return self.__input.text
4270 @property
4271 def pos(self) -> int:
4272 """
4273 Current cursor position, measured in code points before the cursor.
4275 That is, if the text is `"quick brown fox"` with cursor right before the word
4276 "brown", then :attr:`~Input.pos` is equal to `len("quick ")`.
4278 """
4280 return self.__input.pos
4282 @property
4283 def err_region(self) -> tuple[int, int] | None:
4284 return self.__input.err_region
4286 @err_region.setter
4287 def err_region(self, err_region: tuple[int, int] | None, /):
4288 self.__input.err_region = err_region
4290 @bind(Key.ENTER)
4291 @bind("d", ctrl=True)
4292 @help(inline_msg="accept")
4293 def enter(self) -> Result[str] | None:
4294 """accept / select completion"""
4295 if self.__grid_active and (option := self.__grid.get_option()):
4296 self._set_input_state_from_completion(option.value)
4297 self._deactivate_completion()
4298 else:
4299 self._drop_rsuffix()
4300 return Result(self.__input.text)
4302 @bind(Key.TAB)
4303 def tab(self):
4304 """autocomplete"""
4305 if self.__grid_active:
4306 self.__grid.next_item()
4307 if option := self.__grid.get_option():
4308 self._set_input_state_from_completion(option.value)
4309 return
4311 completion = self.__completer.complete(self.__input.text, self.__input.pos)
4312 if len(completion) == 1:
4313 self.__input.checkpoint()
4314 self._set_input_state_from_completion(completion[0])
4315 elif completion:
4316 self.__input.checkpoint()
4317 self.__grid.set_options(
4318 [
4319 Option(
4320 c,
4321 c.completion,
4322 display_text_prefix=c.dprefix,
4323 display_text_suffix=c.dsuffix,
4324 comment=c.comment,
4325 color_tag=c.group_color_tag,
4326 )
4327 for c in completion
4328 ],
4329 default_index=None,
4330 )
4331 self._activate_completion()
4332 else:
4333 self._bell()
4335 @bind(Key.ESCAPE)
4336 def escape(self):
4337 """close autocomplete"""
4338 self._drop_rsuffix()
4339 if self.__grid_active:
4340 self.__input.restore_checkpoint()
4341 self._deactivate_completion()
4343 def default_event_handler(self, e: KeyboardEvent):
4344 if self.__grid_active and e.key in (
4345 Key.ARROW_UP,
4346 Key.ARROW_DOWN,
4347 Key.TAB,
4348 Key.PAGE_UP,
4349 Key.PAGE_DOWN,
4350 Key.HOME,
4351 Key.END,
4352 ):
4353 self._dispatch_completion_event(e)
4354 elif (
4355 self.__grid_active
4356 and self.__grid.index is not None
4357 and e.key in (Key.ARROW_RIGHT, Key.ARROW_LEFT)
4358 ):
4359 self._dispatch_completion_event(e)
4360 else:
4361 self._dispatch_input_event(e)
4363 def _activate_completion(self):
4364 self.__grid_active = True
4366 def _deactivate_completion(self):
4367 self.__grid_active = False
4369 def _set_input_state_from_completion(
4370 self, completion: yuio.complete.Completion, set_rsuffix: bool = True
4371 ):
4372 prefix = completion.iprefix + completion.completion
4373 if set_rsuffix:
4374 prefix += completion.rsuffix
4375 self.__rsuffix = completion
4376 else:
4377 self.__rsuffix = None
4378 self.__input.text = prefix + completion.isuffix
4379 self.__input.pos = len(prefix)
4381 def _dispatch_completion_event(self, e: KeyboardEvent):
4382 self.__rsuffix = None
4383 self.__grid.event(e)
4384 if option := self.__grid.get_option():
4385 self._set_input_state_from_completion(option.value)
4387 def _dispatch_input_event(self, e: KeyboardEvent):
4388 if self.__rsuffix:
4389 # We need to drop current rsuffix in some cases:
4390 if (not e.ctrl and not e.alt and isinstance(e.key, str)) or (
4391 e.key is Key.PASTE and e.paste_str
4392 ):
4393 text = e.key if e.key is not Key.PASTE else e.paste_str
4394 # When user prints something...
4395 if text and text[0] in self.__rsuffix.rsymbols:
4396 # ...that is in `rsymbols`...
4397 self._drop_rsuffix()
4398 elif e in [
4399 KeyboardEvent(Key.ARROW_UP),
4400 KeyboardEvent(Key.ARROW_DOWN),
4401 KeyboardEvent(Key.ARROW_LEFT),
4402 KeyboardEvent("b", ctrl=True),
4403 KeyboardEvent(Key.ARROW_RIGHT),
4404 KeyboardEvent("f", ctrl=True),
4405 KeyboardEvent(Key.ARROW_LEFT, alt=True),
4406 KeyboardEvent("b", alt=True),
4407 KeyboardEvent(Key.ARROW_RIGHT, alt=True),
4408 KeyboardEvent("f", alt=True),
4409 KeyboardEvent(Key.HOME),
4410 KeyboardEvent("a", ctrl=True),
4411 KeyboardEvent(Key.END),
4412 KeyboardEvent("e", ctrl=True),
4413 ]:
4414 # ...or when user moves cursor.
4415 self._drop_rsuffix()
4416 self.__rsuffix = None
4417 self.__input.event(e)
4418 self._deactivate_completion()
4420 def _drop_rsuffix(self):
4421 if self.__rsuffix:
4422 rsuffix = self.__rsuffix.rsuffix
4423 if self.__input.text[: self.__input.pos].endswith(rsuffix):
4424 self._set_input_state_from_completion(self.__rsuffix, set_rsuffix=False)
4426 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
4427 self.__layout = VerticalLayout()
4428 self.__layout.append(self.__input)
4429 if self.__grid_active:
4430 self.__layout.append(self.__grid)
4431 return self.__layout.layout(rc)
4433 def draw(self, rc: RenderContext, /):
4434 self.__layout.draw(rc)
4436 @property
4437 def help_data(self) -> WidgetHelp:
4438 return (
4439 (super().help_data)
4440 .merge(
4441 (self.__grid.help_data)
4442 .without_group("Actions")
4443 .rename_group(Grid._NAVIGATE, "Navigate Completions")
4444 )
4445 .merge(
4446 (self.__input.help_data)
4447 .without_group("Actions")
4448 .rename_group(Input._NAVIGATE, "Navigate Input")
4449 .rename_group(Input._MODIFY, "Modify Input")
4450 )
4451 )
4454class Map(Widget[T], _t.Generic[T, U]):
4455 """
4456 A wrapper that maps result of the given widget using the given function.
4458 ..
4459 >>> class Input(Widget):
4460 ... def event(self, e):
4461 ... return Result("10")
4462 ...
4463 ... def layout(self, rc):
4464 ... return 0, 0
4465 ...
4466 ... def draw(self, rc):
4467 ... pass
4468 >>> class Map(Map):
4469 ... def run(self, term, theme):
4470 ... return self.event(None).value
4471 >>> term, theme = None, None
4473 Example::
4475 >>> # Run `Input` widget, then parse user input as `int`.
4476 >>> int_input = Map(Input(), int)
4477 >>> int_input.run(term, theme)
4478 10
4480 """
4482 def __init__(self, inner: Widget[U], fn: _t.Callable[[U], T], /):
4483 self._inner = inner
4484 self._fn = fn
4486 def event(self, e: KeyboardEvent, /) -> Result[T] | None:
4487 if result := self._inner.event(e):
4488 return Result(self._fn(result.value))
4490 def layout(self, rc: RenderContext, /) -> tuple[int, int]:
4491 return self._inner.layout(rc)
4493 def draw(self, rc: RenderContext, /):
4494 self._inner.draw(rc)
4496 @property
4497 def help_data(self) -> WidgetHelp:
4498 return self._inner.help_data
4501class Apply(Map[T, T], _t.Generic[T]):
4502 """
4503 A wrapper that applies the given function to the result of a wrapped widget.
4505 ..
4506 >>> class Input(Widget):
4507 ... def event(self, e):
4508 ... return Result("foobar!")
4509 ...
4510 ... def layout(self, rc):
4511 ... return 0, 0
4512 ...
4513 ... def draw(self, rc):
4514 ... pass
4515 >>> class Apply(Apply):
4516 ... def run(self, term, theme):
4517 ... return self.event(None).value
4518 >>> term, theme = None, None
4520 Example::
4522 >>> # Run `Input` widget, then print its output before returning
4523 >>> print_output = Apply(Input(), print)
4524 >>> result = print_output.run(term, theme)
4525 foobar!
4526 >>> result
4527 'foobar!'
4529 """
4531 def __init__(self, inner: Widget[T], fn: _t.Callable[[T], None], /):
4532 def mapper(x: T) -> T:
4533 fn(x)
4534 return x
4536 super().__init__(inner, mapper)
4539class Task(Widget[_t.Never]):
4540 """
4541 Widget that's used to render :class:`~yuio.io.Task`\\ s.
4543 """
4545 class Status(enum.Enum):
4546 """
4547 Task status.
4549 """
4551 DONE = "done"
4552 """
4553 Task has finished successfully.
4555 """
4557 ERROR = "error"
4558 """
4559 Task has finished with an error.
4561 """
4563 RUNNING = "running"
4564 """
4565 Task is running.
4567 """
4569 PENDING = "pending"
4570 """
4571 Task is waiting to start.
4573 """
4575 def __init__(
4576 self,
4577 msg: str,
4578 /,
4579 *args,
4580 comment: str | None = None,
4581 ) -> None:
4582 super().__init__()
4584 self._msg: str = msg
4585 self._args: tuple[object, ...] = args
4586 self._comment: str | None = comment
4587 self._comment_args: tuple[object, ...] | None = None
4588 self._progress: float | None = None
4589 self._progress_done: str | None = None
4590 self._progress_total: str | None = None
4592 self.status: Task.Status = Task.Status.PENDING
4594 self._cached_msg: yuio.string.ColorizedString | None = None
4595 self._cached_comment: yuio.string.ColorizedString | None = None
4597 @_t.overload
4598 def progress(self, progress: float | None, /, *, ndigits: int = 2): ...
4600 @_t.overload
4601 def progress(
4602 self,
4603 done: float | int,
4604 total: float | int,
4605 /,
4606 *,
4607 unit: str = "",
4608 ndigits: int = 0,
4609 ): ...
4611 def progress(
4612 self,
4613 *args: float | int | None,
4614 unit: str = "",
4615 ndigits: int | None = None,
4616 ):
4617 """
4618 See :meth:`~yuio.io.Task.progress`.
4620 """
4622 progress = None
4624 if len(args) == 1:
4625 progress = done = args[0]
4626 total = None
4627 if ndigits is None:
4628 ndigits = 2
4629 elif len(args) == 2:
4630 done, total = args
4631 if ndigits is None:
4632 ndigits = (
4633 2 if isinstance(done, float) or isinstance(total, float) else 0
4634 )
4635 else:
4636 raise ValueError(
4637 f"Task.progress() takes between one and two arguments "
4638 f"({len(args)} given)"
4639 )
4641 if done is None:
4642 self._progress = None
4643 self._progress_done = None
4644 self._progress_total = None
4645 return
4647 if len(args) == 1:
4648 done *= 100
4649 unit = "%"
4651 done_str = "%.*f" % (ndigits, done)
4652 if total is None:
4653 self._progress = progress
4654 self._progress_done = done_str + unit
4655 self._progress_total = None
4656 else:
4657 total_str = "%.*f" % (ndigits, total)
4658 self._progress = done / total if total else 0
4659 self._progress_done = done_str
4660 self._progress_total = total_str + unit
4662 def progress_size(
4663 self,
4664 done: float | int,
4665 total: float | int,
4666 /,
4667 *,
4668 ndigits: int = 2,
4669 ):
4670 """
4671 See :meth:`~yuio.io.Task.progress_size`.
4673 """
4675 progress = done / total
4676 done, done_unit = self.__size(done)
4677 total, total_unit = self.__size(total)
4679 if done_unit == total_unit:
4680 done_unit = ""
4682 self._progress = progress
4683 self._progress_done = "%.*f%s" % (ndigits, done, done_unit)
4684 self._progress_total = "%.*f%s" % (ndigits, total, total_unit)
4686 @staticmethod
4687 def __size(n):
4688 for unit in "BKMGT":
4689 if n < 1024:
4690 return n, unit
4691 n /= 1024
4692 return n, "P"
4694 def progress_scale(
4695 self,
4696 done: float | int,
4697 total: float | int,
4698 /,
4699 *,
4700 unit: str = "",
4701 ndigits: int = 2,
4702 ):
4703 """
4704 See :meth:`~yuio.io.Task.progress_scale`.
4706 """
4708 progress = done / total
4709 done, done_unit = self.__unit(done)
4710 total, total_unit = self.__unit(total)
4712 if unit:
4713 done_unit += unit
4714 total_unit += unit
4716 self._progress = progress
4717 self._progress_done = "%.*f%s" % (ndigits, done, done_unit)
4718 self._progress_total = "%.*f%s" % (ndigits, total, total_unit)
4720 @staticmethod
4721 def __unit(n: float) -> tuple[float, str]:
4722 if math.fabs(n) < 1e-33:
4723 return 0, ""
4724 magnitude = max(-8, min(8, int(math.log10(math.fabs(n)) // 3)))
4725 if magnitude < 0:
4726 return n * 10 ** -(3 * magnitude), "munpfazy"[-magnitude - 1]
4727 elif magnitude > 0:
4728 return n / 10 ** (3 * magnitude), "KMGTPEZY"[magnitude - 1]
4729 else:
4730 return n, ""
4732 def comment(self, comment: str | None, /, *args):
4733 """
4734 See :meth:`~yuio.io.Task.comment`.
4736 """
4738 self._comment = comment
4739 self._comment_args = args
4740 self._cached_comment = None
4742 def layout(self, rc: RenderContext) -> tuple[int, int]:
4743 return 1, 1 # Tasks are always one line high.
4745 def draw(self, rc: RenderContext):
4746 return self._draw_task(rc)
4748 def _format_task(self, ctx: yuio.string.ReprContext) -> yuio.string.ColorizedString:
4749 """
4750 Format this task for printing to the log.
4752 """
4754 res = yuio.string.ColorizedString()
4756 status = self.status.value
4758 if decoration := ctx.get_msg_decoration("task"):
4759 res += ctx.get_color(f"task/decoration:{status}")
4760 res += decoration
4762 res += self._format_task_msg(ctx)
4763 res += ctx.get_color(f"task:{status}")
4764 res += " - "
4765 res += ctx.get_color(f"task/progress:{status}")
4766 res += self.status.value
4767 res += ctx.get_color(f"task:{status}")
4769 return res
4771 def _format_task_msg(
4772 self, ctx: yuio.string.ReprContext
4773 ) -> yuio.string.ColorizedString:
4774 """
4775 Format task's message.
4777 """
4779 if self._cached_msg is None:
4780 msg = yuio.string.colorize(
4781 self._msg,
4782 *self._args,
4783 default_color=f"task/heading:{self.status.value}",
4784 ctx=ctx,
4785 )
4786 self._cached_msg = msg
4787 return self._cached_msg
4789 def _format_task_comment(
4790 self, rc: RenderContext
4791 ) -> yuio.string.ColorizedString | None:
4792 """
4793 Format task's comment.
4795 """
4797 if self.status is not Task.Status.RUNNING:
4798 return None
4799 if self._cached_comment is None and self._comment is not None:
4800 comment = yuio.string.colorize(
4801 self._comment,
4802 *(self._comment_args or ()),
4803 default_color=f"task/comment:{self.status.value}",
4804 ctx=rc.make_repr_context(),
4805 )
4806 self._cached_comment = comment
4807 return self._cached_comment
4809 def _draw_task(self, rc: RenderContext):
4810 """
4811 Draw task.
4813 """
4815 self._draw_task_progressbar(rc)
4816 rc.write(self._format_task_msg(rc.make_repr_context()))
4817 self._draw_task_progress(rc)
4818 if comment := self._format_task_comment(rc):
4819 rc.set_color_path(f"task:{self.status.value}")
4820 rc.write(" - ")
4821 rc.write(comment)
4823 def _draw_task_progress(self, rc: RenderContext):
4824 """
4825 Draw number that indicates task's progress.
4827 """
4829 if self.status is not Task.Status.RUNNING:
4830 rc.set_color_path(f"task:{self.status.value}")
4831 rc.write(" - ")
4832 rc.set_color_path(f"task/progress:{self.status.value}")
4833 rc.write(self.status.value)
4834 elif self._progress_done is not None:
4835 rc.set_color_path(f"task:{self.status.value}")
4836 rc.write(" - ")
4837 rc.set_color_path(f"task/progress:{self.status.value}")
4838 rc.write(self._progress_done)
4839 if self._progress_total is not None:
4840 rc.set_color_path(f"task:{self.status.value}")
4841 rc.write("/")
4842 rc.set_color_path(f"task/progress:{self.status.value}")
4843 rc.write(self._progress_total)
4845 def _draw_task_progressbar(self, rc: RenderContext):
4846 """
4847 Draw task's progressbar.
4849 """
4851 progress_bar_start_symbol = rc.theme.get_msg_decoration(
4852 "progress_bar/start_symbol", is_unicode=rc.term.is_unicode
4853 )
4854 progress_bar_end_symbol = rc.theme.get_msg_decoration(
4855 "progress_bar/end_symbol", is_unicode=rc.term.is_unicode
4856 )
4857 total_width = (
4858 rc.theme.progress_bar_width
4859 - yuio.string.line_width(progress_bar_start_symbol)
4860 - yuio.string.line_width(progress_bar_end_symbol)
4861 )
4862 progress_bar_done_symbol = rc.theme.get_msg_decoration(
4863 "progress_bar/done_symbol", is_unicode=rc.term.is_unicode
4864 )
4865 progress_bar_pending_symbol = rc.theme.get_msg_decoration(
4866 "progress_bar/pending_symbol", is_unicode=rc.term.is_unicode
4867 )
4868 if self.status != Task.Status.RUNNING:
4869 rc.set_color_path(f"task/decoration:{self.status.value}")
4870 rc.write(
4871 rc.theme.get_msg_decoration(
4872 "spinner/static_symbol", is_unicode=rc.term.is_unicode
4873 )
4874 )
4875 elif (
4876 self._progress is None
4877 or total_width <= 1
4878 or not progress_bar_done_symbol
4879 or not progress_bar_pending_symbol
4880 ):
4881 rc.set_color_path(f"task/decoration:{self.status.value}")
4882 spinner_pattern = rc.theme.get_msg_decoration(
4883 "spinner/pattern", is_unicode=rc.term.is_unicode
4884 )
4885 if spinner_pattern:
4886 rc.write(spinner_pattern[rc.spinner_state % len(spinner_pattern)])
4887 else:
4888 transition_pattern = rc.theme.get_msg_decoration(
4889 "progress_bar/transition_pattern", is_unicode=rc.term.is_unicode
4890 )
4892 progress = max(0, min(1, self._progress))
4893 if transition_pattern:
4894 done_width = int(total_width * progress)
4895 transition_factor = 1 - (total_width * progress - done_width)
4896 transition_width = 1
4897 else:
4898 done_width = round(total_width * progress)
4899 transition_factor = 0
4900 transition_width = 0
4902 rc.set_color_path(f"task/progressbar:{self.status.value}")
4903 rc.write(progress_bar_start_symbol)
4905 done_color = yuio.color.Color.lerp(
4906 rc.theme.get_color("task/progressbar/done/start"),
4907 rc.theme.get_color("task/progressbar/done/end"),
4908 )
4910 for i in range(0, done_width):
4911 rc.set_color(done_color(i / (total_width - 1)))
4912 rc.write(progress_bar_done_symbol)
4914 if transition_pattern and done_width < total_width:
4915 rc.set_color(done_color(done_width / (total_width - 1)))
4916 rc.write(
4917 transition_pattern[
4918 int(len(transition_pattern) * transition_factor - 1)
4919 ]
4920 )
4922 pending_color = yuio.color.Color.lerp(
4923 rc.theme.get_color("task/progressbar/pending/start"),
4924 rc.theme.get_color("task/progressbar/pending/end"),
4925 )
4927 for i in range(done_width + transition_width, total_width):
4928 rc.set_color(pending_color(i / (total_width - 1)))
4929 rc.write(progress_bar_pending_symbol)
4931 rc.set_color_path(f"task/progressbar:{self.status.value}")
4932 rc.write(progress_bar_end_symbol)
4934 rc.set_color_path(f"task:{self.status.value}")
4935 rc.write(" ")
4937 def __colorized_str__(self, ctx: yuio.string.ReprContext) -> _ColorizedString:
4938 return self._format_task(ctx)
4941@dataclass(slots=True)
4942class _EventStreamState:
4943 ostream: _t.TextIO
4944 istream: _t.TextIO
4945 key: str = ""
4946 index: int = 0
4948 def load(self):
4949 key = ""
4950 while not key:
4951 key = yuio.term._read_keycode(self.ostream, self.istream)
4952 self.key = key
4953 self.index = 0
4955 def next(self):
4956 ch = self.peek()
4957 self.index += 1
4958 return ch
4960 def peek(self):
4961 if self.index >= len(self.key):
4962 return ""
4963 else:
4964 return self.key[self.index]
4966 def tail(self):
4967 return self.key[self.index :]
4970def _event_stream(ostream: _t.TextIO, istream: _t.TextIO) -> _t.Iterator[KeyboardEvent]:
4971 # Implementation is heavily inspired by libtermkey by Paul Evans, MIT license,
4972 # with some additions for modern protocols.
4973 # See https://sw.kovidgoyal.net/kitty/keyboard-protocol/.
4975 state = _EventStreamState(ostream, istream)
4976 while True:
4977 ch = state.next()
4978 if not ch:
4979 state.load()
4980 ch = state.next()
4981 if ch == "\x1b":
4982 alt = False
4983 ch = state.next()
4984 while ch == "\x1b":
4985 alt = True
4986 ch = state.next()
4987 if not ch:
4988 yield KeyboardEvent(Key.ESCAPE, alt=alt)
4989 elif ch == "[":
4990 yield from _parse_csi(state, alt)
4991 elif ch in "N]":
4992 _parse_dcs(state)
4993 elif ch == "O":
4994 yield from _parse_ss3(state, alt)
4995 else:
4996 yield from _parse_char(ch, alt=True)
4997 elif ch == "\x9b":
4998 # CSI
4999 yield from _parse_csi(state, False)
5000 elif ch in "\x90\x9d":
5001 # DCS or SS2
5002 _parse_dcs(state)
5003 elif ch == "\x8f":
5004 # SS3
5005 yield from _parse_ss3(state, False)
5006 else:
5007 # Char
5008 yield from _parse_char(ch)
5011def _parse_ss3(state: _EventStreamState, alt: bool = False):
5012 ch = state.next()
5013 if not ch:
5014 yield KeyboardEvent("O", alt=True)
5015 else:
5016 yield from _parse_ss3_key(ch, alt=alt)
5019def _parse_dcs(state: _EventStreamState):
5020 while True:
5021 ch = state.next()
5022 if ch == "\x9c":
5023 break
5024 elif ch == "\x1b" and state.peek() == "\\":
5025 state.next()
5026 break
5027 elif not ch:
5028 state.load()
5031def _parse_csi(state: _EventStreamState, alt: bool = False):
5032 buffer = ""
5033 while state.peek() and not (0x40 <= ord(state.peek()) <= 0x80):
5034 buffer += state.next()
5035 cmd = state.next()
5036 if not cmd:
5037 yield KeyboardEvent("[", alt=True)
5038 return
5039 if buffer.startswith(("?", "<", ">", "=")):
5040 # Some command response, ignore.
5041 return # pragma: no cover
5042 args = buffer.split(";")
5044 shift = ctrl = False
5045 if len(args) > 1:
5046 try:
5047 modifiers = int(args[1]) - 1
5048 except ValueError: # pragma: no cover
5049 pass
5050 else:
5051 shift = bool(modifiers & 1)
5052 alt |= bool(modifiers & 2)
5053 ctrl = bool(modifiers & 4)
5055 if cmd == "~":
5056 if args[0] == "27":
5057 try:
5058 ch = chr(int(args[2]))
5059 except (ValueError, KeyError): # pragma: no cover
5060 pass
5061 else:
5062 yield from _parse_char(ch, ctrl=ctrl, alt=alt, shift=shift)
5063 elif args[0] == "200":
5064 yield KeyboardEvent(Key.PASTE, paste_str=_read_pasted_content(state))
5065 elif key := _CSI_CODES.get(args[0]):
5066 yield KeyboardEvent(key, ctrl=ctrl, alt=alt, shift=shift)
5067 elif cmd == "u":
5068 try:
5069 ch = chr(int(args[0]))
5070 except ValueError: # pragma: no cover
5071 pass
5072 else:
5073 yield from _parse_char(ch, ctrl=ctrl, alt=alt, shift=shift)
5074 elif cmd in "mMyR":
5075 # Some command response, ignore.
5076 pass # pragma: no cover
5077 else:
5078 yield from _parse_ss3_key(cmd, ctrl=ctrl, alt=alt, shift=shift)
5081def _parse_ss3_key(
5082 cmd: str, ctrl: bool = False, alt: bool = False, shift: bool = False
5083):
5084 if key := _SS3_CODES.get(cmd):
5085 if cmd == "Z":
5086 shift = True
5087 yield KeyboardEvent(key, ctrl=ctrl, alt=alt, shift=shift)
5090_SS3_CODES = {
5091 "A": Key.ARROW_UP,
5092 "B": Key.ARROW_DOWN,
5093 "C": Key.ARROW_RIGHT,
5094 "D": Key.ARROW_LEFT,
5095 "E": Key.HOME,
5096 "F": Key.END,
5097 "H": Key.HOME,
5098 "Z": Key.TAB,
5099 "P": Key.F1,
5100 "Q": Key.F2,
5101 "R": Key.F3,
5102 "S": Key.F4,
5103 "M": Key.ENTER,
5104 " ": " ",
5105 "I": Key.TAB,
5106 "X": "=",
5107 "j": "*",
5108 "k": "+",
5109 "l": ",",
5110 "m": "-",
5111 "n": ".",
5112 "o": "/",
5113 "p": "0",
5114 "q": "1",
5115 "r": "2",
5116 "s": "3",
5117 "t": "4",
5118 "u": "5",
5119 "v": "6",
5120 "w": "7",
5121 "x": "8",
5122 "y": "9",
5123}
5126_CSI_CODES = {
5127 "1": Key.HOME,
5128 "2": Key.INSERT,
5129 "3": Key.DELETE,
5130 "4": Key.END,
5131 "5": Key.PAGE_UP,
5132 "6": Key.PAGE_DOWN,
5133 "7": Key.HOME,
5134 "8": Key.END,
5135 "11": Key.F1,
5136 "12": Key.F2,
5137 "13": Key.F3,
5138 "14": Key.F4,
5139 "15": Key.F5,
5140 "17": Key.F6,
5141 "18": Key.F7,
5142 "19": Key.F8,
5143 "20": Key.F9,
5144 "21": Key.F10,
5145 "23": Key.F11,
5146 "24": Key.F12,
5147 "200": Key.PASTE,
5148}
5151def _parse_char(
5152 ch: str, ctrl: bool = False, alt: bool = False, shift: bool = False
5153) -> _t.Iterable[KeyboardEvent]:
5154 if ch == "\t":
5155 yield KeyboardEvent(Key.TAB, ctrl, alt, shift)
5156 elif ch in "\r\n":
5157 yield KeyboardEvent(Key.ENTER, ctrl, alt, shift)
5158 elif ch == "\x08":
5159 yield KeyboardEvent(Key.BACKSPACE, ctrl, alt, shift)
5160 elif ch == "\x1b":
5161 yield KeyboardEvent(Key.ESCAPE, ctrl, alt, shift)
5162 elif ch == "\x7f":
5163 yield KeyboardEvent(Key.BACKSPACE, ctrl, alt, shift)
5164 elif "\x00" <= ch <= "\x1a":
5165 yield KeyboardEvent(chr(ord(ch) + ord("a") - 0x01), True, alt, shift)
5166 elif "\x1c" <= ch <= "\x1f":
5167 yield KeyboardEvent(chr(ord(ch) + ord("4") - 0x1C), True, alt, shift)
5168 elif ch in string.printable or ord(ch) >= 160:
5169 yield KeyboardEvent(ch, ctrl, alt, shift)
5172def _read_pasted_content(state: _EventStreamState) -> str:
5173 buf = ""
5174 while True:
5175 index = state.tail().find("\x1b[201~")
5176 if index == -1:
5177 buf += state.tail()
5178 else:
5179 buf += state.tail()[:index]
5180 state.index += index
5181 return buf
5182 state.load()