Coverage for yuio / widget.py: 89%

1881 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-12-04 10:05 +0000

1# Yuio project, MIT license. 

2# 

3# https://github.com/taminomara/yuio/ 

4# 

5# You're free to copy this file to your project and edit it for your needs, 

6# just keep this copyright line please :3 

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

125 

126# ruff: noqa: RET503 

127 

128from __future__ import annotations 

129 

130import abc 

131import contextlib 

132import dataclasses 

133import enum 

134import functools 

135import math 

136import re 

137import shutil 

138import string 

139import sys 

140import typing 

141from dataclasses import dataclass 

142 

143import yuio.color 

144import yuio.complete 

145import yuio.md 

146import yuio.string 

147import yuio.term 

148from yuio import _typing as _t 

149from yuio.color import Color as _Color 

150from yuio.string import ColorizedString as _ColorizedString 

151from yuio.string import Esc as _Esc 

152from yuio.string import line_width as _line_width 

153from yuio.term import Term as _Term 

154from yuio.theme import Theme as _Theme 

155 

156__all__ = [ 

157 "Action", 

158 "ActionKey", 

159 "ActionKeys", 

160 "Apply", 

161 "Choice", 

162 "Grid", 

163 "Input", 

164 "InputWithCompletion", 

165 "Key", 

166 "KeyboardEvent", 

167 "Line", 

168 "Map", 

169 "Multiselect", 

170 "Option", 

171 "RenderContext", 

172 "Result", 

173 "SecretInput", 

174 "Text", 

175 "VerticalLayout", 

176 "VerticalLayoutBuilder", 

177 "Widget", 

178 "WidgetHelp", 

179 "bind", 

180 "help", 

181] 

182 

183_SPACE_BETWEEN_COLUMNS = 2 

184_MIN_COLUMN_WIDTH = 10 

185 

186_UNPRINTABLE = "".join([chr(i) for i in range(32)]) + "\x7f" 

187_UNPRINTABLE_TRANS = str.maketrans(_UNPRINTABLE, " " * len(_UNPRINTABLE)) 

188_UNPRINTABLE_RE = r"[" + re.escape(_UNPRINTABLE) + "]" 

189_UNPRINTABLE_RE_WITHOUT_NL = r"[" + re.escape(_UNPRINTABLE.replace("\n", "")) + "]" 

190 

191 

192T = _t.TypeVar("T") 

193U = _t.TypeVar("U") 

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

195 

196 

197class Key(enum.Enum): 

198 """ 

199 Non-character keys. 

200 

201 """ 

202 

203 ENTER = enum.auto() 

204 """ 

205 :kbd:`Enter` key. 

206 

207 """ 

208 

209 ESCAPE = enum.auto() 

210 """ 

211 :kbd:`Escape` key. 

212 

213 """ 

214 

215 INSERT = enum.auto() 

216 """ 

217 :kbd:`Insert` key. 

218 

219 """ 

220 

221 DELETE = enum.auto() 

222 """ 

223 :kbd:`Delete` key. 

224 

225 """ 

226 

227 BACKSPACE = enum.auto() 

228 """ 

229 :kbd:`Backspace` key. 

230 

231 """ 

232 

233 TAB = enum.auto() 

234 """ 

235 :kbd:`Tab` key. 

236 

237 """ 

238 

239 HOME = enum.auto() 

240 """ 

241 :kbd:`Home` key. 

242 

243 """ 

244 

245 END = enum.auto() 

246 """ 

247 :kbd:`End` key. 

248 

249 """ 

250 

251 PAGE_UP = enum.auto() 

252 """ 

253 :kbd:`PageUp` key. 

254 

255 """ 

256 

257 PAGE_DOWN = enum.auto() 

258 """ 

259 :kbd:`PageDown` key. 

260 

261 """ 

262 

263 ARROW_UP = enum.auto() 

264 """ 

265 :kbd:`ArrowUp` key. 

266 

267 """ 

268 

269 ARROW_DOWN = enum.auto() 

270 """ 

271 :kbd:`ArrowDown` key. 

272 

273 """ 

274 

275 ARROW_LEFT = enum.auto() 

276 """ 

277 :kbd:`ArrowLeft` key. 

278 

279 """ 

280 

281 ARROW_RIGHT = enum.auto() 

282 """ 

283 :kbd:`ArrowRight` key. 

284 

285 """ 

286 

287 F1 = enum.auto() 

288 """ 

289 :kbd:`F1` key. 

290 

291 """ 

292 

293 F2 = enum.auto() 

294 """ 

295 :kbd:`F2` key. 

296 

297 """ 

298 

299 F3 = enum.auto() 

300 """ 

301 :kbd:`F3` key. 

302 

303 """ 

304 

305 F4 = enum.auto() 

306 """ 

307 :kbd:`F4` key. 

308 

309 """ 

310 

311 F5 = enum.auto() 

312 """ 

313 :kbd:`F5` key. 

314 

315 """ 

316 

317 F6 = enum.auto() 

318 """ 

319 :kbd:`F6` key. 

320 

321 """ 

322 

323 F7 = enum.auto() 

324 """ 

325 :kbd:`F7` key. 

326 

327 """ 

328 

329 F8 = enum.auto() 

330 """ 

331 :kbd:`F8` key. 

332 

333 """ 

334 

335 F9 = enum.auto() 

336 """ 

337 :kbd:`F9` key. 

338 

339 """ 

340 

341 F10 = enum.auto() 

342 """ 

343 :kbd:`F10` key. 

344 

345 """ 

346 

347 F11 = enum.auto() 

348 """ 

349 :kbd:`F11` key. 

350 

351 """ 

352 

353 F12 = enum.auto() 

354 """ 

355 :kbd:`F12` key. 

356 

357 """ 

358 

359 PASTE = enum.auto() 

360 """ 

361 Triggered when a text is pasted into a terminal. 

362 

363 """ 

364 

365 def __str__(self) -> str: 

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

367 

368 

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

370class KeyboardEvent: 

371 """ 

372 A single keyboard event. 

373 

374 .. warning:: 

375 

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

377 support all keystroke combinations. 

378 

379 Use ``python -m yuio.scripts.showkey`` to check how your terminal reports 

380 keystrokes, and how Yuio interprets them. 

381 

382 """ 

383 

384 key: Key | str 

385 """ 

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

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

388 

389 """ 

390 

391 ctrl: bool = False 

392 """ 

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

394 

395 For letter keys modified with control, the letter is always lowercase; if terminal 

396 supports reporting :kbd:`Shift` being pressed, the :attr:`~KeyboardEvent.shift` 

397 attribute will be set. This does not affect punctuation keys, though: 

398 

399 .. skip-next: 

400 

401 .. code-block:: python 

402 

403 # `Ctrl+X` was pressed. 

404 KeyboardEvent("x", ctrl=True) 

405 

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

407 # to report this correctly, though. 

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

409 

410 # This can't happen. 

411 KeyboardEvent("X", ctrl=True) 

412 

413 # `Ctrl+_` was pressed. On most keyboards, the actual keystroke 

414 # is `Ctrl+Shift+-`, but most terminals can't properly report this. 

415 KeyboardEvent("_", ctrl=True) 

416 

417 """ 

418 

419 alt: bool = False 

420 """ 

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

422 

423 """ 

424 

425 shift: bool = False 

426 """ 

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

428 

429 Note that, when letters are typed with shift, they will not have this flag. 

430 Instead, their upper case version will be set as :attr:`~KeyboardEvent.key`: 

431 

432 .. skip-next: 

433 

434 .. code-block:: python 

435 

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

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

438 

439 .. warning:: 

440 

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

442 

443 """ 

444 

445 paste_str: str | None = dataclasses.field(default=None, compare=False, kw_only=True) 

446 """ 

447 If ``key`` is :attr:`Key.PASTE`, this attribute will contain pasted string. 

448 

449 """ 

450 

451 

452@_t.final 

453class RenderContext: 

454 """ 

455 A canvas onto which widgets render themselves. 

456 

457 This class represents a canvas with size equal to the available space on the terminal. 

458 Like a real terminal, it has a character grid and a virtual cursor that can be moved 

459 around freely. 

460 

461 Before each render, context's canvas is cleared, and then widgets print themselves onto it. 

462 When render ends, context compares new canvas with what's been rendered previously, 

463 and then updates those parts of the real terminal's grid that changed between renders. 

464 

465 This approach allows simplifying widgets (they don't have to track changes and do conditional 

466 screen updates themselves), while still minimizing the amount of data that's sent between 

467 the program and the terminal. It is especially helpful with rendering larger widgets over ssh. 

468 

469 """ 

470 

471 # For tests. 

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

473 

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

475 self._term: _Term = term 

476 self._theme: _Theme = theme 

477 

478 # We have three levels of abstraction here. 

479 # 

480 # First, we have the TTY which our process attached to. 

481 # This TTY has cursor, current color, 

482 # and different drawing capabilities. 

483 # 

484 # Second, we have the canvas. This canvas has same dimensions 

485 # as the underlying TTY. Canvas' contents and actual TTY contents 

486 # are synced in `render` function. 

487 # 

488 # Finally, we have virtual cursor, 

489 # and a drawing frame which clips dimensions of a widget. 

490 # 

491 # 

492 # Drawing frame 

493 # ................... 

494 # . ┌────────┐ . 

495 # . │ hello │ . 

496 # . │ world │ . 

497 # . └────────┘ . 

498 # ................... 

499 # ↓ 

500 # Canvas 

501 # ┌─────────────────┐ 

502 # │ > hello │ 

503 # │ world │ 

504 # │ │ 

505 # └─────────────────┘ 

506 # ↓ 

507 # Real terminal 

508 # ┏━━━━━━━━━━━━━━━━━┯━━━┓ 

509 # ┃ > hello │ ┃ 

510 # ┃ world │ ┃ 

511 # ┃ │ ┃ 

512 # ┠───────────VT100─┤◆◆◆┃ 

513 # ┗█▇█▇█▇█▇█▇█▇█▇█▇█▇█▇█┛ 

514 

515 # Drawing frame and virtual cursor 

516 self._frame_x: int = 0 

517 self._frame_y: int = 0 

518 self._frame_w: int = 0 

519 self._frame_h: int = 0 

520 self._frame_cursor_x: int = 0 # relative to _frame_x 

521 self._frame_cursor_y: int = 0 # relative to _frame_y 

522 self._frame_cursor_color: str = "" 

523 

524 # Canvas 

525 self._width: int = 0 

526 self._height: int = 0 

527 self._final_x: int = 0 

528 self._final_y: int = 0 

529 self._lines: list[list[str]] = [] 

530 self._colors: list[list[str]] = [] 

531 self._prev_lines: list[list[str]] = [] 

532 self._prev_colors: list[list[str]] = [] 

533 

534 # Rendering status 

535 self._full_redraw: bool = False 

536 self._term_x: int = 0 

537 self._term_y: int = 0 

538 self._term_color: str = "" 

539 self._max_term_y: int = 0 

540 self._out: list[str] = [] 

541 self._bell: bool = False 

542 self._in_alternative_buffer: bool = False 

543 self._normal_buffer_term_x: int = 0 

544 self._normal_buffer_term_y: int = 0 

545 

546 # Helpers 

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

548 

549 # Used for tests and debug 

550 self._renders: int = 0 

551 self._bytes_rendered: int = 0 

552 self._total_bytes_rendered: int = 0 

553 

554 @property 

555 def term(self) -> _Term: 

556 """ 

557 Terminal where we render the widgets. 

558 

559 """ 

560 

561 return self._term 

562 

563 @property 

564 def theme(self) -> _Theme: 

565 """ 

566 Current color theme. 

567 

568 """ 

569 

570 return self._theme 

571 

572 @contextlib.contextmanager 

573 def frame( 

574 self, 

575 x: int, 

576 y: int, 

577 /, 

578 *, 

579 width: int | None = None, 

580 height: int | None = None, 

581 ): 

582 """ 

583 Override drawing frame. 

584 

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

586 and they can take the entire frame size. 

587 

588 The idea is that, if you want to draw a widget at specific coordinates, 

589 you make a frame and draw the widget inside said frame. 

590 

591 When new frame is created, cursor's position and color are reset. 

592 When frame is dropped, they are restored. 

593 Therefore, drawing widgets in a frame will not affect current drawing state. 

594 

595 .. 

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

597 >>> theme = _Theme() 

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

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

600 

601 Example:: 

602 

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

604 >>> rc.prepare() 

605 

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

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

608 

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

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

611 20 5 

612 

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

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

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

616 

617 >>> # Now we set our drawing frame to be at (2, 2). 

618 >>> with rc.frame(2, 2): 

619 ... # Out current pos was reset to the frame's top-left corner, 

620 ... # which is now (2, 2). 

621 ... rc.write("+") 

622 ... 

623 ... # Frame dimensions were automatically reduced. 

624 ... print(rc.width, rc.height) 

625 ... 

626 ... # Set pos and all other functions work relative 

627 ... # to the current frame, so writing at (4, 0) 

628 ... # in the current frame will result in text at (6, 2). 

629 ... rc.set_pos(4, 0) 

630 ... rc.write("Hello, world!") 

631 18 3 

632 

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

634 + Hello, world! 

635 <BLANKLINE> 

636 + Hello, world! 

637 <BLANKLINE> 

638 <BLANKLINE> 

639 

640 Usually you don't have to think about frames. If you want to stack 

641 multiple widgets one on top of another, simply use :class:`VerticalLayout`. 

642 In cases where it's not enough though, you'll have to call 

643 :meth:`~Widget.layout` for each of the nested widgets, and then manually 

644 create frames and execute :meth:`~Widget.draw` methods:: 

645 

646 class MyWidget(Widget): 

647 # Let's say we want to print a text indented by four spaces, 

648 # and limit its with by 15. And we also want to print a small 

649 # un-indented heading before it. 

650 

651 def __init__(self): 

652 # This is the text we'll print. 

653 self._nested_widget = Text( 

654 "very long paragraph which potentially can span multiple lines" 

655 ) 

656 

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

658 # The text will be placed at (4, 1), and we'll also limit 

659 # its width. So we'll reflect those constrains 

660 # by arranging a drawing frame. 

661 with rc.frame(4, 1, width=min(rc.width - 4, 15)): 

662 min_h, max_h = self._nested_widget.layout(rc) 

663 

664 # Our own widget will take as much space as the nested text, 

665 # plus one line for our heading. 

666 return min_h + 1, max_h + 1 

667 

668 def draw(self, rc: RenderContext): 

669 # Print a small heading. 

670 rc.set_color_path("bold") 

671 rc.write("Small heading") 

672 

673 # And draw our nested widget, controlling its position 

674 # via a frame. 

675 with rc.frame(4, 1, width=min(rc.width - 4, 15)): 

676 self._nested_widget.draw(rc) 

677 

678 """ 

679 

680 prev_frame_x = self._frame_x 

681 prev_frame_y = self._frame_y 

682 prev_frame_w = self._frame_w 

683 prev_frame_h = self._frame_h 

684 prev_frame_cursor_x = self._frame_cursor_x 

685 prev_frame_cursor_y = self._frame_cursor_y 

686 prev_frame_cursor_color = self._frame_cursor_color 

687 

688 self._frame_x += x 

689 self._frame_y += y 

690 

691 if width is not None: 

692 self._frame_w = width 

693 else: 

694 self._frame_w -= x 

695 if self._frame_w < 0: 

696 self._frame_w = 0 

697 

698 if height is not None: 

699 self._frame_h = height 

700 else: 

701 self._frame_h -= y 

702 if self._frame_h < 0: 

703 self._frame_h = 0 

704 

705 self._frame_cursor_x = 0 

706 self._frame_cursor_y = 0 

707 self._frame_cursor_color = self._none_color 

708 

709 try: 

710 yield 

711 finally: 

712 self._frame_x = prev_frame_x 

713 self._frame_y = prev_frame_y 

714 self._frame_w = prev_frame_w 

715 self._frame_h = prev_frame_h 

716 self._frame_cursor_x = prev_frame_cursor_x 

717 self._frame_cursor_y = prev_frame_cursor_y 

718 self._frame_cursor_color = prev_frame_cursor_color 

719 

720 @property 

721 def width(self) -> int: 

722 """ 

723 Get width of the current frame. 

724 

725 """ 

726 

727 return self._frame_w 

728 

729 @property 

730 def height(self) -> int: 

731 """ 

732 Get height of the current frame. 

733 

734 """ 

735 

736 return self._frame_h 

737 

738 @property 

739 def canvas_width(self) -> int: 

740 """ 

741 Get width of the terminal. 

742 

743 """ 

744 

745 return self._width 

746 

747 @property 

748 def canvas_height(self) -> int: 

749 """ 

750 Get height of the terminal. 

751 

752 """ 

753 

754 return self._height 

755 

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

757 """ 

758 Set current cursor position within the frame. 

759 

760 """ 

761 

762 self._frame_cursor_x = x 

763 self._frame_cursor_y = y 

764 

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

766 """ 

767 Move current cursor position by the given amount. 

768 

769 """ 

770 

771 self._frame_cursor_x += dx 

772 self._frame_cursor_y += dy 

773 

774 def new_line(self): 

775 """ 

776 Move cursor to new line within the current frame. 

777 

778 """ 

779 

780 self._frame_cursor_x = 0 

781 self._frame_cursor_y += 1 

