console-window 2.1.2__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. console_window-2.1.2/README.md → console_window-2.2.0/PKG-INFO +98 -0
  2. console_window-2.1.2/PKG-INFO → console_window-2.2.0/README.md +66 -20
  3. {console_window-2.1.2 → console_window-2.2.0}/console_window/__init__.py +18 -2
  4. console_window-2.2.0/console_window/appearance.py +135 -0
  5. console_window-2.2.0/console_window/cli.py +178 -0
  6. console_window-2.2.0/console_window/glyphs.py +555 -0
  7. console_window-2.2.0/console_window/help.py +66 -0
  8. {console_window-2.1.2 → console_window-2.2.0}/console_window/opts.py +73 -3
  9. {console_window-2.1.2 → console_window-2.2.0}/console_window/screens.py +36 -0
  10. {console_window-2.1.2 → console_window-2.2.0}/console_window/spinner.py +2 -1
  11. {console_window-2.1.2 → console_window-2.2.0}/console_window/theme.py +87 -5
  12. {console_window-2.1.2 → console_window-2.2.0}/console_window/widgets.py +21 -5
  13. {console_window-2.1.2 → console_window-2.2.0}/console_window/window.py +67 -14
  14. {console_window-2.1.2 → console_window-2.2.0}/pyproject.toml +51 -0
  15. console_window-2.1.2/.github/workflows/ci.yml +0 -66
  16. console_window-2.1.2/.gitignore +0 -207
  17. console_window-2.1.2/SampleApp.py +0 -568
  18. console_window-2.1.2/runner +0 -13
  19. console_window-2.1.2/tests/AUDIT.md +0 -120
  20. console_window-2.1.2/tests/CHANGELOG.md +0 -562
  21. console_window-2.1.2/tests/test_backend.py +0 -463
  22. console_window-2.1.2/tests/test_context.py +0 -45
  23. console_window-2.1.2/tests/test_demo_mode.py +0 -97
  24. console_window-2.1.2/tests/test_dialogs.py +0 -177
  25. console_window-2.1.2/tests/test_flash.py +0 -130
  26. console_window-2.1.2/tests/test_geometry.py +0 -116
  27. console_window-2.1.2/tests/test_header.py +0 -309
  28. console_window-2.1.2/tests/test_interactive.py +0 -725
  29. console_window-2.1.2/tests/test_low_colour.py +0 -221
  30. console_window-2.1.2/tests/test_overflow.py +0 -175
  31. console_window-2.1.2/tests/test_prompt_line.py +0 -133
  32. console_window-2.1.2/tests/test_prompt_window.py +0 -288
  33. console_window-2.1.2/tests/test_public_api.py +0 -130
  34. console_window-2.1.2/tests/test_render_retry.py +0 -88
  35. console_window-2.1.2/tests/test_sample_app.py +0 -225
  36. console_window-2.1.2/tests/test_screens.py +0 -172
  37. console_window-2.1.2/tests/test_signals.py +0 -166
  38. console_window-2.1.2/tests/test_spinner.py +0 -276
  39. console_window-2.1.2/tests/test_status.py +0 -240
  40. console_window-2.1.2/tests/test_taken_nav_keys.py +0 -251
  41. console_window-2.1.2/tests/test_textwidth.py +0 -197
  42. console_window-2.1.2/tests/test_theme.py +0 -218
  43. console_window-2.1.2/tests/test_viewport_pad.py +0 -152
  44. console_window-2.1.2/tests/test_widechars.py +0 -194
  45. console_window-2.1.2/tests/test_widgets.py +0 -297
  46. {console_window-2.1.2 → console_window-2.2.0}/LICENSE +0 -0
  47. {console_window-2.1.2 → console_window-2.2.0}/console_window/backend.py +0 -0
  48. {console_window-2.1.2 → console_window-2.2.0}/console_window/context.py +0 -0
  49. {console_window-2.1.2 → console_window-2.2.0}/console_window/geometry.py +0 -0
  50. {console_window-2.1.2 → console_window-2.2.0}/console_window/header.py +0 -0
  51. {console_window-2.1.2 → console_window-2.2.0}/console_window/keys.py +0 -0
  52. {console_window-2.1.2 → console_window-2.2.0}/console_window/signals.py +0 -0
  53. {console_window-2.1.2 → console_window-2.2.0}/console_window/textwidth.py +0 -0
