Coverage for yuio / widget.py: 95%

2162 statements  

« 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 

7 

8""" 

9Basic blocks for building interactive elements. 

10 

11This is a low-level module upon which :mod:`yuio.io` builds 

12its higher-level abstraction. 

13 

14 

15Widget basics 

16------------- 

17 

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. 

22 

23.. autoclass:: Widget 

24 :members: 

25 

26.. autoclass:: Result 

27 :members: 

28 

29.. autofunction:: bind 

30 

31.. autoclass:: Key 

32 :members: 

33 

34.. autoclass:: KeyboardEvent 

35 :members: 

36 

37 

38Drawing and rendering widgets 

39----------------------------- 

40 

41Widgets are rendered through :class:`RenderContext`. It provides simple facilities 

42to print characters on screen and manipulate screen cursor. 

43 

44.. autoclass:: RenderContext 

45 :members: 

46 

47 

48Stacking widgets together 

49------------------------- 

50 

51To get help with drawing multiple widgets and setting their own frames, 

52you can use the :class:`VerticalLayout` class: 

53 

54.. autoclass:: VerticalLayout 

55 

56.. autoclass:: VerticalLayoutBuilder 

57 :members: 

58 

59 

60Widget help 

61----------- 

62 

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. 

65 

66By default, help items are generated from event handler docstrings: 

67all event handlers that have them will be displayed in the help menu. 

68 

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. 

72 

73For even more detailed customization you can decorate an event handler with 

74the :func:`help` decorator: 

75 

76.. autofunction:: help 

77 

78Lastly, you can override :attr:`Widget.help_data` and generate 

79the :class:`WidgetHelp` yourself: 

80 

81.. autoclass:: WidgetHelp 

82 :members: 

83 

84.. class:: ActionKey 

85 

86 A single key associated with an action. 

87 Can be either a hotkey or a string with an arbitrary description. 

88 

89.. class:: ActionKeys 

90 

91 A list of keys associated with an action. 

92 

93.. class:: Action 

94 

95 An action itself, i.e. a set of hotkeys and a description for them. 

96 

97 

98Pre-defined widgets 

99------------------- 

100 

101.. autoclass:: Line 

102 

103.. autoclass:: Text 

104 

105.. autoclass:: Input 

106 

107.. autoclass:: SecretInput 

108 

109.. autoclass:: Grid 

110 

111.. autoclass:: Option 

112 :members: 

113 

114.. autoclass:: Choice 

115 

116.. autoclass:: Multiselect 

117 

118.. autoclass:: InputWithCompletion 

119 

120.. autoclass:: Map 

121 

122.. autoclass:: Apply 

123 

124.. autoclass:: Task 

125 :members: 

126 :private-members: 

127 

128""" 

129 

130# ruff: noqa: RET503 

131 

132from __future__ import annotations 

133 

134import abc 

135import contextlib 

136import dataclasses 

137import enum 

138import functools 

139import math 

140import re 

141import string 

142import sys 

143import time 

144from dataclasses import dataclass 

145 

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 

157 

158import typing 

159from typing import TYPE_CHECKING 

160 

161if TYPE_CHECKING: 

162 import typing_extensions as _t 

163else: 

164 from yuio import _typing as _t 

165 

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] 

194 

195_SPACE_BETWEEN_COLUMNS = 2 

196_MIN_COLUMN_WIDTH = 10 

197 

198 

199T = _t.TypeVar("T") 

200U = _t.TypeVar("U") 

201T_co = _t.TypeVar("T_co", covariant=True) 

202 

203 

204class Key(enum.Enum): 

205 """ 

206 Non-character keys. 

207 

208 """ 

209 

210 ENTER = enum.auto() 

211 """ 

212 :kbd:`Enter` key. 

213 

214 """ 

215 

216 ESCAPE = enum.auto() 

217 """ 

218 :kbd:`Escape` key. 

219 

220 """ 

221 

222 INSERT = enum.auto() 

223 """ 

224 :kbd:`Insert` key. 

225 

226 """ 

227 

228 DELETE = enum.auto() 

229 """ 

230 :kbd:`Delete` key. 

231 

232 """ 

233 

234 BACKSPACE = enum.auto() 

235 """ 

236 :kbd:`Backspace` key. 

237 

238 """ 

239 

240 TAB = enum.auto() 

241 """ 

242 :kbd:`Tab` key. 

243 

244 """ 

245 

246 HOME = enum.auto() 

247 """ 

248 :kbd:`Home` key. 

249 

250 """ 

251 

252 END = enum.auto() 

253 """ 

254 :kbd:`End` key. 

255 

256 """ 

257 

258 PAGE_UP = enum.auto() 

259 """ 

260 :kbd:`PageUp` key. 

261 

262 """ 

263 

264 PAGE_DOWN = enum.auto() 

265 """ 

266 :kbd:`PageDown` key. 

267 

268 """ 

269 

270 ARROW_UP = enum.auto() 

271 """ 

272 :kbd:`ArrowUp` key. 

273 

274 """ 

275 

276 ARROW_DOWN = enum.auto() 

277 """ 

278 :kbd:`ArrowDown` key. 

279 

280 """ 

281 

282 ARROW_LEFT = enum.auto() 

283 """ 

284 :kbd:`ArrowLeft` key. 

285 

286 """ 

287 

288 ARROW_RIGHT = enum.auto() 

289 """ 

290 :kbd:`ArrowRight` key. 

291 

292 """ 

293 

294 F1 = enum.auto() 

295 """ 

296 :kbd:`F1` key. 

297 

298 """ 

299 

300 F2 = enum.auto() 

301 """ 

302 :kbd:`F2` key. 

303 

304 """ 

305 

306 F3 = enum.auto() 

307 """ 

308 :kbd:`F3` key. 

309 

310 """ 

311 

312 F4 = enum.auto() 

313 """ 

314 :kbd:`F4` key. 

315 

316 """ 

317 

318 F5 = enum.auto() 

319 """ 

320 :kbd:`F5` key. 

321 

322 """ 

323 

324 F6 = enum.auto() 

325 """ 

326 :kbd:`F6` key. 

327 

328 """ 

329 

330 F7 = enum.auto() 

331 """ 

332 :kbd:`F7` key. 

333 

334 """ 

335 

336 F8 = enum.auto() 

337 """ 

338 :kbd:`F8` key. 

339 

340 """ 

341 

342 F9 = enum.auto() 

343 """ 

344 :kbd:`F9` key. 

345 

346 """ 

347 

348 F10 = enum.auto() 

349 """ 

350 :kbd:`F10` key. 

351 

352 """ 

353 

354 F11 = enum.auto() 

355 """ 

356 :kbd:`F11` key. 

357 

358 """ 

359 

360 F12 = enum.auto() 

361 """ 

362 :kbd:`F12` key. 

363 

364 """ 

365 

366 PASTE = enum.auto() 

367 """ 

368 Triggered when a text is pasted into a terminal. 

369 

370 """ 

371 

372 def __str__(self) -> str: 

373 return self.name.replace("_", " ").title() 

374 

375 

376@dataclass(frozen=True, slots=True) 

377class KeyboardEvent: 

378 """ 

379 A single keyboard event. 

380 

381 .. warning:: 

382 

383 Protocol for interacting with terminals is quite old, and not all terminals 

384 support all keystroke combinations. 

385 

386 Use :flag:`python -m yuio.scripts.showkey` to check how your terminal reports 

387 keystrokes, and how Yuio interprets them. 

388 

389 """ 

390 

391 key: Key | str 

392 """ 

393 Which key was pressed? Can be a single character, 

394 or a :class:`Key` for non-character keys. 

395 

396 """ 

397 

398 ctrl: bool = False 

399 """ 

400 Whether a :kbd:`Ctrl` modifier was pressed with keystroke. 

401 

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: 

405 

406 .. skip-next: 

407 

408 .. code-block:: python 

409 

410 # `Ctrl+X` was pressed. 

411 KeyboardEvent("x", ctrl=True) 

412 

413 # `Ctrl+Shift+X` was pressed. Not all terminals are able 

414 # to report this correctly, though. 

415 KeyboardEvent("x", ctrl=True, shift=True) 

416 

417 # This can't happen. 

418 KeyboardEvent("X", ctrl=True) 

419 

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) 

423 

424 """ 