782 

783 def set_final_pos(self, x: int, y: int, /): 

784 """ 

785 Set position where the cursor should end up 

786 after everything has been rendered. 

787 

788 By default, cursor will end up at the beginning of the last line. 

789 Components such as :class:`Input` can modify this behavior 

790 and move the cursor into the correct position. 

791 

792 """ 

793 

794 self._final_x = x + self._frame_x 

795 self._final_y = y + self._frame_y 

796 

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

798 """ 

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

800 

801 """ 

802 

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

804 self._term.color_support 

805 ) 

806 

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

808 """ 

809 Set current color. 

810 

811 """ 

812 

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

814 

815 def reset_color(self): 

816 """ 

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

818 

819 """ 

820 

821 self._frame_cursor_color = self._none_color 

822 

823 def write(self, text: yuio.string.AnyString, /, *, max_width: int | None = None): 

824 """ 

825 Write string at the current position using the current color. 

826 Move cursor while printing. 

827 

828 While the displayed text will not be clipped at frame's borders, 

829 its width can be limited by passing `max_width`. Note that 

830 ``rc.write(text, max_width)`` is not the same 

831 as ``rc.write(text[:max_width])``, because the later case 

832 doesn't account for double-width characters. 

833 

834 All whitespace characters in the text, including tabs and newlines, 

835 will be treated as single spaces. If you need to print multiline text, 

836 use :meth:`yuio.string.ColorizedString.wrap` and :meth:`~RenderContext.write_text`. 

837 

838 .. 

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

840 >>> theme = _Theme() 

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

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

843 

844 Example:: 

845 

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

847 >>> rc.prepare() 

848 

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

850 >>> rc.new_line() 

851 >>> rc.write("Hello,\\nworld!") 

852 >>> rc.new_line() 

853 >>> rc.write( 

854 ... "Hello, 🌍!<this text will be clipped>", 

855 ... max_width=10 

856 ... ) 

857 >>> rc.new_line() 

858 >>> rc.write( 

859 ... "Hello, 🌍!<this text will be clipped>"[:10] 

860 ... ) 

861 >>> rc.new_line() 

862 

863 >>> rc.render() 

864 Hello, world! 

865 Hello, world! 

866 Hello, 🌍! 

867 Hello, 🌍!< 

868 <BLANKLINE> 

869 

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

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

872 

873 """ 

874 

875 if not isinstance(text, _ColorizedString): 

876 text = _ColorizedString(text, _isolate_colors=False) 

877 

878 x = self._frame_x + self._frame_cursor_x 

879 y = self._frame_y + self._frame_cursor_y 

880 

881 max_x = self._width 

882 if max_width is not None: 

883 max_x = min(max_x, x + max_width) 

884 self._frame_cursor_x = min(self._frame_cursor_x + text.width, x + max_width) 

885 else: 

886 self._frame_cursor_x = self._frame_cursor_x + text.width 

887 

888 if not 0 <= y < self._height: 

889 for s in text: 

890 if isinstance(s, _Color): 

891 self._frame_cursor_color = s.as_code(self._term.color_support) 

892 return 

893 

894 ll = self._lines[y] 

895 cc = self._colors[y] 

896 

897 for s in text: 

898 if isinstance(s, _Color): 

899 self._frame_cursor_color = s.as_code(self._term.color_support) 

900 continue 

901 elif s in (yuio.string.NO_WRAP_START, yuio.string.NO_WRAP_END): 

902 continue 

903 

904 s = s.translate(_UNPRINTABLE_TRANS) 

905 

906 if s.isascii(): 

907 # Fast track. 

908 if x + len(s) <= 0: 

909 # We're beyond the left terminal border. 

910 x += len(s) 

911 continue 

912 

913 slice_begin = 0 

914 if x < 0: 

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

916 slice_begin = -x 

917 x = 0 

918 

919 if x >= max_x: 

920 # We're beyond the right terminal border. 

921 x += len(s) - slice_begin 

922 continue 

923 

924 slice_end = len(s) 

925 if x + len(s) - slice_begin > max_x: 

926 # We're partially beyond the right terminal border. 

927 slice_end = slice_begin + max_x - x 

928 

929 l = slice_end - slice_begin 

930 self._lines[y][x : x + l] = s[slice_begin:slice_end] 

931 self._colors[y][x : x + l] = [self._frame_cursor_color] * l 

932 x += l 

933 continue 

934 

935 for c in s: 

936 cw = _line_width(c) 

937 if x + cw <= 0: 

938 # We're beyond the left terminal border. 

939 x += cw 

940 continue 

941 elif x < 0: 

942 # This character was split in half by the terminal border. 

943 ll[: x + cw] = [" "] * (x + cw) 

944 cc[: x + cw] = [self._none_color] * (x + cw) 

945 x += cw 

946 continue 

947 elif cw > 0 and x >= max_x: 

948 # We're beyond the right terminal border. 

949 x += cw 

950 break 

951 elif x + cw > max_x: 

952 # This character was split in half by the terminal border. 

953 ll[x:max_x] = " " * (max_x - x) 

954 cc[x:max_x] = [self._frame_cursor_color] * (max_x - x) 

955 x += cw 

956 break 

957 

958 if cw == 0: 

959 # This is a zero-width character. 

960 # We'll append it to the previous cell. 

961 if x > 0: 

962 ll[x - 1] += c 

963 continue 

964 

965 ll[x] = c 

966 cc[x] = self._frame_cursor_color 

967 

968 x += 1 

969 cw -= 1 

970 if cw: 

971 ll[x : x + cw] = [""] * cw 

972 cc[x : x + cw] = [self._frame_cursor_color] * cw 

973 x += cw 

974 

975 def write_text( 

976 self, 

977 lines: _t.Iterable[yuio.string.AnyString], 

978 /, 

979 *, 

980 max_width: int | None = None, 

981 ): 

982 """ 

983 Write multiple lines. 

984 

985 Each line is printed using :meth:`~RenderContext.write`, 

986 so newline characters and tabs within each line are replaced with spaces. 

987 Use :meth:`yuio.string.ColorizedString.wrap` to properly handle them. 

988 

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

990 and back to its original horizontal position. 

991 

992 .. 

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

994 >>> theme = _Theme() 

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

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

997 

998 Example:: 

999 

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

1001 >>> rc.prepare() 

1002 

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

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

1005 

1006 >>> # First line is printed at the cursor's position. 

1007 >>> # All consequent lines are horizontally aligned with first line. 

1008 >>> rc.write_text(["Hello,", "world!"]) 

1009 

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

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

1012 

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

1014 + > Hello, 

1015 world!+ 

1016 <BLANKLINE> 

1017 <BLANKLINE> 

1018 <BLANKLINE> 

1019 

1020 """ 

1021 

1022 x = self._frame_cursor_x 

1023 

1024 for i, line in enumerate(lines): 

1025 if i > 0: 

1026 self._frame_cursor_x = x 

1027 self._frame_cursor_y += 1 

1028 

1029 self.write(line, max_width=max_width) 

1030 

1031 def bell(self): 

1032 """ 

1033 Ring a terminal bell. 

1034 

1035 """ 

1036 

1037 self._bell = True 

1038 

1039 def prepare(self, *, full_redraw: bool = False, alternative_buffer: bool = False): 

1040 """ 

1041 Reset output canvas and prepare context for a new round of widget formatting. 

1042 

1043 """ 

1044 

1045 if self._override_wh: 

1046 width, height = self._override_wh 

1047 else: 

1048 size = shutil.get_terminal_size() 

1049 width = size.columns 

1050 height = size.lines 

1051 

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

1053 if self._in_alternative_buffer != alternative_buffer: 

1054 full_redraw = True 

1055 self._in_alternative_buffer = alternative_buffer 

1056 if alternative_buffer: 

1057 self._out.append("\x1b[m\x1b[?1049h\x1b[2J\x1b[H") 

1058 self._normal_buffer_term_x = self._term_x 

1059 self._normal_buffer_term_y = self._term_y 

1060 self._term_x, self._term_y = 0, 0 

1061 self._term_color = self._none_color 

1062 else: 

1063 self._out.append("\x1b[m\x1b[?1049l") 

1064 self._term_x = self._normal_buffer_term_x 

1065 self._term_y = self._normal_buffer_term_y 

1066 self._term_color = self._none_color 

1067 

1068 # Drawing frame and virtual cursor 

1069 self._frame_x = 0 

1070 self._frame_y = 0 

1071 self._frame_w = width 

1072 self._frame_h = height 

1073 self._frame_cursor_x = 0 

1074 self._frame_cursor_y = 0 

1075 self._frame_cursor_color = self._none_color 

1076 

1077 # Canvas 

1078 self._width = width 

1079 self._height = height 

1080 self._final_x = 0 

1081 self._final_y = 0 

1082 if full_redraw: 

1083 self._max_term_y = 0 

1084 self._prev_lines, self._prev_colors = self._make_empty_canvas() 

1085 else: 

1086 self._prev_lines, self._prev_colors = self._lines, self._colors 

1087 self._lines, self._colors = self._make_empty_canvas() 

1088 

1089 # Rendering status 

1090 self._full_redraw = full_redraw 

1091 

1092 def clear_screen(self): 

1093 """ 

1094 Clear screen and prepare for a full redraw. 

1095 

1096 """ 

1097 

1098 self._out.append("\x1b[2J\x1b[1H") 

1099 self._term_x, self._term_y = 0, 0 

1100 self.prepare(full_redraw=True, alternative_buffer=self._in_alternative_buffer) 

1101 

1102 def _make_empty_canvas( 

1103 self, 

1104 ) -> tuple[list[list[str]], list[list[str]]]: 

1105 lines = [l[:] for l in [[" "] * self._width] * self._height] 

1106 colors = [ 

1107 c[:] for c in [[self._frame_cursor_color] * self._width] * self._height 

1108 ] 

1109 return lines, colors 

1110 

1111 def render(self): 

1112 """ 

1113 Render current canvas onto the terminal. 

1114 

1115 """ 

1116 

1117 if not self.term.can_move_cursor: 

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

1119 self._render_dumb() 

1120 return 

1121 

1122 if self._bell: 

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

1124 self._bell = False 

1125 

1126 if self._full_redraw: 

1127 self._move_term_cursor(0, 0) 

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

1129 

1130 for y in range(self._height): 

1131 line = self._lines[y] 

1132 

1133 for x in range(self._width): 

1134 prev_color = self._prev_colors[y][x] 

1135 color = self._colors[y][x] 

1136 

1137 if color != prev_color or line[x] != self._prev_lines[y][x]: 

1138 self._move_term_cursor(x, y) 

1139 

1140 if color != self._term_color: 

1141 self._out.append(color) 

1142 self._term_color = color 

1143 

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

1145 self._term_x += 1 

1146 

1147 final_x = max(0, min(self._width - 1, self._final_x)) 

1148 final_y = max(0, min(self._height - 1, self._final_y)) 

1149 self._move_term_cursor(final_x, final_y) 

1150 

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

1152 self._term.ostream.write(rendered) 

1153 self._term.ostream.flush() 

1154 self._out.clear() 

1155 

1156 if yuio._debug: 

1157 self._renders += 1 

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

1159 self._total_bytes_rendered += self._bytes_rendered 

1160 

1161 debug_msg = f"n={self._renders:>04},r={self._bytes_rendered:>04},t={self._total_bytes_rendered:>04}" 

1162 term_x, term_y = self._term_x, self._term_y 

1163 self._move_term_cursor(self._width - len(debug_msg), 0) 

1164 color = yuio.color.Color.STYLE_INVERSE | yuio.color.Color.FORE_CYAN 

1165 self._out.append(color.as_code(self._term.color_support)) 

1166 self._out.append(debug_msg) 

1167 self._out.append(self._term_color) 

1168 self._move_term_cursor(term_x, term_y) 

1169 

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

1171 self._term.ostream.flush() 

1172 self._out.clear() 

1173 

1174 def finalize(self): 

1175 """ 

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

1177 

1178 """ 

1179 

1180 self.prepare(full_redraw=True) 

1181 

1182 self._move_term_cursor(0, 0) 

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

1184 self._out.append(self._none_color) 

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

1186 self._term.ostream.flush() 

1187 self._out.clear() 

1188 self._term_color = self._none_color 

1189 

1190 def _move_term_cursor(self, x: int, y: int): 

1191 dy = y - self._term_y 

1192 if y > self._max_term_y: 

1193 self._out.append("\n" * dy) 

1194 self._term_x = 0 

1195 elif dy > 0: 

1196 self._out.append(f"\x1b[{dy}B") 

1197 elif dy < 0: 

1198 self._out.append(f"\x1b[{-dy}A") 

1199 self._term_y = y 

1200 self._max_term_y = max(self._max_term_y, y) 

1201 

1202 if x != self._term_x: 

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

1204 self._term_x = x 

1205 

1206 def _render_dumb(self): 

1207 prev_printed_color = self._none_color 

1208 

1209 for line, colors in zip(self._lines, self._colors): 

1210 for ch, color in zip(line, colors): 

1211 if prev_printed_color != color: 

1212 self._out.append(color) 

1213 prev_printed_color = color 

1214 self._out.append(ch) 

1215 self._out.append("\n") 

1216 

1217 self._term.ostream.writelines( 

1218 [ 

1219 # Trim trailing spaces for doctests. 

1220 re.sub(r" +$", "\n", line, flags=re.MULTILINE) 

1221 for line in "".join(self._out).splitlines() 

1222 ] 

1223 ) 

1224 self._term.ostream.flush() 

1225 self._out.clear() 

1226 

1227 

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

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

1230 """ 

1231 Result of a widget run. 

1232 

1233 We have to wrap the return value of event processors into this class. 

1234 Otherwise we won't be able to distinguish between returning `None` 

1235 as result of a ``Widget[None]``, and not returning anything. 

1236 

1237 """ 

1238 

1239 value: T_co 

1240 """ 

1241 Result of a widget run. 

1242 

1243 """ 

1244 

1245 

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

1247 """ 

1248 Base class for all interactive console elements. 

1249 

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

1251 They always go through the same event loop: 

1252 

1253 .. raw:: html 

1254 

1255 <p> 

1256 <pre class="mermaid"> 

1257 flowchart TD 

1258 Start([Start]) --> Layout["`layout()`"] 

1259 Layout --> Draw["`draw()`"] 

1260 Draw -->|Wait for keyboard event| Event["`Event()`"] 

1261 Event --> Result{{Returned result?}} 

1262 Result -->|no| Layout 

1263 Result -->|yes| Finish([Finish]) 

1264 </pre> 

1265 </p> 

1266 

1267 Widgets run indefinitely until they stop themselves and return a value. 

1268 For example, :class:`Input` will return when user presses :kbd:`Enter`. 

1269 When widget needs to stop, it can return the :meth:`Result` class 

1270 from its event handler. 

1271 

1272 For typing purposes, :class:`Widget` is generic. That is, ``Widget[T]`` 

1273 returns ``T`` from its :meth:`~Widget.run` method. So, :class:`Input`, 

1274 for example, is ``Widget[str]``. 

1275 

1276 Some widgets are ``Widget[Never]`` (see :class:`typing.Never`), indicating that 

1277 they don't ever stop. Others are ``Widget[None]``, indicating that they stop, 

1278 but don't return a value. 

1279 

1280 """ 

1281 

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

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

1284 

1285 __in_help_menu: bool = False 

1286 __bell: bool = False 

1287 

1288 _cur_event: KeyboardEvent | None = None 

1289 """ 

1290 Current event that is being processed. 

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

1292 

1293 """ 

1294 

1295 def __init_subclass__(cls, **kwargs): 

1296 super().__init_subclass__(**kwargs) 

1297 

1298 cls.__bindings = {} 

1299 cls.__callbacks = [] 

1300 

1301 event_handler_names = [] 

1302 for base in reversed(cls.__mro__): 

1303 for name, cb in base.__dict__.items(): 

1304 if ( 

1305 hasattr(cb, "__yuio_keybindings__") 

1306 and name not in event_handler_names 

1307 ): 

1308 event_handler_names.append(name) 

1309 

1310 for name in event_handler_names: 

1311 cb = getattr(cls, name, None) 

1312 if cb is not None and hasattr(cb, "__yuio_keybindings__"): 

1313 bindings: list[_Binding] = cb.__yuio_keybindings__ 

1314 cls.__bindings.update((binding.event, cb) for binding in bindings) 

1315 cls.__callbacks.append(cb) 

1316 

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

1318 """ 

1319 Handle incoming keyboard event. 

1320 

1321 By default, this function dispatches event to handlers registered 

1322 via :func:`bind`. If no handler is found, 

1323 it calls :meth:`~Widget.default_event_handler`. 

1324 

1325 """ 

1326 

1327 self._cur_event = e 

1328 if handler := self.__bindings.get(e): 

1329 return handler(self) 

1330 else: 

1331 return self.default_event_handler(e) 

1332 

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

1334 """ 

1335 Process any event that wasn't caught by other event handlers. 

1336 

1337 """ 

1338 

1339 @abc.abstractmethod 

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

1341 """ 

1342 Prepare widget for drawing, and recalculate its dimensions 

1343 according to new frame dimensions. 

1344 

1345 Yuio's widgets always take all available width. They should return 

1346 their minimum height that they will definitely take, and their maximum 

1347 height that they can potentially take. 

1348 

1349 """ 