@@ -1,3 +1,34 @@
1
+ Metadata-Version: 2.4
2
+ Name: console-window
3
+ Version: 2.2.0
4
+ Summary: A robust curses wrapper for creating Text User Interfaces (TUIs).
5
+ Author-email: Joe Defen <joedef@duck.com>
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Classifier: Development Status :: 5 - Production/Stable
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: POSIX :: Linux
12
+ Classifier: Topic :: Terminals
13
+ Classifier: Topic :: Software Development :: User Interfaces
14
+ License-File: LICENSE
15
+ Requires-Dist: pytest >= 7.0 ; extra == "dev"
16
+ Requires-Dist: pytest-cov >= 4.0 ; extra == "dev"
17
+ Requires-Dist: ruff >= 0.9 ; extra == "dev"
18
+ Requires-Dist: build >= 1.2 ; extra == "dev"
19
+ Requires-Dist: twine >= 7 ; extra == "dev"
20
+ Requires-Dist: packaging >= 26 ; extra == "dev"
21
+ Requires-Dist: tomli >= 2.0 ; extra == "dev" and ( python_version < "3.11")
22
+ Requires-Dist: pytest >= 7.0 ; extra == "test"
23
+ Requires-Dist: pytest-cov >= 4.0 ; extra == "test"
24
+ Requires-Dist: tomli >= 2.0 ; extra == "test" and ( python_version < "3.11")
25
+ Project-URL: Changelog, https://github.com/joedefen/console-window/blob/main/CHANGELOG.md
26
+ Project-URL: Homepage, https://github.com/joedefen/console-window
27
+ Project-URL: Issues, https://github.com/joedefen/console-window/issues
28
+ Project-URL: Repository, https://github.com/joedefen/console-window
29
+ Provides-Extra: dev
30
+ Provides-Extra: test
31
+
1
32
 
2
33
  # console-window
3
34
 
@@ -366,6 +397,72 @@ Mixing the two values raises `AssertionError` rather than silently changing what
366
397
  teardown means. Use `singleton=False` in tests and in applications that create
367
398
  independent windows (for example, one real window plus a headless one).
368
399
 