425 

426 alt: bool = False 

427 """ 

428 Whether an :kbd:`Alt` (:kbd:`Option` on macs) modifier was pressed with keystroke. 

429 

430 """ 

431 

432 shift: bool = False 

433 """ 

434 Whether a :kbd:`Shift` modifier was pressed with keystroke. 

435 

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`: 

438 

439 .. skip-next: 

440 

441 .. code-block:: python 

442 

443 KeyboardEvent("x") # `X` was pressed. 

444 KeyboardEvent("X") # `Shift+X` was pressed. 

445 

446 .. warning:: 

447 

448 Only :kbd:`Shift+Tab` can be reliably reported by all terminals. 

449 

450 """ 

451 

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. 

455 

456 """ 

457 

458 

459@_t.final 

460class RenderContext: 

461 """ 

462 A canvas onto which widgets render themselves. 

463 

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. 

467 

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. 

471 

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. 

475 

476 """ 

477 

478 # For tests. 

479 _override_wh: tuple[int, int] | None = None 

480 

481 def __init__(self, term: _Term, theme: _Theme, /): 

482 self._term: _Term = term 

483 self._theme: _Theme = theme 

484 

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 # ┗█▇█▇█▇█▇█▇█▇█▇█▇█▇█▇█┛ 

521 

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 = "" 

530 

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]] = [] 

541 

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 

554 

555 # Helpers 

556 self._none_color: str = _Color.NONE.as_code(term.color_support) 

557 

558 # Used for tests and debug 

559 self._renders: int = 0 

560 self._bytes_rendered: int = 0 

561 self._total_bytes_rendered: int = 0 

562 

563 @property 

564 def term(self) -> _Term: 

565 """ 

566 Terminal where we render the widgets. 

567 

568 """ 

569 

570 return self._term 

571 

572 @property 

573 def theme(self) -> _Theme: 

574 """ 

575 Current color theme. 

576 

577 """ 

578 

579 return self._theme 

580 

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>`. 

586 

587 """ 

588 

589 return self._spinner_state 

590 

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. 

603 

604 Widgets are always drawn in the frame's top-left corner, 

605 and they can take the entire frame size. 

606 

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. 

609 

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. 

613 

614 .. 

615 >>> term = _Term(sys.stdout, sys.stdin) 

616 >>> theme = _Theme() 

617 >>> rc = RenderContext(term, theme) 

618 >>> rc._override_wh = (20, 5) 

619 

620 Example:: 

621 

622 >>> rc = RenderContext(term, theme) # doctest: +SKIP 

623 >>> rc.prepare() 

624 

625 >>> # By default, our frame is located at (0, 0)... 

626 >>> rc.write("+") 

627 

628 >>> # ...and spans the entire canvas. 

629 >>> print(rc.width, rc.height) 

630 20 5 

631 

632 >>> # Let's write something at (4, 0). 

633 >>> rc.set_pos(4, 0) 

634 >>> rc.write("Hello, world!") 

635 

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 

651 

652 >>> rc.render() # doctest: +NORMALIZE_WHITESPACE 

653 + Hello, world! 

654 <BLANKLINE> 

655 + Hello, world! 

656 <BLANKLINE> 

657 <BLANKLINE> 

658 

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:: 

664 

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. 

669 

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 ) 

675 

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) 

682 

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 

686 

687 def draw(self, rc: RenderContext): 

688 # Print a small heading. 

689 rc.set_color_path("bold") 

690 rc.write("Small heading") 

691 

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) 

696 

697 """ 

698 

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 

706 

707 self._frame_x += x 

708 self._frame_y += y 

709 

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 

716 

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 

723 

724 self._frame_cursor_x = 0 

725 self._frame_cursor_y = 0 

726 self._frame_cursor_color = self._none_color 

727 

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 

738 

739 @property 

740 def width(self) -> int: 

741 """ 

742 Get width of the current frame. 

743 

744 """ 

745 

746 return self._frame_w 

747 

748 @property 

749 def height(self) -> int: 

750 """ 

751 Get height of the current frame. 

752 

753 """ 

754 

755 return self._frame_h 

756 

757 @property 

758 def canvas_width(self) -> int: 

759 """ 

760 Get width of the terminal. 

761 

762 """ 

763 

764 return self._width 

765 

766 @property 

767 def canvas_height(self) -> int: 

768 """ 

769 Get height of the terminal. 

770 

771 """ 

772 

773 return self._height 

774 

775 def set_pos(self, x: int, y: int, /): 

776 """ 

777 Set current cursor position within the frame. 

778 

779 """ 

780 

781 self._frame_cursor_x = x 

782 self._frame_cursor_y = y 

783 

784 def move_pos(self, dx: int, dy: int, /): 

785 """ 

786 Move current cursor position by the given amount. 

787 

788 """ 

789 

790 self._frame_cursor_x += dx 

791 self._frame_cursor_y += dy 

792 

793 def new_line(self): 

794 """ 

795 Move cursor to new line within the current frame. 

796 

797 """ 

798 

799 self._frame_cursor_x = 0 

800 self._frame_cursor_y += 1 

801 

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. 

806 

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. 

810 

811 """ 

812 

813 self._final_x = x + self._frame_x 

814 self._final_y = y + self._frame_y 

815 

816 def set_color_path(self, path: str, /): 

817 """ 

818 Set current color by fetching it from the theme by path. 

819 

820 """ 

821 

822 self._frame_cursor_color = self._theme.get_color(path).as_code( 

823 self._term.color_support 

824 ) 

825 

826 def set_color(self, color: _Color, /): 

827 """ 

828 Set current color. 

829 

830 """ 

831 

832 self._frame_cursor_color = color.as_code(self._term.color_support) 

833 

834 def reset_color(self): 

835 """ 

836 Set current color to the default color of the terminal. 

837 

838 """ 

839 

840 self._frame_cursor_color = self._none_color 

841 

842 def get_msg_decoration(self, name: str, /) -> str: 

843 """ 

844 Get message decoration by name. 

845 

846 """ 

847 

848 return self.theme.get_msg_decoration(name, is_unicode=self.term.is_unicode) 

849 

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. 

854 

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. 