1350 

1351 raise NotImplementedError() 

1352 

1353 @abc.abstractmethod 

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

1355 """ 

1356 Draw the widget. 

1357 

1358 Render context's drawing frame dimensions are guaranteed to be between 

1359 the minimum and the maximum height returned from the last call 

1360 to :meth:`~Widget.layout`. 

1361 

1362 """ 

1363 

1364 raise NotImplementedError() 

1365 

1366 @_t.final 

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

1368 """ 

1369 Read user input and run the widget. 

1370 

1371 """ 

1372 

1373 if not term.is_fully_interactive: 

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

1375 

1376 with yuio.term._enter_raw_mode( 

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

1378 ): 

1379 rc = RenderContext(term, theme) 

1380 

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

1382 

1383 try: 

1384 while True: 

1385 rc.prepare(alternative_buffer=self.__in_help_menu) 

1386 

1387 height = rc.height 

1388 if self.__in_help_menu: 

1389 min_h, max_h = self.__help_menu_layout(rc) 

1390 inline_help_height = 0 

1391 else: 

1392 with rc.frame(0, 0): 

1393 inline_help_height = self.__help_menu_layout_inline(rc)[0] 

1394 if height > inline_help_height: 

1395 height -= inline_help_height 

1396 with rc.frame(0, 0, height=height): 

1397 min_h, max_h = self.layout(rc) 

1398 max_h = max(min_h, min(max_h, height)) 

1399 rc.set_final_pos(0, max_h + inline_help_height) 

1400 if self.__in_help_menu: 

1401 self.__help_menu_draw(rc) 

1402 else: 

1403 with rc.frame(0, 0, height=max_h): 

1404 self.draw(rc) 

1405 if max_h < rc.height: 

1406 with rc.frame(0, max_h, height=rc.height - max_h): 

1407 self.__help_menu_draw_inline(rc) 

1408 

1409 if self.__bell: 

1410 rc.bell() 

1411 self.__bell = False 

1412 rc.render() 

1413 

1414 try: 

1415 event = next(events) 

1416 except StopIteration: 

1417 assert False, "_event_stream supposed to be infinite" 

1418 

1419 if event == KeyboardEvent("c", ctrl=True): 

1420 # windows doesn't handle C-c for us. 

1421 raise KeyboardInterrupt() 

1422 elif event == KeyboardEvent("l", ctrl=True): 

1423 rc.clear_screen() 

1424 elif event == KeyboardEvent(Key.F1) and not self.__in_help_menu: 

1425 self.__in_help_menu = True 

1426 self.__help_menu_line = 0 

1427 self.__last_help_data = None 

1428 elif self.__in_help_menu: 

1429 self.__help_menu_event(event) 

1430 elif result := self.event(event): 

1431 return result.value 

1432 finally: 

1433 rc.finalize() 

1434 

1435 def _bell(self): 

1436 self.__bell = True 

1437 

1438 @property 

1439 def help_data(self) -> WidgetHelp: 

1440 """ 

1441 Data for displaying help messages. 

1442 

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

1444 

1445 """ 

1446 

1447 return self.__help_columns 

1448 

1449 @functools.cached_property 

1450 def __help_columns(self) -> WidgetHelp: 

1451 inline_help: list[Action] = [] 

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

1453 

1454 for cb in self.__callbacks: 

1455 bindings: list[_Binding] = getattr(cb, "__yuio_keybindings__", []) 

1456 help: _Help | None = getattr(cb, "__yuio_help__", None) 

1457 if not bindings: 

1458 continue 

1459 if help is None: 

1460 help = _Help( 

1461 "Actions", 

1462 getattr(cb, "__doc__", None), 

1463 getattr(cb, "__doc__", None), 

1464 ) 

1465 if not help.inline_msg and not help.long_msg: 

1466 continue 

1467 

1468 if help.inline_msg: 

1469 inline_bindings = [ 

1470 binding.event 

1471 for binding in reversed(bindings) 

1472 if binding.show_in_inline_help 

1473 ] 

1474 if inline_bindings: 

1475 inline_help.append((inline_bindings, help.inline_msg)) 

1476 

1477 if help.long_msg: 

1478 menu_bindings = [ 

1479 binding.event 

1480 for binding in reversed(bindings) 

1481 if binding.show_in_detailed_help 

1482 ] 

1483 if menu_bindings: 

1484 groups.setdefault(help.group, []).append( 

1485 (menu_bindings, help.long_msg) 

1486 ) 

1487 

1488 return WidgetHelp(inline_help, groups) 

1489 

1490 __last_help_data: WidgetHelp | None = None 

1491 __prepared_inline_help: list[tuple[list[str], str, str, int]] 

1492 __prepared_groups: dict[str, list[tuple[list[str], str, str, int]]] 

1493 __has_help: bool = True 

1494 __width: int = 0 

1495 __height: int = 0 

1496 __menu_content_height: int = 0 

1497 __help_menu_line: int = 0 

1498 __help_menu_search: bool = False 

1499 __help_menu_search_widget: Input 

1500 __help_menu_search_layout: tuple[int, int] = 0, 0 

1501 __key_width: int = 0 

1502 __wrapped_groups: list[ 

1503 tuple[ 

1504 str, # Title 

1505 list[ # Actions 

1506 tuple[ # Action 

1507 list[str], # Keys 

1508 list[_ColorizedString], # Wrapped msg 

1509 int, # Keys width 

1510 ], 

1511 ], 

1512 ] # FML this type hint -___- 

1513 ] 

1514 __colorized_inline_help: list[ 

1515 tuple[ # Action 

1516 list[str], # Keys 

1517 _ColorizedString, # Title 

1518 int, # Keys width 

1519 ] 

1520 ] 

1521 

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

1523 if not self.__help_menu_search and e in [ 

1524 KeyboardEvent(Key.F1), 

1525 KeyboardEvent(Key.ESCAPE), 

1526 KeyboardEvent(Key.ENTER), 

1527 KeyboardEvent("q"), 

1528 KeyboardEvent("q", ctrl=True), 

1529 ]: 

1530 self.__in_help_menu = False 

1531 self.__help_menu_line = 0 

1532 self.__last_help_data = None 

1533 elif e == KeyboardEvent(Key.ARROW_UP): 

1534 self.__help_menu_line += 1 

1535 elif e == KeyboardEvent(Key.HOME): 

1536 self.__help_menu_line = 0 

1537 elif e == KeyboardEvent(Key.PAGE_UP): 

1538 self.__help_menu_line += self.__height 

1539 elif e == KeyboardEvent(Key.END): 

1540 self.__help_menu_line = -self.__menu_content_height 

1541 elif e == KeyboardEvent(Key.ARROW_DOWN): 

1542 self.__help_menu_line -= 1 

1543 elif e == KeyboardEvent(Key.PAGE_DOWN): 

1544 self.__help_menu_line -= self.__height 

1545 elif not self.__help_menu_search and e == KeyboardEvent(" "): 

1546 self.__help_menu_line -= self.__height 

1547 elif not self.__help_menu_search and e == KeyboardEvent("/"): 

1548 self.__help_menu_search = True 

1549 self.__help_menu_search_widget = Input(decoration="/") 

1550 elif self.__help_menu_search: 

1551 if e == KeyboardEvent(Key.ESCAPE) or ( 

1552 e == KeyboardEvent(Key.BACKSPACE) 

1553 and not self.__help_menu_search_widget.text 

1554 ): 

1555 self.__help_menu_search = False 

1556 self.__last_help_data = None 

1557 del self.__help_menu_search_widget 

1558 self.__help_menu_search_layout = 0, 0 

1559 else: 

1560 self.__help_menu_search_widget.event(e) 

1561 self.__last_help_data = None 

1562 self.__help_menu_line = min( 

1563 max(-self.__menu_content_height + self.__height, self.__help_menu_line), 0 

1564 ) 

1565 

1566 def __clear_layout_cache(self, rc: RenderContext, /) -> bool: 

1567 if self.__width == rc.width and self.__last_help_data == self.help_data: 

1568 return False 

1569 

1570 if self.__width != rc.width: 

1571 self.__help_menu_line = 0 

1572 

1573 self.__width = rc.width 

1574 self.__height = rc.height 

1575 

1576 if self.__last_help_data != self.help_data: 

1577 self.__last_help_data = self.help_data 

1578 self.__prepared_groups = self.__prepare_groups(self.__last_help_data) 

1579 self.__prepared_inline_help = self.__prepare_inline_help( 

1580 self.__last_help_data 

1581 ) 

1582 self.__has_help = bool( 

1583 self.__last_help_data.inline_help or self.__last_help_data.groups 

1584 ) 

1585 

1586 return True 

1587 

1588 def __help_menu_layout(self, rc: RenderContext, /) -> tuple[int, int]: 

1589 if self.__help_menu_search: 

1590 self.__help_menu_search_layout = self.__help_menu_search_widget.layout(rc) 

1591 

1592 if not self.__clear_layout_cache(rc): 

1593 return rc.height, rc.height 

1594 

1595 self.__key_width = 10 

1596 formatter = yuio.md.MdFormatter( 

1597 rc.theme, 

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

1599 allow_headings=False, 

1600 ) 

1601 

1602 self.__wrapped_groups = [] 

1603 for title, actions in self.__prepared_groups.items(): 

1604 wrapped_actions: list[tuple[list[str], list[_ColorizedString], int]] = [] 

1605 for keys, _, msg, key_width in actions: 

1606 wrapped_actions.append((keys, formatter.format(msg), key_width)) 

1607 self.__wrapped_groups.append((title, wrapped_actions)) 

1608 

1609 return rc.height, rc.height 

1610 

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

1612 y = self.__help_menu_line 

1613 

1614 if not self.__wrapped_groups: 

1615 rc.set_color_path("menu/decoration:help_menu") 

1616 rc.write("No actions to display") 

1617 y += 1 

1618 

1619 for title, actions in self.__wrapped_groups: 

1620 rc.set_pos(0, y) 

1621 if title: 

1622 rc.set_color_path("menu/text/heading:help_menu") 

1623 rc.write(title) 

1624 y += 2 

1625 

1626 for keys, lines, key_width in actions: 

1627 if key_width > self.__key_width: 

1628 rc.set_pos(0, y) 

1629 y += 1 

1630 else: 

1631 rc.set_pos(self.__key_width - key_width, y) 

1632 sep = "" 

1633 for key in keys: 

1634 rc.set_color_path("menu/text/help_sep:help_menu") 

1635 rc.write(sep) 

1636 rc.set_color_path("menu/text/help_key:help_menu") 

1637 rc.write(key) 

1638 sep = "/" 

1639 

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

1641 rc.write_text(lines) 

1642 y += len(lines) 

1643 

1644 y += 2 

1645 

1646 self.__menu_content_height = y - self.__help_menu_line 

1647 

1648 with rc.frame(0, rc.height - max(self.__help_menu_search_layout[0], 1)): 

1649 if self.__help_menu_search: 

1650 rc.write(" " * rc.width) 

1651 rc.set_pos(0, 0) 

1652 self.__help_menu_search_widget.draw(rc) 

1653 else: 

1654 rc.set_color_path("menu/decoration:help_menu") 

1655 rc.write(":") 

1656 rc.reset_color() 

1657 rc.write(" " * (rc.width - 1)) 

1658 rc.set_final_pos(1, 0) 

1659 

1660 def __help_menu_layout_inline(self, rc: RenderContext, /) -> tuple[int, int]: 

1661 if not self.__clear_layout_cache(rc): 

1662 return (1, 1) if self.__has_help else (0, 0) 

1663 

1664 if not self.__has_help: 

1665 return 0, 0 

1666 

1667 self.__colorized_inline_help = [] 

1668 for keys, title, _, key_width in self.__prepared_inline_help: 

1669 if keys: 

1670 title_color = "menu/text/help_msg:help" 

1671 else: 

1672 title_color = "menu/text/help_info:help" 

1673 colorized_title = yuio.string.colorize( 

1674 title, default_color=title_color, ctx=rc.theme 

1675 ) 

1676 self.__colorized_inline_help.append((keys, colorized_title, key_width)) 

1677 

1678 return 1, 1 

1679 

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

1681 if not self.__has_help: 

1682 return 

1683 

1684 used_width = _line_width(self._KEY_SYMBOLS[Key.F1]) + 5 

1685 col_sep = "" 

1686 

1687 for keys, title, keys_width in self.__colorized_inline_help: 

1688 action_width = keys_width + bool(keys_width) + title.width + 3 

1689 if used_width + action_width > rc.width: 

1690 break 

1691 

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

1693 rc.write(col_sep) 

1694 

1695 sep = "" 

1696 for key in keys: 

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

1698 rc.write(sep) 

1699 rc.set_color_path("menu/text/help_key:help") 

1700 rc.write(key) 

1701 sep = "/" 

1702 

1703 if keys_width: 

1704 rc.move_pos(1, 0) 

1705 rc.write(title) 

1706 

1707 col_sep = " • " 

1708 

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

1710 rc.write(col_sep) 

1711 rc.set_color_path("menu/text/help_key:help") 

1712 rc.write(self._KEY_SYMBOLS[Key.F1]) 

1713 rc.move_pos(1, 0) 

1714 rc.set_color_path("menu/text/help_msg:help") 

1715 rc.write("help") 

1716 

1717 _ALT = "M-" 

1718 _CTRL = "C-" 

1719 _SHIFT = "S-" 

1720 

1721 _KEY_SYMBOLS = { 

1722 Key.ENTER: "ret", 

1723 Key.ESCAPE: "esc", 

1724 Key.DELETE: "del", 

1725 Key.BACKSPACE: "bsp", 

1726 Key.TAB: "tab", 

1727 Key.HOME: "home", 

1728 Key.END: "end", 

1729 Key.PAGE_UP: "pgup", 

1730 Key.PAGE_DOWN: "pgdn", 

1731 Key.ARROW_UP: "↑", 

1732 Key.ARROW_DOWN: "↓", 

1733 Key.ARROW_LEFT: "←", 

1734 Key.ARROW_RIGHT: "→", 

1735 Key.F1: "f1", 

1736 Key.F2: "f2", 

1737 Key.F3: "f3", 

1738 Key.F4: "f4", 

1739 " ": "␣", 

1740 } 

1741 

1742 def __prepare_inline_help( 

1743 self, data: WidgetHelp 

1744 ) -> list[tuple[list[str], str, str, int]]: 

1745 return [ 

1746 prepared_action 

1747 for action in data.inline_help 

1748 if (prepared_action := self.__prepare_action(action)) and prepared_action[1] 

1749 ] 

1750 

1751 def __prepare_groups( 

1752 self, data: WidgetHelp 

1753 ) -> dict[str, list[tuple[list[str], str, str, int]]]: 

1754 help_data = ( 

1755 data.with_action( 

1756 self._KEY_SYMBOLS[Key.F1], 

1757 group="Other Actions", 

1758 long_msg="toggle help menu", 

1759 ) 

1760 .with_action( 

1761 self._CTRL + "l", 

1762 group="Other Actions", 

1763 long_msg="refresh screen", 

1764 ) 

1765 .with_action( 

1766 self._CTRL + "c", 

1767 group="Other Actions", 

1768 long_msg="send interrupt signal", 

1769 ) 

1770 .with_action( 

1771 "C-...", 

1772 group="Legend", 

1773 long_msg="means `Ctrl+...`", 

1774 ) 

1775 .with_action( 

1776 "M-...", 

1777 group="Legend", 

1778 long_msg=( 

1779 "means `Option+...`" 

1780 if sys.platform == "darwin" 

1781 else "means `Alt+...`" 

1782 ), 

1783 ) 

1784 .with_action( 

1785 "S-...", 

1786 group="Legend", 

1787 long_msg="means `Shift+...`", 

1788 ) 

1789 .with_action( 

1790 "ret", 

1791 group="Legend", 

1792 long_msg="means `Return` or `Enter`", 

1793 ) 

1794 .with_action( 

1795 "bsp", 

1796 group="Legend", 

1797 long_msg="means `Backspace`", 

1798 ) 

1799 ) 

1800 

1801 # Make sure unsorted actions go first. 

1802 groups = {"Actions": []} 

1803 

1804 groups.update( 

1805 { 

1806 title: prepared_actions 

1807 for title, actions in help_data.groups.items() 

1808 if ( 

1809 prepared_actions := [ 

1810 prepared_action 

1811 for action in actions 

1812 if (prepared_action := self.__prepare_action(action)) 

1813 and prepared_action[1] 

1814 ] 

1815 ) 

1816 } 

1817 ) 

1818 

1819 if not groups["Actions"]: 

1820 del groups["Actions"] 

1821 

1822 # Make sure other actions go last. 

1823 if "Other Actions" in groups: 

1824 groups["Other Actions"] = groups.pop("Other Actions") 

1825 if "Legend" in groups: 

1826 groups["Legend"] = groups.pop("Legend") 

1827 

1828 return groups 

1829 

1830 def __prepare_action( 

1831 self, action: Action 

1832 ) -> tuple[list[str], str, str, int] | None: 

1833 if isinstance(action, tuple): 

1834 action_keys, msg = action 

1835 prepared_keys = self.__prepare_keys(action_keys) 

1836 else: 

1837 prepared_keys = [] 

1838 msg = action 

1839 

1840 if self.__help_menu_search: 

1841 pattern = self.__help_menu_search_widget.text 