400
+ ### Symbols on terminals that cannot draw them
401
+
402
+ The UI is built around a handful of non-ASCII symbols (`│`, `⮞`, `█`, `←`, `→`,
403
+ `·`, box drawing). A terminal that cannot encode them does not fail loudly: it
404
+ paints a box, or nothing, and the row quietly loses its meaning. `console-window`
405
+ therefore keeps every decorative symbol in one registry, in two spellings, and
406
+ degrades by itself:
407
+
408
+ * **No code change.** A window detects the terminal when it is constructed — a
409
+ non-UTF-8 output encoding or `TERM=dumb` selects the ASCII spellings — and
410
+ text handed to `add_header()` / `add_body()` / `draw()` / `status()` /
411
+ `flash()` is transliterated before it is measured, so widths and the
412
+ run-length attribute counts stay correct.
413
+ * **Only decoration is rewritten.** `Grüße` stays `Grüße`: the substitution table
414
+ contains registered glyphs, never arbitrary text. Set
415
+ `CONSOLE_WINDOW_GLYPH_AUDIT=1` to log each decorative code point you use but
416
+ did not register, with the exact `register()` call to add.
417
+ * **App vocabulary.** Register what only your app draws:
418
+
419
+ ```python
420
+ Glyphs.register_many({'tick': ('✓', 'OK')}, probe=('tick',))
421
+ ```
422
+
423
+ or pass it with the window: `ConsoleWindowOpts(glyphs={'tick': ('✓', 'OK')},
424
+ glyphs_probe=('tick',))`. Once registered, the glyph both degrades with
425
+ `Glyphs.get('tick')` and appears in the font check via `Glyphs.probe_text()`.
426
+ * **The switches, with no argparse boilerplate.** The bundled helper adds them:
427
+
428
+ ```python
429
+ parser = cli.add_display_arguments(argparse.ArgumentParser())
430
+ opts = parser.parse_args()
431
+ cli.apply_display_arguments(opts, prog='myapp')
432
+ ```
433
+
434
+ `--ascii` forces the fallback (`--ascii=no` forces the full set) and
435
+ `--glyphs {auto,ascii,unicode}` is its symmetric spelling; `--glyphs` wins if
436
+ both are given. `--theme` seeds the first window's colour theme and
437
+ `--no-color` draws the semantic roles with bold/reverse attributes instead of
438
+ colour. Apps without argparse can use
439
+ `CONSOLE_WINDOW_GLYPHS=auto|ascii|unicode`, which outranks detection.
440
+ * **The mode is process-wide**, like the colour-pair table, because there is one
441
+ terminal. `ConsoleWindowOpts(ascii=...)` seeds it and `win.ascii_mode` reads
442
+ it; a window constructed later never reverts an explicit choice.
443
+ * **Help screens can explain it for you.** Return
444
+ `help.theme_paragraph()` from `Screen.extra_help_paragraphs()` and set
445
+ `show_display_notes = True` on a `BasicHelpScreen` subclass to append the font
446
+ check (Unicode mode) or the way back to the full set (ASCII mode). Both are
447
+ off by default, so an upgrading app's help output is unchanged.
448
+
449
+ Themes work the same way: `Theme.create_picker_screen(base, cycle_key=None,
450
+ store=None, note=None)` discovers the key you bound to `spin_theme` for its hint
451
+ line, remembers the choice through any object with `load()`/`save()`, and
452
+ `Theme.initial(cli_theme, store)` returns the theme a new process should start
453
+ on. The library never opens a file.
454
+
455
+ The appearance layer is shared too. `console_window.appearance.semantic_attr(role)`
456
+ returns the installed colour pair for a semantic role, or the matching attribute
457
+ from `MONO_ROLES` when colour is off; it degrades to plain text when the terminal
458
+ has no colour or curses has not been initialised, so a redraw is never aborted by
459
+ a colour. `set_colors_enabled()` / `colors_enabled()` hold the process-wide
460
+ switch, and `resolve_appearance(cli_theme=None, no_color=False, saved_theme='',
461
+ saved_colors=True, environ=None)` returns the `(theme, colors)` to start with:
462
+ `--no-color` first, then an explicit `--theme`, then `NO_COLOR`, then the
463
+ remembered choice. Apps keep their preference file and their own pseudo-themes;
464
+ the library gives them the policy, not the storage.
465
+
369
466
  ## Versioning and Deprecation
370
467
 
371
468
  `console-window` follows semantic versioning: patch releases fix behaviour
