Coverage for yuio / widget.py: 95%

1931 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-05 11:41 +0000

1# Yuio project, MIT license. 

2# 

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

4# 

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

6# just keep this copyright line please :3 

7 

8""" 

9Basic blocks for building interactive elements. 

10 

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

12its higher-level abstraction. 

13 

14 

15Widget basics 

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

17 

18All widgets are are derived from the :class:`Widget` class, where they implement 

19event handlers, layout and rendering routines. Specifically, 

20:meth:`Widget.layout` and :meth:`Widget.draw` are required to implement 

21a widget. 

22 

23.. autoclass:: Widget 

24 :members: 

25 

26.. autoclass:: Result 

27 :members: 

28 

29.. autofunction:: bind 

30 

31.. autoclass:: Key 

32 :members: 

33 

34.. autoclass:: KeyboardEvent 

35 :members: 

36 

37 

38Drawing and rendering widgets 

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

40 

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

42to print characters on screen and manipulate screen cursor. 

43 

44.. autoclass:: RenderContext 

45 :members: 

46 

47 

48Stacking widgets together 

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

50 

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

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

53 

54.. autoclass:: VerticalLayout 

55 

56.. autoclass:: VerticalLayoutBuilder 

57 :members: 

58 

59 

60Widget help 

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

62 

63Widgets automatically generate help: the help menu is available via the :kbd:`F1` key, 

64and there's also inline help that is displayed under the widget. 

65 

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

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

68 

69You can control which keybindings appear in the help menu and inline help 

70by supplying `show_in_inline_help` and `show_in_detailed_help` arguments 

71to the :func:`bind` function. 

72 

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

74the :func:`help` decorator: 

75 

76.. autofunction:: help 

77 

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

79the :class:`WidgetHelp` yourself: 

80 

81.. autoclass:: WidgetHelp 

82 :members: 

83 

84.. class:: ActionKey 

85 

86 A single key associated with an action. 

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

88 

89.. class:: ActionKeys 

90 

91 A list of keys associated with an action. 

92 

93.. class:: Action 

94 

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

96 

97 

98Pre-defined widgets 

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

100 

101.. autoclass:: Line 

102 

103.. autoclass:: Text 

104 

105.. autoclass:: Input 

106 

107.. autoclass:: SecretInput 

108 

109.. autoclass:: Grid 

110 

111.. autoclass:: Option 

112 :members: 

113 

114.. autoclass:: Choice 

115 

116.. autoclass:: Multiselect 

117 

118.. autoclass:: InputWithCompletion 

119 

120.. autoclass:: Map 

121 

122.. autoclass:: Apply 

123 

124""" 

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 string 

138import sys 

139from dataclasses import dataclass 

140 

141import yuio.color 

142import yuio.complete 

143import yuio.md 

144import yuio.string 

145import yuio.term 

146from yuio.color import Color as _Color 

147from yuio.string import ColorizedString as _ColorizedString 

148from yuio.string import Esc as _Esc 

149from yuio.string import line_width as _line_width 

150from yuio.term import Term as _Term 

151from yuio.theme import Theme as _Theme 

152from yuio.util import _UNPRINTABLE_RE, _UNPRINTABLE_RE_WITHOUT_NL, _UNPRINTABLE_TRANS 

153 

154import typing 

155from typing import TYPE_CHECKING 

156 

157if TYPE_CHECKING: 

158 import typing_extensions as _t 

159else: 

160 from yuio import _typing as _t 

161 

162__all__ = [ 

163 "Action", 

164 "ActionKey", 

165 "ActionKeys", 

166 "Apply", 

167 "Choice", 

168 "Grid", 

169 "Input", 

170 "InputWithCompletion", 

171 "Key", 

172 "KeyboardEvent", 

173 "Line", 

174 "Map", 

175 "Multiselect", 

176 "Option", 

177 "RenderContext", 

178 "Result", 

179 "SecretInput", 

180 "Text", 

181 "VerticalLayout", 

182 "VerticalLayoutBuilder", 

183 "Widget", 

184 "WidgetHelp", 

185 "bind", 

186 "help", 

187] 

188 

189_SPACE_BETWEEN_COLUMNS = 2 

190_MIN_COLUMN_WIDTH = 10 

191 

192 

193T = _t.TypeVar("T") 

194U = _t.TypeVar("U") 

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

196 

197 

198class Key(enum.Enum): 

199 """ 

200 Non-character keys. 

201 

202 """ 

203 

204 ENTER = enum.auto() 

205 """ 

206 :kbd:`Enter` key. 

207 

208 """ 

209 

210 ESCAPE = enum.auto() 

211 """ 

212 :kbd:`Escape` key. 

213 

214 """ 

215 

216 INSERT = enum.auto() 

217 """ 

218 :kbd:`Insert` key. 

219 

220 """ 

221 

222 DELETE = enum.auto() 

223 """ 

224 :kbd:`Delete` key. 

225 

226 """ 

227 

228 BACKSPACE = enum.auto() 

229 """ 

230 :kbd:`Backspace` key. 

231 

232 """ 

233 

234 TAB = enum.auto() 

235 """ 

236 :kbd:`Tab` key. 

237 

238 """ 

239 

240 HOME = enum.auto() 

241 """ 

242 :kbd:`Home` key. 

243 

244 """ 

245 

246 END = enum.auto() 

247 """ 

248 :kbd:`End` key. 

249 

250 """ 

251 

252 PAGE_UP = enum.auto() 

253 """ 

254 :kbd:`PageUp` key. 

255 

256 """ 

257 

258 PAGE_DOWN = enum.auto() 

259 """ 

260 :kbd:`PageDown` key. 

261 

262 """ 

263 

264 ARROW_UP = enum.auto() 

265 """ 

266 :kbd:`ArrowUp` key. 

267 

268 """ 

269 

270 ARROW_DOWN = enum.auto() 

271 """ 

272 :kbd:`ArrowDown` key. 

273 

274 """ 

275 

276 ARROW_LEFT = enum.auto() 

277 """ 

278 :kbd:`ArrowLeft` key. 

279 

280 """ 

281 

282 ARROW_RIGHT = enum.auto() 

283 """ 

284 :kbd:`ArrowRight` key. 

285 

286 """ 

287 

288 F1 = enum.auto() 

289 """ 

290 :kbd:`F1` key. 

291 

292 """ 

293 

294 F2 = enum.auto() 

295 """ 

296 :kbd:`F2` key. 

297 

298 """ 

299 

300 F3 = enum.auto() 

301 """ 

302 :kbd:`F3` key. 

303 

304 """ 

305 

306 F4 = enum.auto() 

307 """ 

308 :kbd:`F4` key. 

309 

310 """ 

311 

312 F5 = enum.auto() 

313 """ 

314 :kbd:`F5` key. 

315 

316 """ 

317 

318 F6 = enum.auto() 

319 """ 

320 :kbd:`F6` key. 

321 

322 """ 

323 

324 F7 = enum.auto() 

325 """ 

326 :kbd:`F7` key. 

327 

328 """ 

329 

330 F8 = enum.auto() 

331 """ 

332 :kbd:`F8` key. 

333 

334 """ 

335 

336 F9 = enum.auto() 

337 """ 

338 :kbd:`F9` key. 

339 

340 """ 

341 

342 F10 = enum.auto() 

343 """ 

344 :kbd:`F10` key. 

345 

346 """ 

347 

348 F11 = enum.auto() 

349 """ 

350 :kbd:`F11` key. 

351 

352 """ 

353 

354 F12 = enum.auto() 

355 """ 

356 :kbd:`F12` key. 

357 

358 """ 

359 

360 PASTE = enum.auto() 

361 """ 

362 Triggered when a text is pasted into a terminal. 

363 

364 """ 

365 

366 def __str__(self) -> str: 

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

368 

369 

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

371class KeyboardEvent: 

372 """ 

373 A single keyboard event. 

374 

375 .. warning:: 

376 

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

378 support all keystroke combinations. 

379 

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

381 keystrokes, and how Yuio interprets them. 

382 

383 """ 

384 

385 key: Key | str 

386 """ 

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

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

389 

390 """ 

391 

392 ctrl: bool = False 

393 """ 

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

395 

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

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

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

399 

400 .. skip-next: 

401 

402 .. code-block:: python 

403 

404 # `Ctrl+X` was pressed. 

405 KeyboardEvent("x", ctrl=True) 

406 

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

408 # to report this correctly, though. 

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

410 

411 # This can't happen. 

412 KeyboardEvent("X", ctrl=True) 

413 

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

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

416 KeyboardEvent("_", ctrl=True) 

417 

418 """ 

419 

420 alt: bool = False 

421 """ 

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

423 

424 """ 

425 

426 shift: bool = False 

427 """ 

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

429 

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

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

432 

433 .. skip-next: 

434 

435 .. code-block:: python 

436 

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

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

439 

440 .. warning:: 

441 

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

443 

444 """ 

445 

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

447 """ 

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

449 

450 """ 

451 

452 

453@_t.final 

454class RenderContext: 

455 """ 

456 A canvas onto which widgets render themselves. 

457 

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

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

460 around freely. 

461 

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

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

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

465 

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

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

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

469 

470 """ 

471 

472 # For tests. 

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

474 

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

476 self._term: _Term = term 

477 self._theme: _Theme = theme 

478 

479 # We have three levels of abstraction here. 

480 # 

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

482 # This TTY has cursor, current color, 

483 # and different drawing capabilities. 

484 # 

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

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

487 # are synced in `render` function. 

488 # 

489 # Finally, we have virtual cursor, 

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

491 # 

492 # 

493 # Drawing frame 

494 # ................... 

495 # . ┌────────┐ . 

496 # . │ hello │ . 

497 # . │ world │ . 

498 # . └────────┘ . 

499 # ................... 

500 # ↓ 

501 # Canvas 

502 # ┌─────────────────┐ 

503 # │ > hello │ 

504 # │ world │ 

505 # │ │ 

506 # └─────────────────┘ 

507 # ↓ 

508 # Real terminal 

509 # ┏━━━━━━━━━━━━━━━━━┯━━━┓ 

510 # ┃ > hello │ ┃ 

511 # ┃ world │ ┃ 

512 # ┃ │ ┃ 

513 # ┠───────────VT100─┤◆◆◆┃ 

514 # ┗█▇█▇█▇█▇█▇█▇█▇█▇█▇█▇█┛ 

515 

516 # Drawing frame and virtual cursor 

517 self._frame_x: int = 0 

518 self._frame_y: int = 0 

519 self._frame_w: int = 0 

520 self._frame_h: int = 0 

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

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

523 self._frame_cursor_color: str = "" 

524 

525 # Canvas 

526 self._width: int = 0 

527 self._height: int = 0 

528 self._final_x: int = 0 

529 self._final_y: int = 0 

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

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

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

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

534 

535 # Rendering status 

536 self._full_redraw: bool = False 

537 self._term_x: int = 0 

538 self._term_y: int = 0 

539 self._term_color: str = "" 

540 self._max_term_y: int = 0 

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

542 self._bell: bool = False 

543 self._in_alternative_buffer: bool = False 

544 self._normal_buffer_term_x: int = 0 

545 self._normal_buffer_term_y: int = 0 

546 

547 # Helpers 

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

549 

550 # Used for tests and debug 

551 self._renders: int = 0 

552 self._bytes_rendered: int = 0 

553 self._total_bytes_rendered: int = 0 

554 

555 @property 

556 def term(self) -> _Term: 

557 """ 

558 Terminal where we render the widgets. 

559 

560 """ 

561 

562 return self._term 

563 

564 @property 

565 def theme(self) -> _Theme: 

566 """ 

567 Current color theme. 

568 

569 """ 

570 

571 return self._theme 

572 

573 @contextlib.contextmanager 

574 def frame( 

575 self, 

576 x: int, 

577 y: int, 

578 /, 

579 *, 

580 width: int | None = None, 

581 height: int | None = None, 

582 ): 

583 """ 

584 Override drawing frame. 

585 

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

587 and they can take the entire frame size. 

588 

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

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

591 

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

593 When frame is dropped, they are restored. 

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

595 

596 .. 

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

598 >>> theme = _Theme() 

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

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

601 

602 Example:: 

603 

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

605 >>> rc.prepare() 

606 

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

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

609 

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

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

612 20 5 

613 

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

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

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

617 

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

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

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

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

622 ... rc.write("+") 

623 ... 

624 ... # Frame dimensions were automatically reduced. 

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

626 ... 

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

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

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

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

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

632 18 3 

633 

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

635 + Hello, world! 

636 <BLANKLINE> 

637 + Hello, world! 

638 <BLANKLINE> 

639 <BLANKLINE> 

640 

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

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

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

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

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

646 

647 class MyWidget(Widget): 

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

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

650 # un-indented heading before it. 

651 

652 def __init__(self): 

653 # This is the text we'll print. 

654 self._nested_widget = Text( 

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

656 ) 

657 

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

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

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

661 # by arranging a drawing frame. 

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

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

664 

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

666 # plus one line for our heading. 

667 return min_h + 1, max_h + 1 

668 

669 def draw(self, rc: RenderContext): 

670 # Print a small heading. 

671 rc.set_color_path("bold") 

672 rc.write("Small heading") 

673 

674 # And draw our nested widget, controlling its position 

675 # via a frame. 

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

677 self._nested_widget.draw(rc) 

678 

679 """ 

680 

681 prev_frame_x = self._frame_x 

682 prev_frame_y = self._frame_y 

683 prev_frame_w = self._frame_w 

684 prev_frame_h = self._frame_h 

685 prev_frame_cursor_x = self._frame_cursor_x 

686 prev_frame_cursor_y = self._frame_cursor_y 

687 prev_frame_cursor_color = self._frame_cursor_color 

688 

689 self._frame_x += x 

690 self._frame_y += y 

691 

692 if width is not None: 

693 self._frame_w = width 

694 else: 

695 self._frame_w -= x 

696 if self._frame_w < 0: 

697 self._frame_w = 0 

698 

699 if height is not None: 

700 self._frame_h = height 

701 else: 

702 self._frame_h -= y 

703 if self._frame_h < 0: 

704 self._frame_h = 0 

705 

706 self._frame_cursor_x = 0 

707 self._frame_cursor_y = 0 

708 self._frame_cursor_color = self._none_color 

709 

710 try: 

711 yield 

712 finally: 

713 self._frame_x = prev_frame_x 

714 self._frame_y = prev_frame_y 