1842 if not any(pattern in key for key in prepared_keys) and pattern not in msg: 

1843 return None 

1844 

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

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

1847 

1848 def __prepare_keys(self, action_keys: ActionKeys) -> list[str]: 

1849 if isinstance(action_keys, (str, Key, KeyboardEvent)): 

1850 return [self.__prepare_key(action_keys)] 

1851 else: 

1852 return [self.__prepare_key(action_key) for action_key in action_keys] 

1853 

1854 def __prepare_key(self, action_key: ActionKey) -> str: 

1855 if isinstance(action_key, str): 

1856 return action_key 

1857 elif isinstance(action_key, KeyboardEvent): 

1858 ctrl, alt, shift, key = ( 

1859 action_key.ctrl, 

1860 action_key.alt, 

1861 action_key.shift, 

1862 action_key.key, 

1863 ) 

1864 else: 

1865 ctrl, alt, shift, key = False, False, False, action_key 

1866 

1867 symbol = "" 

1868 

1869 if isinstance(key, str) and key.lower() != key: 

1870 shift = True 

1871 key = key.lower() 

1872 

1873 if shift: 

1874 symbol += self._SHIFT 

1875 

1876 if ctrl: 

1877 symbol += self._CTRL 

1878 

1879 if alt: 

1880 symbol += self._ALT 

1881 

1882 return symbol + (self._KEY_SYMBOLS.get(key) or str(key)) 

1883 

1884 

1885Widget.__init_subclass__() 

1886 

1887 

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

1889class _Binding: 

1890 event: KeyboardEvent 

1891 show_in_inline_help: bool 

1892 show_in_detailed_help: bool 

1893 

1894 def __call__(self, fn: T, /) -> T: 

1895 if not hasattr(fn, "__yuio_keybindings__"): 

1896 setattr(fn, "__yuio_keybindings__", []) 

1897 getattr(fn, "__yuio_keybindings__").append(self) 

1898 

1899 return fn 

1900 

1901 

1902def bind( 

1903 key: Key | str, 

1904 *, 

1905 ctrl: bool = False, 

1906 alt: bool = False, 

1907 shift: bool = False, 

1908 show_in_inline_help: bool = False, 

1909 show_in_detailed_help: bool = True, 

1910) -> _Binding: 

1911 """ 

1912 Register an event handler for a widget. 

1913 

1914 Widget's methods can be registered as handlers for keyboard events. 

1915 When a new event comes in, it is checked to match arguments of this decorator. 

1916 If there is a match, the decorated method is called 

1917 instead of the :meth:`Widget.default_event_handler`. 

1918 

1919 .. note:: 

1920 

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

1922 

1923 If ``show_in_help`` is :data:`True`, this binding will be shown in the widget's 

1924 inline help. If ``show_in_detailed_help`` is :data:`True`, 

1925 this binding will be shown in the widget's help menu. 

1926 

1927 Example:: 

1928 

1929 class MyWidget(Widget): 

1930 @bind(Key.ENTER) 

1931 def enter(self): 

1932 # all `ENTER` events go here. 

1933 ... 

1934 

1935 def default_event_handler(self, e: KeyboardEvent): 

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

1937 ... 

1938 

1939 """ 

1940 

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

1942 return _Binding(e, show_in_inline_help, show_in_detailed_help) 

1943 

1944 

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

1946class _Help: 

1947 group: str = "Actions" 

1948 inline_msg: str | None = None 

1949 long_msg: str | None = None 

1950 

1951 def __call__(self, fn: T, /) -> T: 

1952 h = dataclasses.replace( 

1953 self, 

1954 inline_msg=( 

1955 self.inline_msg 

1956 if self.inline_msg is not None 

1957 else getattr(fn, "__doc__", None) 

1958 ), 

1959 long_msg=( 

1960 self.long_msg 

1961 if self.long_msg is not None 

1962 else getattr(fn, "__doc__", None) 

1963 ), 

1964 ) 

1965 setattr(fn, "__yuio_help__", h) 

1966 

1967 return fn 

1968 

1969 

1970def help( 

1971 *, 

1972 group: str = "Actions", 

1973 inline_msg: str | None = None, 

1974 long_msg: str | None = None, 

1975 msg: str | None = None, 

1976) -> _Help: 

1977 """ 

1978 Set options for how this callback should be displayed. 

1979 

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

1981 

1982 :param group: 

1983 title of a group that this action will appear in when the user opens 

1984 a help menu. Groups appear in order of declaration of their first element. 

1985 :param inline_msg: 

1986 this parameter overrides a message in the inline help. By default, 

1987 it will be taken from a docstring. 

1988 :param long_msg: 

1989 this parameter overrides a message in the help menu. By default, 

1990 it will be taken from a docstring. 

1991 :param msg: 

1992 a shortcut parameter for setting both ``inline_msg`` and ``long_msg`` 

1993 at the same time. 

1994 

1995 Example:: 

1996 

1997 class MyWidget(Widget): 

1998 NAVIGATE = "Navigate" 

1999 

2000 @bind(Key.TAB) 

2001 @help(group=NAVIGATE) 

2002 def tab(self): 

2003 \"""next item\""" 

2004 ... 

2005 

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

2007 @help(group=NAVIGATE) 

2008 def shift_tab(self): 

2009 \"""previous item\""" 

2010 ... 

2011 

2012 """ 

2013 

2014 if msg is not None and inline_msg is None: 

2015 inline_msg = msg 

2016 if msg is not None and long_msg is None: 

2017 long_msg = msg 

2018 

2019 return _Help( 

2020 group, 

2021 inline_msg, 

2022 long_msg, 

2023 ) 

2024 

2025 

2026ActionKey: _t.TypeAlias = Key | KeyboardEvent | str 

2027""" 

2028A single key associated with an action. 

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

2030 

2031""" 

2032 

2033 

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

2035""" 

2036A list of keys associated with an action. 

2037 

2038""" 

2039 

2040 

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

2042""" 

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

2044 

2045""" 

2046 

2047 

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

2049class WidgetHelp: 

2050 """ 

2051 Data for automatic help generation. 

2052 

2053 .. warning:: 

2054 

2055 Do not modify contents of this class in-place. This might break layout 

2056 caching in the widget rendering routine, which will cause displaying 

2057 outdated help messages. 

2058 

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

2060 

2061 """ 

2062 

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

2064 """ 

2065 List of actions to show in the inline help. 

2066 

2067 """ 

2068 

2069 groups: dict[str, list[Action]] = dataclasses.field(default_factory=dict) 

2070 """ 

2071 Dict of group titles and actions to show in the help menu. 

2072 

2073 """ 

2074 

2075 def with_action( 

2076 self, 

2077 *bindings: _Binding | ActionKey, 

2078 group: str = "Actions", 

2079 msg: str | None = None, 

2080 inline_msg: str | None = None, 

2081 long_msg: str | None = None, 

2082 prepend: bool = False, 

2083 prepend_group: bool = False, 

2084 ) -> WidgetHelp: 

2085 """ 

2086 Return a new :class:`WidgetHelp` that has an extra action. 

2087 

2088 :param bindings: 

2089 keys that trigger an action. 

2090 :param group: 

2091 title of a group that this action will appear in when the user opens 

2092 a help menu. Groups appear in order of declaration of their first element. 

2093 :param inline_msg: 

2094 this parameter overrides a message in the inline help. By default, 

2095 it will be taken from a docstring. 

2096 :param long_msg: 

2097 this parameter overrides a message in the help menu. By default, 

2098 it will be taken from a docstring. 

2099 :param msg: 

2100 a shortcut parameter for setting both ``inline_msg`` and ``long_msg`` 

2101 at the same time. 

2102 :param prepend: 

2103 if :data:`True`, action will be added to the beginning of its group. 

2104 :param prepend_group: 

2105 if :data:`True`, group will be added to the beginning of the help menu. 

2106 

2107 """ 

2108 

2109 return WidgetHelp(self.inline_help.copy(), self.groups.copy()).__add_action( 

2110 *bindings, 

2111 group=group, 

2112 inline_msg=inline_msg, 

2113 long_msg=long_msg, 

2114 prepend=prepend, 

2115 prepend_group=prepend_group, 

2116 msg=msg, 

2117 ) 

2118 

2119 def merge(self, other: WidgetHelp, /) -> WidgetHelp: 

2120 """ 

2121 Merge this help data with another one and return 

2122 a new instance of :class:`WidgetHelp`. 

2123 

2124 :param other: 

2125 other :class:`WidgetHelp` for merging. 

2126 

2127 """ 

2128 

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

2130 result.inline_help.extend(other.inline_help) 

2131 for title, actions in other.groups.items(): 

2132 result.groups[title] = result.groups.get(title, []) + actions 

2133 return result 

2134 

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

2136 """ 

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

2138 

2139 :param title: 

2140 title to remove. 

2141 

2142 """ 

2143 

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

2145 result.groups.pop(title, None) 

2146 return result 

2147 

2148 def rename_group(self, title: str, new_title: str, /) -> WidgetHelp: 

2149 """ 

2150 Return a new :class:`WidgetHelp` that has a group with the given title renamed. 

2151 

2152 :param title: 

2153 title to replace. 

2154 :param new_title: 

2155 new title. 

2156 

2157 """ 

2158 

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

2160 if group := result.groups.pop(title, None): 

2161 result.groups[new_title] = result.groups.get(new_title, []) + group 

2162 return result 

2163 

2164 def __add_action( 

2165 self, 

2166 *bindings: _Binding | ActionKey, 

2167 group: str, 

2168 inline_msg: str | None, 

2169 long_msg: str | None, 

2170 prepend: bool, 

2171 prepend_group: bool, 

2172 msg: str | None, 

2173 ) -> WidgetHelp: 

2174 settings = help( 

2175 group=group, 

2176 inline_msg=inline_msg, 

2177 long_msg=long_msg, 

2178 msg=msg, 

2179 ) 

2180 

2181 if settings.inline_msg: 

2182 inline_keys: ActionKeys = [ 

2183 binding.event if isinstance(binding, _Binding) else binding 

2184 for binding in bindings 

2185 if not isinstance(binding, _Binding) or binding.show_in_inline_help 

2186 ] 

2187 if prepend: 

2188 self.inline_help.insert(0, (inline_keys, settings.inline_msg)) 

2189 else: 

2190 self.inline_help.append((inline_keys, settings.inline_msg)) 

2191 

2192 if settings.long_msg: 

2193 menu_keys: ActionKeys = [ 

2194 binding.event if isinstance(binding, _Binding) else binding 

2195 for binding in bindings 

2196 if not isinstance(binding, _Binding) or binding.show_in_detailed_help 

2197 ] 

2198 if prepend_group and settings.group not in self.groups: 

2199 # Re-create self.groups with a new group as a first element. 

2200 groups = {settings.group: [], **self.groups} 

2201 self.groups.clear() 

2202 self.groups.update(groups) 

2203 if prepend: 

2204 self.groups[settings.group] = [ 

2205 (menu_keys, settings.long_msg) 

2206 ] + self.groups.get(settings.group, []) 

2207 else: 

2208 self.groups[settings.group] = self.groups.get(settings.group, []) + [ 

2209 (menu_keys, settings.long_msg) 

2210 ] 

2211 

2212 return self 

2213 

2214 

2215@_t.final 

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

2217 """ 

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

2219 of keyboard events. 

2220 

2221 By default, :class:`VerticalLayout` does not handle incoming keyboard events. 

2222 However, you can create :class:`VerticalLayout` that forwards all keyboard events 

2223 to a particular widget within the stack:: 

2224 

2225 widget = VerticalLayout.builder() \\ 

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

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

2228 .build() 

2229 

2230 result = widget.run(term, theme) 

2231 

2232 """ 

2233 

2234 if _t.TYPE_CHECKING: 

2235 

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

2237 

2238 def __init__(self): 

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

2240 self._event_receiver: int | None = None 

2241 

2242 @_t.overload 