@@ -418,3 +515,4 @@ For more extensive examples, you can look at some projects using `console-window
418
515
  * viewer of /proc/meminfo that shows historical values
419
516
  * [vappman](https://github.com/joedefen/vappman)
420
517
  * convenince wrapper to mostly hide CLI of [AppMan](https://github.com/ivan-hc/AppMan), a life-cycle manager for 2000+ AppImages
518
+
@@ -1,22 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: console-window
3
- Version: 2.1.2
4
- Summary: A robust curses wrapper for creating Text User Interfaces (TUIs).
5
- Author-email: Joe Defen <joedef@duck.com>
6
- Requires-Python: >=3.10
7
- Description-Content-Type: text/markdown
8
- Classifier: Development Status :: 5 - Production/Stable
9
- Classifier: Programming Language :: Python :: 3
10
- Classifier: License :: OSI Approved :: MIT License
11
- Classifier: Operating System :: POSIX :: Linux
12
- Classifier: Topic :: Terminals
13
- Classifier: Topic :: Software Development :: User Interfaces
14
- License-File: LICENSE
15
- Project-URL: Changelog, https://github.com/joedefen/console-window/blob/main/CHANGELOG.md
16
- Project-URL: Homepage, https://github.com/joedefen/console-window
17
- Project-URL: Issues, https://github.com/joedefen/console-window/issues
18
- Project-URL: Repository, https://github.com/joedefen/console-window
19
-
20
1
 
21
2
  # console-window
22
3
 
@@ -385,6 +366,72 @@ Mixing the two values raises `AssertionError` rather than silently changing what
385
366
  teardown means. Use `singleton=False` in tests and in applications that create
386
367
  independent windows (for example, one real window plus a headless one).
387
368
 
369
+ ### Symbols on terminals that cannot draw them
370
+
371
+ The UI is built around a handful of non-ASCII symbols (`│`, `⮞`, `█`, `←`, `→`,
372
+ `·`, box drawing). A terminal that cannot encode them does not fail loudly: it
373
+ paints a box, or nothing, and the row quietly loses its meaning. `console-window`
374
+ therefore keeps every decorative symbol in one registry, in two spellings, and
375
+ degrades by itself:
376
+
377
+ * **No code change.** A window detects the terminal when it is constructed — a
378
+ non-UTF-8 output encoding or `TERM=dumb` selects the ASCII spellings — and
379
+ text handed to `add_header()` / `add_body()` / `draw()` / `status()` /
380
+ `flash()` is transliterated before it is measured, so widths and the
381
+ run-length attribute counts stay correct.
382
+ * **Only decoration is rewritten.** `Grüße` stays `Grüße`: the substitution table
383
+ contains registered glyphs, never arbitrary text. Set
384
+ `CONSOLE_WINDOW_GLYPH_AUDIT=1` to log each decorative code point you use but
385
+ did not register, with the exact `register()` call to add.
386
+ * **App vocabulary.** Register what only your app draws:
387
+
388
+ ```python
389
+ Glyphs.register_many({'tick': ('✓', 'OK')}, probe=('tick',))
390
+ ```
391
+
392
+ or pass it with the window: `ConsoleWindowOpts(glyphs={'tick': ('✓', 'OK')},
393
+ glyphs_probe=('tick',))`. Once registered, the glyph both degrades with
394
+ `Glyphs.get('tick')` and appears in the font check via `Glyphs.probe_text()`.
395
+ * **The switches, with no argparse boilerplate.** The bundled helper adds them:
396
+
397
+ ```python
398
+ parser = cli.add_display_arguments(argparse.ArgumentParser())
399
+ opts = parser.parse_args()
400
+ cli.apply_display_arguments(opts, prog='myapp')
401
+ ```
402
+
403
+ `--ascii` forces the fallback (`--ascii=no` forces the full set) and
404
+ `--glyphs {auto,ascii,unicode}` is its symmetric spelling; `--glyphs` wins if
405
+ both are given. `--theme` seeds the first window's colour theme and
406
+ `--no-color` draws the semantic roles with bold/reverse attributes instead of
407
+ colour. Apps without argparse can use
408
+ `CONSOLE_WINDOW_GLYPHS=auto|ascii|unicode`, which outranks detection.
409
+ * **The mode is process-wide**, like the colour-pair table, because there is one
410
+ terminal. `ConsoleWindowOpts(ascii=...)` seeds it and `win.ascii_mode` reads
411
+ it; a window constructed later never reverts an explicit choice.
412
+ * **Help screens can explain it for you.** Return
413
+ `help.theme_paragraph()` from `Screen.extra_help_paragraphs()` and set
414
+ `show_display_notes = True` on a `BasicHelpScreen` subclass to append the font
415
+ check (Unicode mode) or the way back to the full set (ASCII mode). Both are
416
+ off by default, so an upgrading app's help output is unchanged.
417
+
418
+ Themes work the same way: `Theme.create_picker_screen(base, cycle_key=None,
419
+ store=None, note=None)` discovers the key you bound to `spin_theme` for its hint
420
+ line, remembers the choice through any object with `load()`/`save()`, and
421
+ `Theme.initial(cli_theme, store)` returns the theme a new process should start
422
+ on. The library never opens a file.
423
+
424
+ The appearance layer is shared too. `console_window.appearance.semantic_attr(role)`
425
+ returns the installed colour pair for a semantic role, or the matching attribute
426
+ from `MONO_ROLES` when colour is off; it degrades to plain text when the terminal
427
+ has no colour or curses has not been initialised, so a redraw is never aborted by
428
+ a colour. `set_colors_enabled()` / `colors_enabled()` hold the process-wide
429
+ switch, and `resolve_appearance(cli_theme=None, no_color=False, saved_theme='',
430
+ saved_colors=True, environ=None)` returns the `(theme, colors)` to start with:
431
+ `--no-color` first, then an explicit `--theme`, then `NO_COLOR`, then the
432
+ remembered choice. Apps keep their preference file and their own pseudo-themes;
433
+ the library gives them the policy, not the storage.
434
+
388
435
  ## Versioning and Deprecation
389
436
 
390
437
  `console-window` follows semantic versioning: patch releases fix behaviour
@@ -437,4 +484,3 @@ For more extensive examples, you can look at some projects using `console-window
437
484
  * viewer of /proc/meminfo that shows historical values