860 

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`. 

864 

865 .. 

866 >>> term = _Term(sys.stdout, sys.stdin) 

867 >>> theme = _Theme() 

868 >>> rc = RenderContext(term, theme) 

869 >>> rc._override_wh = (20, 5) 

870 

871 Example:: 

872 

873 >>> rc = RenderContext(term, theme) # doctest: +SKIP 

874 >>> rc.prepare() 

875 

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() 

889 

890 >>> rc.render() 

891 Hello, world! 

892 Hello, world! 

893 Hello, 🌍! 

894 Hello, 🌍!< 

895 <BLANKLINE> 

896 

897 Notice that ``"\\n"`` on the second line was replaced with a space. 

898 Notice also that the last line wasn't properly clipped. 

899 

900 """ 

901 

902 if not isinstance(text, _ColorizedString): 

903 text = _ColorizedString(text, _isolate_colors=False) 

904 

905 x = self._frame_x + self._frame_cursor_x 

906 y = self._frame_y + self._frame_cursor_y 

907 

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 

914 

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 

920 

921 ll = self._lines[y] 

922 cc = self._colors[y] 

923 uu = self._urls[y] 

924 

925 url = "" 

926 

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 

936 

937 s = s.translate(_UNPRINTABLE_TRANS) 

938 

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 

945 

946 slice_begin = 0 

947 if x < 0: 

948 # We're partially beyond the left terminal border. 

949 slice_begin = -x 

950 x = 0 

951 

952 if x >= max_x: 

953 # We're beyond the right terminal border. 

954 x += len(s) - slice_begin 

955 continue 

956 

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 

961 

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 

968 

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 

993 

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 

1000 

1001 ll[x] = c 

1002 cc[x] = self._frame_cursor_color 

1003 uu[x] = url 

1004 

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 

1012 

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. 

1022 

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. 

1026 

1027 After each line, the cursor is moved one line down, 

1028 and back to its original horizontal position. 

1029 

1030 .. 

1031 >>> term = _Term(sys.stdout, sys.stdin) 

1032 >>> theme = _Theme() 

1033 >>> rc = RenderContext(term, theme) 

1034 >>> rc._override_wh = (20, 5) 

1035 

1036 Example:: 

1037 

1038 >>> rc = RenderContext(term, theme) # doctest: +SKIP 

1039 >>> rc.prepare() 

1040 

1041 >>> # Cursor is at (0, 0). 

1042 >>> rc.write("+ > ") 

1043 

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!"]) 

1047 

1048 >>> # Cursor is at the last line. 

1049 >>> rc.write("+") 

1050 

1051 >>> rc.render() # doctest: +NORMALIZE_WHITESPACE 

1052 + > Hello, 

1053 world!+ 

1054 <BLANKLINE> 

1055 <BLANKLINE> 

1056 <BLANKLINE> 

1057 

1058 """ 

1059 

1060 x = self._frame_cursor_x 

1061 

1062 for i, line in enumerate(lines): 

1063 if i > 0: 

1064 self._frame_cursor_x = x 

1065 self._frame_cursor_y += 1 

1066 

1067 self.write(line, max_width=max_width) 

1068 

1069 def bell(self): 

1070 """ 

1071 Ring a terminal bell. 

1072 

1073 """ 

1074 

1075 self._bell = True 

1076 

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. 

1088 

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. 

1104 

1105 """ 

1106 

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 ) 

1117 

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) 

1126 

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. 

1136 

1137 """ 

1138 

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 

1145 

1146 full_redraw = full_redraw or self._width != width or self._height != height 

1147 

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 

1162 

1163 if reset_term_pos: 

1164 self._term_x, self._term_y = 0, 0 

1165 full_redraw = True 

1166 

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 

1175 

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() 

1191 

1192 # Rendering status 

1193 self._full_redraw = full_redraw 

1194 

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 

1199 

1200 def clear_screen(self): 

1201 """ 

1202 Clear screen and prepare for a full redraw. 

1203 

1204 """ 

1205 

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) 

1209 

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 

1219 

1220 def render(self): 

1221 """ 

1222 Render current canvas onto the terminal. 

1223 

1224 """ 

1225 

1226 if not self.term.ostream_is_tty: 

1227 # For tests. Widgets can't work with dumb terminals 

1228 self._render_dumb() 

1229 return 

1230 

1231 if self._bell: 

1232 self._out.append("\a") 

1233 self._bell = False 

1234 

1235 if self._full_redraw: 

1236 self._move_term_cursor(0, 0) 

1237 self._out.append("\x1b[J") 

1238 

1239 term_url = "" 

1240 

1241 for y in range(self._height): 

1242 line = self._lines[y] 

1243 

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] 

1248 

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) 

1255 

1256 if color != self._term_color: 

1257 self._out.append(color) 

1258 self._term_color = color 

1259 

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 

1265 

1266 self._out.append(line[x]) 

1267 self._term_x += 1 

1268 

1269 if term_url: 

1270 self._out.append("\x1b]8;;\x1b\\") 

1271 

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) 

1275 

1276 rendered = "".join(self._out) 

1277 self._term.ostream.write(rendered) 

1278 self._term.ostream.flush() 

1279 self._out.clear() 

1280 

1281 if yuio._debug: 

1282 self._renders += 1 

1283 self._bytes_rendered = len(rendered.encode()) 

1284 self._total_bytes_rendered += self._bytes_rendered 

1285 

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) 

1294 

1295 self._term.ostream.write("".join(self._out)) 

1296 self._term.ostream.flush() 

1297 self._out.clear() 

1298 

1299 def finalize(self): 

1300 """ 

1301 Erase any rendered widget and move cursor to the initial position. 

1302 

1303 """ 

1304 

1305 self.prepare(full_redraw=True) 

1306 

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 

1314 

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) 

1326 

1327 if x != self._term_x: 

1328 self._out.append(f"\x1b[{x + 1}G") 

1329 self._term_x = x 

1330 

1331 def _render_dumb(self): 

1332 prev_printed_color = self._none_color 

1333 

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") 

1341 

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 ) 

1347 

1348 

1349@dataclass(frozen=True, slots=True) 

1350class Result(_t.Generic[T_co]): 

1351 """ 

1352 Result of a widget run. 

1353 

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. 

1357 

1358 """ 

1359 

1360 value: T_co 

1361 """ 

1362 Result of a widget run. 

1363 

1364 """ 

1365 

1366 

1367class Widget(abc.ABC, _t.Generic[T_co]): 

1368 """ 

1369 Base class for all interactive console elements. 

1370 

1371 Widgets are displayed with their :meth:`~Widget.run` method. 

1372 They always go through the same event loop: 

1373 

1374 .. raw:: html 

1375 

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> 

1387 

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. 

1392 

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]``. 

1396 

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. 

1400 

1401 """ 

1402 

1403 __bindings: typing.ClassVar[dict[KeyboardEvent, _t.Callable[[_t.Any], _t.Any]]] 

1404 __callbacks: typing.ClassVar[list[object]] 

1405 

1406 __in_help_menu: bool = False 

1407 __bell: bool = False 

1408 

1409 _cur_event: KeyboardEvent | None = None 

1410 """ 

1411 Current event that is being processed. 

1412 Guaranteed to be not :data:`None` inside event handlers. 

1413 

1414 """ 

1415 

1416 def __init_subclass__(cls, **kwargs): 

1417 super().__init_subclass__(**kwargs) 

1418 

1419 cls.__bindings = {} 

1420 cls.__callbacks = [] 

1421 

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) 

1430 

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) 

1437 

1438 def event(self, e: KeyboardEvent, /) -> Result[T_co] | None: 

1439 """ 

1440 Handle incoming keyboard event. 

1441 

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`. 

1445 

1446 """ 

1447 

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) 

1453 

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. 

1457 

1458 """ 

1459 

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. 

1465 

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. 

1469 

1470 """ 

1471 

1472 raise NotImplementedError() 

1473 

1474 @abc.abstractmethod 

1475 def draw(self, rc: RenderContext, /): 

1476 """ 

1477 Draw the widget. 

