Coverage for yuio / theme.py: 98%

352 statements  

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

1# Yuio project, MIT license. 

2# 

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

4# 

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

6# just keep this copyright line please :3 

7 

8""" 

9Controlling visual aspects of Yuio with themes. 

10 

11Theme base class 

12---------------- 

13 

14The overall look and feel of a Yuio application is declared 

15in a :class:`Theme` object: 

16 

17.. autoclass:: Theme 

18 

19 .. autoattribute:: progress_bar_width 

20 

21 .. autoattribute:: spinner_update_rate_ms 

22 

23 .. autoattribute:: msg_decorations 

24 

25 .. automethod:: set_msg_decoration 

26 

27 .. automethod:: _set_msg_decoration_if_not_overridden 

28 

29 .. autoattribute:: colors 

30 

31 .. automethod:: set_color 

32 

33 .. automethod:: _set_color_if_not_overridden 

34 

35 .. automethod:: get_color 

36 

37 .. automethod:: to_color 

38 

39 .. automethod:: check 

40 

41 

42Default theme 

43------------- 

44 

45Use the following loader to create an instance of the default theme: 

46 

47.. autofunction:: load 

48 

49.. autoclass:: DefaultTheme 

50 

51 

52.. _all-color-paths: 

53 

54Color paths 

55----------- 

56 

57common tags 

58 :class:`DefaultTheme` sets up commonly used colors that you can use 

59 in formatted messages: 

60 

61 - ``code``: inline code, 

62 - ``note``: inline highlighting, 

63 - ``path``: file paths, 

64 - ``flag``: CLI flags, 

65 - ``bold``, ``b``: font style, 

66 - ``dim``, ``d``: font style, 

67 - ``italic``, ``i``: font style, 

68 - ``underline``, ``u``: font style, 

69 - ``inverse``: swap foreground and background colors, 

70 - ``normal``: normal foreground, 

71 - ``normal_dim``: muted foreground (see :attr:`~yuio.color.Color.FORE_NORMAL_DIM`), 

72 - ``red``: foreground, 

73 - ``green``: foreground, 

74 - ``yellow``: foreground, 

75 - ``blue``: foreground, 

76 - ``magenta``: foreground, 

77 - ``cyan``: foreground, 

78 

79 .. note:: 

80 

81 We don't define ``black`` and ``white`` because they can be invisible 

82 with some terminal themes. Prefer ``normal_dim`` when you need a muted color. 

83 

84 We also don't define tags for backgrounds because there's no way to tell 

85 which foreground/background combination will be readable and which will not. 

86 Prefer ``inverse`` when you need to add a background. 

87 

88:samp:`msg/decoration:{tag}` 

89 Color for decorations in front of messages: 

90 

91 - ``msg/decoration:info``: messages from :mod:`yuio.io.info`, 

92 - ``msg/decoration:warning``: messages from :mod:`yuio.io.warning`, 

93 - ``msg/decoration:error``: messages from :mod:`yuio.io.error`, 

94 - ``msg/decoration:success``: messages from :mod:`yuio.io.success`, 

95 - ``msg/decoration:failure``: messages from :mod:`yuio.io.failure`, 

96 - :samp:`msg/decoration:heading/{level}`: messages from :mod:`yuio.io.heading` 

97 and headings in markdown, 

98 - ``msg/decoration:heading/section``: first-level headings in CLI help, 

99 - ``msg/decoration:question``: messages from :func:`yuio.io.ask`, 

100 - ``msg/decoration:list``: bullets in markdown, 

101 - ``msg/decoration:quote``: quote decorations in markdown, 

102 - ``msg/decoration:code``: code decorations in markdown, 

103 - ``msg/decoration:thematic_break``: thematic breaks 

104 (i.e. horizontal rulers) in markdown, 

105 - :samp:`msg/decoration:hr/{weight}`: horizontal rulers (see :func:`yuio.io.hr` 

106 and :func:`yuio.string.Hr`). 

107 

108:samp:`msg/text:{tag}` 

109 Color for the text part of messages: 

110 

111 - ``msg/text:info`` and all other tags from ``msg/decoration``, 

112 - ``msg/text:paragraph``: plain text in markdown, 

113 - :samp:`msg/text:code/{syntax}`: plain text in highlighted code blocks. 

114 

115:samp:`task/...:{status}` 

116 Running and finished tasks: 

117 

118 - :samp:`task/decoration`: decoration before the task, 

119 - ``task/progressbar/done``: filled portion of the progress bar, 

120 - ``task/progressbar/done/start``: gradient start for the filled 

121 portion of the progress bar, 

122 - ``task/progressbar/done/end``: gradient end for the filled 

123 portion of the progress bar, 

124 - ``task/progressbar/pending``: unfilled portion of the progress bar, 

125 - ``task/progressbar/pending/start``: gradient start for the unfilled 

126 portion of the progress bar, 

127 - ``task/progressbar/pending/end``: gradient end for the unfilled 

128 portion of the progress bar, 

129 - ``task/heading``: task title, 

130 - ``task/progress``: number that indicates task progress, 

131 - ``task/comment``: task comment. 

132 

133 ``status`` can be ``running``, ``done``, or ``error``. 

134 

135:samp:`hl/{part}:{syntax}` 

136 Color for highlighted part of code: 

137 

138 - ``hl/comment``: code comments, 

139 - ``hl/kwd``: keyword, 

140 - ``hl/lit``: non-string literals, 

141 - ``hl/punct``: punctuation, 

142 - ``hl/str``: string literals, 

143 - ``hl/str/esc``: escape sequences in strings, 

144 - ``hl/type``: type names, 

145 - ``hl/meta``: diff meta info for diff highlighting, 

146 - ``hl/added``: added lines in diff highlighting, 

147 - ``hl/removed``: removed lines in diff highlighting, 

148 - ``hl/prog``: program name in CLI usage and shell highlighting, 

149 - ``hl/flag``: CLI flags, 

150 - ``hl/metavar``: meta variables in CLI usage. 

151 

152``tb/heading``, ``tb/message``, :samp:`tb/frame/{location}/...` 

153 For highlighted tracebacks: 

154 

155 - ``tb/heading``: traceback heading, 

156 - ``tb/message``: error message, 

157 - :samp:`tb/frame/{location}/file/module`: module name, 

158 - :samp:`tb/frame/{location}/file/line`: line number, 

159 - :samp:`tb/frame/{location}/file/path`: file path, 

160 - :samp:`tb/frame/{location}/code`: code sample at the error line, 

161 - :samp:`tb/frame/{location}/highlight`: highlighting under the code sample. 

162 

163 ``location`` is either ``lib`` or ``usr`` depending on whether the code 

164 is located in site-packages or in user code. 

165 

166:samp:`log/{part}:{level}` 

167 Colors for log records. ``part`` is name of a `log record attribute`__, 

168 level is lowercase name of logging level. 

169 

170 __ https://docs.python.org/3/library/logging.html#logrecord-attributes 

171 

172 .. seealso:: 

173 

174 :class:`yuio.io.Formatter`. 

175 

176input widget 

177 Colors for :class:`yuio.widget.Input`: 

178 

179 - ``menu/decoration:input``: decoration before an input box, 

180 - ``menu/text:input``: entered text in an input box, 

181 - ``menu/text/esc:input``: highlights for invisible characters in an input box, 

182 - ``menu/text/placeholder:input``: placeholder text in an input box, 

183 

184grid widgets 

185 Colors for :class:`yuio.widget.Grid`, :class:`yuio.widget.Choice`, and other 

186 similar widgets: 

187 

188 - :samp:`menu/decoration:choice/{status}/{color_tag}`: 

189 decoration before a grid item, 

190 - :samp:`menu/decoration/comment:choice/{status}/{color_tag}`: 

191 decoration around comments for a grid item, 

192 - :samp:`menu/text:choice/{status}/{color_tag}`: 

193 text of a grid item, 

194 - :samp:`menu/text/comment:choice/{status}/{color_tag}`: 

195 comment for a grid item, 

196 - :samp:`menu/text/prefix:choice/{status}/{color_tag}`: 

197 prefix before the main text of a grid item 

198 (see :attr:`yuio.widget.Option.display_text_prefix` and 

199 :attr:`yuio.complete.Completion.dprefix`), 

200 - :samp:`menu/text/suffix:choice/{status}/{color_tag}`: 

201 suffix after the main text of a grid item 

202 (see :attr:`yuio.widget.Option.display_text_suffix` and 

203 :attr:`yuio.complete.Completion.dsuffix`), 

204 - ``menu/text:choice/status_line``: status line (i.e. "Page x of y"). 

205 - ``menu/text:choice/status_line/number``: page numbers in a status line. 

206 

207 ``status`` is either ``normal`` or ``active``: 

208 

209 - ``normal`` for regular grid items, 

210 - ``active`` for the currently selected item. 

211 

212 ``color_tag`` is whatever tag specified by :attr:`yuio.widget.Option.color_tag` 

213 and :attr:`yuio.complete.Completion.group_color_tag`. Currently supported tags: 

214 

215 - ``none``: color tag is not given, 

216 - ``selected``: items selected in :class:`yuio.widget.Multiselect`, 

217 - ``dir``: directory (in file completion), 

218 - ``exec``: executable file (in file completion), 

219 - ``symlink``: symbolic link (in file completion), 

220 - ``socket``: socket (in file completion), 

221 - ``pipe``: FIFO pipe (in file completion), 

222 - ``block_device``: block device (in file completion), 

223 - ``char_device``: character device (in file completion), 

224 - ``original``: original completion item before spelling correction, 

225 - ``corrected``: completion item that was found after spelling correction. 

226 

227full screen help menu 

228 Colors for help menu that appears when pressing :kbd:`F1`: 

229 

230 - ``menu/text/heading:help_menu``: section heading, 

231 - ``menu/text/help_key:help_menu``: key names, 

232 - ``menu/text/help_sep:help_menu``: separators between key names, 

233 - ``menu/decoration:help_menu``: decorations. 

234 

235inline help menu 

236 Colors for help items rendered under a widget: 

237 

238 - ``menu/text/help_info:help``: help items that aren't associated with any key, 

239 - ``menu/text/help_msg:help``: regular help items, 

240 - ``menu/text/help_key:help``: keybinding names, 

241 - ``menu/text/help_sep:help``: separator between items. 

242 

243 

244.. _all-decorations: 

245 

246Decorations 

247----------- 

248 

249``info`` 

250 Messages from :mod:`yuio.io.info`. 

251 

252``warning`` 

253 Messages from :mod:`yuio.io.warning`. 

254 

255``error`` 

256 Messages from :mod:`yuio.io.error`. 

257 

258``success`` 

259 Messages from :mod:`yuio.io.success`. 

260 

261``failure`` 

262 Messages from :mod:`yuio.io.failure`. 

263 

264:samp:`heading/{level}` 

265 Messages from :mod:`yuio.io.heading` and headings in markdown. 

266 

267``heading/section`` 

268 First-level headings in CLI help. 

269 

270``question`` 

271 Messages from :func:`yuio.io.ask`. 

272 

273``list`` 

274 Bullets in markdown. 

275 

276``quote`` 

277 Quote decorations in markdown. 

278 

279``code`` 

280 Code decorations in markdown. 

281 

282``thematic_break`` 

283 Thematic breaks (i.e. horizontal rulers) in markdown. 

284 

285``overflow`` 

286 Ellipsis symbol for lines that don't fit terminal width. Must be one character wide. 

287 

288:samp:`progress_bar/{position}` 

289 Decorations for progress bars. 

290 

291 Available positions are: 

292 

293 :``start_symbol``: 

294 Start of the progress bar. 

295 :``done_symbol``: 

296 Tiles finished portion of the progress bar, must be one character wide. 

297 :``pending_symbol``: 

298 Tiles unfinished portion of the progress bar, must be one character wide. 

299 :``end_symbol``: 

300 End of the progress bar. 

301 :``transition_pattern``: 

302 If this decoration is empty, there's no symbol between finished and unfinished 

303 parts of the progress bar. 

304 

305 Otherwise, this decoration defines a left-to-right gradient of transition 

306 characters, ordered from most filled to least filled. Each character 

307 must be one character wide. 

308 

309 .. raw:: html 

310 

311 <div class="highlight-text notranslate"> 

312 <div class="highlight"> 

313 <pre class="ascii-graphics"> 

314 <span class="k">[------> ]</span> 

315 │└┬───┘│└┬───────┘│ 

316 │ │ │ │ end_symbol 

317 │ │ │ └ pending_symbol 

318 │ │ └ transition_pattern 

319 │ └ done_symbol 

320 └ start_symbol 

321 </pre> 

322 </div> 

323 </div> 

324 

325 **Example:** 

326 

327 To get the classic blocky look, you can do the following: 

328 

329 .. code-block:: python 

330 

331 class BlockProgressTheme(yuio.theme.DefaultTheme): 

332 msg_decorations = { 

333 "progress_bar/start_symbol": "|", 

334 "progress_bar/end_symbol": "|", 

335 "progress_bar/done_symbol": "█", 

336 "progress_bar/pending_symbol": " ", 

337 "progress_bar/transition_pattern": "█▉▊▋▌▍▎▏ ", 

338 } 

339 

340``spinner/pattern`` 

341 Defines a sequence of symbols that will be used to show spinners for tasks 

342 without known progress. Next element of the sequence will be shown 

343 every :attr:`~Theme.spinner_update_rate_ms`. 

344 

345 You can find some pre-made patterns in py-spinners__ package. 

346 

347 __ https://github.com/ManrajGrover/py-spinners?tab=readme-ov-file 

348 

349``spinner/static_symbol`` 

350 Static spinner symbol, for sub-tasks that've finished running but'. 

351 

352:samp:`hr/{weight}/{position}` 

353 Decorations for horizontal rulers (see :func:`yuio.io.hr` 

354 and :func:`yuio.string.Hr`). 

355 

356 Default theme defines three weights: 

357 

358 - ``0`` prints no ruler (but still prints centered text), 

359 - ``1`` prints normal ruler, 

360 - ``2`` prints bold ruler. 

361 

362 Available positions are: 

363 

364 :``left_start``: 

365 Start of the ruler to the left of the message. 

366 :``left_middle``: 

367 Filler of the ruler to the left of the message. 

368 :``left_end``: 

369 End of the ruler to the left of the message. 

370 :``middle``: 

371 Filler of the ruler that's used if ``msg`` is empty. 

372 :``right_start``: 

373 Start of the ruler to the right of the message. 

374 :``right_middle``: 

375 Filler of the ruler to the right of the message. 

376 :``right_end``: 

377 End of the ruler to the right of the message. 

378 

379 .. raw:: html 

380 

381 <div class="highlight-text notranslate"> 

382 <div class="highlight"> 

383 <pre class="ascii-graphics"> 

384 <span class="k"><------>message<------></span> 

385 │└┬───┘│ │└┬───┘│ 

386 │ │ │ │ │ right_end 

387 │ │ │ │ └ right_middle 

388 │ │ │ └ right_start 

389 │ │ └ left_end 

390 │ └ left_middle 

391 └ left_start 

392 

393 <span class="k"><---------------------></span> 

394 │└┬──────────────────┘│ 

395 │ middle right_end 

396 └ left_start 

397 </pre> 

398 </div> 

399 </div> 

400 

401""" 