438
485
  * [vappman](https://github.com/joedefen/vappman)
439
486
  * convenince wrapper to mostly hide CLI of [AppMan](https://github.com/ivan-hc/AppMan), a life-cycle manager for 2000+ AppImages
440
-
@@ -1,8 +1,16 @@
1
1
  # Single source of truth for the package version: pyproject.toml declares
2
2
  # ``dynamic = ["version"]``, so flit reads this value instead of duplicating it.
3
- __version__ = "2.1.2"
3
+ __version__ = "2.2.0"
4
4
 
5
+ from .appearance import (
6
+ MONO_ROLES,
7
+ colors_enabled,
8
+ resolve_appearance,
9
+ semantic_attr,
10
+ set_colors_enabled,
11
+ )
5
12
  from .context import Context
13
+ from .glyphs import Glyph, Glyphs
6
14
  from .header import HeaderLine, Span
7
15
  from .keys import NAVIGATION_KEYS
8
16
  from .opts import ConsoleWindowOpts
@@ -15,18 +23,21 @@ from .screens import (
15
23
  )
16
24
  from .signals import reset_ctrl_c
17
25
  from .spinner import OptionSpinner
18
- from .theme import Theme
26
+ from .theme import Theme, ThemeStore
19
27
  from .widgets import IncrementalSearchBar, InlineConfirmation, PromptLine
20
28
  from .window import ConsoleWindow, TerminalTooSmallError
21
29
 
22
30
  __all__ = [
23
31
  'HOME_ST',
32
+ 'MONO_ROLES',
24
33
  'NAVIGATION_KEYS',
25
34
  'BasicHelpScreen',
26
35
  'BasicTextScreen',
27
36
  'ConsoleWindow',
28
37
  'ConsoleWindowOpts',
29
38
  'Context',
39
+ 'Glyph',
40
+ 'Glyphs',
30
41
  'HeaderLine',
31
42
  'IncrementalSearchBar',
32
43
  'InlineConfirmation',
@@ -37,5 +48,10 @@ __all__ = [
37
48
  'Span',
38
49
  'TerminalTooSmallError',
39
50
  'Theme',
51
+ 'ThemeStore',
52
+ 'colors_enabled',
40
53
  'reset_ctrl_c',
54
+ 'resolve_appearance',
55
+ 'semantic_attr',
56
+ 'set_colors_enabled',
41
57
  ]
@@ -0,0 +1,135 @@
1
+ """Semantic colour roles, and one resolver for the colour switch.
2
+
3
+ Colour here is decoration, never information. Every role degrades to an
4
+ attribute when colour is off, so the same hierarchy survives on a monochrome
5
+ terminal or under ``--no-color``. Applications kept writing this layer twice:
6
+ the role-to-attribute mapping, the process-wide switch seeded by ``--no-color``
7
+ or ``NO_COLOR``, and a lookup that must never abort a redraw. It now lives
8
+ here.
9
+
10
+ The role ids are :class:`Theme`'s semantic pairs rather than literal colour
11
+ numbers. The library installs those pairs at startup and re-installs them
12
+ whenever a theme is applied, so changing the active theme recolours the whole UI
13
+ without an edit to any draw code.
14
+
15
+ ``--no-color`` is a per-run switch, not a saved preference: it replaces the
16
+ pairs with the attributes in :data:`MONO_ROLES` for this process. Picking a
17
+ theme from the colour-theme screen is a request for colour, so the caller can
18
+ lift the switch again -- :func:`resolve_appearance` does that when it sees an
19
+ explicit theme.
20
+
21
+ Nothing application-specific belongs in this module: preference files, app
22
+ vocabulary and app pseudo-themes stay with the application. An app that has a
23
+ ``mono`` pseudo-theme normalises that name to ``''`` plus ``saved_colors=False``
24
+ before calling :func:`resolve_appearance`.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import curses
30
+ import os
31
+ from collections.abc import Mapping
32
+
33
+ from .theme import Theme
34
+
35
+ __all__ = [
36
+ 'MONO_ROLES',
37
+ 'colors_enabled',
38
+ 'resolve_appearance',
39
+ 'semantic_attr',
40
+ 'set_colors_enabled',
41
+ ]
42
+
43
+ # Role -> attribute used when colour is off. The hierarchy is kept with
44
+ # emphasis instead of hue: the destructive roles are bold+reverse, warnings,
45
+ # successes and emphasis are bold, and the informational roles carry nothing
46
+ # (their surrounding structure already distinguishes them).
47
+ MONO_ROLES: dict[int, int] = {
48
+ Theme.DANGER: curses.A_BOLD | curses.A_REVERSE,
49
+ Theme.ERROR: curses.A_BOLD | curses.A_REVERSE,
50
+ Theme.WARNING: curses.A_BOLD,
51
+ Theme.SUCCESS: curses.A_BOLD,
52
+ Theme.EMPHASIS: curses.A_BOLD,
53
+ Theme.INFO: curses.A_NORMAL,
54
+ Theme.PROGRESS: curses.A_NORMAL,
55
+ Theme.OLD_SUCCESS: curses.A_NORMAL,
56
+ Theme.HOTSWAP: curses.A_NORMAL,
57
+ }
58
+
59
+ # Process-wide switch, seeded at startup by --no-color / NO_COLOR. One
60
+ # terminal, so one decision.
61
+ _COLORS_ENABLED = True
62
+
63
+
64
+ def set_colors_enabled(enabled: bool) -> None:
65
+ """Turn semantic colour on or off for the whole process."""
66
+ global _COLORS_ENABLED # one terminal, one process-wide switch
67
+ _COLORS_ENABLED = bool(enabled)
68
+
69
+
70
+ def colors_enabled() -> bool:
71
+ """True while semantic roles resolve to colour pairs rather than attributes."""
72
+ return _COLORS_ENABLED
73
+
74
+
75
+ def resolve_appearance(
76
+ cli_theme: str | None = None,
77
+ no_color: bool = False,
78
+ saved_theme: str | None = '',
79
+ saved_colors: bool = True,
80
+ environ: Mapping[str, str] | None = None,
81
+ ) -> tuple[str, bool]:
82
+ """Starting ``(theme, colors)`` from the switches, environment and memory.
83
+
84
+ Precedence, highest first: ``--no-color``, an explicit ``--theme`` (a
85
+ statement of intent, so it is honoured even when ``NO_COLOR`` is set), the
86
+ ``NO_COLOR`` convention (any non-empty value disables colour, per
87
+ no-color.org), then the remembered choice.
88
+
89
+ ``cli_theme``/``saved_theme`` are returned resolved to a known library
90
+ theme; ``''`` means "no explicit theme, keep the library default". An
91
+ unknown name resolves to ``'default'``, exactly like :meth:`Theme.resolve`.
92
+
93
+ The result is a decision, not an action: call
94
+ :func:`set_colors_enabled` with the second value (or pass both on to
95
+ whatever applies themes) when the process should start that way.
96
+ """
97
+ env = os.environ if environ is None else environ
98
+
99
+ if no_color:
100
+ colors = False
101
+ elif cli_theme:
102
+ colors = True
103
+ elif env.get('NO_COLOR'):
104
+ colors = False
105
+ else:
106
+ colors = bool(saved_colors)
107
+
108
+ if cli_theme:
109
+ theme = Theme.resolve(cli_theme)
110
+ elif saved_theme:
111
+ theme = Theme.resolve(saved_theme)
112
+ else:
113
+ theme = ''
114
+ return theme, colors
115
+
116
+
117
+ def semantic_attr(pair_id: int) -> int:
118
+ """Attribute for a :class:`Theme` role, or plain when colour is off.
119
+
120
+ With colour off the role becomes an attribute from :data:`MONO_ROLES`.
121
+ Otherwise both ``curses.has_colors()`` and ``curses.color_pair()`` can raise
122
+ before ``initscr()`` (``color_pair()`` refuses to run until then), and a
123
+ failed lookup must never abort a redraw, so any failure degrades to plain
124
+ text. The ``except`` is deliberately broad for that reason: a guarded
125
+ redraw is worth more than a precise exception list, and the fallback is
126
+ always the same.
127
+ """
128
+ if not _COLORS_ENABLED:
129
+ return MONO_ROLES.get(pair_id, curses.A_NORMAL)
130
+ try:
131
+ if not curses.has_colors():
132
+ return 0
133
+ return curses.color_pair(pair_id)
134
+ except Exception: # terminal not initialised yet; never abort a redraw
135
+ return 0
@@ -0,0 +1,178 @@
1
+ """Argument-parser boilerplate for the display options.
2
+
3
+ Every curses app ends up writing the same four switches: a way to force the
4
+ plain-ASCII symbol set, a way to pick a colour theme, a way to turn semantic
5
+ colour off, and a way to print what was detected. This module supplies them,
6
+ so an app's ``main()`` gains two lines instead of sixty:
7
+
8
+ parser = cli.add_display_arguments(parser)
9
+ ...
10
+ cli.apply_display_arguments(opts, prog='myapp')
11
+
12
+ ``--ascii`` and ``--glyphs`` are deliberately symmetric. ``--ascii`` accepts an
13
+ optional boolean (``--ascii``, ``--ascii=no``) and ``--glyphs`` accepts a mode
14
+ (``auto``, ``ascii``, ``unicode``); ``--glyphs`` wins when both are given, and
15
+ "absent" everywhere means *detect*, never "assume Unicode".
16
+
17
+ ``--no-color`` is the appearance switch. :func:`apply_display_arguments` acts on
18
+ it, and an app that remembers a colour preference can settle the whole decision
19
+ with :func:`console_window.appearance.resolve_appearance`.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import sys
26
+ from collections.abc import Callable, Mapping
27
+ from typing import TextIO
28
+
29
+ from . import appearance, glyphs
30
+ from .glyphs import TerminalReport
31
+ from .theme import Theme, ThemeStore
32
+
33
+ __all__ = [
34
+ 'add_display_arguments',
35
+ 'apply_display_arguments',
36
+ 'resolve_force_ascii',
37
+ 'resolve_no_color',
38
+ ]
39
+
40
+ _TRUE_WORDS = frozenset({'1', 'true', 'yes', 'y', 'on'})
41
+ _FALSE_WORDS = frozenset({'0', 'false', 'no', 'n', 'off'})
42
+ _MODES = ('auto', 'ascii', 'unicode')
43
+
44
+
45
+ def _bool_argument(value: str) -> bool:
46
+ """argparse ``type=`` for ``--ascii=VALUE``; bare ``--ascii`` uses ``const``."""
47
+ lowered = value.strip().lower()
48
+ if lowered in _TRUE_WORDS:
49
+ return True
50
+ if lowered in _FALSE_WORDS:
51
+ return False
52
+ raise argparse.ArgumentTypeError(
53
+ f'expected a boolean (yes/no, true/false, on/off), got {value!r}'
54
+ )
55
+
56
+
57
+ def add_display_arguments(
58
+ parser: argparse.ArgumentParser,
59
+ *,
60
+ theme_store: ThemeStore | None = None,
61
+ prog: str | None = None,
62
+ ) -> argparse.ArgumentParser:
63
+ """Add ``--ascii``, ``--glyphs``, ``--theme`` and ``--no-color`` to *parser*.
64
+
65
+ Returns *parser* so a call site can wrap its own construction. *theme_store*
66
+ is accepted for symmetry with :func:`apply_display_arguments` (an app that
67
+ passes one here is one that remembers the choice); it does not change the
68
+ switches. *prog* only shapes the help text.
69
+ """
70
+ name = prog or parser.prog or 'this program'
71
+ remembered = ' (remembered for the next start)' if theme_store is not None else ''
72
+ parser.add_argument(
73
+ '--ascii',
74
+ nargs='?',
75
+ const=True,
76
+ default=None,
77
+ type=_bool_argument,
78
+ metavar='BOOL',
79
+ help=(
80
+ f'use plain-ASCII symbols; --ascii=no forces the Unicode set. '
81
+ f'Without the switch {name} detects the terminal'
82
+ ),
83
+ )
84
+ parser.add_argument(
85
+ '--glyphs',
86
+ choices=_MODES,
87
+ default=None,
88
+ help=(
89
+ 'symbol mode: auto detects the terminal, ascii forces the fallback, '
90
+ 'unicode forces the full set (same thing as --ascii / --ascii=no)'
91
+ ),
92
+ )
93
+ parser.add_argument(
94
+ '--theme',
95
+ choices=Theme.list_all(),
96
+ default=None,
97
+ help=f'color theme{remembered}',
98
+ )
99
+ parser.add_argument(
100
+ '--no-color',
101
+ action='store_true',
102
+ default=None,
103
+ help=(
104
+ 'draw the semantic roles with attributes instead of colour; wins '
105
+ 'over --theme, and NO_COLOR does the same when the switch is absent'
106
+ ),
107
+ )
108
+ return parser
109
+
110
+
111
+ def resolve_force_ascii(args: argparse.Namespace) -> bool | None:
112
+ """Map a parsed namespace to ``force_ascii``: True, False or None (detect)."""
113
+ mode = getattr(args, 'glyphs', None)
114
+ if mode == 'ascii':
115
+ return True
116
+ if mode == 'unicode':
117
+ return False
118
+ if mode == 'auto':
119
+ return None
120
+ flag = getattr(args, 'ascii', None)
121
+ if flag is None or isinstance(flag, bool):
122
+ return flag
123
+ raise TypeError(
124
+ f'args.ascii must be None or a bool (it is {flag!r}); parse it with '
125
+ 'add_display_arguments() or pass a bool'
126
+ )
127
+
128
+
129
+ def resolve_no_color(args: argparse.Namespace) -> bool:
130
+ """Map a parsed namespace to the ``--no-color`` switch (absent means off)."""
131
+ return bool(getattr(args, 'no_color', False))
132
+
133
+
134
+ def apply_display_arguments(
135
+ args: argparse.Namespace,
136
+ *,
137
+ printer: Callable[[str], object] = print,
138
+ prog: str = '',
139
+ environ: Mapping[str, str] | None = None,
140
+ stream: TextIO | None = None,
141
+ ) -> TerminalReport:
142
+ """Set the glyph mode, print the report's notes, seed the theme, honour colour.
143
+
144
+ Call this once, *before* any window is created, so the mode is decided while
145
+ nothing has been drawn. Returns the :class:`~console_window.glyphs.
146
+ TerminalReport` so a caller can react to ``ascii_required`` (for example by
147
+ printing a warning of its own).
148
+
149
+ An explicit ``--no-color`` turns the process-wide colour switch off here; an
150
+ absent switch is left alone, so an app with a remembered preference is free
151
+ to call ``set_colors_enabled()`` with its own decision. The two halves of
152
+ that decision -- the theme to install and whether colour is on -- come from
153
+ :func:`console_window.appearance.resolve_appearance`.
154
+
155
+ *printer* is a callable taking one string; pass ``sys.stderr.write`` style
156
+ wrappers through ``lambda line: print(line, file=...)`` if needed.
157
+ """
158
+ report = glyphs.apply_policy(
159
+ resolve_force_ascii(args),
160
+ environ=environ,
161
+ stream=sys.stdout if stream is None else stream,
162
+ )
163
+ for note in report.notes:
164
+ printer(f'{prog}: {note}' if prog else note)
165
+
166
+ if resolve_no_color(args):
167
+ # Only the explicit flag is acted on; "absent" belongs to the caller.
168
+ appearance.set_colors_enabled(False)
169
+
170
+ theme = getattr(args, 'theme', None)
171
+ if theme:
172
+ if theme in Theme.list_all():
173
+ # No window exists yet, so record the choice for the first one.
174
+ Theme.set_active(theme)
175
+ else:
176
+ message = f'unknown theme {theme!r}; keeping {Theme.resolve(None)!r}'
177
+ printer(f'{prog}: {message}' if prog else message)
178
+ return report