1478 

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`. 

1482 

1483 """ 

1484 

1485 raise NotImplementedError() 

1486 

1487 @_t.final 

1488 def run(self, term: _Term, theme: _Theme, /) -> T_co: 

1489 """ 

1490 Read user input and run the widget. 

1491 

1492 """ 

1493 

1494 if not term.can_run_widgets: 

1495 raise RuntimeError("terminal doesn't support rendering widgets") 

1496 

1497 with yuio.term._enter_raw_mode( 

1498 term.ostream, term.istream, bracketed_paste=True, modify_keyboard=True 

1499 ): 

1500 rc = RenderContext(term, theme) 

1501 

1502 events = _event_stream(term.ostream, term.istream) 

1503 

1504 try: 

1505 while True: 

1506 rc.prepare(alternative_buffer=self.__in_help_menu) 

1507 

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) 

1529 

1530 if self.__bell: 

1531 rc.bell() 

1532 self.__bell = False 

1533 rc.render() 

1534 

1535 try: 

1536 event = next(events) 

1537 except StopIteration: 

1538 assert False, "_event_stream supposed to be infinite" 

1539 

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() 

1554 

1555 def _bell(self): 

1556 self.__bell = True 

1557 

1558 @property 

1559 def help_data(self) -> WidgetHelp: 

1560 """ 

1561 Data for displaying help messages. 

1562 

1563 See :func:`help` for more info. 

1564 

1565 """ 

1566 

1567 return self.__help_columns 

1568 

1569 @functools.cached_property 

1570 def __help_columns(self) -> WidgetHelp: 

1571 inline_help: list[Action] = [] 

1572 groups: dict[str, list[Action]] = {} 

1573 

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 

1587 

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)) 

1596 

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 ) 

1607 

1608 return WidgetHelp(inline_help, groups) 

1609 

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 ] 

1641 

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 ) 

1687 

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 

1691 

1692 if self.__width != rc.width: 

1693 self.__help_menu_line = 0 

1694 

1695 self.__width = rc.width 

1696 self.__height = rc.height 

1697 

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 ) 

1707 

1708 return True 

1709 

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) 

1713 

1714 if not self.__clear_layout_cache(rc): 

1715 return rc.height, rc.height 

1716 

1717 self.__key_width = 10 

1718 ctx = rc.make_repr_context( 

1719 width=min(rc.width, 90) - self.__key_width - 2, 

1720 ) 

1721 

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)) 

1729 

1730 return rc.height, rc.height 

1731 

1732 def __help_menu_draw(self, rc: RenderContext, /): 

1733 y = self.__help_menu_line 

1734 

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 

1739 

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 

1746 

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 = "/" 

1760 

1761 rc.set_pos(0 + self.__key_width + 2, y) 

1762 rc.write_text(lines) 

1763 y += len(lines) 

1764 

1765 y += 2 

1766 

1767 self.__menu_content_height = y - self.__help_menu_line 

1768 

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) 

1780 

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) 

1784 

1785 if not self.__has_help: 

1786 return 0, 0 

1787 

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)) 

1800 

1801 return 1, 1 

1802 

1803 def __help_menu_draw_inline(self, rc: RenderContext, /): 

1804 if not self.__has_help: 

1805 return 

1806 

1807 used_width = _line_width(rc.get_msg_decoration("menu/help/key/f1")) + 5 

1808 col_sep = "" 

1809 

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 

1814 

1815 rc.set_color_path("menu/text/help_sep:help") 

1816 rc.write(col_sep) 

1817 

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 = "/" 

1825 

1826 if keys_width: 

1827 rc.move_pos(1, 0) 

1828 rc.write(title) 

1829 

1830 col_sep = " • " 

1831 

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") 

1839 

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 ] 

1849 

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 ) 

1899 

1900 # Make sure unsorted actions go first. 

1901 groups = {"Input Format": [], "Actions": []} 

1902 

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 ) 

1917 

1918 if not groups["Input Format"]: 

1919 del groups["Input Format"] 

1920 if not groups["Actions"]: 

1921 del groups["Actions"] 

1922 

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") 

1928 

1929 return groups 

1930 

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 

1940 

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 

1945 

1946 title = msg.split("\n\n", maxsplit=1)[0] 

1947 return prepared_keys, title, msg, _line_width("/".join(prepared_keys)) 

1948 

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] 

1954 

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 

1967 

1968 symbol = "" 

1969 

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() 

1978 

1979 if shift: 

1980 symbol += rc.get_msg_decoration("menu/help/key/shift") 

1981 

1982 if ctrl: 

1983 symbol += rc.get_msg_decoration("menu/help/key/ctrl") 

1984 

1985 if alt: 

1986 symbol += rc.get_msg_decoration("menu/help/key/alt") 

1987 

1988 return symbol + (rc.get_msg_decoration(f"menu/help/key/{key}") or key) 

1989 

1990 

1991Widget.__init_subclass__() 

1992 

1993 

1994@dataclass(frozen=True, slots=True) 

1995class _Binding: 

1996 event: KeyboardEvent 

1997 show_in_inline_help: bool 

1998 show_in_detailed_help: bool 

1999 

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) 

2004 

2005 return fn 

2006 

2007 

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. 

2019 

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`. 

2024 

2025 .. note:: 

2026 

2027 :kbd:`Ctrl+L` and :kbd:`F1` are always reserved by the widget itself. 

2028 

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. 

2032 

2033 Example:: 

2034 

2035 class MyWidget(Widget): 

2036 @bind(Key.ENTER) 

2037 def enter(self): 

2038 # all `ENTER` events go here. 

2039 ... 

2040 

2041 def default_event_handler(self, e: KeyboardEvent): 

2042 # all non-`ENTER` events go here (including `ALT+ENTER`). 

2043 ... 

2044 

2045 """ 

2046 

2047 e = KeyboardEvent(key=key, ctrl=ctrl, alt=alt, shift=shift) 

2048 return _Binding(e, show_in_inline_help, show_in_detailed_help) 

2049 

2050 

2051@dataclass(frozen=True, slots=True) 

2052class _Help: 

2053 group: str = "Actions" 

2054 inline_msg: str | None = None 

2055 long_msg: str | None = None 

2056 

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) 

2072 

2073 return fn 

2074 

2075 

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. 

2085 

2086 This decorator controls automatic generation of help messages for a widget. 

2087 

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. 

2100 

2101 Example:: 

2102 

2103 class MyWidget(Widget): 

2104 NAVIGATE = "Navigate" 

2105 

2106 @bind(Key.TAB) 

2107 @help(group=NAVIGATE) 

2108 def tab(self): 

2109 \"""next item\""" 

2110 ... 

2111 

2112 @bind(Key.TAB, shift=True) 

2113 @help(group=NAVIGATE) 

2114 def shift_tab(self): 

2115 \"""previous item\""" 

2116 ... 

2117 

2118 """ 

2119 

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 

2124 

2125 return _Help( 

2126 group, 

2127 inline_msg, 

2128 long_msg, 

2129 ) 

2130 

2131 

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""" 

2138 

2139 

2140ActionKeys: _t.TypeAlias = ActionKey | _t.Collection[ActionKey] 

2141""" 

2142A list of keys associated with an action. 

2143 

2144""" 

2145 

2146 

2147Action: _t.TypeAlias = str | tuple[ActionKeys, str] 

2148""" 

2149An action itself, i.e. a set of hotkeys and a description for them. 

2150 

2151""" 

2152 

2153 

2154@dataclass(frozen=True, slots=True) 

2155class WidgetHelp: 

2156 """ 

2157 Data for automatic help generation. 

2158 

2159 .. warning:: 

2160 

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. 

2164 

2165 Use the provided helpers to modify contents of this class. 

2166 

2167 """ 

2168 

2169 inline_help: list[Action] = dataclasses.field(default_factory=list) 

2170 """ 

2171 List of actions to show in the inline help. 

2172 

2173 """ 

2174 

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. 

2178 

2179 """ 

2180 

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. 

2193 

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. 

2212 

2213 """ 

2214 

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 ) 

2224 

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`. 

2229 

2230 :param other: 

2231 other :class:`WidgetHelp` for merging. 

2232 

2233 """ 

2234 

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 

2240 

2241 def without_group(self, title: str, /) -> WidgetHelp: 

2242 """ 

2243 Return a new :class:`WidgetHelp` that has a group with the given title removed. 

2244 

2245 :param title: 

2246 title to remove. 

2247 

2248 """ 

2249 

2250 result = WidgetHelp(self.inline_help.copy(), self.groups.copy()) 

2251 result.groups.pop(title, None) 

2252 return result 

2253 

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. 

2257 

2258 :param title: 

2259 title to replace. 

2260 :param new_title: 

2261 new title. 

2262 

2263 """ 

2264 

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 

2269 

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 ) 

2286 

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)) 

2297 

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 ] 

2317 

2318 return self 

2319 

2320 

2321@_t.final 

2322class VerticalLayoutBuilder(_t.Generic[T]): 

2323 """ 

2324 Builder for :class:`VerticalLayout` that allows for precise control 

2325 of keyboard events. 

2326 

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:: 

2330 

2331 widget = VerticalLayout.builder() \\ 

2332 .add(Line("Enter something:")) \\ 

2333 .add(Input(), receive_events=True) \\ 

2334 .build() 

2335 

2336 result = widget.run(term, theme) 

2337 

2338 """ 

2339 

2340 if TYPE_CHECKING: 

2341 

2342 def __new__(cls) -> VerticalLayoutBuilder[_t.Never]: ... 

2343 

2344 def __init__(self): 

2345 self._widgets: list[Widget[_t.Any]] = [] 

2346 self._event_receiver: int | None = None 

2347 

2348 @_t.overload 

2349 def add( 

2350 self, widget: Widget[_t.Any], /, *, receive_events: _t.Literal[False] = False 

2351 ) -> VerticalLayoutBuilder[T]: ... 

2352 

2353 @_t.overload 

2354 def add( 

2355 self, widget: Widget[U], /, *, receive_events: _t.Literal[True] 

2356 ) -> VerticalLayoutBuilder[U]: ... 

2357 

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. 

2361 

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. 

2365 

2366 This method does not mutate the builder, but instead returns a new one. 

2367 Use it with method chaining. 

2368 

2369 """ 