2243 def add( 

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

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

2246 

2247 @_t.overload 

2248 def add( 

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

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

2251 

2252 def add(self, widget: Widget[_t.Any], /, *, receive_events=False) -> _t.Any: 

2253 """ 

2254 Add a new widget to the bottom of the layout. 

2255 

2256 If `receive_events` is `True`, all incoming events will be forwarded 

2257 to the added widget. Only the latest widget added with ``receive_events=True`` 

2258 will receive events. 

2259 

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

2261 Use it with method chaining. 

2262 

2263 """ 

2264 

2265 other = VerticalLayoutBuilder() 

2266 

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

2268 other._event_receiver = self._event_receiver 

2269 

2270 if isinstance(widget, VerticalLayout): 

2271 if receive_events and widget._event_receiver is not None: 

2272 other._event_receiver = len(other._widgets) + widget._event_receiver 

2273 elif receive_events: 

2274 other._event_receiver = None 

2275 other._widgets.extend(widget._widgets) 

2276 else: 

2277 if receive_events: 

2278 other._event_receiver = len(other._widgets) 

2279 other._widgets.append(widget) 

2280 

2281 return other 

2282 

2283 def build(self) -> VerticalLayout[T]: 

2284 layout = VerticalLayout() 

2285 layout._widgets = self._widgets 

2286 layout._event_receiver = self._event_receiver 

2287 return _t.cast(VerticalLayout[T], layout) 

2288 

2289 

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

2291 """ 

2292 Helper class for stacking widgets together. 

2293 

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

2295 and draw them all at once. 

2296 

2297 You can use this class as a helper component inside your own widgets, 

2298 or you can use it as a standalone widget. See :class:`~VerticalLayoutBuilder` 

2299 for an example. 

2300 

2301 .. automethod:: append 

2302 

2303 .. automethod:: extend 

2304 

2305 .. automethod:: event 

2306 

2307 .. automethod:: layout 

2308 

2309 .. automethod:: draw 

2310 

2311 """ 

2312 

2313 if _t.TYPE_CHECKING: 

2314 

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

2316 

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

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

2319 self._event_receiver: int | None = None 

2320 

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

2322 self.__min_h: int = 0 

2323 self.__max_h: int = 0 

2324 

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

2326 """ 

2327 Add a widget to the end of the stack. 

2328 

2329 """ 

2330 

2331 if isinstance(widget, VerticalLayout): 

2332 self._widgets.extend(widget._widgets) 

2333 else: 

2334 self._widgets.append(widget) 

2335 

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

2337 """ 

2338 Add multiple widgets to the end of the stack. 

2339 

2340 """ 

2341 

2342 for widget in widgets: 

2343 self.append(widget) 

2344 

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

2346 """ 

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

2348 

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

2350 

2351 """ 

2352 

2353 if self._event_receiver is not None: 

2354 return _t.cast( 

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

2356 ) 

2357 

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

2359 """ 

2360 Calculate layout of the entire stack. 

2361 

2362 """ 

2363 

2364 self.__layouts = [widget.layout(rc) for widget in self._widgets] 

2365 assert all(l[0] <= l[1] for l in self.__layouts), "incorrect layout" 

2366 self.__min_h = sum(l[0] for l in self.__layouts) 

2367 self.__max_h = sum(l[1] for l in self.__layouts) 

2368 return self.__min_h, self.__max_h 

2369 

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

2371 """ 

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

2373 

2374 """ 

2375 

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

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

2378 ) 

2379 

2380 if rc.height <= self.__min_h: 

2381 scale = 0.0 

2382 elif rc.height >= self.__max_h: 

2383 scale = 1.0 

2384 else: 

2385 scale = (rc.height - self.__min_h) / (self.__max_h - self.__min_h) 

2386 

2387 y1 = 0.0 

2388 for widget, (min_h, max_h) in zip(self._widgets, self.__layouts): 

2389 y2 = y1 + min_h + scale * (max_h - min_h) 

2390 

2391 iy1 = round(y1) 

2392 iy2 = round(y2) 

2393 

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

2395 widget.draw(rc) 

2396 

2397 y1 = y2 

2398 

2399 @property 

2400 def help_data(self) -> WidgetHelp: 

2401 if self._event_receiver is not None: 

2402 return self._widgets[self._event_receiver].help_data 

2403 else: 

2404 return WidgetHelp() 

2405 

2406 

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

2408 """ 

2409 A widget that prints a single line of text. 

2410 

2411 """ 

2412 

2413 def __init__( 

2414 self, 

2415 text: yuio.string.AnyString, 

2416 /, 

2417 *, 

2418 color: _Color | str | None = None, 

2419 ): 

2420 self.__text = _ColorizedString(text) 

2421 self.__colorized_text = None 

2422 self.__color = color 

2423 

2424 @property 

2425 def text(self) -> _ColorizedString: 

2426 """ 

2427 Currently displayed text. 

2428 

2429 """ 

2430 return self.__text 

2431 

2432 @text.setter 

2433 def text(self, text: yuio.string.AnyString, /): 

2434 self.__text = _ColorizedString(text) 

2435 

2436 @property 

2437 def color(self) -> _Color | str | None: 

2438 """ 

2439 Color of the currently displayed text. 

2440 

2441 """ 

2442 return self.__color 

2443 

2444 @color.setter 

2445 def color(self, color: _Color | str | None, /): 

2446 self.__color = color 

2447 

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

2449 return 1, 1 

2450 

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

2452 if self.__colorized_text is None: 

2453 if self.__color is None: 

2454 self.__colorized_text = self.__text 

2455 else: 

2456 color = rc.theme.to_color(self.__color) 

2457 self.__colorized_text = self.__text.with_base_color(color) 

2458 

2459 rc.write(self.__colorized_text) 

2460 

2461 

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

2463 """ 

2464 A widget that prints wrapped text. 

2465 

2466 """ 

2467 

2468 def __init__( 

2469 self, 

2470 text: yuio.string.AnyString, 

2471 /, 

2472 ): 

2473 self.__text = _ColorizedString(text) 

2474 self.__wrapped_text: list[_ColorizedString] | None = None 

2475 self.__wrapped_text_width: int = 0 

2476 

2477 @property 

2478 def text(self) -> _ColorizedString: 

2479 """ 

2480 Currently displayed text. 

2481 

2482 """ 

2483 return self.__text 

2484 

2485 @text.setter 

2486 def text(self, text: yuio.string.AnyString, /): 

2487 self.__text = _ColorizedString(text) 

2488 self.__wrapped_text = None 

2489 self.__wrapped_text_width = 0 

2490 

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

2492 if self.__wrapped_text is None or self.__wrapped_text_width != rc.width: 

2493 self.__wrapped_text = self.__text.wrap( 

2494 rc.width, 

2495 break_long_nowrap_words=True, 

2496 ) 

2497 self.__wrapped_text_width = rc.width 

2498 height = len(self.__wrapped_text) 

2499 return height, height 

2500 

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

2502 assert self.__wrapped_text is not None 

2503 rc.write_text(self.__wrapped_text) 

2504 

2505 

2506_CHAR_NAMES = { 

2507 "\u0000": "<NUL>", 

2508 "\u0001": "<SOH>", 

2509 "\u0002": "<STX>", 

2510 "\u0003": "<ETX>", 

2511 "\u0004": "<EOT>", 

2512 "\u0005": "<ENQ>", 

2513 "\u0006": "<ACK>", 

2514 "\u0007": "\\a", 

2515 "\u0008": "\\b", 

2516 "\u0009": "\\t", 

2517 "\u000b": "\\v", 

2518 "\u000c": "\\f", 

2519 "\u000d": "\\r", 

2520 "\u000e": "<SO>", 

2521 "\u000f": "<SI>", 

2522 "\u0010": "<DLE>", 

2523 "\u0011": "<DC1>", 

2524 "\u0012": "<DC2>", 

2525 "\u0013": "<DC3>", 

2526 "\u0014": "<DC4>", 

2527 "\u0015": "<NAK>", 

2528 "\u0016": "<SYN>", 

2529 "\u0017": "<ETB>", 

2530 "\u0018": "<CAN>", 

2531 "\u0019": "<EM>", 

2532 "\u001a": "<SUB>", 

2533 "\u001b": "<ESC>", 

2534 "\u001c": "<FS>", 

2535 "\u001d": "<GS>", 

2536 "\u001e": "<RS>", 

2537 "\u001f": "<US>", 

2538 "\u007f": "<DEL>", 

2539 "\u0080": "<PAD>", 

2540 "\u0081": "<HOP>", 

2541 "\u0082": "<BPH>", 

2542 "\u0083": "<NBH>", 

2543 "\u0084": "<IND>", 

2544 "\u0085": "<NEL>", 

2545 "\u0086": "<SSA>", 

2546 "\u0087": "<ESA>", 

2547 "\u0088": "<HTS>", 

2548 "\u0089": "<HTJ>", 

2549 "\u008a": "<VTS>", 

2550 "\u008b": "<PLD>", 

2551 "\u008c": "<PLU>", 

2552 "\u008d": "<RI>", 

2553 "\u008e": "<SS2>", 

2554 "\u008f": "<SS3>", 

2555 "\u0090": "<DCS>", 

2556 "\u0091": "<PU1>", 

2557 "\u0092": "<PU2>", 

2558 "\u0093": "<STS>", 

2559 "\u0094": "<CCH>", 

2560 "\u0095": "<MW>", 

2561 "\u0096": "<SPA>", 

2562 "\u0097": "<EPA>", 

2563 "\u0098": "<SOS>", 

2564 "\u0099": "<SGCI>", 

2565 "\u009a": "<SCI>", 

2566 "\u009b": "<CSI>", 

2567 "\u009c": "<ST>", 

2568 "\u009d": "<OSC>", 

2569 "\u009e": "<PM>", 

2570 "\u009f": "<APC>", 

2571 "\u00a0": "<NBSP>", 

2572 "\u00ad": "<SHY>", 

2573} 

2574 

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

2576 

2577 

2578def _replace_special_symbols(text: str, esc_color: _Color, n_color: _Color): 

2579 raw: list[_Color | str] = [n_color] 

2580 i = 0 

2581 for match in _ESC_RE.finditer(text): 

2582 if s := text[i : match.start()]: 

2583 raw.append(s) 

2584 raw.append(esc_color) 

2585 raw.append(_Esc(_CHAR_NAMES[match.group(1)])) 

2586 raw.append(n_color) 

2587 i = match.end() 

2588 if i < len(text): 

2589 raw.append(text[i:]) 

2590 return raw 

2591 

2592 

2593def _find_cursor_pos(text: list[_ColorizedString], text_width: int, offset: int): 

2594 total_len = 0 

2595 if not offset: 

2596 return (0, 0) 

2597 for y, line in enumerate(text): 

2598 x = 0 

2599 for part in line: 

2600 if isinstance(part, _Esc): 

2601 l = 1 

2602 dx = len(part) 

2603 elif isinstance(part, str): 

2604 l = len(part) 

2605 dx = _line_width(part) 

2606 else: 

2607 continue 

2608 if total_len + l >= offset: 

2609 if isinstance(part, _Esc): 

2610 x += dx 

2611 else: 

2612 x += _line_width(part[: offset - total_len]) 

2613 if x >= text_width: 

2614 return (0, y + 1) 

2615 else: 

2616 return (0 + x, y) 

2617 break 

2618 x += dx 

2619 total_len += l 

2620 total_len += len(line.explicit_newline) 

2621 if total_len >= offset: 

2622 return (0, y + 1) 

2623 assert False 

2624 

2625 

2626class Input(Widget[str]): 

2627 """ 

2628 An input box. 

2629 

2630 .. vhs:: /_tapes/widget_input.tape 

2631 :alt: Demonstration of `Input` widget. 

2632 :scale: 40% 

2633 

2634 .. note:: 

2635 

2636 :class:`Input` is not optimized to handle long texts or long editing sessions. 

2637 It's best used to get relatively short answers from users 

2638 with :func:`yuio.io.ask`. If you need to edit large text, especially multiline, 

2639 consider using :func:`yuio.io.edit` instead. 

2640 

2641 :param text: 

2642 initial text. 

2643 :param pos: 

2644 initial cursor position, calculated as an offset from beginning of the text. 

2645 Should be ``0 <= pos <= len(text)``. 

2646 :param placeholder: 

2647 placeholder text, shown when input is empty. 

2648 :param decoration: 

2649 decoration printed before the input box. 

2650 :param allow_multiline: 

2651 if `True`, :kbd:`Enter` key makes a new line, otherwise it accepts input. 

2652 In this mode, newlines in pasted text are also preserved. 

2653 :param allow_special_characters: 

2654 If `True`, special characters like tabs or escape symbols are preserved 

2655 and not replaced with whitespaces. 

2656 

2657 """ 

2658 

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

2660 # via hotkeys. 

2661 _WORD_SEPARATORS = string.punctuation + string.whitespace 

2662 

2663 # Character that replaces newlines and unprintable characters when 

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

2665 _UNPRINTABLE_SUBSTITUTOR = " " 

2666 

2667 class _CheckpointType(enum.Enum): 

2668 """ 

2669 Types of entries in the history buffer. 

2670 

2671 """ 

2672 

2673 USR = enum.auto() 

2674 """ 

2675 User-initiated checkpoint. 

2676 

2677 """ 

2678 

2679 SYM = enum.auto() 

2680 """ 

2681 Checkpoint before a symbol was inserted. 

2682 

2683 """ 

2684 

2685 SEP = enum.auto() 

2686 """ 

2687 Checkpoint before a space was inserted. 

2688 

2689 """ 

2690 

2691 DEL = enum.auto() 

2692 """ 

2693 Checkpoint before something was deleted. 

2694 

2695 """ 

2696 

2697 def __init__( 

2698 self, 

2699 *, 

2700 text: str = "", 

2701 pos: int | None = None, 

2702 placeholder: str = "", 

2703 decoration: str = ">", 

2704 allow_multiline: bool = False, 

2705 allow_special_characters: bool = False, 

2706 ): 

2707 self.__text: str = text 

2708 self.__pos: int = len(text) if pos is None else max(0, min(pos, len(text))) 

2709 self.__placeholder: str = placeholder 

2710 self.__decoration: str = decoration 

2711 self.__allow_multiline: bool = allow_multiline 

2712 self.__allow_special_characters: bool = allow_special_characters 

2713 

2714 self.__wrapped_text_width: int = 0 

2715 self.__wrapped_text: list[_ColorizedString] | None = None 

2716 self.__pos_after_wrap: tuple[int, int] | None = None 

2717 

2718 # We keep track of edit history by saving input text 

2719 # and cursor position in this list. 

2720 self.__history: list[tuple[str, int, Input._CheckpointType]] = [ 

2721 (self.__text, self.__pos, Input._CheckpointType.SYM) 

2722 ] 

2723 # Sometimes we don't record all actions. For example, entering multiple spaces 

2724 # one after the other, or entering multiple symbols one after the other, 

2725 # will only generate one checkpoint. We keep track of how many items 

2726 # were skipped this way since the last checkpoint. 

2727 self.__history_skipped_actions = 0 

2728 # After we move a cursor, the logic with skipping checkpoints 

2729 # should be momentarily disabled. This avoids inconsistencies in situations 

2730 # where we've typed a word, moved the cursor, then typed another word. 

2731 self.__require_checkpoint: bool = False 

2732 

2733 # All delete operations save deleted text here. Pressing `C-y` pastes deleted 

2734 # text at the position of the cursor. 

2735 self.__yanked_text: str = "" 

2736 

2737 @property 

2738 def text(self) -> str: 

2739 """ 

2740 Current text in the input box. 

2741 

2742 """ 

2743 return self.__text 

2744 

2745 @text.setter 

2746 def text(self, text: str, /): 

2747 self.__text = text 

2748 self.__wrapped_text = None 

2749 if self.pos > len(text): 

2750 self.pos = len(text) 

2751 

2752 @property 

2753 def pos(self) -> int: 

2754 """ 

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

2756 

2757 That is, if the text is `"quick brown fox"` with cursor right before the word 

2758 "brown", then :attr:`~Input.pos` is equal to `len("quick ")`. 

2759 

2760 """ 

2761 return self.__pos 

2762 

2763 @pos.setter 

2764 def pos(self, pos: int, /): 

2765 self.__pos = max(0, min(pos, len(self.__text))) 

2766 self.__pos_after_wrap = None 

2767 

2768 def checkpoint(self): 

2769 """ 

2770 Manually create an entry in the history buffer. 

2771 

2772 """ 

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

2774 self.__history_skipped_actions = 0 

2775 

2776 def restore_checkpoint(self): 

2777 """ 

2778 Restore the last manually created checkpoint. 

2779 

2780 """ 

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

2782 self.undo() 

2783 

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

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

2786 

2787 if action == prev_action and not self.__require_checkpoint: 

2788 # If we're repeating the same action, don't create a checkpoint. 

2789 # I.e. if we're typing a word, we don't want to create checkpoints 

2790 # for every letter. 

2791 self.__history_skipped_actions += 1 

2792 return 

2793 

2794 prev_skipped_actions = self.__history_skipped_actions 

2795 self.__history_skipped_actions = 0 

2796 

2797 if ( 

2798 action == Input._CheckpointType.SYM 

2799 and prev_action == Input._CheckpointType.SEP 

2800 and prev_skipped_actions == 0 

2801 and not self.__require_checkpoint 

2802 ): 

2803 # If we're inserting a symbol after we've typed a single space, 

2804 # we only want one checkpoint for both space and symbols. 

2805 # Thus, we simply change the type of the last checkpoint. 

2806 self.__history[-1] = prev_text, prev_pos, action 

2807 return 

2808 

2809 if text == prev_text and pos == prev_pos: 

2810 # This could happen when user presses backspace while the cursor 

2811 # is at the text's beginning. We don't want to create 

2812 # a checkpoint for this. 

2813 return 

2814 

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

2816 if len(self.__history) > 50: 

2817 self.__history.pop(0) 

2818 

2819 self.__require_checkpoint = False 

2820 

2821 @bind(Key.ENTER) 

2822 def enter(self) -> Result[str] | None: 

2823 if self.__allow_multiline: 

2824 self.insert("\n") 

2825 else: 

2826 return self.alt_enter() 

2827 

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

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

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

2831 return Result(self.text) 

2832 

2833 _NAVIGATE = "Navigate" 

2834 

2835 @bind(Key.ARROW_UP) 

2836 @bind("p", ctrl=True) 

2837 @help(group=_NAVIGATE) 

2838 def up(self, /, *, checkpoint: bool = True): 

2839 """up""" 

2840 pos = self.pos 

2841 self.home() 

2842 if self.pos: 

2843 width = _line_width(self.text[self.pos : pos]) 

2844 

2845 self.left() 

2846 self.home() 

2847 

2848 pos = self.pos 

2849 text = self.text 

2850 cur_width = 0 

2851 while pos < len(text) and text[pos] != "\n": 

2852 if cur_width >= width: 

2853 break 

2854 cur_width += _line_width(text[pos]) 

2855 pos += 1 

2856 

2857 self.pos = pos 

2858 

2859 self.__require_checkpoint |= checkpoint 

2860 

2861 @bind(Key.ARROW_DOWN) 

2862 @bind("n", ctrl=True) 

2863 @help(group=_NAVIGATE) 

2864 def down(self, /, *, checkpoint: bool = True): 

2865 """down""" 

2866 pos = self.pos 

2867 self.home() 

2868 width = _line_width(self.text[self.pos : pos]) 

2869 self.end() 

2870 

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

2872 self.right() 

2873 

2874 pos = self.pos 

2875 text = self.text 

2876 cur_width = 0 

2877 while pos < len(text) and text[pos] != "\n": 

2878 if cur_width >= width: 

2879 break 

2880 cur_width += _line_width(text[pos]) 

2881 pos += 1 

2882 

2883 self.pos = pos 

2884 

2885 self.__require_checkpoint |= checkpoint 

2886 

2887 @bind(Key.ARROW_LEFT) 

2888 @bind("b", ctrl=True) 

2889 @help(group=_NAVIGATE) 

2890 def left(self, /, *, checkpoint: bool = True): 

2891 """left""" 

2892 self.pos -= 1 

2893 self.__require_checkpoint |= checkpoint 

2894 

2895 @bind(Key.ARROW_RIGHT) 

2896 @bind("f", ctrl=True) 

2897 @help(group=_NAVIGATE) 

2898 def right(self, /, *, checkpoint: bool = True): 

2899 """right""" 

2900 self.pos += 1 

2901 self.__require_checkpoint |= checkpoint 

2902 

2903 @bind(Key.ARROW_LEFT, alt=True) 

2904 @bind("b", alt=True) 

2905 @help(group=_NAVIGATE) 

2906 def left_word(self, /, *, checkpoint: bool = True): 

2907 """left one word""" 

2908 pos = self.pos 

2909 text = self.text 

2910 if pos: 

2911 pos -= 1 

2912 while pos and text[pos] in self._WORD_SEPARATORS and text[pos - 1] != "\n": 

2913 pos -= 1 

2914 while pos and text[pos - 1] not in self._WORD_SEPARATORS: 

2915 pos -= 1 

2916 self.pos = pos 

2917 self.__require_checkpoint |= checkpoint 

2918 

2919 @bind(Key.ARROW_RIGHT, alt=True) 

2920 @bind("f", alt=True) 

2921 @help(group=_NAVIGATE) 

2922 def right_word(self, /, *, checkpoint: bool = True): 

2923 """right one word""" 

2924 pos = self.pos 

2925 text = self.text 

2926 if pos < len(text) and text[pos] == "\n": 

2927 pos += 1 

2928 while ( 

2929 pos < len(text) and text[pos] in self._WORD_SEPARATORS and text[pos] != "\n" 

2930 ): 

2931 pos += 1 

2932 while pos < len(text) and text[pos] not in self._WORD_SEPARATORS: 

2933 pos += 1 

2934 self.pos = pos 

2935 self.__require_checkpoint |= checkpoint 

2936 

2937 @bind(Key.HOME) 

2938 @bind("a", ctrl=True) 

2939 @help(group=_NAVIGATE) 

2940 def home(self, /, *, checkpoint: bool = True): 

2941 """to line start""" 

2942 self.pos = self.text.rfind("\n", 0, self.pos) + 1 

2943 self.__require_checkpoint |= checkpoint 

2944 

2945 @bind(Key.END) 

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

2947 @help(group=_NAVIGATE) 

2948 def end(self, /, *, checkpoint: bool = True): 

2949 """to line end""" 

2950 next_nl = self.text.find("\n", self.pos) 

2951 if next_nl == -1: 

2952 self.pos = len(self.text) 

2953 else: 

2954 self.pos = next_nl 

2955 self.__require_checkpoint |= checkpoint 

2956 

2957 _MODIFY = "Modify" 

2958 

2959 @bind(Key.BACKSPACE) 

2960 @bind("h", ctrl=True) 

2961 @help(group=_MODIFY) 

2962 def backspace(self): 

2963 """backspace""" 

2964 prev_pos = self.pos 

2965 self.left(checkpoint=False) 

2966 if prev_pos != self.pos: 

2967 self._internal_checkpoint(Input._CheckpointType.DEL, self.text, prev_pos) 

2968 self.text = self.text[: self.pos] + self.text[prev_pos:] 

2969 else: 

2970 self._bell() 

2971 

2972 @bind(Key.DELETE) 

2973 @help(group=_MODIFY) 

2974 def delete(self): 

2975 """delete""" 

2976 prev_pos = self.pos 

2977 self.right(checkpoint=False) 

2978 if prev_pos != self.pos: 

2979 self._internal_checkpoint(Input._CheckpointType.DEL, self.text, prev_pos) 

2980 self.text = self.text[:prev_pos] + self.text[self.pos :] 

2981 self.pos = prev_pos 

2982 else: 

2983 self._bell() 

2984 

2985 @bind(Key.BACKSPACE, alt=True) 

2986 @bind("w", ctrl=True) 

2987 @help(group=_MODIFY) 

2988 def backspace_word(self): 

2989 """backspace one word""" 

2990 prev_pos = self.pos 

2991 self.left_word(checkpoint=False) 

2992 if prev_pos != self.pos: 

2993 self._internal_checkpoint(Input._CheckpointType.DEL, self.text, prev_pos) 

2994 self.__yanked_text = self.text[self.pos : prev_pos] 

2995 self.text = self.text[: self.pos] + self.text[prev_pos:] 

2996 else: 

2997 self._bell() 

2998 

2999 @bind(Key.DELETE, alt=True) 

3000 @bind("d", alt=True) 

3001 @help(group=_MODIFY) 

3002 def delete_word(self): 

3003 """delete one word""" 

3004 prev_pos = self.pos 

3005 self.right_word(checkpoint=False) 

3006 if prev_pos != self.pos: 

3007 self._internal_checkpoint(Input._CheckpointType.DEL, self.text, prev_pos) 

3008 self.__yanked_text = self.text[prev_pos : self.pos] 

3009 self.text = self.text[:prev_pos] + self.text[self.pos :] 

3010 self.pos = prev_pos 

3011 else: 

3012 self._bell() 

3013 

3014 @bind("u", ctrl=True) 

3015 @help(group=_MODIFY) 

3016 def backspace_home(self): 

3017 """backspace to the beginning of a line""" 

3018 prev_pos = self.pos 

3019 self.home(checkpoint=False) 

3020 if prev_pos != self.pos: 

3021 self._internal_checkpoint(Input._CheckpointType.DEL, self.text, prev_pos) 

3022 self.__yanked_text = self.text[self.pos : prev_pos] 

3023 self.text = self.text[: self.pos] + self.text[prev_pos:] 

3024 else: 

3025 self._bell() 

3026 

3027 @bind("k", ctrl=True) 

3028 @help(group=_MODIFY) 

3029 def delete_end(self): 

3030 """delete to the ending of a line""" 

3031 prev_pos = self.pos 

3032 self.end(checkpoint=False) 

3033 if prev_pos != self.pos: 

3034 self._internal_checkpoint(Input._CheckpointType.DEL, self.text, prev_pos) 

3035 self.__yanked_text = self.text[prev_pos : self.pos] 

3036 self.text = self.text[:prev_pos] + self.text[self.pos :] 

3037 self.pos = prev_pos 

3038 else: 

3039 self._bell() 

3040 

3041 @bind("y", ctrl=True) 

3042 @help(group=_MODIFY) 

3043 def yank(self): 

3044 """yank (paste the last deleted text)""" 

3045 if self.__yanked_text: 

3046 self.__require_checkpoint = True 

3047 self.insert(self.__yanked_text) 

3048 else: 

3049 self._bell() 

3050 

3051 # the actual shortcut is `C-7`, the rest produce the same code... 

3052 @bind("7", ctrl=True, show_in_detailed_help=False) 

3053 @bind("_", ctrl=True, show_in_detailed_help=False) 

3054 @bind("-", ctrl=True) 

3055 @bind("?", ctrl=True) 

3056 @help(group=_MODIFY) 

3057 def undo(self): 

3058 """undo""" 

3059 self.text, self.pos, _ = self.__history[-1] 

3060 if len(self.__history) > 1: 

3061 self.__history.pop() 

3062 else: 

3063 self._bell() 

3064 

3065 def default_event_handler(self, e: KeyboardEvent): 

3066 if e.key is Key.PASTE: 

3067 self.__require_checkpoint = True 

3068 s = e.paste_str or "" 

3069 if self.__allow_special_characters and self.__allow_multiline: 

3070 pass 

3071 elif self.__allow_multiline: 

3072 s = re.sub(_UNPRINTABLE_RE_WITHOUT_NL, self._UNPRINTABLE_SUBSTITUTOR, s) 

3073 elif self.__allow_special_characters: 

3074 s = s.replace("\n", self._UNPRINTABLE_SUBSTITUTOR) 

3075 else: 

3076 s = re.sub(_UNPRINTABLE_RE, self._UNPRINTABLE_SUBSTITUTOR, s) 

3077 self.insert(s) 

3078 elif e.key is Key.TAB: 

3079 if self.__allow_special_characters: 

3080 self.insert("\t") 

3081 else: 

3082 self.insert(self._UNPRINTABLE_SUBSTITUTOR) 

3083 elif isinstance(e.key, str) and not e.alt and not e.ctrl: 

3084 self.insert(e.key) 

3085 

3086 def insert(self, s: str): 

3087 if not s: 

3088 return 

3089 

3090 self._internal_checkpoint( 

3091 ( 

3092 Input._CheckpointType.SEP 

3093 if s in self._WORD_SEPARATORS 

3094 else Input._CheckpointType.SYM 

3095 ), 

3096 self.text, 

3097 self.pos, 

3098 ) 

3099 

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

3101 self.pos += len(s) 

3102 

3103 @property 

3104 def _decoration_width(self): 

3105 if self.__decoration: 

3106 return _line_width(self.__decoration) + 1 

3107 else: 

3108 return 0 

3109 

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

3111 decoration_width = self._decoration_width 

3112 text_width = rc.width - decoration_width 

3113 if text_width < 2: 

3114 self.__wrapped_text_width = max(text_width, 0) 

3115 self.__wrapped_text = None 

3116 self.__pos_after_wrap = None 

3117 return 0, 0 

3118 

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

3120 self.__wrapped_text_width = text_width 

3121 

3122 # Note: don't use wrap with overflow here 

3123 # or we won't be able to find the cursor position! 

3124 if self.__text: 

3125 self.__wrapped_text = self._prepare_display_text( 

3126 self.__text, 

3127 rc.theme.get_color("menu/text/esc:input"), 

3128 rc.theme.get_color("menu/text:input"), 

3129 ).wrap( 

3130 text_width, 

3131 preserve_spaces=True, 

3132 break_long_nowrap_words=True, 

3133 ) 

3134 self.__pos_after_wrap = None 

3135 else: 

3136 self.__wrapped_text = _ColorizedString( 

3137 [ 

3138 rc.theme.get_color("menu/text/placeholder:input"), 

3139 self.__placeholder, 

3140 ] 

3141 ).wrap( 

3142 text_width, 

3143 preserve_newlines=False, 

3144 break_long_nowrap_words=True, 

3145 ) 

3146 self.__pos_after_wrap = (decoration_width, 0) 

3147 

3148 if self.__pos_after_wrap is None: 

3149 x, y = _find_cursor_pos(self.__wrapped_text, text_width, self.__pos) 

3150 self.__pos_after_wrap = (decoration_width + x, y) 

3151 

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

3153 return height, height 

3154 

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

3156 if self.__decoration: 

3157 rc.set_color_path("menu/decoration:input") 

3158 rc.write(self.__decoration) 

3159 rc.move_pos(1, 0) 

3160 

3161 if self.__wrapped_text is not None: 

3162 rc.write_text(self.__wrapped_text) 

3163 

3164 if self.__pos_after_wrap is not None: 

3165 rc.set_final_pos(*self.__pos_after_wrap) 

3166 

3167 def _prepare_display_text( 

3168 self, text: str, esc_color: _Color, n_color: _Color 

3169 ) -> _ColorizedString: 

3170 return _ColorizedString(_replace_special_symbols(text, esc_color, n_color)) 

3171 

3172 @property 

3173 def help_data(self) -> WidgetHelp: 

3174 if self.__allow_multiline: 

3175 return ( 

3176 super() 

3177 .help_data.with_action( 

3178 KeyboardEvent(Key.ENTER, alt=True), 

3179 KeyboardEvent("d", ctrl=True), 

3180 msg="accept", 

3181 prepend=True, 

3182 ) 

3183 .with_action( 

3184 KeyboardEvent(Key.ENTER), 

3185 group=self._MODIFY, 

3186 long_msg="new line", 

3187 prepend=True, 

3188 ) 

3189 ) 

3190 else: 

3191 return super().help_data 

3192 

3193 

3194class SecretInput(Input): 

3195 """ 

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

3197 

3198 :param text: 

3199 initial text. 

3200 :param pos: 

3201 initial cursor position, calculated as an offset from beginning of the text. 

3202 Should be ``0 <= pos <= len(text)``. 

3203 :param placeholder: 

3204 placeholder text, shown when input is empty. 

3205 :param decoration: 

3206 decoration printed before the input box. 

3207 

3208 """ 

3209 

3210 _WORD_SEPARATORS = "" 

3211 _UNPRINTABLE_SUBSTITUTOR = "" 

3212 

3213 def __init__( 

3214 self, 

3215 *, 

3216 text: str = "", 

3217 pos: int | None = None, 

3218 placeholder: str = "", 

3219 decoration: str = ">", 

3220 ): 

3221 super().__init__( 

3222 text=text, 

3223 pos=pos, 

3224 placeholder=placeholder, 

3225 decoration=decoration, 

3226 allow_multiline=False, 

3227 allow_special_characters=False, 

3228 ) 

3229 

3230 def _prepare_display_text( 

3231 self, text: str, esc_color: _Color, n_color: _Color 

3232 ) -> _ColorizedString: 

3233 return _ColorizedString("*" * len(text)) 

3234 

3235 

3236@dataclass(slots=True) 

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

3238 """ 

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

3240 

3241 """ 

3242 

3243 def __post_init__(self): 

3244 if self.color_tag is None: 

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

3246 

3247 value: T_co 

3248 """ 

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

3250 

3251 """ 

3252 

3253 display_text: str 

3254 """ 

3255 What should be displayed in the autocomplete list. 

3256 

3257 """ 

3258 

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

3260 """ 

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

3262 

3263 """ 

3264 

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

3266 """ 

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

3268 

3269 """ 

3270 

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

3272 """ 

3273 Option's short comment. 

3274 

3275 """ 

3276 

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

3278 """ 

3279 Option's color tag. 

3280 

3281 This color tag will be used to display option. 

3282 Specifically, color for the option will be looked up py path 

3283 :samp:``menu/{element}:choice/{status}/{color_tag}``. 

3284 

3285 """ 

3286 

3287 

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

3289 """ 

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

3291 

3292 .. note:: 

3293 

3294 On its own, :class:`Grid` doesn't return when you press :kbd:`Enter` 

3295 or :kbd:`Ctrl+D`. It's meant to be used as part of another widget. 

3296 

3297 :param options: 

3298 list of options displayed in the grid. 

3299 :param decoration: 

3300 decoration printed before the selected option. 

3301 :param default_index: 

3302 index of the initially selected option. 

3303 :param min_rows: 

3304 minimum number of rows that the grid should occupy before it starts 

3305 splitting options into columns. This option is ignored if there isn't enough 

3306 space on the screen. 

3307 

3308 """ 

3309 

3310 def __init__( 

3311 self, 

3312 options: list[Option[T]], 

3313 /, 

3314 *, 

3315 decoration: str = ">", 

3316 default_index: int | None = 0, 

3317 min_rows: int | None = 5, 

3318 ): 

3319 self.__options: list[Option[T]] 

3320 self.__index: int | None 

3321 self.__min_rows: int | None = min_rows 

3322 self.__max_column_width: int 

3323 self.__column_width: int 

3324 self.__num_rows: int 

3325 self.__num_columns: int 

3326 

3327 self.__decoration = decoration 

3328 

3329 self.set_options(options) 

3330 self.index = default_index 

3331 

3332 @property 

3333 def _page_size(self) -> int: 

3334 return self.__num_rows * self.__num_columns 

3335 

3336 @property 

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

3338 """ 

3339 Index of the currently selected option. 

3340 

3341 """ 

3342 

3343 return self.__index 

3344 

3345 @index.setter 

3346 def index(self, idx: int | None): 

3347 if idx is None or not self.__options: 

3348 self.__index = None 

3349 elif self.__options: 

3350 self.__index = idx % len(self.__options) 

3351 

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

3353 """ 

3354 Get the currently selected option, 

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

3356 

3357 """ 

3358 

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

3360 return self.__options[self.__index] 

3361 

3362 def has_options(self) -> bool: 

3363 """ 

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

3365 

3366 """ 

3367 

3368 return bool(self.__options) 

3369 

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

3371 """ 

3372 Get all options. 

3373 

3374 """ 

3375 

3376 return self.__options 

3377 

3378 def set_options( 

3379 self, 

3380 options: list[Option[T]], 

3381 /, 

3382 default_index: int | None = 0, 

3383 ): 

3384 """ 

3385 Set a new list of options. 

3386 

3387 """ 

3388 

3389 self.__options = options 

3390 self.__max_column_width = max( 

3391 0, _MIN_COLUMN_WIDTH, *map(self._get_option_width, options) 

3392 ) 

3393 self.index = default_index 

3394 

3395 _NAVIGATE = "Navigate" 

3396 

3397 @bind(Key.ARROW_UP) 

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

3399 @help(group=_NAVIGATE) 

3400 def prev_item(self): 

3401 """previous item""" 

3402 if not self.__options: 

3403 return 

3404 

3405 if self.__index is None: 

3406 self.__index = 0 

3407 else: 

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

3409 

3410 @bind(Key.ARROW_DOWN) 

3411 @bind(Key.TAB) 

3412 @help(group=_NAVIGATE) 

3413 def next_item(self): 

3414 """next item""" 

3415 if not self.__options: 

3416 return 

3417 

3418 if self.__index is None: 

3419 self.__index = 0 

3420 else: 

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

3422 

3423 @bind(Key.ARROW_LEFT) 

3424 @help(group=_NAVIGATE) 

3425 def prev_column(self): 

3426 """previous column""" 

3427 if not self.__options: 

3428 return 

3429 

3430 if self.__index is None: 

3431 self.__index = 0 

3432 else: 

3433 total_grid_capacity = self.__num_rows * math.ceil( 

3434 len(self.__options) / self.__num_rows 

3435 ) 

3436 

3437 self.__index = (self.__index - self.__num_rows) % total_grid_capacity 

3438 if self.__index >= len(self.__options): 

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

3440 

3441 @bind(Key.ARROW_RIGHT) 

3442 @help(group=_NAVIGATE) 

3443 def next_column(self): 

3444 """next column""" 

3445 if not self.__options: 

3446 return 

3447 

3448 if self.__index is None: 

3449 self.__index = 0 

3450 else: 

3451 total_grid_capacity = self.__num_rows * math.ceil( 

3452 len(self.__options) / self.__num_rows 

3453 ) 

3454 

3455 self.__index = (self.__index + self.__num_rows) % total_grid_capacity 

3456 if self.__index >= len(self.__options): 

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

3458 

3459 @bind(Key.PAGE_UP) 

3460 @help(group=_NAVIGATE) 

3461 def prev_page(self): 

3462 """previous page""" 

3463 if not self.__options: 

3464 return 

3465 

3466 if self.__index is None: 

3467 self.__index = 0 

3468 else: 

3469 self.__index -= self.__index % self._page_size 

3470 self.__index -= 1 

3471 if self.__index < 0: 

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

3473 

3474 @bind(Key.PAGE_DOWN) 

3475 @help(group=_NAVIGATE) 

3476 def next_page(self): 

3477 """next page""" 

3478 if not self.__options: 

3479 return 

3480 

3481 if self.__index is None: 

3482 self.__index = 0 

3483 else: 

3484 self.__index -= self.__index % self._page_size 

3485 self.__index += self._page_size 

3486 if self.__index > len(self.__options): 

3487 self.__index = 0 

3488 

3489 @bind(Key.HOME) 

3490 @help(group=_NAVIGATE) 

3491 def home(self): 

3492 """first page""" 

3493 if not self.__options: 

3494 return 

3495 

3496 if self.__index is None: 

3497 self.__index = 0 

3498 else: 

3499 self.__index = 0 

3500 

3501 @bind(Key.END) 

3502 @help(group=_NAVIGATE) 

3503 def end(self): 

3504 """last page""" 

3505 if not self.__options: 

3506 return 

3507 

3508 if self.__index is None: 

3509 self.__index = 0 

3510 else: 

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

3512 

3513 def default_event_handler(self, e: KeyboardEvent): 

3514 if isinstance(e.key, str): 

3515 key = e.key.casefold() 

3516 if ( 

3517 self.__options 

3518 and self.__index is not None 

3519 and self.__options[self.__index].display_text.casefold().startswith(key) 

3520 ): 

3521 start = self.__index + 1 

3522 else: 

3523 start = 0 

3524 for i in range(start, start + len(self.__options)): 

3525 index = i % len(self.__options) 

3526 if self.__options[index].display_text.casefold().startswith(key): 

3527 self.__index = index 

3528 break 

3529 

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

3531 self.__column_width = max(1, min(self.__max_column_width, rc.width)) 

3532 self.__num_columns = num_columns = max(1, rc.width // self.__column_width) 

3533 self.__num_rows = max( 

3534 1, 

3535 min(self.__min_rows or 1, len(self.__options)), 

3536 min(math.ceil(len(self.__options) / num_columns), rc.height), 

3537 ) 

3538 

3539 additional_space = 0 

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

3541 if pages > 1: 

3542 additional_space = 1 

3543 

3544 return 1 + additional_space, self.__num_rows + additional_space 

3545 

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

3547 if not self.__options: 

3548 rc.set_color_path("menu/decoration:choice") 

3549 rc.write("No options to display") 

3550 return 

3551 

3552 # Adjust for the actual available height. 

3553 self.__num_rows = max(1, min(self.__num_rows, rc.height)) 

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

3555 if pages > 1 and self.__num_rows > 1: 

3556 self.__num_rows -= 1 

3557 

3558 column_width = self.__column_width 

3559 num_rows = self.__num_rows 

3560 page_size = self._page_size 

3561 

3562 page_start_index = 0 

3563 if page_size and self.__index is not None: 

3564 page_start_index = self.__index - self.__index % page_size 

3565 page = self.__options[page_start_index : page_start_index + page_size] 

3566 

3567 if self.__num_columns > 1: 

3568 available_column_width = column_width - _SPACE_BETWEEN_COLUMNS 

3569 else: 

3570 available_column_width = column_width 

3571 

3572 for i, option in enumerate(page): 

3573 x = i // num_rows 

3574 y = i % num_rows 

3575 

3576 rc.set_pos(x * column_width, y) 

3577 

3578 index = i + page_start_index 

3579 is_current = index == self.__index 

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

3581 

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

3583 if pages > 1: 

3584 page = (self.index or 0) // self._page_size + 1 

3585 rc.set_pos(0, num_rows) 

3586 rc.set_color_path("menu/text:choice/status_line") 

3587 rc.write("Page ") 

3588 rc.set_color_path("menu/text:choice/status_line/number") 

3589 rc.write(f"{page}") 

3590 rc.set_color_path("menu/text:choice/status_line") 

3591 rc.write(" of ") 

3592 rc.set_color_path("menu/text:choice/status_line/number") 

3593 rc.write(f"{pages}") 

3594 

3595 def _get_option_width(self, option: Option[object]): 

3596 return ( 

3597 _SPACE_BETWEEN_COLUMNS 

3598 + (_line_width(self.__decoration) + 1 if self.__decoration else 0) 

3599 + (_line_width(option.display_text_prefix)) 

3600 + (_line_width(option.display_text)) 

3601 + (_line_width(option.display_text_suffix)) 

3602 + (3 if option.comment else 0) 

3603 + (_line_width(option.comment) if option.comment else 0) 

3604 ) 

3605 

3606 def _render_option( 

3607 self, 

3608 rc: RenderContext, 

3609 width: int, 

3610 option: Option[object], 

3611 is_active: bool, 

3612 ): 

3613 left_prefix_width = _line_width(option.display_text_prefix) 

3614 left_main_width = _line_width(option.display_text) 

3615 left_suffix_width = _line_width(option.display_text_suffix) 

3616 left_width = left_prefix_width + left_main_width + left_suffix_width 

3617 left_decoration_width = ( 

3618 _line_width(self.__decoration) + 1 if self.__decoration else 0 

3619 ) 

3620 

3621 right = option.comment or "" 

3622 right_width = _line_width(right) 

3623 right_decoration_width = 3 if right else 0 

3624 

3625 total_width = ( 

3626 left_decoration_width + left_width + right_decoration_width + right_width 

3627 ) 

3628 

3629 if total_width > width: 

3630 right_width = max(right_width - (total_width - width), 0) 

3631 if right_width == 0: 

3632 right = "" 

3633 right_decoration_width = 0 

3634 total_width = ( 

3635 left_decoration_width 

3636 + left_width 

3637 + right_decoration_width 

3638 + right_width 

3639 ) 

3640 

3641 if total_width > width: 

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

3643 total_width = left_decoration_width + left_width 

3644 

3645 if is_active: 

3646 status_tag = "active" 

3647 else: 

3648 status_tag = "normal" 

3649 

3650 if self.__decoration and is_active: 

3651 rc.set_color_path(f"menu/decoration:choice/{status_tag}/{option.color_tag}") 

3652 rc.write(self.__decoration) 

3653 rc.set_color_path(f"menu/text:choice/{status_tag}/{option.color_tag}") 

3654 rc.write(" ") 

3655 elif self.__decoration: 

3656 rc.set_color_path(f"menu/text:choice/{status_tag}/{option.color_tag}") 

3657 rc.write(" " * left_decoration_width) 

3658 

3659 rc.set_color_path(f"menu/text/prefix:choice/{status_tag}/{option.color_tag}") 

3660 rc.write(option.display_text_prefix, max_width=left_width) 

3661 rc.set_color_path(f"menu/text:choice/{status_tag}/{option.color_tag}") 

3662 rc.write(option.display_text, max_width=left_width - left_prefix_width) 

3663 rc.set_color_path(f"menu/text/suffix:choice/{status_tag}/{option.color_tag}") 

3664 rc.write( 

3665 option.display_text_suffix, 

3666 max_width=left_width - left_prefix_width - left_main_width, 

3667 ) 

3668 rc.set_color_path(f"menu/text:choice/{status_tag}/{option.color_tag}") 

3669 rc.write( 

3670 " " 

3671 * ( 

3672 width 

3673 - left_decoration_width 

3674 - left_width 

3675 - right_decoration_width 

3676 - right_width 

3677 ) 

3678 ) 

3679 

3680 if right: 

3681 rc.set_color_path( 

3682 f"menu/decoration/comment:choice/{status_tag}/{option.color_tag}" 

3683 ) 

3684 rc.write(" [") 

3685 rc.set_color_path( 

3686 f"menu/text/comment:choice/{status_tag}/{option.color_tag}" 

3687 ) 

3688 rc.write(right, max_width=right_width) 

3689 rc.set_color_path( 

3690 f"menu/decoration/comment:choice/{status_tag}/{option.color_tag}" 

3691 ) 

3692 rc.write("]") 

3693 

3694 @property 

3695 def help_data(self) -> WidgetHelp: 

3696 return super().help_data.with_action( 

3697 "1..9", 

3698 "a..z", 

3699 long_msg="quick select", 

3700 ) 

3701 

3702 

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

3704 """ 

3705 Allows choosing from pre-defined options. 

3706 

3707 .. vhs:: /_tapes/widget_choice.tape 

3708 :alt: Demonstration of `Choice` widget. 

3709 :scale: 40% 

3710 

3711 :param options: 

3712 list of choice options. 

3713 :param mapper: 

3714 maps option to a text that will be used for filtering. By default, 

3715 uses :attr:`Option.display_text`. This argument is ignored 

3716 if a custom ``filter`` is given. 

3717 :param filter: 

3718 customizes behavior of list filtering. The default filter extracts text 

3719 from an option using the ``mapper``, and checks if it starts with the search 

3720 query. 

3721 :param default_index: 

3722 index of the initially selected option. 

3723 

3724 """ 

3725 

3726 @_t.overload 

3727 def __init__( 

3728 self, 

3729 options: list[Option[T]], 

3730 /, 

3731 *, 

3732 mapper: _t.Callable[[Option[T]], str] = lambda x: ( 

3733 x.display_text or str(x.value) 

3734 ), 

3735 default_index: int = 0, 

3736 ): ... 

3737 

3738 @_t.overload 

3739 def __init__( 

3740 self, 

3741 options: list[Option[T]], 

3742 /, 

3743 *, 

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

3745 default_index: int = 0, 

3746 ): ... 

3747 

3748 def __init__( 

3749 self, 

3750 options: list[Option[T]], 

3751 /, 

3752 *, 

3753 mapper: _t.Callable[[Option[T]], str] = lambda x: x.display_text 

3754 or str(x.value), 

3755 filter: _t.Callable[[Option[T], str], bool] | None = None, 

3756 default_index: int = 0, 

3757 ): 

3758 self.__options = options 

3759 

3760 if filter is None: 

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

3762 

3763 self.__filter = filter 

3764 

3765 self.__default_index = default_index 

3766 

3767 self.__input = Input(placeholder="Filter options...", decoration="/") 

3768 self.__grid = Grid[T]([]) 

3769 

3770 self.__enable_search = False 

3771 

3772 self.__layout: VerticalLayout[_t.Never] 

3773 

3774 self.__update_completion() 

3775 

3776 @bind("/") 

3777 def search(self): 

3778 """search""" 

3779 if not self.__enable_search: 

3780 self.__enable_search = True 

3781 else: 

3782 self.__input.event(KeyboardEvent("/")) 

3783 self.__update_completion() 

3784 

3785 @bind(Key.ENTER) 

3786 @bind(Key.ENTER, alt=True, show_in_detailed_help=False) 

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

3788 def enter(self) -> Result[T] | None: 

3789 """select""" 

3790 option = self.__grid.get_option() 

3791 if option is not None: 

3792 return Result(option.value) 

3793 else: 

3794 self._bell() 

3795 

3796 @bind(Key.ESCAPE) 

3797 def esc(self): 

3798 self.__input.text = "" 

3799 self.__update_completion() 

3800 self.__enable_search = False 

3801 

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

3803 if not self.__enable_search and e == KeyboardEvent(" "): 

3804 return self.enter() 

3805 if not self.__enable_search or e.key in ( 

3806 Key.ARROW_UP, 

3807 Key.ARROW_DOWN, 

3808 Key.TAB, 

3809 Key.ARROW_LEFT, 

3810 Key.ARROW_RIGHT, 

3811 Key.PAGE_DOWN, 

3812 Key.PAGE_UP, 

3813 Key.HOME, 

3814 Key.END, 

3815 ): 

3816 self.__grid.event(e) 

3817 elif e == KeyboardEvent(Key.BACKSPACE) and not self.__input.text: 

3818 self.__enable_search = False 

3819 else: 

3820 self.__input.event(e) 

3821 self.__update_completion() 

3822 

3823 def __update_completion(self): 

3824 query = self.__input.text 

3825 

3826 index = 0 

3827 options = [] 

3828 cur_option = self.__grid.get_option() 

3829 for i, option in enumerate(self.__options): 

3830 if not query or self.__filter(option, query): 

3831 if option is cur_option or ( 

3832 cur_option is None and i == self.__default_index 

3833 ): 

3834 index = len(options) 

3835 options.append(option) 

3836 

3837 self.__grid.set_options(options) 

3838 self.__grid.index = index 

3839 

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

3841 self.__layout = VerticalLayout() 

3842 self.__layout.append(self.__grid) 

3843 

3844 if self.__enable_search: 

3845 self.__layout.append(self.__input) 

3846 

3847 return self.__layout.layout(rc) 

3848 

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

3850 self.__layout.draw(rc) 

3851 

3852 @property 

3853 def help_data(self) -> WidgetHelp: 

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

3855 

3856 

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

3858 """ 

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

3860 

3861 .. vhs:: /_tapes/widget_multiselect.tape 

3862 :alt: Demonstration of `Multiselect` widget. 

3863 :scale: 40% 

3864 

3865 :param options: 

3866 list of choice options. 

3867 :param mapper: 

3868 maps option to a text that will be used for filtering. By default, 

3869 uses :attr:`Option.display_text`. This argument is ignored 

3870 if a custom ``filter`` is given. 

3871 :param filter: 

3872 customizes behavior of list filtering. The default filter extracts text 

3873 from an option using the ``mapper``, and checks if it starts with the search 

3874 query. 

3875 :param default_index: 

3876 index of the initially selected option. 

3877 

3878 """ 

3879 

3880 @_t.overload 

3881 def __init__( 

3882 self, 

3883 options: list[Option[T]], 

3884 /, 

3885 *, 

3886 mapper: _t.Callable[[Option[T]], str] = lambda x: x.display_text 

3887 or str(x.value), 

3888 ): ... 

3889 

3890 @_t.overload 

3891 def __init__( 

3892 self, 

3893 options: list[Option[T]], 

3894 /, 

3895 *, 

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

3897 ): ... 

3898 

3899 def __init__( 

3900 self, 

3901 options: list[Option[T]], 

3902 /, 

3903 *, 

3904 mapper: _t.Callable[[Option[T]], str] = lambda x: x.display_text 

3905 or str(x.value), 

3906 filter: _t.Callable[[Option[T], str], bool] | None = None, 

3907 ): 

3908 self.__options = [ 

3909 _t.cast( 

3910 Option[tuple[T, bool]], 

3911 dataclasses.replace( 

3912 option, 

3913 value=(option.value, False), 

3914 display_text_prefix="- " + option.display_text_prefix, 

3915 color_tag=None, 

3916 ), 

3917 ) 

3918 for option in options 

3919 ] 

3920 

3921 if filter is None: 

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

3923 

3924 self.__filter = filter 

3925 

3926 self.__input = Input(placeholder="Filter options...", decoration="/") 

3927 self.__grid = Grid[tuple[T, bool]]([]) 

3928 

3929 self.__enable_search = False 

3930 

3931 self.__layout: VerticalLayout[_t.Never] 

3932 

3933 self.__update_completion() 

3934 

3935 @bind(Key.ENTER) 

3936 @bind(" ") 

3937 def select(self): 

3938 """select""" 

3939 if self.__enable_search and self._cur_event == KeyboardEvent(" "): 

3940 self.__input.event(KeyboardEvent(" ")) 

3941 self.__update_completion() 

3942 return 

3943 option = self.__grid.get_option() 

3944 if option is not None: 

3945 option.value = (option.value[0], not option.value[1]) 

3946 option.display_text_prefix = ( 

3947 "*" if option.value[1] else "-" 

3948 ) + option.display_text_prefix[1:] 

3949 option.color_tag = "selected" if option.value[1] else None 

3950 self.__update_completion() 

3951 

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

3953 @bind("d", ctrl=True, show_in_inline_help=True) 

3954 def enter(self) -> Result[list[T]] | None: 

3955 """accept""" 

3956 return Result([option.value[0] for option in self.__options if option.value[1]]) 

3957 

3958 @bind("/") 

3959 def search(self): 

3960 """search""" 

3961 if not self.__enable_search: 

3962 self.__enable_search = True 

3963 else: 

3964 self.__input.event(KeyboardEvent("/")) 

3965 self.__update_completion() 

3966 

3967 @bind(Key.ESCAPE) 

3968 def esc(self): 

3969 """exit search""" 

3970 self.__input.text = "" 

3971 self.__update_completion() 

3972 self.__enable_search = False 

3973 

3974 def default_event_handler(self, e: KeyboardEvent) -> Result[list[T]] | None: 

3975 if not self.__enable_search or e.key in ( 

3976 Key.ARROW_UP, 

3977 Key.ARROW_DOWN, 

3978 Key.TAB, 

3979 Key.ARROW_LEFT, 

3980 Key.ARROW_RIGHT, 

3981 Key.PAGE_DOWN, 

3982 Key.PAGE_UP, 

3983 Key.HOME, 

3984 Key.END, 

3985 ): 

3986 self.__grid.event(e) 

3987 elif e == KeyboardEvent(Key.BACKSPACE) and not self.__input.text: 

3988 self.__enable_search = False 

3989 else: 

3990 self.__input.event(e) 

3991 self.__update_completion() 

3992 

3993 def __update_completion(self): 

3994 query = self.__input.text 

3995 

3996 index = 0 

3997 options = [] 

3998 cur_option = self.__grid.get_option() 

3999 for option in self.__options: 

4000 if not query or self.__filter( 

4001 _t.cast(Option[T], dataclasses.replace(option, value=option.value[0])), 

4002 query, 

4003 ): 

4004 if option is cur_option: 

4005 index = len(options) 

4006 options.append(option) 

4007 

4008 self.__grid.set_options(options) 

4009 self.__grid.index = index 

4010 

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

4012 self.__layout = VerticalLayout() 

4013 self.__layout.append(self.__grid) 

4014 

4015 if self.__enable_search: 

4016 self.__layout.append(self.__input) 

4017 

4018 return self.__layout.layout(rc) 

4019 

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

4021 self.__layout.draw(rc) 

4022 

4023 @property 

4024 def help_data(self) -> WidgetHelp: 

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

4026 

4027 

4028class InputWithCompletion(Widget[str]): 

4029 """ 

4030 An input box with tab completion. 

4031 

4032 .. vhs:: /_tapes/widget_completion.tape 

4033 :alt: Demonstration of `InputWithCompletion` widget. 

4034 :scale: 40% 

4035 

4036 """ 

4037 

4038 def __init__( 

4039 self, 

4040 completer: yuio.complete.Completer, 

4041 /, 

4042 *, 

4043 placeholder: str = "", 

4044 decoration: str = ">", 

4045 completion_item_decoration: str = ">", 

4046 ): 

4047 self.__completer = completer 

4048 

4049 self.__input = Input(placeholder=placeholder, decoration=decoration) 

4050 self.__grid = Grid[yuio.complete.Completion]( 

4051 [], decoration=completion_item_decoration, min_rows=None 

4052 ) 

4053 self.__grid_active = False 

4054 

4055 self.__layout: VerticalLayout[_t.Never] 

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

4057 

4058 @bind(Key.ENTER) 

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

4060 @help(inline_msg="accept") 

4061 def enter(self) -> Result[str] | None: 

4062 """accept / select completion""" 

4063 if self.__grid_active and (option := self.__grid.get_option()): 

4064 self._set_input_state_from_completion(option.value) 

4065 self._deactivate_completion() 

4066 else: 

4067 self._drop_rsuffix() 

4068 return Result(self.__input.text) 

4069 

4070 @bind(Key.TAB) 

4071 def tab(self): 

4072 """autocomplete""" 

4073 if self.__grid_active: 

4074 self.__grid.next_item() 

4075 if option := self.__grid.get_option(): 

4076 self._set_input_state_from_completion(option.value) 

4077 return 

4078 

4079 completion = self.__completer.complete(self.__input.text, self.__input.pos) 

4080 if len(completion) == 1: 

4081 self.__input.checkpoint() 

4082 self._set_input_state_from_completion(completion[0]) 

4083 elif completion: 

4084 self.__input.checkpoint() 

4085 self.__grid.set_options( 

4086 [ 

4087 Option( 

4088 c, 

4089 c.completion, 

4090 display_text_prefix=c.dprefix, 

4091 display_text_suffix=c.dsuffix, 

4092 comment=c.comment, 

4093 color_tag=c.group_color_tag, 

4094 ) 

4095 for c in completion 

4096 ], 

4097 default_index=None, 

4098 ) 

4099 self._activate_completion() 

4100 else: 

4101 self._bell() 

4102 

4103 @bind(Key.ESCAPE) 

4104 def escape(self): 

4105 """close autocomplete""" 

4106 self._drop_rsuffix() 

4107 if self.__grid_active: 

4108 self.__input.restore_checkpoint() 

4109 self._deactivate_completion() 

4110 

4111 def default_event_handler(self, e: KeyboardEvent): 

4112 if self.__grid_active and e.key in ( 

4113 Key.ARROW_UP, 

4114 Key.ARROW_DOWN, 

4115 Key.TAB, 

4116 Key.PAGE_UP, 

4117 Key.PAGE_DOWN, 

4118 Key.HOME, 

4119 Key.END, 

4120 ): 

4121 self._dispatch_completion_event(e) 

4122 elif ( 

4123 self.__grid_active 

4124 and self.__grid.index is not None 

4125 and e.key in (Key.ARROW_RIGHT, Key.ARROW_LEFT) 

4126 ): 

4127 self._dispatch_completion_event(e) 

4128 else: 

4129 self._dispatch_input_event(e) 

4130 

4131 def _activate_completion(self): 

4132 self.__grid_active = True 

4133 

4134 def _deactivate_completion(self): 

4135 self.__grid_active = False 

4136 

4137 def _set_input_state_from_completion( 

4138 self, completion: yuio.complete.Completion, set_rsuffix: bool = True 

4139 ): 

4140 prefix = completion.iprefix + completion.completion 

4141 if set_rsuffix: 

4142 prefix += completion.rsuffix 

4143 self.__rsuffix = completion 

4144 else: 

4145 self.__rsuffix = None 

4146 self.__input.text = prefix + completion.isuffix 

4147 self.__input.pos = len(prefix) 

4148 

4149 def _dispatch_completion_event(self, e: KeyboardEvent): 

4150 self.__rsuffix = None 

4151 self.__grid.event(e) 

4152 if option := self.__grid.get_option(): 

4153 self._set_input_state_from_completion(option.value) 

4154 

4155 def _dispatch_input_event(self, e: KeyboardEvent): 

4156 if self.__rsuffix: 

4157 # We need to drop current rsuffix in some cases: 

4158 if (not e.ctrl and not e.alt and isinstance(e.key, str)) or ( 

4159 e.key is Key.PASTE and e.paste_str 

4160 ): 

4161 text = e.key if e.key is not Key.PASTE else e.paste_str 

4162 # When user prints something... 

4163 if text and text[0] in self.__rsuffix.rsymbols: 

4164 # ...that is in `rsymbols`... 

4165 self._drop_rsuffix() 

4166 elif e in [ 

4167 KeyboardEvent(Key.ARROW_UP), 

4168 KeyboardEvent(Key.ARROW_DOWN), 

4169 KeyboardEvent(Key.ARROW_LEFT), 

4170 KeyboardEvent("b", ctrl=True), 

4171 KeyboardEvent(Key.ARROW_RIGHT), 

4172 KeyboardEvent("f", ctrl=True), 

4173 KeyboardEvent(Key.ARROW_LEFT, alt=True), 

4174 KeyboardEvent("b", alt=True), 

4175 KeyboardEvent(Key.ARROW_RIGHT, alt=True), 

4176 KeyboardEvent("f", alt=True), 

4177 KeyboardEvent(Key.HOME), 

4178 KeyboardEvent("a", ctrl=True), 

4179 KeyboardEvent(Key.END), 

4180 KeyboardEvent("e", ctrl=True), 

4181 ]: 

4182 # ...or when user moves cursor. 

4183 self._drop_rsuffix() 

4184 self.__rsuffix = None 

4185 self.__input.event(e) 

4186 self._deactivate_completion() 

4187 

4188 def _drop_rsuffix(self): 

4189 if self.__rsuffix: 

4190 rsuffix = self.__rsuffix.rsuffix 

4191 if self.__input.text[: self.__input.pos].endswith(rsuffix): 

4192 self._set_input_state_from_completion(self.__rsuffix, set_rsuffix=False) 

4193 

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

4195 self.__layout = VerticalLayout() 

4196 self.__layout.append(self.__input) 

4197 if self.__grid_active: 

4198 self.__layout.append(self.__grid) 

4199 return self.__layout.layout(rc) 

4200 

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

4202 self.__layout.draw(rc) 

4203 

4204 @property 

4205 def help_data(self) -> WidgetHelp: 

4206 return ( 

4207 (super().help_data) 

4208 .merge( 

4209 (self.__grid.help_data) 

4210 .without_group("Actions") 

4211 .rename_group(Grid._NAVIGATE, "Navigate Completions") 

4212 ) 

4213 .merge( 

4214 (self.__input.help_data) 

4215 .without_group("Actions") 

4216 .rename_group(Input._NAVIGATE, "Navigate Input") 

4217 .rename_group(Input._MODIFY, "Modify Input") 

4218 ) 

4219 ) 

4220 

4221 

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

4223 """ 

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

4225 

4226 .. 

4227 >>> class Input(Widget): 

4228 ... def event(self, e): 

4229 ... return Result("10") 

4230 ... 

4231 ... def layout(self, rc): 

4232 ... return 0, 0 

4233 ... 

4234 ... def draw(self, rc): 

4235 ... pass 

4236 >>> class Map(Map): 

4237 ... def run(self, term, theme): 

4238 ... return self.event(None).value 

4239 >>> term, theme = None, None 

4240 

4241 Example:: 

4242 

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

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

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

4246 10 

4247 

4248 """ 

4249 

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

4251 self.__inner = inner 

4252 self.__fn = fn 

4253 

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

4255 if result := self.__inner.event(e): 

4256 return Result(self.__fn(result.value)) 

4257 

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

4259 return self.__inner.layout(rc) 

4260 

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

4262 self.__inner.draw(rc) 

4263 

4264 @property 

4265 def help_data(self) -> WidgetHelp: 

4266 return self.__inner.help_data 

4267 

4268 

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

4270 """ 

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

4272 

4273 .. 

4274 >>> class Input(Widget): 

4275 ... def event(self, e): 

4276 ... return Result("foobar!") 

4277 ... 

4278 ... def layout(self, rc): 

4279 ... return 0, 0 

4280 ... 

4281 ... def draw(self, rc): 

4282 ... pass 

4283 >>> class Apply(Apply): 

4284 ... def run(self, term, theme): 

4285 ... return self.event(None).value 

4286 >>> term, theme = None, None 

4287 

4288 Example:: 

4289 

4290 >>> # Run `Input` widget, then print its output before returning 

4291 >>> print_output = Apply(Input(), print) 

4292 >>> result = print_output.run(term, theme) 

4293 foobar! 

4294 >>> result 

4295 'foobar!' 

4296 

4297 """ 

4298 

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

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

4301 fn(x) 

4302 return x 

4303 

4304 super().__init__(inner, mapper) 

4305 

4306 

4307@dataclass(slots=True) 

4308class _EventStreamState: 

4309 ostream: _t.TextIO 

4310 istream: _t.TextIO 

4311 key: str = "" 

4312 index: int = 0 

4313 

4314 def load(self): 

4315 self.key = yuio.term._read_keycode(self.ostream, self.istream) 

4316 self.index = 0 

4317 

4318 def next(self): 

4319 ch = self.peek() 

4320 self.index += 1 

4321 return ch 

4322 

4323 def peek(self): 

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

4325 return "" 

4326 else: 

4327 return self.key[self.index] 

4328 

4329 def tail(self): 

4330 return self.key[self.index :] 

4331 

4332 

4333def _event_stream(ostream: _t.TextIO, istream: _t.TextIO) -> _t.Iterator[KeyboardEvent]: 

4334 # Implementation is heavily inspired by libtermkey by Paul Evans, MIT license. 

4335 

4336 state = _EventStreamState(ostream, istream) 

4337 while True: 

4338 ch = state.next() 

4339 if not ch: 

4340 state.load() 

4341 ch = state.next() 

4342 if ch == "\x1b": 

4343 alt = False 

4344 ch = state.next() 

4345 while ch == "\x1b": 

4346 alt = True 

4347 ch = state.next() 

4348 if not ch: 

4349 yield KeyboardEvent(Key.ESCAPE, alt=alt) 

4350 elif ch == "[": 

4351 yield from _parse_csi(state, alt) 

4352 elif ch in "N]": 

4353 _parse_dcs(state) 

4354 elif ch == "O": 

4355 yield from _parse_ss3(state, alt) 

4356 else: 

4357 yield from _parse_char(ch, alt=True) 

4358 elif ch == "\x9b": 

4359 # CSI 

4360 yield from _parse_csi(state, False) 

4361 elif ch in "\x90\x9d": 

4362 # DCS or SS2 

4363 _parse_dcs(state) 

4364 elif ch == "\x8f": 

4365 # SS3 

4366 yield from _parse_ss3(state, False) 

4367 else: 

4368 # Char 

4369 yield from _parse_char(ch) 

4370 

4371 

4372def _parse_ss3(state: _EventStreamState, alt: bool = False): 

4373 ch = state.next() 

4374 if not ch: 

4375 yield KeyboardEvent("O", alt=True) 

4376 else: 

4377 yield from _parse_ss3_key(ch, alt=alt) 

4378 

4379 

4380def _parse_dcs(state: _EventStreamState): 

4381 while True: 

4382 ch = state.next() 

4383 if ch == "\x9c": 

4384 break 

4385 elif ch == "\x1b" and state.peek() == "\\": 

4386 state.next() 

4387 break 

4388 elif not ch: 

4389 state.load() 

4390 

4391 

4392def _parse_csi(state: _EventStreamState, alt: bool = False): 

4393 buffer = "" 

4394 while state.peek() and not (0x40 <= ord(state.peek()) <= 0x80): 

4395 buffer += state.next() 

4396 cmd = state.next() 

4397 if not cmd: 

4398 yield KeyboardEvent("[", alt=True) 

4399 return 

4400 if buffer.startswith(("?", "<", ">", "=")): 

4401 # Some command response, ignore. 

4402 return 

4403 args = buffer.split(";") 

4404 

4405 shift = ctrl = False 

4406 if len(args) > 1: 

4407 try: 

4408 modifiers = int(args[1]) - 1 

4409 except ValueError: 

4410 pass 

4411 else: 

4412 shift = bool(modifiers & 1) 

4413 alt |= bool(modifiers & 2) 

4414 ctrl = bool(modifiers & 4) 

4415 

4416 if cmd == "~": 

4417 if args[0] == "27": 

4418 try: 

4419 ch = chr(int(args[2])) 

4420 except (ValueError, KeyError): 

4421 pass 

4422 else: 

4423 yield from _parse_char(ch, ctrl=ctrl, alt=alt) 

4424 elif args[0] == "200": 

4425 yield KeyboardEvent(Key.PASTE, paste_str=_read_pasted_content(state)) 

4426 elif key := _CSI_CODES.get(args[0]): 

4427 yield KeyboardEvent(key, ctrl=ctrl, alt=alt, shift=shift) 

4428 elif cmd == "u": 

4429 try: 

4430 ch = chr(int(args[0])) 

4431 except ValueError: 

4432 pass 

4433 else: 

4434 yield from _parse_char(ch, ctrl=ctrl, alt=alt, shift=shift) 

4435 elif cmd in "mMyR": 

4436 # Some command response, ignore. 

4437 pass 

4438 else: 

4439 yield from _parse_ss3_key(cmd, ctrl=ctrl, alt=alt, shift=shift) 

4440 

4441 

4442def _parse_ss3_key( 

4443 cmd: str, ctrl: bool = False, alt: bool = False, shift: bool = False 

4444): 

4445 if key := _SS3_CODES.get(cmd): 

4446 if cmd == "Z": 

4447 shift = True 

4448 yield KeyboardEvent(key, ctrl=ctrl, alt=alt, shift=shift) 

4449 

4450 

4451_SS3_CODES = { 

4452 "A": Key.ARROW_UP, 

4453 "B": Key.ARROW_DOWN, 

4454 "C": Key.ARROW_RIGHT, 

4455 "D": Key.ARROW_LEFT, 

4456 "E": Key.HOME, 

4457 "F": Key.END, 

4458 "H": Key.HOME, 

4459 "Z": Key.TAB, 

4460 "P": Key.F1, 

4461 "Q": Key.F2, 

4462 "R": Key.F3, 

4463 "S": Key.F4, 

4464 "M": Key.ENTER, 

4465 " ": " ", 

4466 "I": Key.TAB, 

4467 "X": "=", 

4468 "j": "*", 

4469 "k": "+", 

4470 "l": ",", 

4471 "m": "-", 

4472 "n": ".", 

4473 "o": "/", 

4474 "p": "0", 

4475 "q": "1", 

4476 "r": "2", 

4477 "s": "3", 

4478 "t": "4", 

4479 "u": "5", 

4480 "v": "6", 

4481 "w": "7", 

4482 "x": "8", 

4483 "y": "9", 

4484} 

4485 

4486 

4487_CSI_CODES = { 

4488 "1": Key.HOME, 

4489 "2": Key.INSERT, 

4490 "3": Key.DELETE, 

4491 "4": Key.END, 

4492 "5": Key.PAGE_UP, 

4493 "6": Key.PAGE_DOWN, 

4494 "7": Key.HOME, 

4495 "8": Key.END, 

4496 "11": Key.F1, 

4497 "12": Key.F2, 

4498 "13": Key.F3, 

4499 "14": Key.F4, 

4500 "15": Key.F5, 

4501 "17": Key.F6, 

4502 "18": Key.F7, 

4503 "19": Key.F8, 

4504 "20": Key.F9, 

4505 "21": Key.F10, 

4506 "23": Key.F11, 

4507 "24": Key.F12, 

4508 "200": Key.PASTE, 

4509} 

4510 

4511 

4512def _parse_char( 

4513 ch: str, ctrl: bool = False, alt: bool = False, shift: bool = False 

4514) -> _t.Iterable[KeyboardEvent]: 

4515 if ch == "\t": 

4516 yield KeyboardEvent(Key.TAB, ctrl, alt, shift) 

4517 elif ch in "\r\n": 

4518 yield KeyboardEvent(Key.ENTER, ctrl, alt, shift) 

4519 elif ch == "\x08": 

4520 yield KeyboardEvent(Key.BACKSPACE, ctrl, alt, shift) 

4521 elif ch == "\x1b": 

4522 yield KeyboardEvent(Key.ESCAPE, ctrl, alt, shift) 

4523 elif ch == "\x7f": 

4524 yield KeyboardEvent(Key.BACKSPACE, ctrl, alt, shift) 

4525 elif "\x00" <= ch <= "\x1a": 

4526 yield KeyboardEvent(chr(ord(ch) + ord("a") - 0x1), True, alt, shift) 

4527 elif "\x0c" <= ch <= "\x1f": 

4528 yield KeyboardEvent(chr(ord(ch) + ord("4") - 0x1C), True, alt, shift) 

4529 elif ch in string.printable or ord(ch) >= 160: 

4530 yield KeyboardEvent(ch, ctrl, alt, shift) 

4531 

4532 

4533def _read_pasted_content(state: _EventStreamState) -> str: 

4534 buf = "" 

4535 while True: 

4536 index = state.tail().find("\x1b[201~") 

4537 if index == -1: 

4538 buf += state.tail() 

4539 else: 

4540 buf += state.tail()[:index] 

4541 state.index += index 

4542 return buf 

4543 state.load()