402 

403from __future__ import annotations 

404 

405import dataclasses 

406import functools 

407import os 

408import pathlib 

409import warnings 

410from dataclasses import dataclass 

411from enum import IntFlag 

412 

413import yuio.color 

414import yuio.term 

415from yuio import _typing as _t 

416 

417__all__ = [ 

418 "DefaultTheme", 

419 "RecursiveThemeWarning", 

420 "TableJunction", 

421 "Theme", 

422 "ThemeWarning", 

423 "load", 

424] 

425 

426K = _t.TypeVar("K") 

427V = _t.TypeVar("V") 

428 

429 

430class ThemeWarning(yuio.YuioWarning): 

431 pass 

432 

433 

434class RecursiveThemeWarning(ThemeWarning): 

435 pass 

436 

437 

438class _ImmutableDictProxy(_t.Mapping[K, V], _t.Generic[K, V]): # pragma: no cover 

439 def __init__(self, data: dict[K, V], /, *, attr: str): 

440 self.__data = data 

441 self.__attr = attr 

442 

443 def items(self) -> _t.ItemsView[K, V]: 

444 return self.__data.items() 

445 

446 def keys(self) -> _t.KeysView[K]: 

447 return self.__data.keys() 

448 

449 def values(self) -> _t.ValuesView[V]: 

450 return self.__data.values() 

451 

452 def __len__(self): 

453 return len(self.__data) 

454 

455 def __getitem__(self, key): 

456 return self.__data[key] 

457 

458 def __iter__(self): 

459 return iter(self.__data) 

460 

461 def __contains__(self, key): 

462 return key in self.__data 

463 

464 def __repr__(self): 

465 return repr(self.__data) 