2370 

2371 other = VerticalLayoutBuilder() 

2372 

2373 other._widgets = self._widgets.copy() 

2374 other._event_receiver = self._event_receiver 

2375 

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) 

2386 

2387 return other 

2388 

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) 

2394 

2395 

2396class VerticalLayout(Widget[T], _t.Generic[T]): 

2397 """ 

2398 Helper class for stacking widgets together. 

2399 

2400 You can stack your widgets together, then calculate their layout 

2401 and draw them all at once. 

2402 

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. 

2406 

2407 .. automethod:: append 

2408 

2409 .. automethod:: extend 

2410 

2411 .. automethod:: event 

2412 

2413 .. automethod:: layout 

2414 

2415 .. automethod:: draw 

2416 

2417 """ 

2418 

2419 if TYPE_CHECKING: 

2420 

2421 def __new__(cls, *widgets: Widget[object]) -> VerticalLayout[_t.Never]: ... 

2422 

2423 def __init__(self, *widgets: Widget[object]): 

2424 self._widgets: list[Widget[object]] = list(widgets) 

2425 self._event_receiver: int | None = None 

2426 

2427 self.__layouts: list[tuple[int, int]] = [] 

2428 self.__min_h: int = 0 

2429 self.__max_h: int = 0 

2430 

2431 def append(self, widget: Widget[_t.Any], /): 

2432 """ 

2433 Add a widget to the end of the stack. 

2434 

2435 """ 

2436 

2437 if isinstance(widget, VerticalLayout): 

2438 self._widgets.extend(widget._widgets) 

2439 else: 

2440 self._widgets.append(widget) 

2441 

2442 def extend(self, widgets: _t.Iterable[Widget[_t.Any]], /): 

2443 """ 

2444 Add multiple widgets to the end of the stack. 

2445 

2446 """ 

2447 

2448 for widget in widgets: 

2449 self.append(widget) 

2450 

2451 def event(self, e: KeyboardEvent) -> Result[T] | None: 

2452 """ 

2453 Dispatch event to the widget that was added with ``receive_events=True``. 

2454 

2455 See :class:`~VerticalLayoutBuilder` for details. 

2456 

2457 """ 

2458 

2459 if self._event_receiver is not None: 

2460 return _t.cast( 

2461 Result[T] | None, self._widgets[self._event_receiver].event(e) 

2462 ) 

2463 

2464 def layout(self, rc: RenderContext, /) -> tuple[int, int]: 

2465 """ 

2466 Calculate layout of the entire stack. 

2467 

2468 """ 

2469 

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 

2475 

2476 def draw(self, rc: RenderContext, /): 

2477 """ 

2478 Draw the stack according to the calculated layout and available height. 

2479 

2480 """ 

2481 

2482 assert len(self._widgets) == len(self.__layouts), ( 

2483 "you need to call `VerticalLayout.layout()` before `VerticalLayout.draw()`" 

2484 ) 

2485 

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) 

2492 

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) 

2496 

2497 iy1 = round(y1) 

2498 iy2 = round(y2) 

2499 

2500 with rc.frame(0, iy1, height=iy2 - iy1): 

2501 widget.draw(rc) 

2502 

2503 y1 = y2 

2504 

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() 

2511 

2512 

2513class Empty(Widget[_t.Never]): 

2514 """ 

2515 An empty widget with no size. 

2516 

2517 """ 

2518 

2519 def layout(self, rc: RenderContext, /) -> tuple[int, int]: 

2520 return 0, 0 

2521 

2522 def draw(self, rc: RenderContext, /): 

2523 pass 

2524 

2525 

2526class Line(Widget[_t.Never]): 

2527 """ 

2528 A widget that prints a single line of text. 

2529 

2530 """ 

2531 

2532 def __init__( 

2533 self, 

2534 text: yuio.string.Colorable, 

2535 /, 

2536 ): 

2537 self.__text = text 

2538 self.__colorized_text = None 

2539 

2540 @property 

2541 def text(self) -> yuio.string.Colorable: 

2542 """ 

2543 Currently displayed text. 

2544 

2545 """ 

2546 

2547 return self.__text 

2548 

2549 @text.setter 

2550 def text(self, text: yuio.string.Colorable, /): 

2551 self.__text = text 

2552 self.__colorized_text = None 

2553 

2554 def layout(self, rc: RenderContext, /) -> tuple[int, int]: 

2555 return 1, 1 

2556 

2557 def draw(self, rc: RenderContext, /): 

2558 if self.__colorized_text is None: 

2559 self.__colorized_text = rc.make_repr_context().str(self.__text) 

2560 

2561 rc.write(self.__colorized_text) 

2562 

2563 

2564class Text(Widget[_t.Never]): 

2565 """ 

2566 A widget that prints wrapped text. 

2567 

2568 """ 

2569 

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 

2578 

2579 @property 

2580 def text(self) -> yuio.string.Colorable: 

2581 """ 

2582 Currently displayed text. 

2583 

2584 """ 

2585 

2586 return self.__text 

2587 

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 

2593 

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 

2604 

2605 def draw(self, rc: RenderContext, /): 

2606 assert self.__wrapped_text is not None 

2607 rc.write_text(self.__wrapped_text) 

2608 

2609 

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} 

2678 

2679_ESC_RE = re.compile(r"([" + re.escape("".join(map(str, _CHAR_NAMES))) + "])") 

2680 

2681 

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 

2695 

2696 

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 

2728 

2729 

2730class Input(Widget[str]): 

2731 """ 

2732 An input box. 

2733 

2734 .. vhs:: /_tapes/widget_input.tape 

2735 :alt: Demonstration of `Input` widget. 

2736 :width: 480 

2737 :height: 240 

2738 

2739 .. note:: 

2740 

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. 

2745 

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. 

2761 

2762 """ 

2763 

2764 # Characters that count as word separators, used when navigating input text 

2765 # via hotkeys. 

2766 _WORD_SEPARATORS = string.punctuation + string.whitespace 

2767 

2768 # Character that replaces newlines and unprintable characters when 

2769 # `allow_multiline`/`allow_special_characters` is `False`. 

2770 _UNPRINTABLE_SUBSTITUTOR = " " 

2771 

2772 class _CheckpointType(enum.Enum): 

2773 """ 

2774 Types of entries in the history buffer. 

2775 

2776 """ 

2777 

2778 USR = enum.auto() 

2779 """ 

2780 User-initiated checkpoint. 

2781 

2782 """ 

2783 

2784 SYM = enum.auto() 

2785 """ 

2786 Checkpoint before a symbol was inserted. 

2787 

2788 """ 

2789 

2790 SEP = enum.auto() 

2791 """ 

2792 Checkpoint before a space was inserted. 

2793 

2794 """ 

2795 

2796 DEL = enum.auto() 

2797 """ 

2798 Checkpoint before something was deleted. 

2799 

2800 """ 

2801 

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 

2818 

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 

2822 

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 

2837 

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 = "" 

2841 

2842 self.__err_region: tuple[int, int] | None = None 

2843 

2844 @property 

2845 def text(self) -> str: 

2846 """ 

2847 Current text in the input box. 

2848 

2849 """ 

2850 return self.__text 

2851 

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 

2859 

2860 @property 

2861 def pos(self) -> int: 

2862 """ 

2863 Current cursor position, measured in code points before the cursor. 

2864 

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 ")`. 

2867 

2868 """ 

2869 return self.__pos 

2870 

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 

2875 

2876 @property 

2877 def err_region(self) -> tuple[int, int] | None: 

2878 return self.__err_region 

2879 

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 

2884 

2885 def checkpoint(self): 

2886 """ 

2887 Manually create an entry in the history buffer. 

2888 

2889 """ 