715 self._frame_w = prev_frame_w 

716 self._frame_h = prev_frame_h 

717 self._frame_cursor_x = prev_frame_cursor_x 

718 self._frame_cursor_y = prev_frame_cursor_y 

719 self._frame_cursor_color = prev_frame_cursor_color 

720 

721 @property 

722 def width(self) -> int: 

723 """ 

724 Get width of the current frame. 

725 

726 """ 

727 

728 return self._frame_w 

729 

730 @property 

731 def height(self) -> int: 

732 """ 

733 Get height of the current frame. 

734 

735 """ 

736 

737 return self._frame_h 

738 

739 @property 

740 def canvas_width(self) -> int: 

741 """ 

742 Get width of the terminal. 

743 

744 """ 

745 

746 return self._width 

747 

748 @property 

749 def canvas_height(self) -> int: 

750 """ 

751 Get height of the terminal. 

752 

753 """ 

754 

755 return self._height 

756 

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

758 """ 

759 Set current cursor position within the frame. 

760 

761 """ 

762 

763 self._frame_cursor_x = x 

764 self._frame_cursor_y = y 

765 

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

767 """ 

768 Move current cursor position by the given amount. 

769 

770 """ 

771 

772 self._frame_cursor_x += dx 

773 self._frame_cursor_y += dy 

774 

775 def new_line(self): 

776 """ 

777 Move cursor to new line within the current frame. 

778 

779 """ 

780 

781 self._frame_cursor_x = 0 

782 self._frame_cursor_y += 1 

783 

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

785 """ 

786 Set position where the cursor should end up 

787 after everything has been rendered. 

788 

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

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

791 and move the cursor into the correct position. 

792 

793 """ 

794 

795 self._final_x = x + self._frame_x 

796 self._final_y = y + self._frame_y 

797 

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

799 """ 

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

801 

802 """ 

803 

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

805 self._term.color_support 

806 ) 

807 

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

809 """ 

810 Set current color. 

811 

812 """ 

813 

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

815 

816 def reset_color(self): 

817 """ 

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

819 

820 """ 

821 

822 self._frame_cursor_color = self._none_color 

823 

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

825 """ 

826 Get message decoration by name. 

827 

828 """ 

829 

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

831 

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

833 """ 

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

835 Move cursor while printing. 

836 

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

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

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

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

841 doesn't account for double-width characters. 

842 

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

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

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

846 

847 .. 

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

849 >>> theme = _Theme() 

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

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

852 

853 Example:: 

854 

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

856 >>> rc.prepare() 

857 

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

859 >>> rc.new_line() 

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

861 >>> rc.new_line() 

862 >>> rc.write( 

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

864 ... max_width=10 

865 ... ) 

866 >>> rc.new_line() 

867 >>> rc.write( 

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

869 ... ) 

870 >>> rc.new_line() 

871 

872 >>> rc.render() 

873 Hello, world! 

874 Hello, world! 

875 Hello, 🌍! 

876 Hello, 🌍!< 

877 <BLANKLINE> 

878 

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

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

881 

882 """ 

883 

884 if not isinstance(text, _ColorizedString): 

885 text = _ColorizedString(text, _isolate_colors=False) 

886 

887 x = self._frame_x + self._frame_cursor_x 

888 y = self._frame_y + self._frame_cursor_y 

889 

890 max_x = self._width 

891 if max_width is not None: 

892 max_x = min(max_x, x + max_width) 

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

894 else: 

895 self._frame_cursor_x = self._frame_cursor_x + text.width 

896 

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

898 for s in text: 

899 if isinstance(s, _Color): 

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

901 return 

902 

903 ll = self._lines[y] 

904 cc = self._colors[y] 

905 

906 for s in text: 

907 if isinstance(s, _Color): 

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

909 continue 

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

911 continue 

912 

913 s = s.translate(_UNPRINTABLE_TRANS) 

914 

915 if s.isascii(): 

916 # Fast track. 

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

918 # We're beyond the left terminal border. 

919 x += len(s) 

920 continue 

921 

922 slice_begin = 0 

923 if x < 0: 

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

925 slice_begin = -x 

926 x = 0 

927 

928 if x >= max_x: 

929 # We're beyond the right terminal border. 

930 x += len(s) - slice_begin 

931 continue 

932 

933 slice_end = len(s) 

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

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

936 slice_end = slice_begin + max_x - x 

937 

938 l = slice_end - slice_begin 

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

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

941 x += l 

942 continue 

943 

944 for c in s: 

945 cw = _line_width(c) 

946 if x + cw <= 0: 

947 # We're beyond the left terminal border. 

948 x += cw 

949 continue 

950 elif x < 0: 

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

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

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

954 x += cw 

955 continue 

956 elif cw > 0 and x >= max_x: 

957 # We're beyond the right terminal border. 

958 x += cw 

959 break 

960 elif x + cw > max_x: 

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

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

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

964 x += cw 

965 break 

966 

967 if cw == 0: 

968 # This is a zero-width character. 

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

970 if x > 0: 

971 ll[x - 1] += c 

972 continue 

973 

974 ll[x] = c 

975 cc[x] = self._frame_cursor_color 

976 

977 x += 1 

978 cw -= 1 

979 if cw: 

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

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

982 x += cw 

983 

984 def write_text( 

985 self, 

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

987 /, 

988 *, 

989 max_width: int | None = None, 

990 ): 

991 """ 

992 Write multiple lines. 

993 

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

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

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

997 

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

999 and back to its original horizontal position. 

1000 

1001 .. 

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

1003 >>> theme = _Theme() 

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

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

1006 

1007 Example:: 

1008 

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

1010 >>> rc.prepare() 

1011 

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

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

1014 

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

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

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

1018 

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

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

1021 

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

1023 + > Hello, 

1024 world!+ 

1025 <BLANKLINE> 

1026 <BLANKLINE> 

1027 <BLANKLINE> 

1028 

1029 """ 

1030 

1031 x = self._frame_cursor_x 

1032 

1033 for i, line in enumerate(lines): 

1034 if i > 0: 

1035 self._frame_cursor_x = x 

1036 self._frame_cursor_y += 1 

1037 

1038 self.write(line, max_width=max_width) 

1039 

1040 def bell(self): 

1041 """ 

1042 Ring a terminal bell. 

1043 

1044 """ 

1045 

1046 self._bell = True 

1047 

1048 def make_repr_context( 

1049 self, 

1050 *, 

1051 multiline: bool | None = None, 

1052 highlighted: bool | None = None, 

1053 max_depth: int | None = None, 

1054 width: int | None = None, 

1055 ) -> yuio.string.ReprContext: 

1056 """ 

1057 Create a new :class:`~yuio.string.ReprContext` for rendering colorized strings 

1058 inside widgets. 

1059 

1060 :param multiline: 

1061 sets initial value for 

1062 :attr:`ReprContext.multiline <yuio.string.ReprContext.multiline>`. 

1063 :param highlighted: 

1064 sets initial value for 

1065 :attr:`ReprContext.highlighted <yuio.string.ReprContext.highlighted>`. 

1066 :param max_depth: 

1067 sets initial value for 

1068 :attr:`ReprContext.max_depth <yuio.string.ReprContext.max_depth>`. 

1069 :param width: 

1070 sets initial value for 

1071 :attr:`ReprContext.width <yuio.string.ReprContext.width>`. 

1072 If not given, uses current frame's width. 

1073 :returns: 

1074 a new repr context suitable for rendering colorized strings. 

1075 

1076 """ 

1077 

1078 if width is None: 

1079 width = self._frame_w 

1080 return yuio.string.ReprContext( 

1081 term=self._term, 

1082 theme=self._theme, 

1083 multiline=multiline, 

1084 highlighted=highlighted, 

1085 max_depth=max_depth, 

1086 width=width, 

1087 ) 

1088 

1089 def prepare( 

1090 self, 

1091 *, 

1092 full_redraw: bool = False, 

1093 alternative_buffer: bool = False, 

1094 reset_term_pos: bool = False, 

1095 ): 

1096 """ 

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

1098 

1099 """ 

1100 

1101 if self._override_wh: 

1102 width, height = self._override_wh 

1103 else: 

1104 size = yuio.term.get_tty_size(fallback=(self._theme.fallback_width, 24)) 

1105 width = size.columns 

1106 height = size.lines 

1107 

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

1109 

1110 if self._in_alternative_buffer != alternative_buffer: 

1111 full_redraw = True 

1112 self._in_alternative_buffer = alternative_buffer 

1113 if alternative_buffer: 

1114 self._out.append("\x1b[<u\x1b[?1049h\x1b[m\x1b[2J\x1b[H\x1b[>1u") 

1115 self._normal_buffer_term_x = self._term_x 

1116 self._normal_buffer_term_y = self._term_y 

1117 self._term_x, self._term_y = 0, 0 

1118 self._term_color = self._none_color 

1119 else: 

1120 self._out.append("\x1b[<u\x1b[?1049l\x1b[m\x1b[>1u") 

1121 self._term_x = self._normal_buffer_term_x 

1122 self._term_y = self._normal_buffer_term_y 

1123 self._term_color = self._none_color 

1124 

1125 if reset_term_pos: 

1126 self._term_x, self._term_y = 0, 0 

1127 full_redraw = True 

1128 

1129 # Drawing frame and virtual cursor 

1130 self._frame_x = 0 

1131 self._frame_y = 0 

1132 self._frame_w = width 

1133 self._frame_h = height 

1134 self._frame_cursor_x = 0 

1135 self._frame_cursor_y = 0 

1136 self._frame_cursor_color = self._none_color 

1137 

1138 # Canvas 

1139 self._width = width 

1140 self._height = height 

1141 self._final_x = 0 

1142 self._final_y = 0 

1143 if full_redraw: 

1144 self._max_term_y = 0 

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

1146 else: 

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

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

1149 

1150 # Rendering status 

1151 self._full_redraw = full_redraw 

1152 

1153 def clear_screen(self): 

1154 """ 

1155 Clear screen and prepare for a full redraw. 

1156 

1157 """ 

1158 

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

1160 self._term_x, self._term_y = 0, 0 

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

1162 

1163 def _make_empty_canvas( 

1164 self, 

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

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

1167 colors = [ 

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

1169 ] 

1170 return lines, colors 

1171 

1172 def render(self): 

1173 """ 

1174 Render current canvas onto the terminal. 

1175 

1176 """ 

1177 

1178 if not self.term.ostream_is_tty: 

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

1180 self._render_dumb() 

1181 return 

1182 

1183 if self._bell: 

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

1185 self._bell = False 

1186 

1187 if self._full_redraw: 

1188 self._move_term_cursor(0, 0) 

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

1190 

1191 for y in range(self._height): 

1192 line = self._lines[y] 

1193 

1194 for x in range(self._width): 

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

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

1197 

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

1199 self._move_term_cursor(x, y) 

1200 

1201 if color != self._term_color: 

1202 self._out.append(color) 

1203 self._term_color = color 

1204 

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

1206 self._term_x += 1 

1207 

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

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

1210 self._move_term_cursor(final_x, final_y) 

1211 

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

1213 self._term.ostream.write(rendered) 

1214 self._term.ostream.flush() 

1215 self._out.clear() 

1216 

1217 if yuio._debug: 

1218 self._renders += 1 

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

1220 self._total_bytes_rendered += self._bytes_rendered 

1221 

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

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

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

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

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

1227 self._out.append(debug_msg) 

1228 self._out.append(self._term_color) 

1229 self._move_term_cursor(term_x, term_y) 

1230 

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

1232 self._term.ostream.flush() 

1233 self._out.clear() 

1234 

1235 def finalize(self): 

1236 """ 

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

1238 

1239 """ 

1240 

1241 self.prepare(full_redraw=True) 

1242 

1243 self._move_term_cursor(0, 0) 

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

1245 self._out.append(self._none_color) 

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

1247 self._term.ostream.flush() 

1248 self._out.clear() 

1249 self._term_color = self._none_color 

1250 

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

1252 dy = y - self._term_y 

1253 if y > self._max_term_y: 

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

1255 self._term_x = 0 

1256 elif dy > 0: 

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

1258 elif dy < 0: 

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

1260 self._term_y = y 

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

1262 

1263 if x != self._term_x: 

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

1265 self._term_x = x 

1266 

1267 def _render_dumb(self): 

1268 prev_printed_color = self._none_color 

1269 

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

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

1272 if prev_printed_color != color: 

1273 self._out.append(color) 

1274 prev_printed_color = color 

1275 self._out.append(ch) 

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

1277 

1278 self._term.ostream.writelines( 

1279 # Trim trailing spaces for doctests. 

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

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

1282 ) 

1283 

1284 

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

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

1287 """ 

1288 Result of a widget run. 

1289 

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

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

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

1293 

1294 """ 

1295 

1296 value: T_co 

1297 """ 

1298 Result of a widget run. 

1299 

1300 """ 

1301 

1302 

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

1304 """ 

1305 Base class for all interactive console elements. 

1306 

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

1308 They always go through the same event loop: 

1309 

1310 .. raw:: html 

1311 

1312 <p> 

1313 <pre class="mermaid"> 

1314 flowchart TD 

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

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

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

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

1319 Result -->|no| Layout 

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

1321 </pre> 

1322 </p> 

1323 

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

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

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

1327 from its event handler. 

1328 

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

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

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

1332 

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

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

1335 but don't return a value. 

1336 

1337 """ 

1338 

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

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

1341 

1342 __in_help_menu: bool = False 

1343 __bell: bool = False 

1344 

1345 _cur_event: KeyboardEvent | None = None 

1346 """ 

1347 Current event that is being processed. 

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

1349 

1350 """ 

1351 

1352 def __init_subclass__(cls, **kwargs): 

1353 super().__init_subclass__(**kwargs) 

1354 

1355 cls.__bindings = {} 

1356 cls.__callbacks = [] 

1357 

1358 event_handler_names = [] 

1359 for base in reversed(cls.__mro__): 

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

1361 if ( 

1362 hasattr(cb, "__yuio_keybindings__") 

1363 and name not in event_handler_names 

1364 ): 

1365 event_handler_names.append(name) 

1366 

1367 for name in event_handler_names: 

1368 cb = getattr(cls, name, None) 

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

1370 bindings: list[_Binding] = cb.__yuio_keybindings__ 

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

1372 cls.__callbacks.append(cb) 

1373 

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

1375 """ 

1376 Handle incoming keyboard event. 

1377 

1378 By default, this function dispatches event to handlers registered 

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

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

1381 

1382 """ 

1383 

1384 self._cur_event = e 

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

1386 return handler(self) 

1387 else: 

1388 return self.default_event_handler(e) 

1389 

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

1391 """ 

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

1393 

1394 """ 

1395 

1396 @abc.abstractmethod 

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

1398 """ 

1399 Prepare widget for drawing, and recalculate its dimensions 

1400 according to new frame dimensions. 

1401 

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

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

1404 height that they can potentially take. 

1405 

1406 """ 

1407 

1408 raise NotImplementedError() 

1409 

1410 @abc.abstractmethod 

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

1412 """ 

1413 Draw the widget. 

1414 

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

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

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

1418 

1419 """ 

1420 

1421 raise NotImplementedError() 

1422 

1423 @_t.final 

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

1425 """ 

1426 Read user input and run the widget. 

1427 

1428 """ 

1429 

1430 if not term.can_run_widgets: 

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

1432 

1433 with yuio.term._enter_raw_mode( 

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

1435 ): 

1436 rc = RenderContext(term, theme) 

1437 

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

1439 

1440 try: 

1441 while True: 

1442 rc.prepare(alternative_buffer=self.__in_help_menu) 

1443 

1444 height = rc.height 

1445 if self.__in_help_menu: 

1446 min_h, max_h = self.__help_menu_layout(rc) 

1447 inline_help_height = 0 

1448 else: 

1449 with rc.frame(0, 0): 

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

1451 if height > inline_help_height: 

1452 height -= inline_help_height 

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

1454 min_h, max_h = self.layout(rc) 

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

1456 rc.set_final_pos(0, max_h + inline_help_height) 

1457 if self.__in_help_menu: 

1458 self.__help_menu_draw(rc) 

1459 else: 

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

1461 self.draw(rc) 

1462 if max_h < rc.height: 

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

1464 self.__help_menu_draw_inline(rc) 

1465 

1466 if self.__bell: 

1467 rc.bell() 

1468 self.__bell = False 

1469 rc.render() 

1470 

1471 try: 

1472 event = next(events) 

1473 except StopIteration: 

1474 assert False, "_event_stream supposed to be infinite" 

1475 

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

1477 raise KeyboardInterrupt() 

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

1479 rc.clear_screen() 

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

1481 self.__in_help_menu = True 

1482 self.__help_menu_line = 0 

1483 self.__last_help_data = None 

1484 elif self.__in_help_menu: 

1485 self.__help_menu_event(event) 

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

1487 return result.value 

1488 finally: 

1489 rc.finalize() 

1490 

1491 def _bell(self): 

1492 self.__bell = True 

1493 

1494 @property 

1495 def help_data(self) -> WidgetHelp: 

1496 """ 

1497 Data for displaying help messages. 

1498 

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

1500 

1501 """ 

1502 

1503 return self.__help_columns 

1504 

1505 @functools.cached_property 

1506 def __help_columns(self) -> WidgetHelp: 

1507 inline_help: list[Action] = [] 

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

1509 

1510 for cb in self.__callbacks: 

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

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

1513 if not bindings: 

1514 continue 

1515 if help is None: 

1516 help = _Help( 

1517 "Actions", 

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

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

1520 ) 

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

1522 continue 

1523 

1524 if help.inline_msg: 

1525 inline_bindings = [ 

1526 binding.event 

1527 for binding in reversed(bindings) 

1528 if binding.show_in_inline_help 

1529 ] 

1530 if inline_bindings: 

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

1532 

1533 if help.long_msg: 

1534 menu_bindings = [ 

1535 binding.event 

1536 for binding in reversed(bindings) 

1537 if binding.show_in_detailed_help 

1538 ] 

1539 if menu_bindings: 

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

1541 (menu_bindings, help.long_msg) 

1542 ) 