466 

467 def __setitem__(self, key, item): 

468 raise RuntimeError(f"Theme.{self.__attr} is immutable") 

469 

470 def __delitem__(self, key): 

471 raise RuntimeError(f"Theme.{self.__attr} is immutable") 

472 

473 

474class Theme: 

475 """ 

476 Base class for Yuio themes. 

477 

478 .. warning:: 

479 

480 Do not change theme contents after it was passed to :func:`yuio.io.setup`. 

481 Otherwise there's a risc of race conditions. 

482 

483 """ 

484 

485 msg_decorations: _t.Mapping[str, str] = {} 

486 """ 

487 Decorative symbols for certain text elements, such as headings, 

488 list items, etc. 

489 

490 This mapping becomes immutable once a theme class is created. The only possible 

491 way to modify it is by using :meth:`~Theme.set_msg_decoration` 

492 or :meth:`~Theme._set_msg_decoration_if_not_overridden`. 

493 

494 """ 

495 

496 __msg_decorations: dict[str, str] 

497 """ 

498 An actual mutable version of :attr:`~Theme.msg_decorations` 

499 is kept here, because ``__init_subclass__`` will replace 

500 :attr:`~Theme.msg_decorations` with an immutable proxy. 

501 

502 """ 

503 

504 __msg_decoration_sources: dict[str, type | None] = {} 

505 """ 

506 Keeps track of where a message decoration was inherited from. This var is used 

507 to avoid ``__init__``-ing message decorations that were overridden in a subclass. 

508 

509 """ 

510 

511 table_drawing_symbols: _t.Mapping[int, str] = {} 

512 """ 

513 TODO! 

514 """ 

515 

516 __table_drawing_symbols: dict[int, str] = {} 

517 """ 

518 An actual mutable version of :attr:`~Theme.table_drawing_symbols` 

519 is kept here, because ``__init_subclass__`` will replace 

520 :attr:`~Theme.table_drawing_symbols` with an immutable proxy. 

521 

522 """ 

523 

524 __table_drawing_symbol_sources: dict[int, type | None] = {} 

525 """ 

526 Keeps track of where a table drawing symbol was inherited from. This var is used 

527 to avoid ``__init__``-ing table drawing symbols that were overridden in a subclass. 

528 

529 """ 

530 

531 progress_bar_width: int = 15 

532 """ 

533 Width of a progress bar for :class:`yuio.io.Task`. 

534 

535 """ 

536 

537 spinner_update_rate_ms: int = 200 

538 """ 

539 How often the spinner pattern changes. 

540 

541 """ 

542 

543 colors: _t.Mapping[str, str | yuio.color.Color] = {} 

544 """ 

545 Mapping of color paths to actual colors. 

546 

547 Themes use color paths to describe styles and colors for different 

548 parts of an application. Color paths are similar to file paths, 

549 they use snake case identifiers separated by slashes, and consist of 

550 two parts separated by a colon. 

551 

552 The first part represents an object, i.e. what we're coloring. 

553 

554 The second part represents a context, i.e. what is the state or location 

555 of an object that we're coloring. 

556 

557 For example, a color for the filled part of the task's progress bar 

558 has path ``"task/progressbar/done"``, a color for a text of an error 

559 log record has path ``"log/message:error"``, and a color for a string escape 

560 sequence in a highlighted python code has path ``"hl/str/esc:python"``. 

561 

562 A color at a certain path is propagated to all sub-paths. For example, 

563 if ``"task/progressbar"`` is bold, and ``"task/progressbar/done"`` is green, 

564 the final color will be bold green. 

565 

566 Each color path can be associated with an instance of :class:`~yuio.color.Color` 

567 or with another path. 

568 

569 If path is mapped to a :class:`~yuio.color.Color`, then the path is associated 

570 with that particular color. 

571 

572 If path is mapped to another path, then the path is associated with 

573 the color value for that other path (please don't create recursions here). 

574 

575 You can combine multiple paths within the same mapping by separating them with 

576 whitespaces. In this case colors for those paths are combined. 

577 

578 For example: 

579 

580 .. code-block:: python 

581 

582 colors = { 

583 "heading_color": "bold", 

584 "error_color": "red", 

585 "tb/heading": "heading_color error_color", 

586 } 

587 

588 Here, color of traceback's heading ``"tb/heading"`` will be bold and red. 

589 

590 When deriving from a theme, you can override this mapping. When looking up 

591 colors via :meth:`~Theme.get_color`, base classes will be tried for color, 

592 in order of method resolution. 

593 

594 This mapping becomes immutable once a theme class is created. The only possible 

595 way to modify it is by using :meth:`~Theme.set_color` 

596 or :meth:`~Theme._set_color_if_not_overridden`. 

597 

598 """ 

599 

600 __colors: dict[str, str | yuio.color.Color] 

601 """ 

602 An actual mutable version of :attr:`~Theme.colors` 

603 is kept here, because ``__init_subclass__`` will replace 

604 :attr:`~Theme.colors` with an immutable proxy. 

605 

606 """ 

607 

608 __color_sources: dict[str, type | None] = {} 

609 """ 

610 Keeps track of where a color was inherited from. This var is used 

611 to avoid ``__init__``-ing colors that were overridden in a subclass. 

612 

613 """ 

614 

615 __expected_source: type | None = None 

616 """ 

617 When running an ``__init__`` function, this variable will be set to the class 

618 that implemented it, regardless of type of ``self``. 

619 

620 That is, inside ``DefaultTheme.__init__``, ``__expected_source`` is set 

621 to ``DefaultTheme``, in ``MyTheme.__init__`` it is ``MyTheme``, etc. 

622 

623 This is possible because ``__init_subclass__`` wraps any implementation 

624 of ``__init__`` into a wrapper that sets this variable. 

625 

626 """ 

627 

628 def __init__(self): 

629 self.__color_cache: dict[str, yuio.color.Color | None] = {} 

630 

631 def __init_subclass__(cls, **kwargs): 

632 super().__init_subclass__(**kwargs) 

633 

634 colors = {} 

635 color_sources = {} 

636 for base in reversed(cls.__mro__): 

637 base_colors = getattr(base, "_Theme__colors", {}) 

638 colors.update(base_colors) 

639 base_color_sources = getattr(base, "_Theme__color_sources", {}) 

640 color_sources.update(base_color_sources) 

641 

642 colors.update(cls.colors) 

643 color_sources.update(dict.fromkeys(cls.colors.keys(), cls)) 

644 

645 cls.__colors = colors 

646 cls.__color_sources = color_sources 

647 cls.colors = _ImmutableDictProxy(cls.__colors, attr="colors") 

648 

649 msg_decorations = {} 

650 msg_decoration_sources = {} 

651 for base in reversed(cls.__mro__): 

652 base_msg_decorations = getattr(base, "_Theme__msg_decorations", {}) 

653 msg_decorations.update(base_msg_decorations) 

654 base_msg_decoration_sources = getattr( 

655 base, "_Theme__msg_decoration_sources", {} 

656 ) 

657 msg_decoration_sources.update(base_msg_decoration_sources) 

658 

659 msg_decorations.update(cls.msg_decorations) 

660 msg_decoration_sources.update(dict.fromkeys(cls.msg_decorations.keys(), cls)) 

661 

662 cls.__msg_decorations = msg_decorations 

663 cls.__msg_decoration_sources = msg_decoration_sources 

664 cls.msg_decorations = _ImmutableDictProxy( 

665 cls.__msg_decorations, attr="msg_decorations" 

666 ) 

667 

668 table_drawing_symbols = {} 

669 table_drawing_symbol_sources = {} 

670 for base in reversed(cls.__mro__): 

671 base_table_drawing_symbols = getattr( 

672 base, "_Theme__table_drawing_symbols", {} 

673 ) 

674 table_drawing_symbols.update(base_table_drawing_symbols) 

675 base_table_drawing_symbol_sources = getattr( 

676 base, "_Theme__table_drawing_symbol_sources", {} 

677 ) 

678 table_drawing_symbol_sources.update(base_table_drawing_symbol_sources) 

679 

680 table_drawing_symbols.update(cls.table_drawing_symbols) 

681 table_drawing_symbol_sources.update( 

682 dict.fromkeys(cls.table_drawing_symbols.keys(), cls) 

683 ) 

684 

685 cls.__table_drawing_symbols = table_drawing_symbols 

686 cls.__table_drawing_symbol_sources = table_drawing_symbol_sources 

687 cls.table_drawing_symbols = _ImmutableDictProxy( 

688 cls.__table_drawing_symbols, attr="table_drawing_symbols" 

689 ) 