2890 self.__history.append((self.text, self.pos, Input._CheckpointType.USR)) 

2891 self.__history_skipped_actions = 0 

2892 

2893 def restore_checkpoint(self): 

2894 """ 

2895 Restore the last manually created checkpoint. 

2896 

2897 """ 

2898 if self.__history[-1][2] is Input._CheckpointType.USR: 

2899 self.undo() 

2900 

2901 def _internal_checkpoint(self, action: Input._CheckpointType, text: str, pos: int): 

2902 prev_text, prev_pos, prev_action = self.__history[-1] 

2903 

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 

2910 

2911 prev_skipped_actions = self.__history_skipped_actions 

2912 self.__history_skipped_actions = 0 

2913 

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 

2925 

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 

2931 

2932 self.__history.append((text, pos, action)) 

2933 if len(self.__history) > 50: 

2934 self.__history.pop(0) 

2935 

2936 self.__require_checkpoint = False 

2937 

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() 

2944 

2945 @bind(Key.ENTER, alt=True) 

2946 @bind("d", ctrl=True) 

2947 def alt_enter(self) -> Result[str] | None: 

2948 return Result(self.text) 

2949 

2950 _NAVIGATE = "Navigate" 

2951 

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]) 

2961 

2962 self.left() 

2963 self.home() 

2964 

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 

2973 

2974 self.pos = pos 

2975 

2976 self.__require_checkpoint |= checkpoint 

2977 

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() 

2987 

2988 if self.pos < len(self.text): 

2989 self.right() 

2990 

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 

2999 

3000 self.pos = pos 

3001 

3002 self.__require_checkpoint |= checkpoint 

3003 

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 

3011 

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 

3019 

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 

3035 

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 

3053 

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 

3061 

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 

3073 

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 

3083 

3084 _MODIFY = "Modify" 

3085 

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() 

3098 

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() 

3111 

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() 

3125 

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() 

3140 

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() 

3153 

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() 

3167 

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() 

3177 

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() 

3192 

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) 

3213 

3214 def insert(self, s: str): 

3215 if not s: 

3216 return 

3217 

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 ) 

3227 

3228 self.text = self.text[: self.pos] + s + self.text[self.pos :] 

3229 self.pos += len(s) 

3230 

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 

3240 

3241 if self.__wrapped_text is None or self.__wrapped_text_width != text_width: 

3242 self.__wrapped_text_width = text_width 

3243 

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) 

3268 

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) 

3272 

3273 height = max(len(self.__wrapped_text), self.__pos_after_wrap[1] + 1) 

3274 return height, height 

3275 

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) 

3280 

3281 if self.__wrapped_text is not None: 

3282 rc.write_text(self.__wrapped_text) 

3283 

3284 if self.__pos_after_wrap is not None: 

3285 rc.set_final_pos(*self.__pos_after_wrap) 

3286 

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 

3299 

3300 @property 

3301 def help_data(self) -> WidgetHelp: 

3302 help_data = super().help_data 

3303 

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 ) 

3316 

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 ) 

3324 

3325 return help_data 

3326 

3327 

3328class SecretInput(Input): 

3329 """ 

3330 An input box that shows stars instead of entered symbols. 

3331 

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. 

3341 

3342 """ 

3343 

3344 _WORD_SEPARATORS = "" 

3345 _UNPRINTABLE_SUBSTITUTOR = "" 

3346 

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 ) 

3363 

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)) 

3368 

3369 

3370@dataclass(slots=True) 

3371class Option(_t.Generic[T_co]): 

3372 """ 

3373 An option for the :class:`Grid` and :class:`Choice` widgets. 

3374 

3375 """ 

3376 

3377 def __post_init__(self): 

3378 if self.color_tag is None: 

3379 object.__setattr__(self, "color_tag", "none") 

3380 

3381 value: T_co 

3382 """ 

3383 Option's value that will be returned from widget. 

3384 

3385 """ 

3386 

3387 display_text: str 

3388 """ 

3389 What should be displayed in the autocomplete list. 

3390 

3391 """ 

3392 

3393 display_text_prefix: str = dataclasses.field(default="", kw_only=True) 

3394 """ 

3395 Prefix that will be displayed before :attr:`~Option.display_text`. 

3396 

3397 """ 

3398 

3399 display_text_suffix: str = dataclasses.field(default="", kw_only=True) 

3400 """ 

3401 Suffix that will be displayed after :attr:`~Option.display_text`. 

3402 

3403 """ 

3404 

3405 comment: str | None = dataclasses.field(default=None, kw_only=True) 

3406 """ 

3407 Option's short comment. 

3408 

3409 """ 

3410 

3411 color_tag: str | None = dataclasses.field(default=None, kw_only=True) 

3412 """ 

3413 Option's color tag. 

3414 

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}``. 

3418 

3419 """ 

3420 

3421 selected: bool = dataclasses.field(default=False, kw_only=True) 

3422 """ 

3423 For multi-choice widgets, whether this option is chosen or not. 

3424 

3425 """ 

3426 

3427 

3428class Grid(Widget[_t.Never], _t.Generic[T]): 

3429 """ 

3430 A helper widget that shows up in :class:`Choice` and :class:`InputWithCompletion`. 

3431 

3432 .. note:: 

3433 

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. 

3436 

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. 

3447 

3448 """ 

3449 

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 

3468 

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 

3472 

3473 self.set_options(options) 

3474 self.index = default_index 

3475 

3476 @property 

3477 def _page_size(self) -> int: 

3478 return self.__num_rows * self.__num_columns 

3479 

3480 @property 

3481 def index(self) -> int | None: 

3482 """ 

3483 Index of the currently selected option. 

3484 

3485 """ 

3486 

3487 return self.__index 

3488 

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) 

3495 

3496 def get_option(self) -> Option[T] | None: 

3497 """ 

3498 Get the currently selected option, 

3499 or `None` if there are no options selected. 

3500 

3501 """ 

3502 

3503 if self.__options and self.__index is not None: 

3504 return self.__options[self.__index] 

3505 

3506 def has_options(self) -> bool: 

3507 """ 

3508 Return :data:`True` if the options list is not empty. 

3509 

3510 """ 

3511 

3512 return bool(self.__options) 

3513 

3514 def get_options(self) -> _t.Sequence[Option[T]]: 

3515 """ 

3516 Get all options. 

3517 

3518 """ 

3519 

3520 return self.__options 

3521 

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. 

3530 