1543 

1544 return WidgetHelp(inline_help, groups) 

1545 

1546 __last_help_data: WidgetHelp | None = None 

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

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

1549 __has_help: bool = True 

1550 __width: int = 0 

1551 __height: int = 0 

1552 __menu_content_height: int = 0 

1553 __help_menu_line: int = 0 

1554 __help_menu_search: bool = False 

1555 __help_menu_search_widget: Input 

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

1557 __key_width: int = 0 

1558 __wrapped_groups: list[ 

1559 tuple[ 

1560 str, # Title 

1561 list[ # Actions 

1562 tuple[ # Action 

1563 list[str], # Keys 

1564 list[_ColorizedString], # Wrapped msg 

1565 int, # Keys width 

1566 ], 

1567 ], 

1568 ] # FML this type hint -___- 

1569 ] 

1570 __colorized_inline_help: list[ 

1571 tuple[ # Action 

1572 list[str], # Keys 

1573 _ColorizedString, # Title 

1574 int, # Keys width 

1575 ] 

1576 ] 

1577 

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

1579 if not self.__help_menu_search and e in [ 

1580 KeyboardEvent(Key.F1), 

1581 KeyboardEvent(Key.ESCAPE), 

1582 KeyboardEvent(Key.ENTER), 

1583 KeyboardEvent("q"), 

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

1585 ]: 

1586 self.__in_help_menu = False 

1587 self.__help_menu_line = 0 

1588 self.__last_help_data = None 

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

1590 self.__help_menu_line += 1 

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

1592 self.__help_menu_line = 0 

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

1594 self.__help_menu_line += self.__height 

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

1596 self.__help_menu_line = -self.__menu_content_height 

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

1598 self.__help_menu_line -= 1 

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

1600 self.__help_menu_line -= self.__height 

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

1602 self.__help_menu_line -= self.__height 

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

1604 self.__help_menu_search = True 

1605 self.__help_menu_search_widget = Input( 

1606 decoration_path="menu/input/decoration_search" 

1607 ) 

1608 elif self.__help_menu_search: 

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

1610 e == KeyboardEvent(Key.BACKSPACE) 

1611 and not self.__help_menu_search_widget.text 

1612 ): 

1613 self.__help_menu_search = False 

1614 self.__last_help_data = None 

1615 del self.__help_menu_search_widget 

1616 self.__help_menu_search_layout = 0, 0 

1617 else: 

1618 self.__help_menu_search_widget.event(e) 

1619 self.__last_help_data = None 

1620 self.__help_menu_line = min( 

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

1622 ) 

1623 

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

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

1626 return False 

1627 

1628 if self.__width != rc.width: 

1629 self.__help_menu_line = 0 

1630 

1631 self.__width = rc.width 

1632 self.__height = rc.height 

1633 

1634 if self.__last_help_data != self.help_data: 

1635 self.__last_help_data = self.help_data 

1636 self.__prepared_groups = self.__prepare_groups(self.__last_help_data, rc) 

1637 self.__prepared_inline_help = self.__prepare_inline_help( 

1638 self.__last_help_data, rc 

1639 ) 

1640 self.__has_help = bool( 

1641 self.__last_help_data.inline_help or self.__last_help_data.groups 

1642 ) 

1643 

1644 return True 

1645 

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

1647 if self.__help_menu_search: 

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

1649 

1650 if not self.__clear_layout_cache(rc): 

1651 return rc.height, rc.height 

1652 

1653 self.__key_width = 10 

1654 formatter = yuio.md.MdFormatter( 

1655 rc.make_repr_context( 

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

1657 ), 

1658 allow_headings=False, 

1659 ) 

1660 

1661 self.__wrapped_groups = [] 

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

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

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

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

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

1667 

1668 return rc.height, rc.height 

1669 

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

1671 y = self.__help_menu_line 

1672 

1673 if not self.__wrapped_groups: 

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

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

1676 y += 1 

1677 

1678 for title, actions in self.__wrapped_groups: 

1679 rc.set_pos(0, y) 

1680 if title: 

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

1682 rc.write(title) 

1683 y += 2 

1684 

1685 for keys, lines, key_width in actions: 

1686 if key_width > self.__key_width: 

1687 rc.set_pos(0, y) 

1688 y += 1 

1689 else: 

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

1691 sep = "" 

1692 for key in keys: 

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

1694 rc.write(sep) 

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

1696 rc.write(key) 

1697 sep = "/" 

1698 

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

1700 rc.write_text(lines) 

1701 y += len(lines) 

1702 

1703 y += 2 

1704 

1705 self.__menu_content_height = y - self.__help_menu_line 

1706 

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

1708 if self.__help_menu_search: 

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

1710 rc.set_pos(0, 0) 

1711 self.__help_menu_search_widget.draw(rc) 

1712 else: 

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

1714 rc.write(rc.get_msg_decoration("menu/help/decoration")) 

1715 rc.reset_color() 

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

1717 rc.set_final_pos(1, 0) 

1718 

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

1720 if not self.__clear_layout_cache(rc): 

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

1722 

1723 if not self.__has_help: 

1724 return 0, 0 

1725 

1726 self.__colorized_inline_help = [] 

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

1728 if keys: 

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

1730 else: 

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

1732 colorized_title = yuio.string.colorize( 

1733 title, 

1734 default_color=title_color, 

1735 ctx=rc.make_repr_context(), 

1736 ) 

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

1738 

1739 return 1, 1 

1740 

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

1742 if not self.__has_help: 

1743 return 

1744 

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

1746 col_sep = "" 

1747 

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

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

1750 if used_width + action_width > rc.width: 

1751 break 

1752 

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

1754 rc.write(col_sep) 

1755 

1756 sep = "" 

1757 for key in keys: 

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

1759 rc.write(sep) 

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

1761 rc.write(key) 

1762 sep = "/" 

1763 

1764 if keys_width: 

1765 rc.move_pos(1, 0) 

1766 rc.write(title) 

1767 

1768 col_sep = " • " 

1769 

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

1771 rc.write(col_sep) 

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

1773 rc.write(rc.get_msg_decoration("menu/help/key/f1")) 

1774 rc.move_pos(1, 0) 

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

1776 rc.write("help") 

1777 