690 

691 if init := cls.__dict__.get("__init__", None): 

692 

693 @functools.wraps(init) 

694 def _wrapped_init(_self, *args, **kwargs): 

695 prev_expected_source = _self._Theme__expected_source 

696 _self._Theme__expected_source = cls 

697 try: 

698 return init(_self, *args, **kwargs) 

699 finally: 

700 _self._Theme__expected_source = prev_expected_source 

701 

702 cls.__init__ = _wrapped_init # type: ignore 

703 

704 def _set_msg_decoration_if_not_overridden( 

705 self, 

706 name: str, 

707 msg_decoration: str, 

708 /, 

709 ): 

710 """ 

711 Set message decoration by name, but only if it wasn't overridden 

712 in a subclass. 

713 

714 This method should be called from ``__init__`` implementations 

715 to dynamically set message decorations. It will only set the decoration 

716 if it was not overridden by any child class. 

717 

718 """ 

719 

720 if self.__expected_source is None: 

721 raise RuntimeError( 

722 "_set_msg_decoration_if_not_overridden should only be called from __init__" 

723 ) 

724 source = self.__msg_decoration_sources.get(name, Theme) 

725 # The class that's `__init__` is currently running should be a parent 

726 # of the msg_decoration's source. This means that the msg_decoration was assigned by a parent. 

727 if source is not None and issubclass(self.__expected_source, source): 

728 self.set_msg_decoration(name, msg_decoration) 

729 

730 def set_msg_decoration( 

731 self, 

732 name: str, 

733 msg_decoration: str, 

734 /, 

735 ): 

736 """ 

737 Set message decoration by name. 

738 

739 """ 

740 

741 if "_Theme__msg_decorations" not in self.__dict__: 

742 self.__msg_decorations = self.__class__.__msg_decorations.copy() 

743 self.__msg_decoration_sources = ( 

744 self.__class__.__msg_decoration_sources.copy() 

745 ) 

746 self.msg_decorations = _ImmutableDictProxy( 

747 self.__msg_decorations, attr="msg_decorations" 

748 ) 

749 self.__msg_decorations[name] = msg_decoration 

750 self.__msg_decoration_sources[name] = self.__expected_source 

751 

752 def _set_table_drawing_symbol_if_not_overridden( 

753 self, 

754 code: int, 

755 table_drawing_symbol: str, 

756 /, 

757 ): 

758 """ 

759 Set table drawing symbol by code, but only if it wasn't overridden 

760 in a subclass. 

761 

762 This method should be called from ``__init__`` implementations 

763 to dynamically set table drawing symbols. It will only set the symbol 

764 if it was not overridden by any child class. 

765 

766 """ 

767 

768 if self.__expected_source is None: 

769 raise RuntimeError( 

770 "_set_table_drawing_symbol_if_not_overridden should only be called from __init__" 

771 ) 

772 source = self.__table_drawing_symbol_sources.get(code, Theme) 

773 # The class that's `__init__` is currently running should be a parent 

774 # of the table_drawing_symbol's source. This means that the table_drawing_symbol was assigned by a parent. 

775 if source is not None and issubclass(self.__expected_source, source): 

776 self.set_table_drawing_symbol(code, table_drawing_symbol) 

777 

778 def set_table_drawing_symbol( 

779 self, 

780 code: int, 

781 table_drawing_symbol: str, 

782 /, 

783 ): 

784 """ 

785 Set table drawing symbol by code. 

786 

787 """ 

788 

789 if "_Theme__table_drawing_symbols" not in self.__dict__: 

790 self.__table_drawing_symbols = self.__class__.__table_drawing_symbols.copy() 

791 self.__table_drawing_symbol_sources = ( 

792 self.__class__.__table_drawing_symbol_sources.copy() 

793 ) 

794 self.table_drawing_symbols = _ImmutableDictProxy( 

795 self.__table_drawing_symbols, attr="table_drawing_symbols" 

796 ) 

797 self.__table_drawing_symbols[code] = table_drawing_symbol 

798 self.__table_drawing_symbol_sources[code] = self.__expected_source 

799 

800 def _set_color_if_not_overridden( 

801 self, 

802 path: str, 

803 color: str | yuio.color.Color, 

804 /, 

805 ): 

806 """ 

807 Set color by path, but only if the color was not overridden in a subclass. 

808 

809 This method should be called from ``__init__`` implementations 

810 to dynamically set colors. It will only set the color if it was not overridden 

811 by any child class. 

812 

813 """ 

814 

815 if self.__expected_source is None: 

816 raise RuntimeError( 

817 "_set_color_if_not_overridden should only be called from __init__" 

818 ) 

819 source = self.__color_sources.get(path, Theme) 

820 # The class who's `__init__` is currently running should be a parent 

821 # of the color's source. This means that the color was assigned by a parent. 

822 if source is not None and issubclass(self.__expected_source, source): 

823 self.set_color(path, color) 

824 

825 def set_color( 

826 self, 

827 path: str, 

828 color: str | yuio.color.Color, 

829 /, 

830 ): 

831 """ 

832 Set color by path. 

833 

834 """ 

835 

836 if "_Theme__colors" not in self.__dict__: 

837 self.__colors = self.__class__.__colors.copy() 

838 self.__color_sources = self.__class__.__color_sources.copy() 

839 self.colors = _ImmutableDictProxy(self.__colors, attr="colors") 

840 self.__colors[path] = color 

841 self.__color_sources[path] = self.__expected_source 

842 self.__color_cache.clear() 

843 self.__dict__.pop("_Theme__color_tree", None) 

844 

845 @dataclass(kw_only=True, slots=True) 

846 class __ColorTree: 

847 """ 

848 Prefix-like tree that contains all of the theme's colors. 

849 

850 """ 

851 

852 colors: str | yuio.color.Color = yuio.color.Color.NONE 

853 """ 

854 Colors in this node. 

855 

856 """ 

857 

858 loc: dict[str, Theme.__ColorTree] = dataclasses.field(default_factory=dict) 

859 """ 

860 Location part of the tree. 

861 

862 """ 

863 

864 ctx: dict[str, Theme.__ColorTree] = dataclasses.field(default_factory=dict) 

865 """ 

866 Context part of the tree. 

867 

868 """ 

869 

870 @functools.cached_property 

871 def __color_tree(self) -> Theme.__ColorTree: 

872 root = self.__ColorTree() 

873 

874 for path, colors in self.__colors.items(): 

875 loc, ctx = self.__parse_path(path) 

876 

877 node = root 

878 

879 for part in loc: 

880 if part not in node.loc: 

881 node.loc[part] = self.__ColorTree() 

882 node = node.loc[part] 

883 

884 for part in ctx: 

885 if part not in node.ctx: 

886 node.ctx[part] = self.__ColorTree() 

887 node = node.ctx[part] 

888 

889 node.colors = colors 

890 

891 return root 

892 

893 @staticmethod 

894 def __parse_path(path: str, /) -> tuple[list[str], list[str]]: 

895 path_parts = path.split(":", maxsplit=1) 

896 if len(path_parts) == 1: 

897 loc, ctx = path_parts[0], "" 

898 else: 

899 loc, ctx = path_parts 

900 return loc.split("/") if loc else [], ctx.split("/") if ctx else [] 

901 

902 @_t.final 

903 def get_color(self, paths: str, /) -> yuio.color.Color: 

904 """ 

905 Lookup a color by path. 

906 

907 """ 

908 

909 color = yuio.color.Color.NONE 

910 for path in paths.split(): 

911 color |= self.__get_color(path) 

912 return color 

913 

914 def __get_color(self, path: str, /) -> yuio.color.Color: 

915 res: yuio.color.Color | None | yuio.Missing = self.__color_cache.get( 

916 path, yuio.MISSING 

917 ) 

918 if res is None: 

919 warnings.warn(f"recursive color path {path!r}", RecursiveThemeWarning) 

920 return yuio.color.Color.NONE 

921 elif res is not yuio.MISSING: 

922 return res 

923 

924 self.__color_cache[path] = None 

925 if path.startswith("#") and len(path) == 7: 

926 try: 

927 res = yuio.color.Color.fore_from_hex(path) 

928 except ValueError as e: 

929 warnings.warn(f"invalid color code {path!r}: {e}", ThemeWarning) 

930 res = yuio.color.Color.NONE 

931 elif path[:3].lower() == "bg#" and len(path) == 9: 

932 try: 

933 res = yuio.color.Color.back_from_hex(path[2:]) 

934 except ValueError as e: 