3531 """ 

3532 

3533 self.__options = options 

3534 self.__max_column_width = None 

3535 self.index = default_index 

3536 

3537 _NAVIGATE = "Navigate" 

3538 

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 

3546 

3547 if self.__index is None: 

3548 self.__index = 0 

3549 else: 

3550 self.__index = (self.__index - 1) % len(self.__options) 

3551 

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 

3559 

3560 if self.__index is None: 

3561 self.__index = 0 

3562 else: 

3563 self.__index = (self.__index + 1) % len(self.__options) 

3564 

3565 @bind(Key.ARROW_LEFT) 

3566 @help(group=_NAVIGATE) 

3567 def prev_column(self): 

3568 """previous column""" 

3569 if not self.__options: 

3570 return 

3571 

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 ) 

3578 

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 

3582 

3583 @bind(Key.ARROW_RIGHT) 

3584 @help(group=_NAVIGATE) 

3585 def next_column(self): 

3586 """next column""" 

3587 if not self.__options: 

3588 return 

3589 

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 ) 

3596 

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 

3600 

3601 @bind(Key.PAGE_UP) 

3602 @help(group=_NAVIGATE) 

3603 def prev_page(self): 

3604 """previous page""" 

3605 if not self.__options: 

3606 return 

3607 

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 

3615 

3616 @bind(Key.PAGE_DOWN) 

3617 @help(group=_NAVIGATE) 

3618 def next_page(self): 

3619 """next page""" 

3620 if not self.__options: 

3621 return 

3622 

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 

3630 

3631 @bind(Key.HOME) 

3632 @help(group=_NAVIGATE) 

3633 def home(self): 

3634 """first page""" 

3635 if not self.__options: 

3636 return 

3637 

3638 if self.__index is None: 

3639 self.__index = 0 

3640 else: 

3641 self.__index = 0 

3642 

3643 @bind(Key.END) 

3644 @help(group=_NAVIGATE) 

3645 def end(self): 

3646 """last page""" 

3647 if not self.__options: 

3648 return 

3649 

3650 if self.__index is None: 

3651 self.__index = 0 

3652 else: 

3653 self.__index = len(self.__options) - 1 

3654 

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 

3671 

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 ) 

3682 

3683 decoration_width = _line_width(active_item_decoration) + max( 

3684 _line_width(selected_item_decoration), 

3685 _line_width(deselected_item_decoration), 

3686 ) 

3687 

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 ) 

3704 

3705 additional_space = 0 

3706 pages = math.ceil(len(self.__options) / self._page_size) 

3707 if pages > 1: 

3708 additional_space = 1 

3709 

3710 return 1 + additional_space, self.__num_rows + additional_space 

3711 

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 

3717 

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 

3723 

3724 column_width = self.__column_width 

3725 num_rows = self.__num_rows 

3726 page_size = self._page_size 

3727 

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] 

3732 

3733 if self.__num_columns > 1: 

3734 available_column_width = column_width - _SPACE_BETWEEN_COLUMNS 

3735 else: 

3736 available_column_width = column_width 

3737 

3738 for i, option in enumerate(page): 

3739 x = i // num_rows 

3740 y = i % num_rows 

3741 

3742 rc.set_pos(x * column_width, y) 

3743 

3744 index = i + page_start_index 

3745 is_current = index == self.__index 

3746 self._render_option(rc, available_column_width, option, is_current) 

3747 

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}") 

3760 

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 ) 

3771 

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 ) 

3794 

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 ) 

3802 

3803 right = option.comment or "" 

3804 right_width = _line_width(right) 

3805 right_decoration_width = 3 if right else 0 

3806 

3807 total_width = ( 

3808 left_decoration_width + left_width + right_decoration_width + right_width 

3809 ) 

3810 

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 ) 

3822 

3823 if total_width > width: 

3824 left_width = max(left_width - (total_width - width), 3) 

3825 total_width = left_decoration_width + left_width 

3826 

3827 if is_active: 

3828 status_tag = "active" 

3829 else: 

3830 status_tag = "normal" 

3831 

3832 if option.selected: 

3833 color_tag = "selected" 

3834 else: 

3835 color_tag = option.color_tag 

3836 

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) 

3843 

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 ) 

3857 

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 ) 

3878 

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("]") 

3890 

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 ) 

3898 

3899 

3900class Choice(Widget[T], _t.Generic[T]): 

3901 """ 

3902 Allows choosing from pre-defined options. 

3903 

3904 .. vhs:: /_tapes/widget_choice.tape 

3905 :alt: Demonstration of `Choice` widget. 

3906 :width: 480 

3907 :height: 240 

3908 

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. 

3921 

3922 """ 

3923 

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 ): ... 

3937 

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 ): ... 

3949 

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 

3963 

3964 if filter is None: 

3965 filter = lambda x, q: mapper(x).lstrip().startswith(q) 

3966 

3967 self.__filter = filter 

3968 

3969 self.__default_index = default_index 

3970 

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 ) 

3977 

3978 self.__enable_search = False 

3979 

3980 self.__layout: VerticalLayout[_t.Never] 

3981 

3982 self.__update_completion() 

3983 

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() 

3992 

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() 

4003 

4004 @bind(Key.ESCAPE) 

4005 def esc(self): 

4006 self.__input.text = "" 

4007 self.__update_completion() 

4008 self.__enable_search = False 

4009 

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() 

4030 

4031 def __update_completion(self): 

4032 query = self.__input.text 

4033 

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) 

4044 

4045 self.__grid.set_options(options) 

4046 self.__grid.index = index 

4047 

4048 def layout(self, rc: RenderContext, /) -> tuple[int, int]: 

4049 self.__layout = VerticalLayout() 

4050 self.__layout.append(self.__grid) 

4051 

4052 if self.__enable_search: 

4053 self.__layout.append(self.__input) 

4054 

4055 return self.__layout.layout(rc) 

4056 

4057 def draw(self, rc: RenderContext, /): 

4058 self.__layout.draw(rc) 

4059 

4060 @property 

4061 def help_data(self) -> WidgetHelp: 

4062 return super().help_data.merge(self.__grid.help_data) 

4063 

4064 

4065class Multiselect(Widget[list[T]], _t.Generic[T]): 

4066 """ 

4067 Like :class:`Choice`, but allows selecting multiple items. 

4068 

4069 .. vhs:: /_tapes/widget_multiselect.tape 

4070 :alt: Demonstration of `Multiselect` widget. 

4071 :width: 480 

4072 :height: 240 

4073 

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. 

4086 

4087 """ 

4088 

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 ): ... 

4098 

4099 @_t.overload 

4100 def __init__( 

4101 self, 

4102 options: list[Option[T]], 

4103 /, 

4104 *, 

4105 filter: _t.Callable[[Option[T], str], bool], 

4106 ): ... 

4107 

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 

4122 

4123 if filter is None: 

4124 filter = lambda x, q: mapper(x).lstrip().startswith(q) 

4125 

4126 self.__filter = filter 

4127 

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 ) 

4137 

4138 self.__enable_search = False 

4139 

4140 self.__layout: VerticalLayout[_t.Never] 

4141 

4142 self.__update_completion() 

4143 

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() 

4156 

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]) 

4162 

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() 

4171 

4172 @bind(Key.ESCAPE) 

4173 def esc(self): 

4174 """exit search""" 

4175 self.__input.text = "" 

4176 self.__update_completion() 

4177 self.__enable_search = False 

4178 

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() 

4197 

4198 def __update_completion(self): 

4199 query = self.__input.text 

4200 

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) 

4209 

4210 self.__grid.set_options(options) 

4211 self.__grid.index = index 

4212 

4213 def layout(self, rc: RenderContext, /) -> tuple[int, int]: 

4214 self.__layout = VerticalLayout() 

4215 self.__layout.append(self.__grid) 

4216 

4217 if self.__enable_search: 

4218 self.__layout.append(self.__input) 

4219 

4220 return self.__layout.layout(rc) 

4221 

4222 def draw(self, rc: RenderContext, /): 

4223 self.__layout.draw(rc) 

4224 

4225 @property 

4226 def help_data(self) -> WidgetHelp: 

4227 return super().help_data.merge(self.__grid.help_data) 

4228 

4229 

4230class InputWithCompletion(Widget[str]): 

4231 """ 

4232 An input box with tab completion. 

4233 

4234 .. vhs:: /_tapes/widget_completion.tape 

4235 :alt: Demonstration of `InputWithCompletion` widget. 

4236 :width: 480 

4237 :height: 240 

4238 

4239 """ 

4240 

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 

4251 

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 

4257 

4258 self.__layout: VerticalLayout[_t.Never] 

4259 self.__rsuffix: yuio.complete.Completion | None = None 

4260 

4261 @property 

4262 def text(self) -> str: 

4263 """ 

4264 Current text in the input box. 

4265 

4266 """ 

4267 

4268 return self.__input.text 

4269 

4270 @property 

4271 def pos(self) -> int: 

4272 """ 

4273 Current cursor position, measured in code points before the cursor. 

4274 

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 ")`. 

4277 

4278 """ 

4279 

4280 return self.__input.pos 

4281 

4282 @property 

4283 def err_region(self) -> tuple[int, int] | None: 

4284 return self.__input.err_region 

4285 

4286 @err_region.setter 

4287 def err_region(self, err_region: tuple[int, int] | None, /): 

4288 self.__input.err_region = err_region 

4289 

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) 

4301 

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 

4310 

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() 

4334 

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() 

4342 

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) 

4362 

4363 def _activate_completion(self): 

4364 self.__grid_active = True 

4365 

4366 def _deactivate_completion(self): 

4367 self.__grid_active = False 

4368 

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) 

4380 

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) 

4386 

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() 

4419 

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) 

4425 

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) 

4432 

4433 def draw(self, rc: RenderContext, /): 

4434 self.__layout.draw(rc) 

4435 

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 ) 

4452 

4453 

4454class Map(Widget[T], _t.Generic[T, U]): 

4455 """ 