1778 def __prepare_inline_help( 

1779 self, data: WidgetHelp, rc: RenderContext 

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

1781 return [ 

1782 prepared_action 

1783 for action in data.inline_help 

1784 if (prepared_action := self.__prepare_action(action, rc)) 

1785 and prepared_action[1] 

1786 ] 

1787 

1788 def __prepare_groups( 

1789 self, data: WidgetHelp, rc: RenderContext 

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

1791 help_data = ( 

1792 data.with_action( 

1793 rc.get_msg_decoration("menu/help/key/f1"), 

1794 group="Other Actions", 

1795 long_msg="toggle help menu", 

1796 ) 

1797 .with_action( 

1798 rc.get_msg_decoration("menu/help/key/ctrl") + "l", 

1799 group="Other Actions", 

1800 long_msg="refresh screen", 

1801 ) 

1802 .with_action( 

1803 rc.get_msg_decoration("menu/help/key/ctrl") + "c", 

1804 group="Other Actions", 

1805 long_msg="send interrupt signal", 

1806 ) 

1807 .with_action( 

1808 rc.get_msg_decoration("menu/help/key/ctrl") + "...", 

1809 group="Legend", 

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

1811 ) 

1812 .with_action( 

1813 rc.get_msg_decoration("menu/help/key/alt") + "...", 

1814 group="Legend", 

1815 long_msg=( 

1816 "means `Option+...`" 

1817 if sys.platform == "darwin" 

1818 else "means `Alt+...`" 

1819 ), 

1820 ) 

1821 .with_action( 

1822 rc.get_msg_decoration("menu/help/key/shift") + "...", 

1823 group="Legend", 

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

1825 ) 

1826 .with_action( 

1827 rc.get_msg_decoration("menu/help/key/enter"), 

1828 group="Legend", 

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

1830 ) 

1831 .with_action( 

1832 rc.get_msg_decoration("menu/help/key/backspace"), 

1833 group="Legend", 

1834 long_msg="means `Backspace`", 

1835 ) 

1836 ) 

1837 

1838 # Make sure unsorted actions go first. 

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

1840 

1841 groups.update( 

1842 { 

1843 title: prepared_actions 

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

1845 if ( 

1846 prepared_actions := [ 

1847 prepared_action 

1848 for action in actions 

1849 if (prepared_action := self.__prepare_action(action, rc)) 

1850 and prepared_action[1] 

1851 ] 

1852 ) 

1853 } 

1854 ) 

1855 

1856 if not groups["Input Format"]: 

1857 del groups["Input Format"] 

1858 if not groups["Actions"]: 

1859 del groups["Actions"] 

1860 

1861 # Make sure other actions go last. 

1862 if "Other Actions" in groups: 

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

1864 if "Legend" in groups: 

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

1866 

1867 return groups 

1868 

1869 def __prepare_action( 

1870 self, action: Action, rc: RenderContext 

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

1872 if isinstance(action, tuple): 

1873 action_keys, msg = action 

1874 prepared_keys = self.__prepare_keys(action_keys, rc) 

1875 else: 

1876 prepared_keys = [] 

1877 msg = action 

1878 

1879 if self.__help_menu_search: 

1880 pattern = self.__help_menu_search_widget.text 

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

1882 return None 

1883 

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

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

1886 

1887 def __prepare_keys(self, action_keys: ActionKeys, rc: RenderContext) -> list[str]: 

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

1889 return [self.__prepare_key(action_keys, rc)] 

1890 else: 

1891 return [self.__prepare_key(action_key, rc) for action_key in action_keys] 

1892 

1893 def __prepare_key(self, action_key: ActionKey, rc: RenderContext) -> str: 

1894 if isinstance(action_key, str): 

1895 return action_key 

1896 elif isinstance(action_key, KeyboardEvent): 

1897 ctrl, alt, shift, key = ( 

1898 action_key.ctrl, 

1899 action_key.alt, 

1900 action_key.shift, 

1901 action_key.key, 

1902 ) 

1903 else: 

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

1905 

1906 symbol = "" 

1907 

1908 if isinstance(key, str): 

1909 if key.lower() != key: 

1910 shift = True 

1911 key = key.lower() 

1912 elif key == " ": 

1913 key = "space" 

1914 else: 

1915 key = key.name.lower() 

1916 

1917 if shift: 

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

1919 

1920 if ctrl: 

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

1922 

1923 if alt: 

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

1925 

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

1927 

1928 

1929Widget.__init_subclass__() 

1930 

1931 

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

1933class _Binding: 

1934 event: KeyboardEvent 

1935 show_in_inline_help: bool 

1936 show_in_detailed_help: bool 

1937 

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

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

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

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

1942 

1943 return fn 

1944 

1945 

1946def bind( 

1947 key: Key | str, 

1948 *, 

1949 ctrl: bool = False, 

1950 alt: bool = False, 

1951 shift: bool = False, 

1952 show_in_inline_help: bool = False, 

1953 show_in_detailed_help: bool = True, 

1954) -> _Binding: 

1955 """ 

1956 Register an event handler for a widget. 

1957 

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

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

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

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

1962 

1963 .. note:: 

1964 

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

1966 

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

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

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

1970 

1971 Example:: 

1972 

1973 class MyWidget(Widget): 

1974 @bind(Key.ENTER) 

1975 def enter(self): 

1976 # all `ENTER` events go here. 

1977 ... 

1978 

1979 def default_event_handler(self, e: KeyboardEvent): 

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

1981 ... 

1982 

1983 """ 

1984 

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

1986 return _Binding(e, show_in_inline_help, show_in_detailed_help) 

1987 

1988 

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

1990class _Help: 

1991 group: str = "Actions" 

1992 inline_msg: str | None = None 

1993 long_msg: str | None = None 

1994 

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

1996 h = dataclasses.replace( 

1997 self, 

1998 inline_msg=( 

1999 self.inline_msg 

2000 if self.inline_msg is not None 

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

2002 ), 

2003 long_msg=( 

2004 self.long_msg 

2005 if self.long_msg is not None 

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

2007 ), 

2008 ) 

2009 setattr(fn, "__yuio_help__", h) 

2010 

2011 return fn 

2012 

2013 

2014def help( 

2015 *, 

2016 group: str = "Actions", 

2017 inline_msg: str | None = None, 

2018 long_msg: str | None = None, 

2019 msg: str | None = None, 

2020) -> _Help: 

2021 """ 

2022 Set options for how this callback should be displayed. 

2023 

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

2025 

2026 :param group: 

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

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

2029 :param inline_msg: 

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

2031 it will be taken from a docstring. 

2032 :param long_msg: 

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

2034 it will be taken from a docstring. 

2035 :param msg: 

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

2037 at the same time. 

2038 

2039 Example:: 

2040 

2041 class MyWidget(Widget): 

2042 NAVIGATE = "Navigate" 

2043 

2044 @bind(Key.TAB) 

2045 @help(group=NAVIGATE) 

2046 def tab(self): 

2047 \"""next item\""" 

2048 ... 

2049 

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

2051 @help(group=NAVIGATE) 

2052 def shift_tab(self): 

2053 \"""previous item\""" 

2054 ... 

2055 

2056 """ 

2057 

2058 if msg is not None and inline_msg is None: 

2059 inline_msg = msg 

2060 if msg is not None and long_msg is None: 

2061 long_msg = msg 

2062 

2063 return _Help( 

2064 group, 

2065 inline_msg, 

2066 long_msg, 

2067 ) 

2068 

2069 

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

2071""" 

2072A single key associated with an action. 

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

2074 

2075""" 

2076 

2077 

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

2079""" 

2080A list of keys associated with an action. 

2081 

2082""" 

2083 

2084 

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

2086""" 

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

2088 

2089""" 

2090 

2091 

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

2093class WidgetHelp: 

2094 """ 

2095 Data for automatic help generation. 

2096 

2097 .. warning:: 

2098 

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

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

2101 outdated help messages. 

2102 

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

2104 

2105 """ 

2106 

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

2108 """ 

2109 List of actions to show in the inline help. 

2110 

2111 """ 

2112 

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

2114 """ 

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

2116 

2117 """ 

2118 

2119 def with_action( 

2120 self, 

2121 *bindings: _Binding | ActionKey, 

2122 group: str = "Actions", 

2123 msg: str | None = None, 

2124 inline_msg: str | None = None, 

2125 long_msg: str | None = None, 

2126 prepend: bool = False, 

2127 prepend_group: bool = False, 

2128 ) -> WidgetHelp: 

2129 """ 

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

2131 

2132 :param bindings: 

2133 keys that trigger an action. 

2134 :param group: 

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

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

2137 :param inline_msg: 

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

2139 it will be taken from a docstring. 

2140 :param long_msg: 

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

2142 it will be taken from a docstring. 

2143 :param msg: 

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

2145 at the same time. 

2146 :param prepend: 

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

2148 :param prepend_group: 

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

2150 

2151 """ 

2152 

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

2154 *bindings, 

2155 group=group, 

2156 inline_msg=inline_msg, 

2157 long_msg=long_msg, 

2158 prepend=prepend, 

2159 prepend_group=prepend_group, 

2160 msg=msg, 

2161 ) 

2162 

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

2164 """ 

2165 Merge this help data with another one and return 

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

2167 

2168 :param other: 

2169 other :class:`WidgetHelp` for merging. 

2170 

2171 """ 

2172 

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

2174 result.inline_help.extend(other.inline_help) 

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

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

2177 return result 

2178 

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

2180 """ 

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

2182 

2183 :param title: 

2184 title to remove. 

2185 

2186 """ 

2187 

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

2189 result.groups.pop(title, None) 

2190 return result 

2191 

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

2193 """ 

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

2195 

2196 :param title: 

2197 title to replace. 

2198 :param new_title: 

2199 new title. 

2200 

2201 """ 

2202 

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

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

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

2206 return result 

2207 

2208 def __add_action( 

2209 self, 

2210 *bindings: _Binding | ActionKey, 

2211 group: str, 

2212 inline_msg: str | None, 

2213 long_msg: str | None, 

2214 prepend: bool, 

2215 prepend_group: bool, 

2216 msg: str | None, 

2217 ) -> WidgetHelp: 

2218 settings = help( 

2219 group=group, 

2220 inline_msg=inline_msg, 

2221 long_msg=long_msg, 

2222 msg=msg, 

2223 ) 

2224 

2225 if settings.inline_msg: 

2226 inline_keys: ActionKeys = [ 

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

2228 for binding in bindings 

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

2230 ] 

2231 if prepend: 

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

2233 else: 

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

2235 

2236 if settings.long_msg: 

2237 menu_keys: ActionKeys = [ 

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

2239 for binding in bindings 

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

2241 ] 

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

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

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

2245 self.groups.clear() 

2246 self.groups.update(groups) 

2247 if prepend: 

2248 self.groups[settings.group] = [ 

2249 (menu_keys, settings.long_msg) 

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

2251 else: 

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

2253 (menu_keys, settings.long_msg) 

2254 ] 

2255 

2256 return self 

2257 

2258 

2259@_t.final 

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

2261 """ 

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

2263 of keyboard events. 

2264 

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

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

2267 to a particular widget within the stack:: 

2268 

2269 widget = VerticalLayout.builder() \\ 

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

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

2272 .build() 

2273 

2274 result = widget.run(term, theme) 

2275 

2276 """ 

2277 

2278 if TYPE_CHECKING: 

2279 

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

2281 

2282 def __init__(self): 

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

2284 self._event_receiver: int | None = None 

2285 

2286 @_t.overload 

2287 def add( 

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

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

2290 

2291 @_t.overload 

2292 def add( 

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

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

2295 

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

2297 """ 

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

2299 

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

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

2302 will receive events. 

2303 

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

2305 Use it with method chaining. 

2306 

2307 """ 

2308 

2309 other = VerticalLayoutBuilder() 

2310 

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

2312 other._event_receiver = self._event_receiver 

2313 

2314 if isinstance(widget, VerticalLayout): 

2315 if receive_events and widget._event_receiver is not None: 

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

2317 elif receive_events: 

2318 other._event_receiver = None 

2319 other._widgets.extend(widget._widgets) 

2320 else: 

2321 if receive_events: 

2322 other._event_receiver = len(other._widgets) 

2323 other._widgets.append(widget) 

2324 

2325 return other 

2326 

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

2328 layout = VerticalLayout() 

2329 layout._widgets = self._widgets 

2330 layout._event_receiver = self._event_receiver 

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

2332 

2333 

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

2335 """ 

2336 Helper class for stacking widgets together. 

2337 

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

2339 and draw them all at once. 

2340 

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

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

2343 for an example. 

2344 

2345 .. automethod:: append 

2346 

2347 .. automethod:: extend 

2348 

2349 .. automethod:: event 

2350 

2351 .. automethod:: layout 

2352 

2353 .. automethod:: draw 

2354 

2355 """ 

2356 

2357 if TYPE_CHECKING: 

2358 

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

2360 

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

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

2363 self._event_receiver: int | None = None 

2364 

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

2366 self.__min_h: int = 0 

2367 self.__max_h: int = 0 

2368 

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

2370 """ 

2371 Add a widget to the end of the stack. 

2372 

2373 """ 

2374 

2375 if isinstance(widget, VerticalLayout): 

2376 self._widgets.extend(widget._widgets) 

2377 else: 

2378 self._widgets.append(widget) 

2379 

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

2381 """ 

2382 Add multiple widgets to the end of the stack. 

2383 

2384 """ 

2385 

2386 for widget in widgets: 

2387 self.append(widget) 

2388 

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

2390 """ 

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

2392 

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

2394 

2395 """ 

2396 

2397 if self._event_receiver is not None: 

2398 return _t.cast( 

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

2400 ) 

2401 

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

2403 """ 

2404 Calculate layout of the entire stack. 

2405 

2406 """ 

2407 

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

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

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

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

2412 return self.__min_h, self.__max_h 

2413 

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

2415 """ 

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

2417 

2418 """ 

2419 

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

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

2422 ) 

2423 

2424 if rc.height <= self.__min_h: 

2425 scale = 0.0 

2426 elif rc.height >= self.__max_h: 

2427 scale = 1.0 

2428 else: 

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

2430 

2431 y1 = 0.0 

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

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

2434 

2435 iy1 = round(y1) 

2436 iy2 = round(y2) 

2437 

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

2439 widget.draw(rc) 

2440 

2441 y1 = y2 

2442 

2443 @property 

2444 def help_data(self) -> WidgetHelp: 

2445 if self._event_receiver is not None: 

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

2447 else: 

2448 return WidgetHelp() 

2449 

2450 

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

2452 """ 

2453 A widget that prints a single line of text. 

2454 

2455 """ 

2456 

2457 def __init__( 

2458 self, 

2459 text: yuio.string.Colorable, 

2460 /, 

2461 ): 

2462 self.__text = text 

2463 self.__colorized_text = None 

2464 

2465 @property 

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

2467 """ 

2468 Currently displayed text. 

2469 

2470 """ 

2471 

2472 return self.__text 

2473 

2474 @text.setter 

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

2476 self.__text = text 

2477 self.__colorized_text = None 

2478 

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

2480 return 1, 1 

2481 

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

2483 if self.__colorized_text is None: 

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

2485 

2486 rc.write(self.__colorized_text) 

2487 

2488 

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

2490 """ 

2491 A widget that prints wrapped text. 

2492 

2493 """ 

2494 

2495 def __init__( 

2496 self, 

2497 text: yuio.string.Colorable, 

2498 /, 

2499 ): 

2500 self.__text = text 

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

2502 self.__wrapped_text_width: int = 0 

2503 

2504 @property 

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

2506 """ 

2507 Currently displayed text. 

2508 

2509 """ 

2510 

2511 return self.__text 

2512 

2513 @text.setter 

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

2515 self.__text = text 

2516 self.__wrapped_text = None 

2517 self.__wrapped_text_width = 0 

2518 

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

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

2521 colorized_text = rc.make_repr_context().str(self.__text) 

2522 self.__wrapped_text = colorized_text.wrap( 

2523 rc.width, 

2524 break_long_nowrap_words=True, 

2525 ) 

2526 self.__wrapped_text_width = rc.width 

2527 height = len(self.__wrapped_text) 

2528 return height, height 

2529 

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

2531 assert self.__wrapped_text is not None 

2532 rc.write_text(self.__wrapped_text) 

2533 

2534 

2535_CHAR_NAMES = { 

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

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

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

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

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

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

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

2543 "\u0007": "\\a", 

2544 "\u0008": "\\b", 

2545 "\u0009": "\\t", 

2546 "\u000b": "\\v", 

2547 "\u000c": "\\f", 

2548 "\u000d": "\\r", 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2602} 

2603 

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

2605 

2606 

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

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

2609 i = 0 

2610 for match in _ESC_RE.finditer(text): 

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

2612 raw.append(s) 

2613 raw.append(esc_color) 

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

2615 raw.append(n_color) 

2616 i = match.end() 

2617 if i < len(text): 

2618 raw.append(text[i:]) 

2619 return raw 

2620 

2621 

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

2623 total_len = 0 

2624 if not offset: 

2625 return (0, 0) 

2626 for y, line in enumerate(text): 

2627 x = 0 

2628 for part in line: 

2629 if isinstance(part, _Esc): 

2630 l = 1 

2631 dx = len(part) 

2632 elif isinstance(part, str): 

2633 l = len(part) 

2634 dx = _line_width(part) 

2635 else: 

2636 continue 

2637 if total_len + l >= offset: 

2638 if isinstance(part, _Esc): 

2639 x += dx 

2640 else: 

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

2642 if x >= text_width: 

2643 return (0, y + 1) 

2644 else: 

2645 return (0 + x, y) 

2646 break 

2647 x += dx 

2648 total_len += l 

2649 total_len += len(line.explicit_newline) 

2650 if total_len >= offset: 

2651 return (0, y + 1) 

2652 assert False 

2653 

2654 

2655class Input(Widget[str]): 

2656 """ 

2657 An input box. 

2658 

2659 .. vhs:: /_tapes/widget_input.tape 

2660 :alt: Demonstration of `Input` widget. 

2661 :scale: 40% 

2662 

2663 .. note:: 

2664 

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

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

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

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

2669 

2670 :param text: 

2671 initial text. 

2672 :param pos: 

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

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

2675 :param placeholder: 

2676 placeholder text, shown when input is empty. 

2677 :param decoration_path: 

2678 path that will be used to look up decoration printed before the input box. 

2679 :param allow_multiline: 

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

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

2682 :param allow_special_characters: 

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

2684 and not replaced with whitespaces. 

2685 

2686 """ 

2687 

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

2689 # via hotkeys. 

2690 _WORD_SEPARATORS = string.punctuation + string.whitespace 

2691 

2692 # Character that replaces newlines and unprintable characters when 

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

2694 _UNPRINTABLE_SUBSTITUTOR = " " 

2695 

2696 class _CheckpointType(enum.Enum): 

2697 """ 

2698 Types of entries in the history buffer. 

2699 

2700 """ 

2701 

2702 USR = enum.auto() 

2703 """ 

2704 User-initiated checkpoint. 

2705 

2706 """ 

2707 

2708 SYM = enum.auto() 

2709 """ 

2710 Checkpoint before a symbol was inserted. 

2711 

2712 """ 

2713 

2714 SEP = enum.auto() 

2715 """ 

2716 Checkpoint before a space was inserted. 

2717 

2718 """ 

2719 

2720 DEL = enum.auto() 

2721 """ 

2722 Checkpoint before something was deleted. 

2723 

2724 """ 

2725 

2726 def __init__( 

2727 self, 

2728 *, 

2729 text: str = "", 

2730 pos: int | None = None, 

2731 placeholder: str = "", 

2732 decoration_path: str = "menu/input/decoration", 

2733 allow_multiline: bool = False, 

2734 allow_special_characters: bool = False, 

2735 ): 

2736 self.__text: str = text 

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

2738 self.__placeholder: str = placeholder 

2739 self.__decoration_path: str = decoration_path 

2740 self.__allow_multiline: bool = allow_multiline 

2741 self.__allow_special_characters: bool = allow_special_characters 

2742 

2743 self.__wrapped_text_width: int = 0 

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

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

2746 

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

2748 # and cursor position in this list. 

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

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

2751 ] 

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

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

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

2755 # were skipped this way since the last checkpoint. 

2756 self.__history_skipped_actions = 0 

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

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

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

2760 self.__require_checkpoint: bool = False 

2761 

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

2763 # text at the position of the cursor. 

2764 self.__yanked_text: str = "" 

2765 

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

2767 

2768 @property 

2769 def text(self) -> str: 

2770 """ 

2771 Current text in the input box. 

2772 

2773 """ 

2774 return self.__text 

2775 

2776 @text.setter 

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

2778 self.__text = text 

2779 self.__wrapped_text = None 

2780 if self.pos > len(text): 

2781 self.pos = len(text) 

2782 self.__err_region = None 

2783 

2784 @property 

2785 def pos(self) -> int: 

2786 """ 

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

2788 

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

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

2791 

2792 """ 

2793 return self.__pos 

2794 

2795 @pos.setter 

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

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

2798 self.__pos_after_wrap = None 

2799 

2800 @property 

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

2802 return self.__err_region 

2803 

2804 @err_region.setter 

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

2806 self.__err_region = err_region 

2807 self.__wrapped_text = None 

2808 

2809 def checkpoint(self): 

2810 """ 

2811 Manually create an entry in the history buffer. 

2812 

2813 """ 

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

2815 self.__history_skipped_actions = 0 

2816 

2817 def restore_checkpoint(self): 

2818 """ 

2819 Restore the last manually created checkpoint. 

2820 

2821 """ 

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

2823 self.undo() 

2824 

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

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

2827 

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

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

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

2831 # for every letter. 

2832 self.__history_skipped_actions += 1 

2833 return 

2834 

2835 prev_skipped_actions = self.__history_skipped_actions 

2836 self.__history_skipped_actions = 0 

2837 

2838 if ( 

2839 action == Input._CheckpointType.SYM 

2840 and prev_action == Input._CheckpointType.SEP 

2841 and prev_skipped_actions == 0 

2842 and not self.__require_checkpoint 

2843 ): 

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

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

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

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

2848 return 

2849 

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

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

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

2853 # a checkpoint for this. 

2854 return 

2855 

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

2857 if len(self.__history) > 50: 

2858 self.__history.pop(0) 

2859 

2860 self.__require_checkpoint = False 

2861 

2862 @bind(Key.ENTER) 

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

2864 if self.__allow_multiline: 

2865 self.insert("\n") 

2866 else: 

2867 return self.alt_enter() 

2868 

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

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

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

2872 return Result(self.text) 

2873 

2874 _NAVIGATE = "Navigate" 

2875 

2876 @bind(Key.ARROW_UP) 

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

2878 @help(group=_NAVIGATE) 

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

2880 """up""" 

2881 pos = self.pos 

2882 self.home() 

2883 if self.pos: 

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

2885 

2886 self.left() 

2887 self.home() 

2888 

2889 pos = self.pos 

2890 text = self.text 

2891 cur_width = 0 

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

2893 if cur_width >= width: 

2894 break 

2895 cur_width += _line_width(text[pos]) 

2896 pos += 1 

2897 

2898 self.pos = pos 

2899 

2900 self.__require_checkpoint |= checkpoint 

2901 

2902 @bind(Key.ARROW_DOWN) 

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

2904 @help(group=_NAVIGATE) 

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

2906 """down""" 

2907 pos = self.pos 

2908 self.home() 

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

2910 self.end() 

2911 

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

2913 self.right() 

2914 

2915 pos = self.pos 

2916 text = self.text 

2917 cur_width = 0 

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

2919 if cur_width >= width: 

2920 break 

2921 cur_width += _line_width(text[pos]) 

2922 pos += 1 

2923 

2924 self.pos = pos 

2925 

2926 self.__require_checkpoint |= checkpoint 

2927 

2928 @bind(Key.ARROW_LEFT) 

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

2930 @help(group=_NAVIGATE) 

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

2932 """left""" 

2933 self.pos -= 1 

2934 self.__require_checkpoint |= checkpoint 

2935 

2936 @bind(Key.ARROW_RIGHT) 

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

2938 @help(group=_NAVIGATE) 

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

2940 """right""" 

2941 self.pos += 1 

2942 self.__require_checkpoint |= checkpoint 

2943 

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

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

2946 @help(group=_NAVIGATE) 

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

2948 """left one word""" 

2949 pos = self.pos 

2950 text = self.text 

2951 if pos: 

2952 pos -= 1 

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

2954 pos -= 1 

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

2956 pos -= 1 

2957 self.pos = pos 

2958 self.__require_checkpoint |= checkpoint 

2959 

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

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

2962 @help(group=_NAVIGATE) 

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

2964 """right one word""" 

2965 pos = self.pos 

2966 text = self.text 

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

2968 pos += 1 

2969 while ( 

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

2971 ): 

2972 pos += 1 

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

2974 pos += 1 

2975 self.pos = pos 

2976 self.__require_checkpoint |= checkpoint 

2977 

2978 @bind(Key.HOME) 

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

2980 @help(group=_NAVIGATE) 

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

2982 """to line start""" 

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

2984 self.__require_checkpoint |= checkpoint 

2985 

2986 @bind(Key.END) 

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

2988 @help(group=_NAVIGATE) 

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

2990 """to line end""" 

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

2992 if next_nl == -1: 

2993 self.pos = len(self.text) 

2994 else: 

2995 self.pos = next_nl 

2996 self.__require_checkpoint |= checkpoint 

2997 

2998 @bind("g", ctrl=True) 

2999 def go_to_err(self, /, *, checkpoint: bool = True): 

3000 if not self.__err_region: 

3001 return 

3002 if self.pos == self.__err_region[1]: 

3003 self.pos = self.__err_region[0] 

3004 else: 

3005 self.pos = self.__err_region[1] 

3006 self.__require_checkpoint |= checkpoint 

3007 

3008 _MODIFY = "Modify" 

3009 

3010 @bind(Key.BACKSPACE) 

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

3012 @help(group=_MODIFY) 

3013 def backspace(self): 

3014 """backspace""" 

3015 prev_pos = self.pos 

3016 self.left(checkpoint=False) 

3017 if prev_pos != self.pos: 

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

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

3020 else: 

3021 self._bell() 

3022 

3023 @bind(Key.DELETE) 

3024 @help(group=_MODIFY) 

3025 def delete(self): 

3026 """delete""" 

3027 prev_pos = self.pos 

3028 self.right(checkpoint=False) 

3029 if prev_pos != self.pos: 

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

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

3032 self.pos = prev_pos 

3033 else: 

3034 self._bell() 

3035 

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

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

3038 @help(group=_MODIFY) 

3039 def backspace_word(self): 

3040 """backspace one word""" 

3041 prev_pos = self.pos 

3042 self.left_word(checkpoint=False) 

3043 if prev_pos != self.pos: 

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

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

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

3047 else: 

3048 self._bell() 

3049 

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

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

3052 @help(group=_MODIFY) 

3053 def delete_word(self): 

3054 """delete one word""" 

3055 prev_pos = self.pos 

3056 self.right_word(checkpoint=False) 

3057 if prev_pos != self.pos: 

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

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

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

3061 self.pos = prev_pos 

3062 else: 

3063 self._bell() 

3064 

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

3066 @help(group=_MODIFY) 

3067 def backspace_home(self): 

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

3069 prev_pos = self.pos 

3070 self.home(checkpoint=False) 

3071 if prev_pos != self.pos: 

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

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

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

3075 else: 

3076 self._bell() 

3077 

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

3079 @help(group=_MODIFY) 

3080 def delete_end(self): 

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

3082 prev_pos = self.pos 

3083 self.end(checkpoint=False) 

3084 if prev_pos != self.pos: 

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

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

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

3088 self.pos = prev_pos 

3089 else: 

3090 self._bell() 

3091 

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

3093 @help(group=_MODIFY) 

3094 def yank(self): 

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

3096 if self.__yanked_text: 

3097 self.__require_checkpoint = True 

3098 self.insert(self.__yanked_text) 

3099 else: 

3100 self._bell() 

3101 

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

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

3104 @bind("-", ctrl=True, shift=True, show_in_detailed_help=False) 

3105 @bind("?", ctrl=True, show_in_detailed_help=False) 

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

3107 @bind("z", ctrl=True) 

3108 @help(group=_MODIFY) 

3109 def undo(self): 

3110 """undo""" 

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

3112 if len(self.__history) > 1: 

3113 self.__history.pop() 

3114 else: 

3115 self._bell() 

3116 

3117 def default_event_handler(self, e: KeyboardEvent): 

3118 if e.key is Key.PASTE: 

3119 self.__require_checkpoint = True 

3120 s = e.paste_str or "" 

3121 if self.__allow_special_characters and self.__allow_multiline: 

3122 pass 

3123 elif self.__allow_multiline: 

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

3125 elif self.__allow_special_characters: 

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

3127 else: 

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

3129 self.insert(s) 

3130 elif e.key is Key.TAB: 

3131 if self.__allow_special_characters: 

3132 self.insert("\t") 

3133 else: 

3134 self.insert(self._UNPRINTABLE_SUBSTITUTOR) 

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

3136 self.insert(e.key) 

3137 

3138 def insert(self, s: str): 

3139 if not s: 

3140 return 

3141 

3142 self._internal_checkpoint( 

3143 ( 

3144 Input._CheckpointType.SEP 

3145 if s in self._WORD_SEPARATORS 

3146 else Input._CheckpointType.SYM 

3147 ), 

3148 self.text, 

3149 self.pos, 

3150 ) 

3151 

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

3153 self.pos += len(s) 

3154 

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

3156 decoration = rc.get_msg_decoration(self.__decoration_path) 

3157 decoration_width = _line_width(decoration) 

3158 text_width = rc.width - decoration_width 

3159 if text_width < 2: 

3160 self.__wrapped_text_width = max(text_width, 0) 

3161 self.__wrapped_text = None 

3162 self.__pos_after_wrap = None 

3163 return 0, 0 

3164 

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

3166 self.__wrapped_text_width = text_width 

3167 

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

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

3170 if self.__text: 

3171 self.__wrapped_text = self._prepare_display_text( 

3172 self.__text, 

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

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

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

3176 ).wrap( 

3177 text_width, 

3178 preserve_spaces=True, 

3179 break_long_nowrap_words=True, 

3180 ) 

3181 self.__pos_after_wrap = None 

3182 else: 

3183 self.__wrapped_text = _ColorizedString( 

3184 [ 

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

3186 self.__placeholder, 

3187 ] 

3188 ).wrap( 

3189 text_width, 

3190 preserve_newlines=False, 

3191 break_long_nowrap_words=True, 

3192 ) 

3193 self.__pos_after_wrap = (decoration_width, 0) 

3194 

3195 if self.__pos_after_wrap is None: 

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

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

3198 

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

3200 return height, height 

3201 

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

3203 if decoration := rc.get_msg_decoration(self.__decoration_path): 

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

3205 rc.write(decoration) 

3206 

3207 if self.__wrapped_text is not None: 

3208 rc.write_text(self.__wrapped_text) 

3209 

3210 if self.__pos_after_wrap is not None: 

3211 rc.set_final_pos(*self.__pos_after_wrap) 

3212 

3213 def _prepare_display_text( 

3214 self, text: str, esc_color: _Color, n_color: _Color, err_color: _Color 

3215 ) -> _ColorizedString: 

3216 res = _ColorizedString() 

3217 if self.__err_region: 

3218 start, end = self.__err_region 

3219 res += _replace_special_symbols(text[:start], esc_color, n_color) 

3220 res += _replace_special_symbols(text[start:end], esc_color, err_color) 

3221 res += _replace_special_symbols(text[end:], esc_color, n_color) 

3222 else: 

3223 res += _replace_special_symbols(text, esc_color, n_color) 

3224 return res 

3225 

3226 @property 

3227 def help_data(self) -> WidgetHelp: 

3228 help_data = super().help_data 

3229 

3230 if self.__allow_multiline: 

3231 help_data = help_data.with_action( 

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

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

3234 msg="accept", 

3235 prepend=True, 

3236 ).with_action( 

3237 KeyboardEvent(Key.ENTER), 

3238 group=self._MODIFY, 

3239 long_msg="new line", 

3240 prepend=True, 

3241 ) 

3242 

3243 if self.__err_region: 

3244 help_data = help_data.with_action( 

3245 KeyboardEvent("g", ctrl=True), 

3246 group=self._NAVIGATE, 

3247 msg="go to error", 

3248 prepend=True, 

3249 ) 

3250 

3251 return help_data 

3252 

3253 

3254class SecretInput(Input): 

3255 """ 

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

3257 

3258 :param text: 

3259 initial text. 

3260 :param pos: 

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

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

3263 :param placeholder: 

3264 placeholder text, shown when input is empty. 

3265 :param decoration: 

3266 decoration printed before the input box. 

3267 

3268 """ 

3269 

3270 _WORD_SEPARATORS = "" 

3271 _UNPRINTABLE_SUBSTITUTOR = "" 

3272 

3273 def __init__( 

3274 self, 

3275 *, 

3276 text: str = "", 

3277 pos: int | None = None, 

3278 placeholder: str = "", 

3279 decoration_path: str = "menu/input/decoration", 

3280 ): 

3281 super().__init__( 

3282 text=text, 

3283 pos=pos, 

3284 placeholder=placeholder, 

3285 decoration_path=decoration_path, 

3286 allow_multiline=False, 

3287 allow_special_characters=False, 

3288 ) 

3289 

3290 def _prepare_display_text( 

3291 self, text: str, esc_color: _Color, n_color: _Color, err_color: _Color 

3292 ) -> _ColorizedString: 

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

3294 

3295 

3296@dataclass(slots=True) 

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

3298 """ 

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

3300 

3301 """ 

3302 

3303 def __post_init__(self): 

3304 if self.color_tag is None: 

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

3306 

3307 value: T_co 

3308 """ 

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

3310 

3311 """ 

3312 

3313 display_text: str 

3314 """ 

3315 What should be displayed in the autocomplete list. 

3316 

3317 """ 

3318 

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

3320 """ 

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

3322 

3323 """ 

3324 

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

3326 """ 

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

3328 

3329 """ 

3330 

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

3332 """ 

3333 Option's short comment. 

3334 

3335 """ 

3336 

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

3338 """ 

3339 Option's color tag. 

3340 

3341 This color tag will be used to display option. 

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

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

3344 

3345 """ 

3346 

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

3348 """ 

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

3350 

3351 """ 

3352 

3353 

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

3355 """ 

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

3357 

3358 .. note:: 

3359 

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

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

3362 

3363 :param options: 

3364 list of options displayed in the grid. 

3365 :param decoration: 

3366 decoration printed before the selected option. 

3367 :param default_index: 

3368 index of the initially selected option. 

3369 :param min_rows: 

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

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

3372 space on the screen. 

3373 

3374 """ 

3375 

3376 def __init__( 

3377 self, 

3378 options: list[Option[T]], 

3379 /, 

3380 *, 

3381 active_item_decoration_path: str = "menu/choice/decoration/active_item", 

3382 selected_item_decoration_path: str = "", 

3383 deselected_item_decoration_path: str = "", 

3384 default_index: int | None = 0, 

3385 min_rows: int | None = 5, 

3386 ): 

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

3388 self.__index: int | None 

3389 self.__min_rows: int | None = min_rows 

3390 self.__max_column_width: int | None 

3391 self.__column_width: int 

3392 self.__num_rows: int 

3393 self.__num_columns: int 

3394 

3395 self.__active_item_decoration_path = active_item_decoration_path 

3396 self.__selected_item_decoration_path = selected_item_decoration_path 

3397 self.__deselected_item_decoration_path = deselected_item_decoration_path 

3398 

3399 self.set_options(options) 

3400 self.index = default_index 

3401 

3402 @property 

3403 def _page_size(self) -> int: 

3404 return self.__num_rows * self.__num_columns 

3405 

3406 @property 

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

3408 """ 

3409 Index of the currently selected option. 

3410 

3411 """ 

3412 

3413 return self.__index 

3414 

3415 @index.setter 

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

3417 if idx is None or not self.__options: 

3418 self.__index = None 

3419 elif self.__options: 

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

3421 

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

3423 """ 

3424 Get the currently selected option, 

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

3426 

3427 """ 

3428 

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

3430 return self.__options[self.__index] 

3431 

3432 def has_options(self) -> bool: 

3433 """ 

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

3435 

3436 """ 

3437 

3438 return bool(self.__options) 

3439 

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

3441 """ 

3442 Get all options. 

3443 

3444 """ 

3445 

3446 return self.__options 

3447 

3448 def set_options( 

3449 self, 

3450 options: list[Option[T]], 

3451 /, 

3452 default_index: int | None = 0, 

3453 ): 

3454 """ 

3455 Set a new list of options. 

3456 

3457 """ 

3458 

3459 self.__options = options 

3460 self.__max_column_width = None 

3461 self.index = default_index 

3462 

3463 _NAVIGATE = "Navigate" 

3464 

3465 @bind(Key.ARROW_UP) 

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

3467 @help(group=_NAVIGATE) 

3468 def prev_item(self): 

3469 """previous item""" 

3470 if not self.__options: 

3471 return 

3472 

3473 if self.__index is None: 

3474 self.__index = 0 

3475 else: 

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

3477 

3478 @bind(Key.ARROW_DOWN) 

3479 @bind(Key.TAB) 

3480 @help(group=_NAVIGATE) 

3481 def next_item(self): 

3482 """next item""" 

3483 if not self.__options: 

3484 return 

3485 

3486 if self.__index is None: 

3487 self.__index = 0 

3488 else: 

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

3490 

3491 @bind(Key.ARROW_LEFT) 

3492 @help(group=_NAVIGATE) 

3493 def prev_column(self): 

3494 """previous column""" 

3495 if not self.__options: 

3496 return 

3497 

3498 if self.__index is None: 

3499 self.__index = 0 

3500 else: 

3501 total_grid_capacity = self.__num_rows * math.ceil( 

3502 len(self.__options) / self.__num_rows 

3503 ) 

3504 

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

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

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

3508 

3509 @bind(Key.ARROW_RIGHT) 

3510 @help(group=_NAVIGATE) 

3511 def next_column(self): 

3512 """next column""" 

3513 if not self.__options: 

3514 return 

3515 

3516 if self.__index is None: 

3517 self.__index = 0 

3518 else: 

3519 total_grid_capacity = self.__num_rows * math.ceil( 

3520 len(self.__options) / self.__num_rows 

3521 ) 

3522 

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

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

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

3526 

3527 @bind(Key.PAGE_UP) 

3528 @help(group=_NAVIGATE) 

3529 def prev_page(self): 

3530 """previous page""" 

3531 if not self.__options: 

3532 return 

3533 

3534 if self.__index is None: 

3535 self.__index = 0 

3536 else: 

3537 self.__index -= self.__index % self._page_size 

3538 self.__index -= 1 

3539 if self.__index < 0: 

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

3541 

3542 @bind(Key.PAGE_DOWN) 

3543 @help(group=_NAVIGATE) 

3544 def next_page(self): 

3545 """next page""" 

3546 if not self.__options: 

3547 return 

3548 

3549 if self.__index is None: 

3550 self.__index = 0 

3551 else: 

3552 self.__index -= self.__index % self._page_size 

3553 self.__index += self._page_size 

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

3555 self.__index = 0 

3556 

3557 @bind(Key.HOME) 

3558 @help(group=_NAVIGATE) 

3559 def home(self): 

3560 """first page""" 

3561 if not self.__options: 

3562 return 

3563 

3564 if self.__index is None: 

3565 self.__index = 0 

3566 else: 

3567 self.__index = 0 

3568 

3569 @bind(Key.END) 

3570 @help(group=_NAVIGATE) 

3571 def end(self): 

3572 """last page""" 

3573 if not self.__options: 

3574 return 

3575 

3576 if self.__index is None: 

3577 self.__index = 0 

3578 else: 

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

3580 

3581 def default_event_handler(self, e: KeyboardEvent): 

3582 if isinstance(e.key, str): 

3583 key = e.key.casefold() 

3584 if ( 

3585 self.__options 

3586 and self.__index is not None 

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

3588 ): 

3589 start = self.__index + 1 

3590 else: 

3591 start = 0 

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

3593 index = i % len(self.__options) 

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

3595 self.__index = index 

3596 break 

3597 

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

3599 active_item_decoration = rc.get_msg_decoration( 

3600 self.__active_item_decoration_path 

3601 ) 

3602 selected_item_decoration = rc.get_msg_decoration( 

3603 self.__selected_item_decoration_path 

3604 ) 

3605 deselected_item_decoration = rc.get_msg_decoration( 

3606 self.__deselected_item_decoration_path 

3607 ) 

3608 

3609 decoration_width = _line_width(active_item_decoration) + max( 

3610 _line_width(selected_item_decoration), 

3611 _line_width(deselected_item_decoration), 

3612 ) 

3613 

3614 if self.__max_column_width is None: 

3615 self.__max_column_width = max( 

3616 0, 

3617 _MIN_COLUMN_WIDTH, 

3618 *( 

3619 self._get_option_width(option, decoration_width) 

3620 for option in self.__options 

3621 ), 

3622 ) 

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

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

3625 self.__num_rows = max( 

3626 1, 

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

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

3629 ) 

3630 

3631 additional_space = 0 

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

3633 if pages > 1: 

3634 additional_space = 1 

3635 

3636 return 1 + additional_space, self.__num_rows + additional_space 

3637 

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

3639 if not self.__options: 

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

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

3642 return 

3643 

3644 # Adjust for the actual available height. 

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

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

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

3648 self.__num_rows -= 1 

3649 

3650 column_width = self.__column_width 

3651 num_rows = self.__num_rows 

3652 page_size = self._page_size 

3653 

3654 page_start_index = 0 

3655 if page_size and self.__index is not None: 

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

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

3658 

3659 if self.__num_columns > 1: 

3660 available_column_width = column_width - _SPACE_BETWEEN_COLUMNS 

3661 else: 

3662 available_column_width = column_width 

3663 

3664 for i, option in enumerate(page): 

3665 x = i // num_rows 

3666 y = i % num_rows 

3667 

3668 rc.set_pos(x * column_width, y) 

3669 

3670 index = i + page_start_index 

3671 is_current = index == self.__index 

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

3673 

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

3675 if pages > 1: 

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

3677 rc.set_pos(0, num_rows) 

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

3679 rc.write("Page ") 

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

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

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

3683 rc.write(" of ") 

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

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

3686 

3687 def _get_option_width(self, option: Option[object], decoration_width: int): 

3688 return ( 

3689 _SPACE_BETWEEN_COLUMNS 

3690 + decoration_width 

3691 + (_line_width(option.display_text_prefix)) 

3692 + (_line_width(option.display_text)) 

3693 + (_line_width(option.display_text_suffix)) 

3694 + (3 if option.comment else 0) 

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

3696 ) 

3697 

3698 def _render_option( 

3699 self, 

3700 rc: RenderContext, 

3701 width: int, 

3702 option: Option[object], 

3703 is_active: bool, 

3704 ): 

3705 active_item_decoration = rc.get_msg_decoration( 

3706 self.__active_item_decoration_path 

3707 ) 

3708 active_item_decoration_width = _line_width(active_item_decoration) 

3709 selected_item_decoration = rc.get_msg_decoration( 

3710 self.__selected_item_decoration_path 

3711 ) 

3712 selected_item_decoration_width = _line_width(selected_item_decoration) 

3713 deselected_item_decoration = rc.get_msg_decoration( 

3714 self.__deselected_item_decoration_path 

3715 ) 

3716 deselected_item_decoration_width = _line_width(deselected_item_decoration) 

3717 item_selection_decoration_width = max( 

3718 selected_item_decoration_width, deselected_item_decoration_width 

3719 ) 

3720 

3721 left_prefix_width = _line_width(option.display_text_prefix) 

3722 left_main_width = _line_width(option.display_text) 

3723 left_suffix_width = _line_width(option.display_text_suffix) 

3724 left_width = left_prefix_width + left_main_width + left_suffix_width 

3725 left_decoration_width = ( 

3726 active_item_decoration_width + item_selection_decoration_width 

3727 ) 

3728 

3729 right = option.comment or "" 

3730 right_width = _line_width(right) 

3731 right_decoration_width = 3 if right else 0 

3732 

3733 total_width = ( 

3734 left_decoration_width + left_width + right_decoration_width + right_width 

3735 ) 

3736 

3737 if total_width > width: 

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

3739 if right_width == 0: 

3740 right = "" 

3741 right_decoration_width = 0 

3742 total_width = ( 

3743 left_decoration_width 

3744 + left_width 

3745 + right_decoration_width 

3746 + right_width 

3747 ) 

3748 

3749 if total_width > width: 

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

3751 total_width = left_decoration_width + left_width 

3752 

3753 if is_active: 

3754 status_tag = "active" 

3755 else: 

3756 status_tag = "normal" 

3757 

3758 if option.selected: 

3759 color_tag = "selected" 

3760 else: 

3761 color_tag = option.color_tag 

3762 

3763 if is_active: 

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

3765 rc.write(active_item_decoration) 

3766 else: 

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

3768 rc.write(" " * active_item_decoration_width) 

3769 

3770 if option.selected: 

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

3772 rc.write(selected_item_decoration) 

3773 rc.write( 

3774 " " * (item_selection_decoration_width - selected_item_decoration_width) 

3775 ) 

3776 else: 

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

3778 rc.write(deselected_item_decoration) 

3779 rc.write( 

3780 " " 

3781 * (item_selection_decoration_width - deselected_item_decoration_width) 

3782 ) 

3783 

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

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

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

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

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

3789 rc.write( 

3790 option.display_text_suffix, 

3791 max_width=left_width - left_prefix_width - left_main_width, 

3792 ) 

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

3794 rc.write( 

3795 " " 

3796 * ( 

3797 width 

3798 - left_decoration_width 

3799 - left_width 

3800 - right_decoration_width 

3801 - right_width 

3802 ) 

3803 ) 

3804 

3805 if right: 

3806 rc.set_color_path( 

3807 f"menu/decoration/comment:choice/{status_tag}/{color_tag}" 

3808 ) 

3809 rc.write(" [") 

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

3811 rc.write(right, max_width=right_width) 

3812 rc.set_color_path( 

3813 f"menu/decoration/comment:choice/{status_tag}/{color_tag}" 

3814 ) 

3815 rc.write("]") 

3816 

3817 @property 

3818 def help_data(self) -> WidgetHelp: 

3819 return super().help_data.with_action( 

3820 "1..9", 

3821 "a..z", 

3822 long_msg="quick select", 

3823 ) 

3824 

3825 

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

3827 """ 

3828 Allows choosing from pre-defined options. 

3829 

3830 .. vhs:: /_tapes/widget_choice.tape 

3831 :alt: Demonstration of `Choice` widget. 

3832 :scale: 40% 

3833 

3834 :param options: 

3835 list of choice options. 

3836 :param mapper: 

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

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

3839 if a custom `filter` is given. 

3840 :param filter: 

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

3842 from an option using the `mapper`, and checks if it starts with the search 

3843 query. 

3844 :param default_index: 

3845 index of the initially selected option. 

3846 

3847 """ 

3848 

3849 @_t.overload 

3850 def __init__( 

3851 self, 

3852 options: list[Option[T]], 

3853 /, 

3854 *, 

3855 mapper: _t.Callable[[Option[T]], str] = lambda x: ( 

3856 x.display_text or str(x.value) 

3857 ), 

3858 default_index: int = 0, 

3859 search_bar_decoration_path: str = "menu/input/decoration_search", 

3860 active_item_decoration_path: str = "menu/choice/decoration/active_item", 

3861 ): ... 

3862 

3863 @_t.overload 

3864 def __init__( 

3865 self, 

3866 options: list[Option[T]], 

3867 /, 

3868 *, 

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

3870 default_index: int = 0, 

3871 search_bar_decoration_path: str = "menu/input/decoration_search", 

3872 active_item_decoration_path: str = "menu/choice/decoration/active_item", 

3873 ): ... 

3874 

3875 def __init__( 

3876 self, 

3877 options: list[Option[T]], 

3878 /, 

3879 *, 

3880 mapper: _t.Callable[[Option[T]], str] = lambda x: x.display_text 

3881 or str(x.value), 

3882 filter: _t.Callable[[Option[T], str], bool] | None = None, 

3883 default_index: int = 0, 

3884 search_bar_decoration_path: str = "menu/input/decoration_search", 

3885 active_item_decoration_path: str = "menu/choice/decoration/active_item", 

3886 ): 

3887 self.__options = options 

3888 

3889 if filter is None: 

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

3891 

3892 self.__filter = filter 

3893 

3894 self.__default_index = default_index 

3895 

3896 self.__input = Input( 

3897 placeholder="Filter options...", decoration_path=search_bar_decoration_path 

3898 ) 

3899 self.__grid = Grid[T]( 

3900 [], active_item_decoration_path=active_item_decoration_path 

3901 ) 

3902 

3903 self.__enable_search = False 

3904 

3905 self.__layout: VerticalLayout[_t.Never] 

3906 

3907 self.__update_completion() 

3908 

3909 @bind("/") 

3910 def search(self): 

3911 """search""" 

3912 if not self.__enable_search: 

3913 self.__enable_search = True 

3914 else: 

3915 self.__input.event(KeyboardEvent("/")) 

3916 self.__update_completion() 

3917 

3918 @bind(Key.ENTER) 

3919 @bind(Key.ENTER, alt=True, show_in_detailed_help=False) 

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

3921 def enter(self) -> Result[T] | None: 

3922 """select""" 

3923 option = self.__grid.get_option() 

3924 if option is not None: 

3925 return Result(option.value) 

3926 else: 

3927 self._bell() 

3928 

3929 @bind(Key.ESCAPE) 

3930 def esc(self): 

3931 self.__input.text = "" 

3932 self.__update_completion() 

3933 self.__enable_search = False 

3934 

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

3936 if not self.__enable_search and e == KeyboardEvent(" "): 

3937 return self.enter() 

3938 if not self.__enable_search or e.key in ( 

3939 Key.ARROW_UP, 

3940 Key.ARROW_DOWN, 

3941 Key.TAB, 

3942 Key.ARROW_LEFT, 

3943 Key.ARROW_RIGHT, 

3944 Key.PAGE_DOWN, 

3945 Key.PAGE_UP, 

3946 Key.HOME, 

3947 Key.END, 

3948 ): 

3949 self.__grid.event(e) 

3950 elif e == KeyboardEvent(Key.BACKSPACE) and not self.__input.text: 

3951 self.__enable_search = False 

3952 else: 

3953 self.__input.event(e) 

3954 self.__update_completion() 

3955 

3956 def __update_completion(self): 

3957 query = self.__input.text 

3958 

3959 index = 0 

3960 options = [] 

3961 cur_option = self.__grid.get_option() 

3962 for i, option in enumerate(self.__options): 

3963 if not query or self.__filter(option, query): 

3964 if option is cur_option or ( 

3965 cur_option is None and i == self.__default_index 

3966 ): 

3967 index = len(options) 

3968 options.append(option) 

3969 

3970 self.__grid.set_options(options) 

3971 self.__grid.index = index 

3972 

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

3974 self.__layout = VerticalLayout() 

3975 self.__layout.append(self.__grid) 

3976 

3977 if self.__enable_search: 

3978 self.__layout.append(self.__input) 

3979 

3980 return self.__layout.layout(rc) 

3981 

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

3983 self.__layout.draw(rc) 

3984 

3985 @property 

3986 def help_data(self) -> WidgetHelp: 

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

3988 

3989 

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

3991 """ 

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

3993 

3994 .. vhs:: /_tapes/widget_multiselect.tape 

3995 :alt: Demonstration of `Multiselect` widget. 

3996 :scale: 40% 

3997 

3998 :param options: 

3999 list of choice options. 

4000 :param mapper: 

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

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

4003 if a custom `filter` is given. 

4004 :param filter: 

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

4006 from an option using the `mapper`, and checks if it starts with the search 

4007 query. 

4008 :param default_index: 

4009 index of the initially selected option. 

4010 

4011 """ 

4012 

4013 @_t.overload 

4014 def __init__( 

4015 self, 

4016 options: list[Option[T]], 

4017 /, 

4018 *, 

4019 mapper: _t.Callable[[Option[T]], str] = lambda x: x.display_text 

4020 or str(x.value), 

4021 ): ... 

4022 

4023 @_t.overload 

4024 def __init__( 

4025 self, 

4026 options: list[Option[T]], 

4027 /, 

4028 *, 

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

4030 ): ... 

4031 

4032 def __init__( 

4033 self, 

4034 options: list[Option[T]], 

4035 /, 

4036 *, 

4037 mapper: _t.Callable[[Option[T]], str] = lambda x: x.display_text 

4038 or str(x.value), 

4039 filter: _t.Callable[[Option[T], str], bool] | None = None, 

4040 search_bar_decoration_path: str = "menu/input/decoration_search", 

4041 active_item_decoration_path: str = "menu/choice/decoration/active_item", 

4042 selected_item_decoration_path: str = "menu/choice/decoration/selected_item", 

4043 deselected_item_decoration_path: str = "menu/choice/decoration/deselected_item", 

4044 ): 

4045 self.__options = options 

4046 

4047 if filter is None: 

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

4049 

4050 self.__filter = filter 

4051 

4052 self.__input = Input( 

4053 placeholder="Filter options...", decoration_path=search_bar_decoration_path 

4054 ) 

4055 self.__grid = Grid[tuple[T, bool]]( 

4056 [], 

4057 active_item_decoration_path=active_item_decoration_path, 

4058 selected_item_decoration_path=selected_item_decoration_path, 

4059 deselected_item_decoration_path=deselected_item_decoration_path, 

4060 ) 

4061 

4062 self.__enable_search = False 

4063 

4064 self.__layout: VerticalLayout[_t.Never] 

4065 

4066 self.__update_completion() 

4067 

4068 @bind(Key.ENTER) 

4069 @bind(" ") 

4070 def select(self): 

4071 """select""" 

4072 if self.__enable_search and self._cur_event == KeyboardEvent(" "): 

4073 self.__input.event(KeyboardEvent(" ")) 

4074 self.__update_completion() 

4075 return 

4076 option = self.__grid.get_option() 

4077 if option is not None: 

4078 option.selected = not option.selected 

4079 self.__update_completion() 

4080 

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

4082 @bind("d", ctrl=True, show_in_inline_help=True) 

4083 def enter(self) -> Result[list[T]] | None: 

4084 """accept""" 

4085 return Result([option.value for option in self.__options if option.selected]) 

4086 

4087 @bind("/") 

4088 def search(self): 

4089 """search""" 

4090 if not self.__enable_search: 

4091 self.__enable_search = True 

4092 else: 

4093 self.__input.event(KeyboardEvent("/")) 

4094 self.__update_completion() 

4095 

4096 @bind(Key.ESCAPE) 

4097 def esc(self): 

4098 """exit search""" 

4099 self.__input.text = "" 

4100 self.__update_completion() 

4101 self.__enable_search = False 

4102 

4103 def default_event_handler(self, e: KeyboardEvent) -> Result[list[T]] | None: 

4104 if not self.__enable_search or e.key in ( 

4105 Key.ARROW_UP, 

4106 Key.ARROW_DOWN, 

4107 Key.TAB, 

4108 Key.ARROW_LEFT, 

4109 Key.ARROW_RIGHT, 

4110 Key.PAGE_DOWN, 

4111 Key.PAGE_UP, 

4112 Key.HOME, 

4113 Key.END, 

4114 ): 

4115 self.__grid.event(e) 

4116 elif e == KeyboardEvent(Key.BACKSPACE) and not self.__input.text: 

4117 self.__enable_search = False 

4118 else: 

4119 self.__input.event(e) 

4120 self.__update_completion() 

4121 

4122 def __update_completion(self): 

4123 query = self.__input.text 

4124 

4125 index = 0 

4126 options = [] 

4127 cur_option = self.__grid.get_option() 

4128 for option in self.__options: 

4129 if not query or self.__filter(option, query): 

4130 if option is cur_option: 

4131 index = len(options) 

4132 options.append(option) 

4133 

4134 self.__grid.set_options(options) 

4135 self.__grid.index = index 

4136 

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

4138 self.__layout = VerticalLayout() 

4139 self.__layout.append(self.__grid) 

4140 

4141 if self.__enable_search: 

4142 self.__layout.append(self.__input) 

4143 

4144 return self.__layout.layout(rc) 

4145 

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

4147 self.__layout.draw(rc) 

4148 

4149 @property 

4150 def help_data(self) -> WidgetHelp: 

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

4152 

4153 

4154class InputWithCompletion(Widget[str]): 

4155 """ 

4156 An input box with tab completion. 

4157 

4158 .. vhs:: /_tapes/widget_completion.tape 

4159 :alt: Demonstration of `InputWithCompletion` widget. 

4160 :scale: 40% 

4161 

4162 """ 

4163 

4164 def __init__( 

4165 self, 

4166 completer: yuio.complete.Completer, 

4167 /, 

4168 *, 

4169 placeholder: str = "", 

4170 decoration_path: str = "menu/input/decoration", 

4171 active_item_decoration_path: str = "menu/choice/decoration/active_item", 

4172 ): 

4173 self.__completer = completer 

4174 

4175 self.__input = Input(placeholder=placeholder, decoration_path=decoration_path) 

4176 self.__grid = Grid[yuio.complete.Completion]( 

4177 [], active_item_decoration_path=active_item_decoration_path, min_rows=None 

4178 ) 

4179 self.__grid_active = False 

4180 

4181 self.__layout: VerticalLayout[_t.Never] 

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

4183 

4184 @property 

4185 def text(self) -> str: 

4186 """ 

4187 Current text in the input box. 

4188 

4189 """ 

4190 

4191 return self.__input.text 

4192 

4193 @property 

4194 def pos(self) -> int: 

4195 """ 

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

4197 

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

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

4200 

4201 """ 

4202 

4203 return self.__input.pos 

4204 

4205 @property 

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

4207 return self.__input.err_region 

4208 

4209 @err_region.setter 

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

4211 self.__input.err_region = err_region 

4212 

4213 @bind(Key.ENTER) 

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

4215 @help(inline_msg="accept") 

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

4217 """accept / select completion""" 

4218 if self.__grid_active and (option := self.__grid.get_option()): 

4219 self._set_input_state_from_completion(option.value) 

4220 self._deactivate_completion() 

4221 else: 

4222 self._drop_rsuffix() 

4223 return Result(self.__input.text) 

4224 

4225 @bind(Key.TAB) 

4226 def tab(self): 

4227 """autocomplete""" 

4228 if self.__grid_active: 

4229 self.__grid.next_item() 

4230 if option := self.__grid.get_option(): 

4231 self._set_input_state_from_completion(option.value) 

4232 return 

4233 

4234 completion = self.__completer.complete(self.__input.text, self.__input.pos) 

4235 if len(completion) == 1: 

4236 self.__input.checkpoint() 

4237 self._set_input_state_from_completion(completion[0]) 

4238 elif completion: 

4239 self.__input.checkpoint() 

4240 self.__grid.set_options( 

4241 [ 

4242 Option( 

4243 c, 

4244 c.completion, 

4245 display_text_prefix=c.dprefix, 

4246 display_text_suffix=c.dsuffix, 

4247 comment=c.comment, 

4248 color_tag=c.group_color_tag, 

4249 ) 

4250 for c in completion 

4251 ], 

4252 default_index=None, 

4253 ) 

4254 self._activate_completion() 

4255 else: 

4256 self._bell() 

4257 

4258 @bind(Key.ESCAPE) 

4259 def escape(self): 

4260 """close autocomplete""" 

4261 self._drop_rsuffix() 

4262 if self.__grid_active: 

4263 self.__input.restore_checkpoint() 

4264 self._deactivate_completion() 

4265 

4266 def default_event_handler(self, e: KeyboardEvent): 

4267 if self.__grid_active and e.key in ( 

4268 Key.ARROW_UP, 

4269 Key.ARROW_DOWN, 

4270 Key.TAB, 

4271 Key.PAGE_UP, 

4272 Key.PAGE_DOWN, 

4273 Key.HOME, 

4274 Key.END, 

4275 ): 

4276 self._dispatch_completion_event(e) 

4277 elif ( 

4278 self.__grid_active 

4279 and self.__grid.index is not None 

4280 and e.key in (Key.ARROW_RIGHT, Key.ARROW_LEFT) 

4281 ): 

4282 self._dispatch_completion_event(e) 

4283 else: 

4284 self._dispatch_input_event(e) 

4285 

4286 def _activate_completion(self): 

4287 self.__grid_active = True 

4288 

4289 def _deactivate_completion(self): 

4290 self.__grid_active = False 

4291 

4292 def _set_input_state_from_completion( 

4293 self, completion: yuio.complete.Completion, set_rsuffix: bool = True 

4294 ): 

4295 prefix = completion.iprefix + completion.completion 

4296 if set_rsuffix: 

4297 prefix += completion.rsuffix 

4298 self.__rsuffix = completion 

4299 else: 

4300 self.__rsuffix = None 

4301 self.__input.text = prefix + completion.isuffix 

4302 self.__input.pos = len(prefix) 

4303 

4304 def _dispatch_completion_event(self, e: KeyboardEvent): 

4305 self.__rsuffix = None 

4306 self.__grid.event(e) 

4307 if option := self.__grid.get_option(): 

4308 self._set_input_state_from_completion(option.value) 

4309 

4310 def _dispatch_input_event(self, e: KeyboardEvent): 

4311 if self.__rsuffix: 

4312 # We need to drop current rsuffix in some cases: 

4313 if (not e.ctrl and not e.alt and isinstance(e.key, str)) or ( 

4314 e.key is Key.PASTE and e.paste_str 

4315 ): 

4316 text = e.key if e.key is not Key.PASTE else e.paste_str 

4317 # When user prints something... 

4318 if text and text[0] in self.__rsuffix.rsymbols: 

4319 # ...that is in `rsymbols`... 

4320 self._drop_rsuffix() 

4321 elif e in [ 

4322 KeyboardEvent(Key.ARROW_UP), 

4323 KeyboardEvent(Key.ARROW_DOWN), 

4324 KeyboardEvent(Key.ARROW_LEFT), 

4325 KeyboardEvent("b", ctrl=True), 

4326 KeyboardEvent(Key.ARROW_RIGHT), 

4327 KeyboardEvent("f", ctrl=True), 

4328 KeyboardEvent(Key.ARROW_LEFT, alt=True), 

4329 KeyboardEvent("b", alt=True), 

4330 KeyboardEvent(Key.ARROW_RIGHT, alt=True), 

4331 KeyboardEvent("f", alt=True), 

4332 KeyboardEvent(Key.HOME), 

4333 KeyboardEvent("a", ctrl=True), 

4334 KeyboardEvent(Key.END), 

4335 KeyboardEvent("e", ctrl=True), 

4336 ]: 

4337 # ...or when user moves cursor. 

4338 self._drop_rsuffix() 

4339 self.__rsuffix = None 

4340 self.__input.event(e) 

4341 self._deactivate_completion() 

4342 

4343 def _drop_rsuffix(self): 

4344 if self.__rsuffix: 

4345 rsuffix = self.__rsuffix.rsuffix 

4346 if self.__input.text[: self.__input.pos].endswith(rsuffix): 

4347 self._set_input_state_from_completion(self.__rsuffix, set_rsuffix=False) 

4348 

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

4350 self.__layout = VerticalLayout() 

4351 self.__layout.append(self.__input) 

4352 if self.__grid_active: 

4353 self.__layout.append(self.__grid) 

4354 return self.__layout.layout(rc) 

4355 

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

4357 self.__layout.draw(rc) 

4358 

4359 @property 

4360 def help_data(self) -> WidgetHelp: 

4361 return ( 

4362 (super().help_data) 

4363 .merge( 

4364 (self.__grid.help_data) 

4365 .without_group("Actions") 

4366 .rename_group(Grid._NAVIGATE, "Navigate Completions") 

4367 ) 

4368 .merge( 

4369 (self.__input.help_data) 

4370 .without_group("Actions") 

4371 .rename_group(Input._NAVIGATE, "Navigate Input") 

4372 .rename_group(Input._MODIFY, "Modify Input") 

4373 ) 

4374 ) 

4375 

4376 

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

4378 """ 

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

4380 

4381 .. 

4382 >>> class Input(Widget): 

4383 ... def event(self, e): 

4384 ... return Result("10") 

4385 ... 

4386 ... def layout(self, rc): 

4387 ... return 0, 0 

4388 ... 

4389 ... def draw(self, rc): 

4390 ... pass 

4391 >>> class Map(Map): 

4392 ... def run(self, term, theme): 

4393 ... return self.event(None).value 

4394 >>> term, theme = None, None 

4395 

4396 Example:: 

4397 

4398 >>> # Run `Input` widget, then parse user input as `int`. 

4399 >>> int_input = Map(Input(), int) 

4400 >>> int_input.run(term, theme) 

4401 10 

4402 

4403 """ 

4404 

4405 def __init__(self, inner: Widget[U], fn: _t.Callable[[U], T], /): 

4406 self._inner = inner 

4407 self._fn = fn 

4408 

4409 def event(self, e: KeyboardEvent, /) -> Result[T] | None: 

4410 if result := self._inner.event(e): 

4411 return Result(self._fn(result.value)) 

4412 

4413 def layout(self, rc: RenderContext, /) -> tuple[int, int]: 

4414 return self._inner.layout(rc) 

4415 

4416 def draw(self, rc: RenderContext, /): 

4417 self._inner.draw(rc) 

4418 

4419 @property 

4420 def help_data(self) -> WidgetHelp: 

4421 return self._inner.help_data 

4422 

4423 

4424class Apply(Map[T, T], _t.Generic[T]): 

4425 """ 

4426 A wrapper that applies the given function to the result of a wrapped widget. 

4427 

4428 .. 

4429 >>> class Input(Widget): 

4430 ... def event(self, e): 

4431 ... return Result("foobar!") 

4432 ... 

4433 ... def layout(self, rc): 

4434 ... return 0, 0 

4435 ... 

4436 ... def draw(self, rc): 

4437 ... pass 

4438 >>> class Apply(Apply): 

4439 ... def run(self, term, theme): 

4440 ... return self.event(None).value 

4441 >>> term, theme = None, None 

4442 

4443 Example:: 

4444 

4445 >>> # Run `Input` widget, then print its output before returning 

4446 >>> print_output = Apply(Input(), print) 

4447 >>> result = print_output.run(term, theme) 

4448 foobar! 

4449 >>> result 

4450 'foobar!' 

4451 

4452 """ 

4453 

4454 def __init__(self, inner: Widget[T], fn: _t.Callable[[T], None], /): 

4455 def mapper(x: T) -> T: 

4456 fn(x) 

4457 return x 

4458 

4459 super().__init__(inner, mapper) 

4460 

4461 

4462@dataclass(slots=True) 

4463class _EventStreamState: 

4464 ostream: _t.TextIO 

4465 istream: _t.TextIO 

4466 key: str = "" 

4467 index: int = 0 

4468 

4469 def load(self): 

4470 key = "" 

4471 while not key: 

4472 key = yuio.term._read_keycode(self.ostream, self.istream) 

4473 self.key = key 

4474 self.index = 0 

4475 

4476 def next(self): 

4477 ch = self.peek() 

4478 self.index += 1 

4479 return ch 

4480 

4481 def peek(self): 

4482 if self.index >= len(self.key): 

4483 return "" 

4484 else: 

4485 return self.key[self.index] 

4486 

4487 def tail(self): 

4488 return self.key[self.index :] 

4489 

4490 

4491def _event_stream(ostream: _t.TextIO, istream: _t.TextIO) -> _t.Iterator[KeyboardEvent]: 

4492 # Implementation is heavily inspired by libtermkey by Paul Evans, MIT license, 

4493 # with some additions for modern protocols. 

4494 # See https://sw.kovidgoyal.net/kitty/keyboard-protocol/. 

4495 

4496 state = _EventStreamState(ostream, istream) 

4497 while True: 

4498 ch = state.next() 

4499 if not ch: 

4500 state.load() 

4501 ch = state.next() 

4502 if ch == "\x1b": 

4503 alt = False 

4504 ch = state.next() 

4505 while ch == "\x1b": 

4506 alt = True 

4507 ch = state.next() 

4508 if not ch: 

4509 yield KeyboardEvent(Key.ESCAPE, alt=alt) 

4510 elif ch == "[": 

4511 yield from _parse_csi(state, alt) 

4512 elif ch in "N]": 

4513 _parse_dcs(state) 

4514 elif ch == "O": 

4515 yield from _parse_ss3(state, alt) 

4516 else: 

4517 yield from _parse_char(ch, alt=True) 

4518 elif ch == "\x9b": 

4519 # CSI 

4520 yield from _parse_csi(state, False) 

4521 elif ch in "\x90\x9d": 

4522 # DCS or SS2 

4523 _parse_dcs(state) 

4524 elif ch == "\x8f": 

4525 # SS3 

4526 yield from _parse_ss3(state, False) 

4527 else: 

4528 # Char 

4529 yield from _parse_char(ch) 

4530 

4531 

4532def _parse_ss3(state: _EventStreamState, alt: bool = False): 

4533 ch = state.next() 

4534 if not ch: 

4535 yield KeyboardEvent("O", alt=True) 

4536 else: 

4537 yield from _parse_ss3_key(ch, alt=alt) 

4538 

4539 

4540def _parse_dcs(state: _EventStreamState): 

4541 while True: 

4542 ch = state.next() 

4543 if ch == "\x9c": 

4544 break 

4545 elif ch == "\x1b" and state.peek() == "\\": 

4546 state.next() 

4547 break 

4548 elif not ch: 

4549 state.load() 

4550 

4551 

4552def _parse_csi(state: _EventStreamState, alt: bool = False): 

4553 buffer = "" 

4554 while state.peek() and not (0x40 <= ord(state.peek()) <= 0x80): 

4555 buffer += state.next() 

4556 cmd = state.next() 

4557 if not cmd: 

4558 yield KeyboardEvent("[", alt=True) 

4559 return 

4560 if buffer.startswith(("?", "<", ">", "=")): 

4561 # Some command response, ignore. 

4562 return # pragma: no cover 

4563 args = buffer.split(";") 

4564 

4565 shift = ctrl = False 

4566 if len(args) > 1: 

4567 try: 

4568 modifiers = int(args[1]) - 1 

4569 except ValueError: # pragma: no cover 

4570 pass 

4571 else: 

4572 shift = bool(modifiers & 1) 

4573 alt |= bool(modifiers & 2) 

4574 ctrl = bool(modifiers & 4) 

4575 

4576 if cmd == "~": 

4577 if args[0] == "27": 

4578 try: 

4579 ch = chr(int(args[2])) 

4580 except (ValueError, KeyError): # pragma: no cover 

4581 pass 

4582 else: 

4583 yield from _parse_char(ch, ctrl=ctrl, alt=alt, shift=shift) 

4584 elif args[0] == "200": 

4585 yield KeyboardEvent(Key.PASTE, paste_str=_read_pasted_content(state)) 

4586 elif key := _CSI_CODES.get(args[0]): 

4587 yield KeyboardEvent(key, ctrl=ctrl, alt=alt, shift=shift) 

4588 elif cmd == "u": 

4589 try: 

4590 ch = chr(int(args[0])) 

4591 except ValueError: # pragma: no cover 

4592 pass 

4593 else: 

4594 yield from _parse_char(ch, ctrl=ctrl, alt=alt, shift=shift) 

4595 elif cmd in "mMyR": 

4596 # Some command response, ignore. 

4597 pass # pragma: no cover 

4598 else: 

4599 yield from _parse_ss3_key(cmd, ctrl=ctrl, alt=alt, shift=shift) 

4600 

4601 

4602def _parse_ss3_key( 

4603 cmd: str, ctrl: bool = False, alt: bool = False, shift: bool = False 

4604): 

4605 if key := _SS3_CODES.get(cmd): 

4606 if cmd == "Z": 

4607 shift = True 

4608 yield KeyboardEvent(key, ctrl=ctrl, alt=alt, shift=shift) 

4609 

4610 

4611_SS3_CODES = { 

4612 "A": Key.ARROW_UP, 

4613 "B": Key.ARROW_DOWN, 

4614 "C": Key.ARROW_RIGHT, 

4615 "D": Key.ARROW_LEFT, 

4616 "E": Key.HOME, 

4617 "F": Key.END, 

4618 "H": Key.HOME, 

4619 "Z": Key.TAB, 

4620 "P": Key.F1, 

4621 "Q": Key.F2, 

4622 "R": Key.F3, 

4623 "S": Key.F4, 

4624 "M": Key.ENTER, 

4625 " ": " ", 

4626 "I": Key.TAB, 

4627 "X": "=", 

4628 "j": "*", 

4629 "k": "+", 

4630 "l": ",", 

4631 "m": "-", 

4632 "n": ".", 

4633 "o": "/", 

4634 "p": "0", 

4635 "q": "1", 

4636 "r": "2", 

4637 "s": "3", 

4638 "t": "4", 

4639 "u": "5", 

4640 "v": "6", 

4641 "w": "7", 

4642 "x": "8", 

4643 "y": "9", 

4644} 

4645 

4646 

4647_CSI_CODES = { 

4648 "1": Key.HOME, 

4649 "2": Key.INSERT, 

4650 "3": Key.DELETE, 

4651 "4": Key.END, 

4652 "5": Key.PAGE_UP, 

4653 "6": Key.PAGE_DOWN, 

4654 "7": Key.HOME, 

4655 "8": Key.END, 

4656 "11": Key.F1, 

4657 "12": Key.F2, 

4658 "13": Key.F3, 

4659 "14": Key.F4, 

4660 "15": Key.F5, 

4661 "17": Key.F6, 

4662 "18": Key.F7, 

4663 "19": Key.F8, 

4664 "20": Key.F9, 

4665 "21": Key.F10, 

4666 "23": Key.F11, 

4667 "24": Key.F12, 

4668 "200": Key.PASTE, 

4669} 

4670 

4671 

4672def _parse_char( 

4673 ch: str, ctrl: bool = False, alt: bool = False, shift: bool = False 

4674) -> _t.Iterable[KeyboardEvent]: 

4675 if ch == "\t": 

4676 yield KeyboardEvent(Key.TAB, ctrl, alt, shift) 

4677 elif ch in "\r\n": 

4678 yield KeyboardEvent(Key.ENTER, ctrl, alt, shift) 

4679 elif ch == "\x08": 

4680 yield KeyboardEvent(Key.BACKSPACE, ctrl, alt, shift) 

4681 elif ch == "\x1b": 

4682 yield KeyboardEvent(Key.ESCAPE, ctrl, alt, shift) 

4683 elif ch == "\x7f": 

4684 yield KeyboardEvent(Key.BACKSPACE, ctrl, alt, shift) 

4685 elif "\x00" <= ch <= "\x1a": 

4686 yield KeyboardEvent(chr(ord(ch) + ord("a") - 0x01), True, alt, shift) 

4687 elif "\x1c" <= ch <= "\x1f": 

4688 yield KeyboardEvent(chr(ord(ch) + ord("4") - 0x1C), True, alt, shift) 

4689 elif ch in string.printable or ord(ch) >= 160: 

4690 yield KeyboardEvent(ch, ctrl, alt, shift) 

4691 

4692 

4693def _read_pasted_content(state: _EventStreamState) -> str: 

4694 buf = "" 

4695 while True: 

4696 index = state.tail().find("\x1b[201~") 

4697 if index == -1: 

4698 buf += state.tail() 

4699 else: 

4700 buf += state.tail()[:index] 

4701 state.index += index 

4702 return buf 

4703 state.load()