935 warnings.warn(f"invalid color code {path!r}: {e}", ThemeWarning) 

936 res = yuio.color.Color.NONE 

937 else: 

938 loc, ctx = self.__parse_path(path) 

939 res = self.__get_color_in_loc(self.__color_tree, loc, ctx) 

940 self.__color_cache[path] = res 

941 return res 

942 

943 def __get_color_in_loc( 

944 self, node: Theme.__ColorTree, loc: list[str], ctx: list[str] 

945 ): 

946 color = yuio.color.Color.NONE 

947 

948 for part in loc: 

949 if part not in node.loc: 

950 break 

951 color |= self.__get_color_in_ctx(node, ctx) 

952 node = node.loc[part] 

953 

954 return color | self.__get_color_in_ctx(node, ctx) 

955 

956 def __get_color_in_ctx(self, node: Theme.__ColorTree, ctx: list[str]): 

957 color = yuio.color.Color.NONE 

958 

959 for part in ctx: 

960 if part not in node.ctx: 

961 break 

962 color |= self.__get_color_in_node(node) 

963 node = node.ctx[part] 

964 

965 return color | self.__get_color_in_node(node) 

966 

967 def __get_color_in_node(self, node: Theme.__ColorTree) -> yuio.color.Color: 

968 color = yuio.color.Color.NONE 

969 

970 if isinstance(node.colors, str): 

971 color |= self.get_color(node.colors) 

972 else: 

973 color |= node.colors 

974 

975 return color 

976 

977 def to_color( 

978 self, color_or_path: yuio.color.Color | str | None, / 

979 ) -> yuio.color.Color: 

980 """ 

981 Convert color or color path to color. 

982 

983 """ 

984 

985 if color_or_path is None: 

986 return yuio.color.Color.NONE 

987 elif isinstance(color_or_path, yuio.color.Color): 

988 return color_or_path 

989 else: 

990 return self.get_color(color_or_path) 

991 

992 def check(self): 

993 """ 

994 Check theme for recursion. 

995 

996 This method is slow, and should be called from unit tests of your application. 

997 

998 """ 

999 

1000 if "" in self.colors: 

1001 warnings.warn("colors map contains an empty key", ThemeWarning) 

1002 

1003 for k, v in self.colors.items(): 

1004 if not v: 

1005 warnings.warn(f"color value for path {k!r} is empty", ThemeWarning) 

1006 

1007 err_path = None 

1008 with warnings.catch_warnings(): 

1009 warnings.simplefilter("error", category=RecursiveThemeWarning) 

1010 for k in self.colors: 

1011 try: 

1012 self.get_color(k) 

1013 except RecursiveThemeWarning: 

1014 err_path = k 

1015 if err_path is None: 

1016 return 

1017 

1018 self.__color_cache.clear() 

1019 recursive_path = [] 

1020 get_color_inner = self.__get_color 

1021 

1022 def get_color(path: str): 

1023 recursive_path.append(path) 

1024 return get_color_inner(path) 

1025 

1026 self.__get_color = get_color 

1027 

1028 try: 

1029 with warnings.catch_warnings(): 

1030 warnings.simplefilter("error", category=RecursiveThemeWarning) 

1031 self.get_color(err_path) 

1032 except RecursiveThemeWarning: 

1033 self.__get_color = get_color_inner 

1034 else: 

1035 assert False, ( 

1036 "unreachable, please report hitting this assert " 

1037 "to https://github.com/taminomara/yuio/issues" 

1038 ) 

1039 

1040 raise RecursiveThemeWarning( 

1041 f"infinite recursion in color path {err_path!r}:\n " 

1042 + "\n ".join( 

1043 f"{path!r} -> {self.colors.get(path)!r}" for path in recursive_path[:-1] 

1044 ) 

1045 ) 

1046 

1047 

1048Theme.__init_subclass__() 

1049 

1050 

1051class DefaultTheme(Theme): 

1052 """ 

1053 Default Yuio theme. Adapts for terminal background color, 

1054 if one can be detected. 

1055 

1056 This theme defines *main colors*, which you can override by subclassing. 

1057 

1058 - ``"heading_color"``: for headings, 

1059 - ``"primary_color"``: for main text, 

1060 - ``"accent_color"``, ``"accent_color_2"``: for visually highlighted elements, 

1061 - ``"secondary_color"``: for visually dimmed elements, 

1062 - ``"error_color"``: for everything that indicates an error, 

1063 - ``"warning_color"``: for everything that indicates a warning, 

1064 - ``"success_color"``: for everything that indicates a success, 

1065 - ``"critical_color"``: for critical or internal errors, 

1066 - ``"low_priority_color_a"``: for auxiliary elements such as help widget, 

1067 - ``"low_priority_color_b"``: for auxiliary elements such as help widget, 

1068 even lower priority. 

1069 

1070 """ 

1071 