4456 A wrapper that maps result of the given widget using the given function. 

4457 

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 

4472 

4473 Example:: 

4474 

4475 >>> # Run `Input` widget, then parse user input as `int`. 

4476 >>> int_input = Map(Input(), int) 

4477 >>> int_input.run(term, theme) 

4478 10 

4479 

4480 """ 

4481 

4482 def __init__(self, inner: Widget[U], fn: _t.Callable[[U], T], /): 

4483 self._inner = inner 

4484 self._fn = fn 

4485 

4486 def event(self, e: KeyboardEvent, /) -> Result[T] | None: 

4487 if result := self._inner.event(e): 

4488 return Result(self._fn(result.value)) 

4489 

4490 def layout(self, rc: RenderContext, /) -> tuple[int, int]: 

4491 return self._inner.layout(rc) 

4492 

4493 def draw(self, rc: RenderContext, /): 

4494 self._inner.draw(rc) 

4495 

4496 @property 

4497 def help_data(self) -> WidgetHelp: 

4498 return self._inner.help_data 

4499 

4500 

4501class Apply(Map[T, T], _t.Generic[T]): 

4502 """ 

4503 A wrapper that applies the given function to the result of a wrapped widget. 

4504 

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 

4519 

4520 Example:: 

4521 

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!' 

4528 

4529 """ 

4530 

4531 def __init__(self, inner: Widget[T], fn: _t.Callable[[T], None], /): 

4532 def mapper(x: T) -> T: 

4533 fn(x) 

4534 return x 

4535 

4536 super().__init__(inner, mapper) 

4537 

4538 

4539class Task(Widget[_t.Never]): 

4540 """ 

4541 Widget that's used to render :class:`~yuio.io.Task`\\ s. 

4542 

4543 """ 

4544 

4545 class Status(enum.Enum): 

4546 """ 

4547 Task status. 

4548 

4549 """ 

4550 

4551 DONE = "done" 

4552 """ 

4553 Task has finished successfully. 

4554 

4555 """ 

4556 

4557 ERROR = "error" 

4558 """ 

4559 Task has finished with an error. 

4560 

4561 """ 

4562 

4563 RUNNING = "running" 

4564 """ 

4565 Task is running. 

4566 

4567 """ 

4568 

4569 PENDING = "pending" 

4570 """ 

4571 Task is waiting to start. 

4572 

4573 """ 

4574 

4575 def __init__( 

4576 self, 

4577 msg: str, 

4578 /, 

4579 *args, 

4580 comment: str | None = None, 

4581 ) -> None: 

4582 super().__init__() 

4583 

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 

4591 

4592 self.status: Task.Status = Task.Status.PENDING 

4593 

4594 self._cached_msg: yuio.string.ColorizedString | None = None 

4595 self._cached_comment: yuio.string.ColorizedString | None = None 

4596 

4597 @_t.overload 

4598 def progress(self, progress: float | None, /, *, ndigits: int = 2): ... 

4599 

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 ): ... 

4610 

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`. 

4619 

4620 """ 

4621 

4622 progress = None 

4623 

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 ) 

4640 

4641 if done is None: 

4642 self._progress = None 

4643 self._progress_done = None 

4644 self._progress_total = None 

4645 return 

4646 

4647 if len(args) == 1: 

4648 done *= 100 

4649 unit = "%" 

4650 

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 

4661 

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`. 

4672 

4673 """ 

4674 

4675 progress = done / total 

4676 done, done_unit = self.__size(done) 

4677 total, total_unit = self.__size(total) 

4678 

4679 if done_unit == total_unit: 

4680 done_unit = "" 

4681 

4682 self._progress = progress 

4683 self._progress_done = "%.*f%s" % (ndigits, done, done_unit) 

4684 self._progress_total = "%.*f%s" % (ndigits, total, total_unit) 

4685 

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" 

4693 

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`. 

4705 

4706 """ 

4707 

4708 progress = done / total 

4709 done, done_unit = self.__unit(done) 

4710 total, total_unit = self.__unit(total) 

4711 

4712 if unit: 

4713 done_unit += unit 

4714 total_unit += unit 

4715 

4716 self._progress = progress 

4717 self._progress_done = "%.*f%s" % (ndigits, done, done_unit) 

4718 self._progress_total = "%.*f%s" % (ndigits, total, total_unit) 

4719 

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, "" 

4731 

4732 def comment(self, comment: str | None, /, *args): 

4733 """ 

4734 See :meth:`~yuio.io.Task.comment`. 

4735 

4736 """ 

4737 

4738 self._comment = comment 

4739 self._comment_args = args 

4740 self._cached_comment = None 

4741 

4742 def layout(self, rc: RenderContext) -> tuple[int, int]: 

4743 return 1, 1 # Tasks are always one line high. 

4744 

4745 def draw(self, rc: RenderContext): 

4746 return self._draw_task(rc) 

4747 

4748 def _format_task(self, ctx: yuio.string.ReprContext) -> yuio.string.ColorizedString: 

4749 """ 

4750 Format this task for printing to the log. 

4751 

4752 """ 

4753 

4754 res = yuio.string.ColorizedString() 

4755 

4756 status = self.status.value 

4757 

4758 if decoration := ctx.get_msg_decoration("task"): 

4759 res += ctx.get_color(f"task/decoration:{status}") 

4760 res += decoration 

4761 

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}") 

4768 

4769 return res 

4770 

4771 def _format_task_msg( 

4772 self, ctx: yuio.string.ReprContext 

4773 ) -> yuio.string.ColorizedString: 

4774 """ 

4775 Format task's message. 

4776 

4777 """ 

4778 

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 

4788 

4789 def _format_task_comment( 

4790 self, rc: RenderContext 

4791 ) -> yuio.string.ColorizedString | None: 

4792 """ 

4793 Format task's comment. 

4794 

4795 """ 

4796 

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 

4808 

4809 def _draw_task(self, rc: RenderContext): 

4810 """ 

4811 Draw task. 

4812 

4813 """ 

4814 

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) 

4822 

4823 def _draw_task_progress(self, rc: RenderContext): 

4824 """ 

4825 Draw number that indicates task's progress. 

4826 

4827 """ 

4828 

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) 

4844 

4845 def _draw_task_progressbar(self, rc: RenderContext): 

4846 """ 

4847 Draw task's progressbar. 

4848 

4849 """ 

4850 

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 ) 

4891 

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 

4901 

4902 rc.set_color_path(f"task/progressbar:{self.status.value}") 

4903 rc.write(progress_bar_start_symbol) 

4904 

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 ) 

4909 

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) 

4913 

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 ) 

4921 

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 ) 

4926 

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) 

4930 

4931 rc.set_color_path(f"task/progressbar:{self.status.value}") 

4932 rc.write(progress_bar_end_symbol) 

4933 

4934 rc.set_color_path(f"task:{self.status.value}") 

4935 rc.write(" ") 

4936 

4937 def __colorized_str__(self, ctx: yuio.string.ReprContext) -> _ColorizedString: 

4938 return self._format_task(ctx) 

4939 

4940 

4941@dataclass(slots=True) 

4942class _EventStreamState: 

4943 ostream: _t.TextIO 

4944 istream: _t.TextIO 

4945 key: str = "" 

4946 index: int = 0 

4947 

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 

4954 

4955 def next(self): 

4956 ch = self.peek() 

4957 self.index += 1 

4958 return ch 

4959 

4960 def peek(self): 

4961 if self.index >= len(self.key): 

4962 return "" 

4963 else: 

4964 return self.key[self.index] 

4965 

4966 def tail(self): 

4967 return self.key[self.index :] 

4968 

4969 

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/. 

4974 

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) 

5009 

5010 

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) 

5017 

5018 

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() 

5029 

5030 

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(";") 

5043 

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) 

5054 

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) 

5079 

5080 

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) 

5088 

5089 

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} 

5124 

5125 

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} 

5149 

5150 

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) 

5170 

5171 

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()