1072 colors = { 

1073 # 

1074 # Main settings 

1075 # ------------- 

1076 # This section controls the overall theme look. 

1077 # Most likely you'll want to change accent colors from here. 

1078 "heading_color": "bold primary_color", 

1079 "primary_color": "normal", 

1080 "accent_color": "magenta", 

1081 "accent_color_2": "cyan", 

1082 "secondary_color": "normal_dim", 

1083 "error_color": "red", 

1084 "warning_color": "yellow", 

1085 "success_color": "green", 

1086 "critical_color": "inverse error_color", 

1087 "low_priority_color_a": "normal_dim", 

1088 "low_priority_color_b": "normal_dim", 

1089 # 

1090 # Common tags 

1091 # ----------- 

1092 "code": "italic", 

1093 "note": "accent_color_2", 

1094 "path": "code", 

1095 "flag": "note", 

1096 # 

1097 # Styles 

1098 # ------ 

1099 "bold": yuio.color.Color.STYLE_BOLD, 

1100 "b": "bold", 

1101 "dim": yuio.color.Color.STYLE_DIM, 

1102 "d": "dim", 

1103 "italic": yuio.color.Color.STYLE_ITALIC, 

1104 "i": "italic", 

1105 "underline": yuio.color.Color.STYLE_UNDERLINE, 

1106 "u": "underline", 

1107 "inverse": yuio.color.Color.STYLE_INVERSE, 

1108 # 

1109 # Foreground 

1110 # ---------- 

1111 # Note: we don't have tags for background because it's impossible to guarantee 

1112 # that they'll work nicely with whatever foreground you choose. Prefer using 

1113 # `inverse` instead. 

1114 "normal": yuio.color.Color.FORE_NORMAL, 

1115 "normal_dim": yuio.color.Color.FORE_NORMAL_DIM, 

1116 "black": yuio.color.Color.FORE_BLACK, 

1117 "red": yuio.color.Color.FORE_RED, 

1118 "green": yuio.color.Color.FORE_GREEN, 

1119 "yellow": yuio.color.Color.FORE_YELLOW, 

1120 "blue": yuio.color.Color.FORE_BLUE, 

1121 "magenta": yuio.color.Color.FORE_MAGENTA, 

1122 "cyan": yuio.color.Color.FORE_CYAN, 

1123 "white": yuio.color.Color.FORE_WHITE, 

1124 # 

1125 # IO messages and text 

1126 # -------------------- 

1127 "msg/decoration": "secondary_color", 

1128 "msg/decoration:heading": "heading_color accent_color", 

1129 "msg/decoration:thematic_break": "secondary_color", 

1130 "msg/text": "primary_color", 

1131 "msg/text:heading": "heading_color", 

1132 "msg/text:heading/1": "accent_color", 

1133 "msg/text:heading/section": "accent_color", 

1134 "msg/text:question": "heading_color", 

1135 "msg/text:error": "error_color", 

1136 "msg/text:warning": "warning_color", 

1137 "msg/text:success": "heading_color success_color", 

1138 "msg/text:failure": "heading_color error_color", 

1139 "msg/text:info": "primary_color", 

1140 "msg/text:thematic_break": "secondary_color", 

1141 # 

1142 # Log messages 

1143 # ------------ 

1144 "log/name": "dim accent_color_2", 

1145 "log/pathname": "dim", 

1146 "log/filename": "dim", 

1147 "log/module": "dim", 

1148 "log/lineno": "dim", 

1149 "log/funcName": "dim", 

1150 "log/created": "dim", 

1151 "log/asctime": "dim", 

1152 "log/msecs": "dim", 

1153 "log/relativeCreated": "dim", 

1154 "log/thread": "dim", 

1155 "log/threadName": "dim", 

1156 "log/taskName": "dim", 

1157 "log/process": "dim", 

1158 "log/processName": "dim", 

1159 "log/levelno": "log/levelname", 

1160 "log/levelno:critical": "log/levelname:critical", 

1161 "log/levelno:error": "log/levelname:error", 

1162 "log/levelno:warning": "log/levelname:warning", 

1163 "log/levelno:info": "log/levelname:info", 

1164 "log/levelno:debug": "log/levelname:debug", 

1165 "log/levelname": "heading_color", 

1166 "log/levelname:critical": "critical_color", 

1167 "log/levelname:error": "error_color", 

1168 "log/levelname:warning": "warning_color", 

1169 "log/levelname:info": "success_color", 

1170 "log/levelname:debug": "dim", 

1171 "log/message": "primary_color", 

1172 "log/message:critical": "bold error_color", 

1173 "log/message:debug": "dim", 

1174 "log/colMessage": "log/message", 

1175 "log/colMessage:critical": "log/message:critical", 

1176 "log/colMessage:error": "log/message:error", 

1177 "log/colMessage:warning": "log/message:warning", 

1178 "log/colMessage:info": "log/message:info", 

1179 "log/colMessage:debug": "log/message:debug", 

1180 # 

1181 # Tasks and progress bars 

1182 # ----------------------- 

1183 "task": "secondary_color", 

1184 "task/decoration": "msg/decoration:heading", 

1185 "task/decoration:running": "accent_color", 

1186 "task/decoration:done": "success_color", 

1187 "task/decoration:error": "error_color", 

1188 "task/progressbar/done": "accent_color", 

1189 "task/progressbar/done/start": "blue", 

1190 "task/progressbar/done/end": "accent_color", 

1191 "task/progressbar/pending": "secondary_color", 

1192 "task/heading": "heading_color", 

1193 "task/progress": "secondary_color", 

1194 "task/comment": "primary_color", 

1195 # 

1196 # Syntax highlighting 

1197 # ------------------- 

1198 "hl/kwd": "bold", 

1199 "hl/str": "yellow", 

1200 "hl/str/esc": "accent_color", 

1201 "hl/punct": "secondary_color", 

1202 "hl/comment": "green", 

1203 "hl/lit": "blue", 

1204 "hl/type": "cyan", 

1205 "hl/prog": "bold underline", 

1206 "hl/flag": "flag", 

1207 "hl/metavar": "bold", 

1208 "hl/meta": "accent_color", 

1209 "hl/added": "green", 

1210 "hl/removed": "red", 

1211 "tb/heading": "bold red", 

1212 "tb/message": "tb/heading", 

1213 "tb/frame/usr/file/module": "accent_color", 

1214 "tb/frame/usr/file/line": "accent_color", 

1215 "tb/frame/usr/file/path": "accent_color", 

1216 "tb/frame/usr/code": yuio.color.Color.NONE, 

1217 "tb/frame/usr/highlight": "low_priority_color_a", 

1218 "tb/frame/lib": "dim", 

1219 "tb/frame/lib/file/module": "tb/frame/usr/file/module", 

1220 "tb/frame/lib/file/line": "tb/frame/usr/file/line", 

1221 "tb/frame/lib/file/path": "tb/frame/usr/file/path", 

1222 "tb/frame/lib/code": "tb/frame/usr/code", 

1223 "tb/frame/lib/highlight": "tb/frame/usr/highlight", 

1224 # 

1225 # Menu and widgets 

1226 # ---------------- 

1227 "menu/text": "primary_color", 

1228 "menu/text/heading": "menu/text heading_color", 

1229 "menu/text/help_info:help": "low_priority_color_a", 

1230 "menu/text/help_msg:help": "low_priority_color_b", 

1231 "menu/text/help_key:help": "low_priority_color_a", 

1232 "menu/text/help_sep:help": "low_priority_color_b", 

1233 "menu/text/help_key:help_menu": "accent_color_2", 

1234 "menu/text/help_sep:help_menu": "secondary_color", 

1235 "menu/text/esc": "white on_magenta", 

1236 "menu/text/comment": "accent_color_2", 

1237 "menu/text:choice/active": "accent_color", 

1238 "menu/text:choice/active/selected": "bold", 

1239 "menu/text:choice/normal/selected": "accent_color_2 bold", 

1240 "menu/text:choice/normal/dir": "blue", 

1241 "menu/text:choice/normal/exec": "red", 

1242 "menu/text:choice/normal/symlink": "magenta", 

1243 "menu/text:choice/normal/socket": "green", 

1244 "menu/text:choice/normal/pipe": "yellow", 

1245 "menu/text:choice/normal/block_device": "cyan bold", 

1246 "menu/text:choice/normal/char_device": "yellow bold", 

1247 "menu/text/comment:choice/normal/original": "success_color", 

1248 "menu/text/comment:choice/active/original": "success_color", 

1249 "menu/text/comment:choice/normal/corrected": "error_color", 

1250 "menu/text/comment:choice/active/corrected": "error_color", 

1251 "menu/text/prefix:choice/normal": "primary_color", 

1252 "menu/text/prefix:choice/normal/selected": "accent_color_2 bold", 

1253 "menu/text/prefix:choice/active": "accent_color", 

1254 "menu/text/prefix:choice/active/selected": "bold", 

1255 "menu/text/suffix:choice/normal": "primary_color", 

1256 "menu/text/suffix:choice/normal/selected": "accent_color_2 bold", 

1257 "menu/text/suffix:choice/active": "accent_color", 

1258 "menu/text/suffix:choice/active/selected": "bold", 

1259 "menu/text:choice/status_line": "low_priority_color_b", 

1260 "menu/text:choice/status_line/number": "low_priority_color_a", 

1261 "menu/text/placeholder": "secondary_color", 

1262 "menu/decoration": "accent_color", 

1263 "menu/decoration/quick-select": "secondary_color", 

1264 "menu/decoration/comment": "secondary_color", 

1265 "menu/decoration:choice/normal": "menu/text", 

1266 } 

1267 """ 

1268 Colors for default theme are separated into several sections. 

1269 

1270 The main section (the first one) has common settings which are referenced 

1271 from all other sections. You'll probably want to override 

1272 

1273 """ 

1274 

1275 def __init__(self, term: yuio.term.Term): 

1276 super().__init__() 

1277 

1278 if term.is_unicode: 

1279 decorations = _MSG_DECORATIONS_UNICODE 

1280 table_symbols = _TABLE_SYMBOLS_UNICODE 

1281 else: 

1282 decorations = _MSG_DECORATIONS_ASCII 

1283 table_symbols = _TABLE_SYMBOLS_ASCII 

1284 for k, v in decorations.items(): 

1285 self._set_msg_decoration_if_not_overridden(k, v) 

1286 for k, v in table_symbols.items(): 

1287 self._set_table_drawing_symbol_if_not_overridden(k, v) 

1288 

1289 if (colors := term.terminal_theme) is None: 

1290 return 

1291 

1292 # Gradients look bad in other modes. 

1293 if term.supports_colors_true: 

1294 self._set_color_if_not_overridden( 

1295 "normal", yuio.color.Color(fore=colors.foreground) 

1296 ) 

1297 self._set_color_if_not_overridden( 

1298 "black", yuio.color.Color(fore=colors.black) 

1299 ) 

1300 self._set_color_if_not_overridden( 

1301 "red", 

1302 yuio.color.Color(fore=colors.red), 

1303 ) 

1304 self._set_color_if_not_overridden( 

1305 "green", yuio.color.Color(fore=colors.green) 

1306 ) 

1307 self._set_color_if_not_overridden( 

1308 "yellow", yuio.color.Color(fore=colors.yellow) 

1309 ) 

1310 self._set_color_if_not_overridden( 

1311 "blue", yuio.color.Color(fore=colors.blue) 

1312 ) 

1313 self._set_color_if_not_overridden( 

1314 "magenta", yuio.color.Color(fore=colors.magenta) 

1315 ) 

1316 self._set_color_if_not_overridden( 

1317 "cyan", yuio.color.Color(fore=colors.cyan) 

1318 ) 

1319 self._set_color_if_not_overridden( 

1320 "white", yuio.color.Color(fore=colors.white) 

1321 ) 

1322 

1323 if colors.lightness == yuio.term.Lightness.UNKNOWN: 

1324 return 

1325 

1326 background = colors.background 

1327 foreground = colors.foreground 

1328 

1329 if colors.lightness is colors.lightness.DARK: 

1330 self._set_color_if_not_overridden( 

1331 "low_priority_color_a", 

1332 yuio.color.Color( 

1333 fore=foreground.match_luminosity(background.lighten(0.30)) 

1334 ), 

1335 ) 

1336 self._set_color_if_not_overridden( 

1337 "low_priority_color_b", 

1338 yuio.color.Color( 

1339 fore=foreground.match_luminosity(background.lighten(0.25)) 

1340 ), 

1341 ) 

1342 else: 

1343 self._set_color_if_not_overridden( 

1344 "low_priority_color_a", 

1345 yuio.color.Color( 

1346 fore=foreground.match_luminosity(background.darken(0.30)) 

1347 ), 

1348 ) 

1349 self._set_color_if_not_overridden( 

1350 "low_priority_color_b", 

1351 yuio.color.Color( 

1352 fore=foreground.match_luminosity(background.darken(0.25)) 

1353 ), 

1354 ) 

1355 

1356 

1357def load( 

1358 term: yuio.term.Term, 

1359 theme_ctor: _t.Callable[[yuio.term.Term], Theme] | None = None, 

1360 /, 

1361) -> Theme: 

1362 """ 

1363 Loads a default theme. 

1364 

1365 """ 

1366 

1367 # NOTE: loading themes from json is beta, don't use it yet. 

1368 

1369 if theme_ctor is None: 

1370 theme_ctor = DefaultTheme 

1371 

1372 if not (path := os.environ.get("YUIO_THEME_PATH")): 

1373 return theme_ctor(term) 

1374 

1375 import yuio.config 

1376 import yuio.parse 

1377 

1378 class ThemeData(yuio.config.Config): 

1379 include: list[str] | str | None = None 

1380 progress_bar_width: _t.Annotated[int, yuio.parse.Ge(0)] | None = None 

1381 spinner_update_rate_ms: _t.Annotated[int, yuio.parse.Ge(0)] | None = None 

1382 msg_decorations: dict[str, str] = yuio.config.field( 

1383 default={}, 

1384 merge=lambda l, r: {**l, **r}, 

1385 ) 

1386 colors: dict[str, str] = yuio.config.field( 

1387 default={}, 

1388 merge=lambda l, r: {**l, **r}, 

1389 ) 

1390 

1391 seen = set() 

1392 stack = [pathlib.Path(path)] 

1393 loaded_partials = [] 

1394 while stack: 

1395 path = stack.pop() 

1396 if path in seen: 

1397 continue 

1398 if not path.exists(): 

1399 warnings.warn(f"theme file {path} does not exist", ThemeWarning) 

1400 continue 

1401 if not path.is_file(): 

1402 warnings.warn(f"theme file {path} is not a file", ThemeWarning) 

1403 continue 

1404 try: 

1405 loaded = ThemeData.load_from_json_file(path, ignore_unknown_fields=True) 

1406 except yuio.parse.ParsingError as e: 

1407 warnings.warn(str(e), ThemeWarning) 

1408 continue 

1409 loaded_partials.append(loaded) 

1410 include = loaded.include 

1411 if isinstance(include, str): 

1412 include = [include] 

1413 if include: 

1414 stack.extend([path.parent / new_path for new_path in include]) 

1415 

1416 theme_data = ThemeData() 

1417 for partial in reversed(loaded_partials): 

1418 theme_data.update(partial) 

1419 

1420 theme = theme_ctor(term) 

1421 

1422 if theme_data.progress_bar_width is not None: 

1423 theme.progress_bar_width = theme_data.progress_bar_width 

1424 if theme_data.spinner_update_rate_ms is not None: 

1425 theme.spinner_update_rate_ms = theme_data.spinner_update_rate_ms 

1426 

1427 for k, v in theme_data.msg_decorations.items(): 

1428 theme.set_msg_decoration(k, v) 

1429 

1430 for k, v in theme_data.colors.items(): 

1431 theme.set_color(k, v) 

1432 

1433 return theme 

1434 

1435 

1436class TableJunction(IntFlag): 

1437 WEST = 1 << 0 

1438 WEST_ALT = 1 << 1 

1439 SOUTH = 1 << 2 

1440 SOUTH_ALT = 1 << 3 

1441 EAST = 1 << 4 

1442 EAST_ALT = 1 << 5 

1443 NORTH = 1 << 6 

1444 NORTH_ALT = 1 << 7 

1445 ALT_STYLE = 1 << 8 

1446 

1447 def __repr__(self) -> str: 

1448 res = "".join( 

1449 [ 

1450 ["", "n", "", "N"][ 

1451 bool(self & self.NORTH) + 2 * bool(self & self.NORTH_ALT) 

1452 ], 

1453 ["", "e", "", "E"][ 

1454 bool(self & self.EAST) + 2 * bool(self & self.EAST_ALT) 

1455 ], 

1456 ["", "s", "", "S"][ 

1457 bool(self & self.SOUTH) + 2 * bool(self & self.SOUTH_ALT) 

1458 ], 

1459 ["", "w", "", "W"][ 

1460 bool(self & self.WEST) + 2 * bool(self & self.WEST_ALT) 

1461 ], 

1462 ["-", "="][bool(self & self.ALT_STYLE)], 

1463 ] 

1464 ) 

1465 return f"<{self.__class__.__name__} {res}>" 

1466 

1467 

1468_MSG_DECORATIONS_UNICODE: dict[str, str] = { 

1469 "heading/section": "", 

1470 "heading/1": "⣿ ", 

1471 "heading/2": "", 

1472 "heading/3": "", 

1473 "heading/4": "", 

1474 "heading/5": "", 

1475 "heading/6": "", 

1476 "question": "> ", 

1477 "task": "> ", 

1478 "thematic_break": "╌╌╌╌╌╌╌╌", 

1479 "list": "• ", 

1480 "quote": "> ", 

1481 "code": " " * 8, 

1482 "overflow": "…", 

1483 "hr/1/left_start": "─", 

1484 "hr/1/left_middle": "─", 

1485 "hr/1/left_end": "╴", 

1486 "hr/1/middle": "─", 

1487 "hr/1/right_start": "╶", 

1488 "hr/1/right_middle": "─", 

1489 "hr/1/right_end": "─", 

1490 "hr/2/left_start": "━", 

1491 "hr/2/left_middle": "━", 

1492 "hr/2/left_end": "╸", 

1493 "hr/2/middle": "━", 

1494 "hr/2/right_start": "╺", 

1495 "hr/2/right_middle": "━", 

1496 "hr/2/right_end": "━", 

1497 "progress_bar/start_symbol": "", 

1498 "progress_bar/end_symbol": "", 

1499 "progress_bar/done_symbol": "■", # "█", 

1500 "progress_bar/pending_symbol": "□", # " ", 

1501 "progress_bar/transition_pattern": "", # "█▉▊▋▌▍▎▏ ", 

1502 "spinner/pattern": "⣤⣤⣤⠶⠛⠛⠛⠶", 

1503 "spinner/static_symbol": "⣿", 

1504 # TODO: support these in widgets 

1505 # 'menu/current_item': '▶︎', 

1506 # 'menu/selected_item': '★', 

1507 # 'menu/default_item': '★', 

1508 # 'menu/select': '#', 

1509 # 'menu/search': '/', 

1510} 

1511 

1512# fmt: off 

1513_TABLE_SYMBOLS_UNICODE: dict[int, str] = { 

1514 0x000: " ", 0x040: "╵", 0x0C0: "╹", 0x010: "╶", 0x050: "└", 0x0D0: "┖", 0x030: "╺", 

1515 0x070: "┕", 0x0F0: "┗", 0x001: "╴", 0x041: "┘", 0x0C1: "┚", 0x011: "─", 0x051: "┴", 

1516 0x0D1: "┸", 0x031: "╼", 0x071: "┶", 0x0F1: "┺", 0x003: "╸", 0x043: "┙", 0x0C3: "┛", 

1517 0x013: "╾", 0x053: "┵", 0x0D3: "┹", 0x033: "━", 0x073: "┷", 0x0F3: "┻", 0x004: "╷", 

1518 0x044: "│", 0x0C4: "╿", 0x014: "┌", 0x054: "├", 0x0D4: "┞", 0x034: "┍", 0x074: "┝", 

1519 0x0F4: "┡", 0x005: "┐", 0x045: "┤", 0x0C5: "┦", 0x015: "┬", 0x055: "┼", 0x0D5: "╀", 

1520 0x035: "┮", 0x075: "┾", 0x0F5: "╄", 0x007: "┑", 0x047: "┥", 0x0C7: "┩", 0x017: "┭", 

1521 0x057: "┽", 0x0D7: "╃", 0x037: "┯", 0x077: "┿", 0x0F7: "╇", 0x00C: "╻", 0x04C: "╽", 

1522 0x0CC: "┃", 0x01C: "┎", 0x05C: "┟", 0x0DC: "┠", 0x03C: "┎", 0x07C: "┢", 0x0FC: "┣", 

1523 0x00D: "┒", 0x04D: "┧", 0x0CD: "┨", 0x01D: "┰", 0x05D: "╁", 0x0DD: "╂", 0x03D: "┲", 

1524 0x07D: "╆", 0x0FD: "╊", 0x00F: "┓", 0x04F: "┪", 0x0CF: "┫", 0x01F: "┱", 0x05F: "╅", 

1525 0x0DF: "╉", 0x03F: "┳", 0x07F: "╈", 0x0FF: "╋", 0x100: " ", 0x140: "╵", 0x1C0: "║", 

1526 0x110: "╶", 0x150: "└", 0x1D0: "╙", 0x130: "═", 0x170: "╘", 0x1F0: "╚", 0x101: "╴", 

1527 0x141: "┘", 0x1C1: "╜", 0x111: "─", 0x151: "┴", 0x1D1: "╨", 0x131: "═", 0x171: "╧", 

1528 0x1F1: "╩", 0x103: "╸", 0x143: "╛", 0x1C3: "╝", 0x113: "═", 0x153: "╧", 0x1D3: "╩", 

1529 0x133: "═", 0x173: "╧", 0x1F3: "╩", 0x104: "╷", 0x144: "│", 0x1C4: "║", 0x114: "┌", 

1530 0x154: "├", 0x1D4: "╟", 0x134: "╒", 0x174: "╞", 0x1F4: "╠", 0x105: "┐", 0x145: "┤", 

1531 0x1C5: "╢", 0x115: "┬", 0x155: "┼", 0x1D5: "╫", 0x135: "╤", 0x175: "╪", 0x1F5: "╬", 

1532 0x107: "╕", 0x147: "╡", 0x1C7: "╣", 0x117: "╤", 0x157: "╪", 0x1D7: "╬", 0x137: "╤", 

1533 0x177: "╪", 0x1F7: "╬", 0x10C: "║", 0x14C: "║", 0x1CC: "║", 0x11C: "╓", 0x15C: "╟", 

1534 0x1DC: "╟", 0x13C: "╔", 0x17C: "╠", 0x1FC: "╠", 0x10D: "╖", 0x14D: "╢", 0x1CD: "╢", 

1535 0x11D: "╥", 0x15D: "╫", 0x1DD: "╫", 0x13D: "╦", 0x17D: "╬", 0x1FD: "╬", 0x10F: "╗", 

1536 0x14F: "╣", 0x1CF: "╣", 0x11F: "╦", 0x15F: "╬", 0x1DF: "╬", 0x13F: "╦", 0x17F: "╬", 

1537 0x1FF: "╬", 

1538} 

1539# fmt: on 

1540 

1541_MSG_DECORATIONS_ASCII: dict[str, str] = { 

1542 "heading/section": "", 

1543 "heading/1": "# ", 

1544 "heading/2": "", 

1545 "heading/3": "", 

1546 "heading/4": "", 

1547 "heading/5": "", 

1548 "heading/6": "", 

1549 "question": "> ", 

1550 "task": "> ", 

1551 "thematic_break": "-" * 8, 

1552 "list": "* ", 

1553 "quote": "> ", 

1554 "code": " " * 8, 

1555 "overflow": "~", 

1556 "progress_bar/start_symbol": "[", 

1557 "progress_bar/end_symbol": "]", 

1558 "progress_bar/done_symbol": "-", 

1559 "progress_bar/pending_symbol": " ", 

1560 "progress_bar/transition_pattern": ">", 

1561 "spinner/pattern": "|||/-\\", 

1562 "spinner/static_symbol": ">", 

1563 "hr/1/left_start": "-", 

1564 "hr/1/left_middle": "-", 

1565 "hr/1/left_end": " ", 

1566 "hr/1/middle": "-", 

1567 "hr/1/right_start": " ", 

1568 "hr/1/right_middle": "-", 

1569 "hr/1/right_end": "-", 

1570 "hr/2/left_start": "=", 

1571 "hr/2/left_middle": "=", 

1572 "hr/2/left_end": " ", 

1573 "hr/2/middle": "=", 

1574 "hr/2/right_start": " ", 

1575 "hr/2/right_middle": "=", 

1576 "hr/2/right_end": "=", 

1577 # TODO: support these in widgets 

1578 # 'menu/current_item': '>', 

1579 # 'menu/selected_item': '*', 

1580 # 'menu/default_item': '*', 

1581 # 'menu/select': '#', 

1582 # 'menu/search': '/', 

1583} 

1584 

1585# fmt: off 

1586_TABLE_SYMBOLS_ASCII: dict[int, str] = { 

1587 0x000: " ", 0x040: "+", 0x0C0: "+", 0x010: "+", 0x050: "+", 0x0D0: "+", 0x030: "+", 

1588 0x070: "+", 0x0F0: "+", 0x001: "+", 0x041: "+", 0x0C1: "+", 0x011: "-", 0x051: "+", 

1589 0x0D1: "+", 0x031: "+", 0x071: "+", 0x0F1: "+", 0x003: "+", 0x043: "+", 0x0C3: "+", 

1590 0x013: "+", 0x053: "+", 0x0D3: "+", 0x033: "=", 0x073: "+", 0x0F3: "+", 0x004: "+", 

1591 0x044: "|", 0x0C4: "+", 0x014: "+", 0x054: "+", 0x0D4: "+", 0x034: "+", 0x074: "+", 

1592 0x0F4: "+", 0x005: "+", 0x045: "+", 0x0C5: "+", 0x015: "+", 0x055: "+", 0x0D5: "+", 

1593 0x035: "+", 0x075: "+", 0x0F5: "+", 0x007: "+", 0x047: "+", 0x0C7: "+", 0x017: "+", 

1594 0x057: "+", 0x0D7: "+", 0x037: "+", 0x077: "+", 0x0F7: "+", 0x00C: "+", 0x04C: "+", 

1595 0x0CC: "|", 0x01C: "+", 0x05C: "+", 0x0DC: "+", 0x03C: "+", 0x07C: "+", 0x0FC: "+", 

1596 0x00D: "+", 0x04D: "+", 0x0CD: "+", 0x01D: "+", 0x05D: "+", 0x0DD: "+", 0x03D: "+", 

1597 0x07D: "+", 0x0FD: "+", 0x00F: "+", 0x04F: "+", 0x0CF: "+", 0x01F: "+", 0x05F: "+", 

1598 0x0DF: "+", 0x03F: "+", 0x07F: "+", 0x0FF: "+", 0x100: " ", 0x140: "+", 0x1C0: "#", 

1599 0x110: "+", 0x150: "+", 0x1D0: "#", 0x130: "#", 0x170: "#", 0x1F0: "#", 0x101: "+", 

1600 0x141: "+", 0x1C1: "#", 0x111: "-", 0x151: "+", 0x1D1: "#", 0x131: "#", 0x171: "#", 

1601 0x1F1: "#", 0x103: "#", 0x143: "#", 0x1C3: "#", 0x113: "#", 0x153: "#", 0x1D3: "#", 

1602 0x133: "*", 0x173: "#", 0x1F3: "#", 0x104: "+", 0x144: "|", 0x1C4: "#", 0x114: "+", 

1603 0x154: "+", 0x1D4: "#", 0x134: "#", 0x174: "#", 0x1F4: "#", 0x105: "+", 0x145: "+", 

1604 0x1C5: "#", 0x115: "+", 0x155: "+", 0x1D5: "#", 0x135: "#", 0x175: "#", 0x1F5: "#", 

1605 0x107: "#", 0x147: "#", 0x1C7: "#", 0x117: "#", 0x157: "#", 0x1D7: "#", 0x137: "#", 

1606 0x177: "#", 0x1F7: "#", 0x10C: "#", 0x14C: "#", 0x1CC: "*", 0x11C: "#", 0x15C: "#", 

1607 0x1DC: "#", 0x13C: "#", 0x17C: "#", 0x1FC: "#", 0x10D: "#", 0x14D: "#", 0x1CD: "#", 

1608 0x11D: "#", 0x15D: "#", 0x1DD: "#", 0x13D: "#", 0x17D: "#", 0x1FD: "#", 0x10F: "#", 

1609 0x14F: "#", 0x1CF: "#", 0x11F: "#", 0x15F: "#", 0x1DF: "#", 0x13F: "#", 0x17F: "#", 

1610 0x1FF: "#", 

1611} 

1612# fmt: on