console-window 2.1.1__tar.gz → 2.1.3__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 (62) hide show
  1. {console_window-2.1.1 → console_window-2.1.3}/PKG-INFO +55 -1
  2. {console_window-2.1.1 → console_window-2.1.3}/README.md +54 -0
  3. {console_window-2.1.1 → console_window-2.1.3}/SampleApp.py +26 -1
  4. {console_window-2.1.1 → console_window-2.1.3}/console_window/__init__.py +6 -2
  5. console_window-2.1.3/console_window/cli.py +145 -0
  6. console_window-2.1.3/console_window/glyphs.py +555 -0
  7. console_window-2.1.3/console_window/help.py +66 -0
  8. {console_window-2.1.1 → console_window-2.1.3}/console_window/opts.py +73 -3
  9. {console_window-2.1.1 → console_window-2.1.3}/console_window/screens.py +36 -0
  10. {console_window-2.1.1 → console_window-2.1.3}/console_window/spinner.py +2 -1
  11. {console_window-2.1.1 → console_window-2.1.3}/console_window/textwidth.py +31 -3
  12. {console_window-2.1.1 → console_window-2.1.3}/console_window/theme.py +87 -5
  13. {console_window-2.1.1 → console_window-2.1.3}/console_window/widgets.py +40 -8
  14. {console_window-2.1.1 → console_window-2.1.3}/console_window/window.py +162 -41
  15. console_window-2.1.3/docs/GLYPH-MIGRATION-PLAN.md +380 -0
  16. {console_window-2.1.1 → console_window-2.1.3}/tests/CHANGELOG.md +86 -0
  17. console_window-2.1.3/tests/conftest.py +23 -0
  18. console_window-2.1.3/tests/test_cli_display_args.py +131 -0
  19. console_window-2.1.3/tests/test_demo_mode.py +97 -0
  20. console_window-2.1.3/tests/test_glyph_policy.py +141 -0
  21. console_window-2.1.3/tests/test_glyph_rendering.py +124 -0
  22. console_window-2.1.3/tests/test_glyph_transliteration.py +107 -0
  23. console_window-2.1.3/tests/test_glyphs.py +121 -0
  24. {console_window-2.1.1 → console_window-2.1.3}/tests/test_interactive.py +11 -0
  25. console_window-2.1.3/tests/test_prompt_window.py +288 -0
  26. {console_window-2.1.1 → console_window-2.1.3}/tests/test_public_api.py +4 -1
  27. {console_window-2.1.1 → console_window-2.1.3}/tests/test_textwidth.py +34 -0
  28. console_window-2.1.3/tests/test_theme_picker.py +165 -0
  29. console_window-2.1.1/tests/test_demo_mode.py +0 -46
  30. console_window-2.1.1/tests/test_prompt_window.py +0 -152
  31. {console_window-2.1.1 → console_window-2.1.3}/.github/workflows/ci.yml +0 -0
  32. {console_window-2.1.1 → console_window-2.1.3}/.gitignore +0 -0
  33. {console_window-2.1.1 → console_window-2.1.3}/LICENSE +0 -0
  34. {console_window-2.1.1 → console_window-2.1.3}/console_window/backend.py +0 -0
  35. {console_window-2.1.1 → console_window-2.1.3}/console_window/context.py +0 -0
  36. {console_window-2.1.1 → console_window-2.1.3}/console_window/geometry.py +0 -0
  37. {console_window-2.1.1 → console_window-2.1.3}/console_window/header.py +0 -0
  38. {console_window-2.1.1 → console_window-2.1.3}/console_window/keys.py +0 -0
  39. {console_window-2.1.1 → console_window-2.1.3}/console_window/signals.py +0 -0
  40. {console_window-2.1.1 → console_window-2.1.3}/pyproject.toml +0 -0
  41. {console_window-2.1.1 → console_window-2.1.3}/runner +0 -0
  42. {console_window-2.1.1 → console_window-2.1.3}/tests/AUDIT.md +0 -0
  43. {console_window-2.1.1 → console_window-2.1.3}/tests/test_backend.py +0 -0
  44. {console_window-2.1.1 → console_window-2.1.3}/tests/test_context.py +0 -0
  45. {console_window-2.1.1 → console_window-2.1.3}/tests/test_dialogs.py +0 -0
  46. {console_window-2.1.1 → console_window-2.1.3}/tests/test_flash.py +0 -0
  47. {console_window-2.1.1 → console_window-2.1.3}/tests/test_geometry.py +0 -0
  48. {console_window-2.1.1 → console_window-2.1.3}/tests/test_header.py +0 -0
  49. {console_window-2.1.1 → console_window-2.1.3}/tests/test_low_colour.py +0 -0
  50. {console_window-2.1.1 → console_window-2.1.3}/tests/test_overflow.py +0 -0
  51. {console_window-2.1.1 → console_window-2.1.3}/tests/test_prompt_line.py +0 -0
  52. {console_window-2.1.1 → console_window-2.1.3}/tests/test_render_retry.py +0 -0
  53. {console_window-2.1.1 → console_window-2.1.3}/tests/test_sample_app.py +0 -0
  54. {console_window-2.1.1 → console_window-2.1.3}/tests/test_screens.py +0 -0
  55. {console_window-2.1.1 → console_window-2.1.3}/tests/test_signals.py +0 -0
  56. {console_window-2.1.1 → console_window-2.1.3}/tests/test_spinner.py +0 -0
  57. {console_window-2.1.1 → console_window-2.1.3}/tests/test_status.py +0 -0
  58. {console_window-2.1.1 → console_window-2.1.3}/tests/test_taken_nav_keys.py +0 -0
  59. {console_window-2.1.1 → console_window-2.1.3}/tests/test_theme.py +0 -0
  60. {console_window-2.1.1 → console_window-2.1.3}/tests/test_viewport_pad.py +0 -0
  61. {console_window-2.1.1 → console_window-2.1.3}/tests/test_widechars.py +0 -0
  62. {console_window-2.1.1 → console_window-2.1.3}/tests/test_widgets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: console-window
3
- Version: 2.1.1
3
+ Version: 2.1.3
4
4
  Summary: A robust curses wrapper for creating Text User Interfaces (TUIs).
5
5
  Author-email: Joe Defen <joedef@duck.com>
6
6
  Requires-Python: >=3.10
@@ -385,6 +385,60 @@ Mixing the two values raises `AssertionError` rather than silently changing what
385
385
  teardown means. Use `singleton=False` in tests and in applications that create
386
386
  independent windows (for example, one real window plus a headless one).
387
387
 
388
+ ### Symbols on terminals that cannot draw them
389
+
390
+ The UI is built around a handful of non-ASCII symbols (`│`, `⮞`, `█`, `←`, `→`,
391
+ `·`, box drawing). A terminal that cannot encode them does not fail loudly: it
392
+ paints a box, or nothing, and the row quietly loses its meaning. `console-window`
393
+ therefore keeps every decorative symbol in one registry, in two spellings, and
394
+ degrades by itself:
395
+
396
+ * **No code change.** A window detects the terminal when it is constructed — a
397
+ non-UTF-8 output encoding or `TERM=dumb` selects the ASCII spellings — and
398
+ text handed to `add_header()` / `add_body()` / `draw()` / `status()` /
399
+ `flash()` is transliterated before it is measured, so widths and the
400
+ run-length attribute counts stay correct.
401
+ * **Only decoration is rewritten.** `Grüße` stays `Grüße`: the substitution table
402
+ contains registered glyphs, never arbitrary text. Set
403
+ `CONSOLE_WINDOW_GLYPH_AUDIT=1` to log each decorative code point you use but
404
+ did not register, with the exact `register()` call to add.
405
+ * **App vocabulary.** Register what only your app draws:
406
+
407
+ ```python
408
+ Glyphs.register_many({'tick': ('✓', 'OK')}, probe=('tick',))
409
+ ```
410
+
411
+ or pass it with the window: `ConsoleWindowOpts(glyphs={'tick': ('✓', 'OK')},
412
+ glyphs_probe=('tick',))`. Once registered, the glyph both degrades with
413
+ `Glyphs.get('tick')` and appears in the font check via `Glyphs.probe_text()`.
414
+ * **Two switches, no argparse boilerplate.** The bundled helper adds them:
415
+
416
+ ```python
417
+ parser = cli.add_display_arguments(argparse.ArgumentParser())
418
+ opts = parser.parse_args()
419
+ cli.apply_display_arguments(opts, prog='myapp')
420
+ ```
421
+
422
+ `--ascii` forces the fallback (`--ascii=no` forces the full set) and
423
+ `--glyphs {auto,ascii,unicode}` is its symmetric spelling; `--glyphs` wins if
424
+ both are given. `--theme` seeds the first window's colour theme. Apps without
425
+ argparse can use `CONSOLE_WINDOW_GLYPHS=auto|ascii|unicode`, which outranks
426
+ detection.
427
+ * **The mode is process-wide**, like the colour-pair table, because there is one
428
+ terminal. `ConsoleWindowOpts(ascii=...)` seeds it and `win.ascii_mode` reads
429
+ it; a window constructed later never reverts an explicit choice.
430
+ * **Help screens can explain it for you.** Return
431
+ `help.theme_paragraph()` from `Screen.extra_help_paragraphs()` and set
432
+ `show_display_notes = True` on a `BasicHelpScreen` subclass to append the font
433
+ check (Unicode mode) or the way back to the full set (ASCII mode). Both are
434
+ off by default, so an upgrading app's help output is unchanged.
435
+
436
+ Themes work the same way: `Theme.create_picker_screen(base, cycle_key=None,
437
+ store=None, note=None)` discovers the key you bound to `spin_theme` for its hint
438
+ line, remembers the choice through any object with `load()`/`save()`, and
439
+ `Theme.initial(cli_theme, store)` returns the theme a new process should start
440
+ on. The library never opens a file.
441
+
388
442
  ## Versioning and Deprecation
389
443
 
390
444
  `console-window` follows semantic versioning: patch releases fix behaviour
@@ -366,6 +366,60 @@ Mixing the two values raises `AssertionError` rather than silently changing what
366
366
  teardown means. Use `singleton=False` in tests and in applications that create
367
367
  independent windows (for example, one real window plus a headless one).
368
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
+ * **Two switches, 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. Apps without
406
+ argparse can use `CONSOLE_WINDOW_GLYPHS=auto|ascii|unicode`, which outranks
407
+ detection.
408
+ * **The mode is process-wide**, like the colour-pair table, because there is one
409
+ terminal. `ConsoleWindowOpts(ascii=...)` seeds it and `win.ascii_mode` reads
410
+ it; a window constructed later never reverts an explicit choice.
411
+ * **Help screens can explain it for you.** Return
412
+ `help.theme_paragraph()` from `Screen.extra_help_paragraphs()` and set
413
+ `show_display_notes = True` on a `BasicHelpScreen` subclass to append the font
414
+ check (Unicode mode) or the way back to the full set (ASCII mode). Both are
415
+ off by default, so an upgrading app's help output is unchanged.
416
+
417
+ Themes work the same way: `Theme.create_picker_screen(base, cycle_key=None,
418
+ store=None, note=None)` discovers the key you bound to `spin_theme` for its hint
419
+ line, remembers the choice through any object with `load()`/`save()`, and
420
+ `Theme.initial(cli_theme, store)` returns the theme a new process should start
421
+ on. The library never opens a file.
422
+
369
423
  ## Versioning and Deprecation
370
424
 
371
425
  `console-window` follows semantic versioning: patch releases fix behaviour
@@ -20,6 +20,13 @@ from console_window import (
20
20
  ScreenStack,
21
21
  Theme,
22
22
  )
23
+ from console_window import help as help_text
24
+
25
+ #: The demo's own vocabulary: a glyph the library does not know about. It is
26
+ #: handed to the window (not registered globally) to show the option route; the
27
+ #: registered fallback means ``\u2713`` degrades to ``OK`` automatically.
28
+ DEMO_GLYPHS = {'tick': ('\u2713', 'OK')}
29
+ DEMO_PROBE = ('tick', 'cursor', 'choice', 'scroll_prev', 'scroll_next')
23
30
 
24
31
  # Screen IDs
25
32
  MAIN_ST = 0
@@ -67,6 +74,9 @@ class SampleApp:
67
74
  # Configure console window
68
75
  win_opts = ConsoleWindowOpts(
69
76
  head_line=True,
77
+ # Symbol set: one app glyph plus the font check the HELP screen shows.
78
+ glyphs=DEMO_GLYPHS,
79
+ glyphs_probe=DEMO_PROBE,
70
80
  # No row sizing needed: the pads are viewport-sized and rows live in
71
81
  # the model, so the 'z' stress mode's 2000 filler rows need no
72
82
  # option. max_rows is only a safety ceiling (32767 by default).
@@ -498,9 +508,15 @@ class MainScreen(DemoScreen):
498
508
 
499
509
 
500
510
  class HelpScreen(DemoScreen, BasicHelpScreen):
511
+ # Show the library's font-check / ASCII-fallback note under our own text.
512
+ show_display_notes = True
513
+
501
514
  def __init__(self, app):
502
515
  super().__init__('HELP', app)
503
516
 
517
+ def extra_help_paragraphs(self):
518
+ return (help_text.theme_paragraph(open_key='T', cycle_key='t'),)
519
+
504
520
  # def draw_screen(self):
505
521
  # app = self.app
506
522
  # app.win.set_pick_mode(False)
@@ -545,13 +561,22 @@ class HistoryScreen(DemoScreen):
545
561
  self.text_screen.draw_screen()
546
562
 
547
563
 
548
- class ThemeScreen(Theme.create_picker_screen(DemoScreen)):
564
+ class ThemeScreen(Theme.create_picker_screen(DemoScreen, cycle_key='t')):
549
565
  """THEMES screen supplied by Theme.create_picker_screen()."""
550
566
 
551
567
  def __init__(self, app):
552
568
  super().__init__('THEMES', app)
553
569
 
554
570
  def main(backend=None, singleton=True):
571
+ # The same two lines every console-window app needs for --ascii/--theme.
572
+ import argparse
573
+
574
+ from console_window import cli
575
+
576
+ parser = cli.add_display_arguments(argparse.ArgumentParser(prog='SampleApp'))
577
+ opts = parser.parse_args()
578
+ cli.apply_display_arguments(opts, prog='SampleApp')
579
+
555
580
  try:
556
581
  app = SampleApp(backend=backend, singleton=singleton)
557
582
  app.main_loop()
@@ -1,8 +1,9 @@
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.1"
3
+ __version__ = "2.1.3"
4
4
 
5
5
  from .context import Context
6
+ from .glyphs import Glyph, Glyphs
6
7
  from .header import HeaderLine, Span
7
8
  from .keys import NAVIGATION_KEYS
8
9
  from .opts import ConsoleWindowOpts
@@ -15,7 +16,7 @@ from .screens import (
15
16
  )
16
17
  from .signals import reset_ctrl_c
17
18
  from .spinner import OptionSpinner
18
- from .theme import Theme
19
+ from .theme import Theme, ThemeStore
19
20
  from .widgets import IncrementalSearchBar, InlineConfirmation, PromptLine
20
21
  from .window import ConsoleWindow, TerminalTooSmallError
21
22
 
@@ -27,6 +28,8 @@ __all__ = [
27
28
  'ConsoleWindow',
28
29
  'ConsoleWindowOpts',
29
30
  'Context',
31
+ 'Glyph',
32
+ 'Glyphs',
30
33
  'HeaderLine',
31
34
  'IncrementalSearchBar',
32
35
  'InlineConfirmation',
@@ -37,5 +40,6 @@ __all__ = [
37
40
  'Span',
38
41
  'TerminalTooSmallError',
39
42
  'Theme',
43
+ 'ThemeStore',
40
44
  'reset_ctrl_c',
41
45
  ]
@@ -0,0 +1,145 @@
1
+ """Argument-parser boilerplate for the display options.
2
+
3
+ Every curses app ends up writing the same three switches: a way to force the
4
+ plain-ASCII symbol set, a way to pick a colour theme, and a way to print what
5
+ was detected. This module supplies them, so an app's ``main()`` gains two
6
+ lines instead of sixty:
7
+
8
+ parser = cli.add_display_arguments(parser)
9
+ ...
10
+ cli.apply_display_arguments(opts, prog='myapp')
11
+
12
+ The two switches are deliberately symmetric. ``--ascii`` accepts an optional
13
+ 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
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import sys
22
+ from collections.abc import Callable, Mapping
23
+ from typing import TextIO
24
+
25
+ from . import glyphs
26
+ from .glyphs import TerminalReport
27
+ from .theme import Theme, ThemeStore
28
+
29
+ __all__ = ['add_display_arguments', 'apply_display_arguments', 'resolve_force_ascii']
30
+
31
+ _TRUE_WORDS = frozenset({'1', 'true', 'yes', 'y', 'on'})
32
+ _FALSE_WORDS = frozenset({'0', 'false', 'no', 'n', 'off'})
33
+ _MODES = ('auto', 'ascii', 'unicode')
34
+
35
+
36
+ def _bool_argument(value: str) -> bool:
37
+ """argparse ``type=`` for ``--ascii=VALUE``; bare ``--ascii`` uses ``const``."""
38
+ lowered = value.strip().lower()
39
+ if lowered in _TRUE_WORDS:
40
+ return True
41
+ if lowered in _FALSE_WORDS:
42
+ return False
43
+ raise argparse.ArgumentTypeError(
44
+ f'expected a boolean (yes/no, true/false, on/off), got {value!r}'
45
+ )
46
+
47
+
48
+ def add_display_arguments(
49
+ parser: argparse.ArgumentParser,
50
+ *,
51
+ theme_store: ThemeStore | None = None,
52
+ prog: str | None = None,
53
+ ) -> argparse.ArgumentParser:
54
+ """Add ``--ascii``, ``--glyphs`` and ``--theme`` to *parser*.
55
+
56
+ Returns *parser* so a call site can wrap its own construction. *theme_store*
57
+ is accepted for symmetry with :func:`apply_display_arguments` (an app that
58
+ passes one here is one that remembers the choice); it does not change the
59
+ switches. *prog* only shapes the help text.
60
+ """
61
+ name = prog or parser.prog or 'this program'
62
+ remembered = ' (remembered for the next start)' if theme_store is not None else ''
63
+ parser.add_argument(
64
+ '--ascii',
65
+ nargs='?',
66
+ const=True,
67
+ default=None,
68
+ type=_bool_argument,
69
+ metavar='BOOL',
70
+ help=(
71
+ f'use plain-ASCII symbols; --ascii=no forces the Unicode set. '
72
+ f'Without the switch {name} detects the terminal'
73
+ ),
74
+ )
75
+ parser.add_argument(
76
+ '--glyphs',
77
+ choices=_MODES,
78
+ default=None,
79
+ help=(
80
+ 'symbol mode: auto detects the terminal, ascii forces the fallback, '
81
+ 'unicode forces the full set (same thing as --ascii / --ascii=no)'
82
+ ),
83
+ )
84
+ parser.add_argument(
85
+ '--theme',
86
+ choices=Theme.list_all(),
87
+ default=None,
88
+ help=f'color theme{remembered}',
89
+ )
90
+ return parser
91
+
92
+
93
+ def resolve_force_ascii(args: argparse.Namespace) -> bool | None:
94
+ """Map a parsed namespace to ``force_ascii``: True, False or None (detect)."""
95
+ mode = getattr(args, 'glyphs', None)
96
+ if mode == 'ascii':
97
+ return True
98
+ if mode == 'unicode':
99
+ return False
100
+ if mode == 'auto':
101
+ return None
102
+ flag = getattr(args, 'ascii', None)
103
+ if flag is None or isinstance(flag, bool):
104
+ return flag
105
+ raise TypeError(
106
+ f'args.ascii must be None or a bool (it is {flag!r}); parse it with '
107
+ 'add_display_arguments() or pass a bool'
108
+ )
109
+
110
+
111
+ def apply_display_arguments(
112
+ args: argparse.Namespace,
113
+ *,
114
+ printer: Callable[[str], object] = print,
115
+ prog: str = '',
116
+ environ: Mapping[str, str] | None = None,
117
+ stream: TextIO | None = None,
118
+ ) -> TerminalReport:
119
+ """Set the glyph mode, print the report's notes, seed the theme.
120
+
121
+ Call this once, *before* any window is created, so the mode is decided while
122
+ nothing has been drawn. Returns the :class:`~console_window.glyphs.
123
+ TerminalReport` so a caller can react to ``ascii_required`` (for example by
124
+ printing a warning of its own).
125
+
126
+ *printer* is a callable taking one string; pass ``sys.stderr.write`` style
127
+ wrappers through ``lambda line: print(line, file=...)`` if needed.
128
+ """
129
+ report = glyphs.apply_policy(
130
+ resolve_force_ascii(args),
131
+ environ=environ,
132
+ stream=sys.stdout if stream is None else stream,
133
+ )
134
+ for note in report.notes:
135
+ printer(f'{prog}: {note}' if prog else note)
136
+
137
+ theme = getattr(args, 'theme', None)
138
+ if theme:
139
+ if theme in Theme.list_all():
140
+ # No window exists yet, so record the choice for the first one.
141
+ Theme.set_active(theme)
142
+ else:
143
+ message = f'unknown theme {theme!r}; keeping {Theme.resolve(None)!r}'
144
+ printer(f'{prog}: {message}' if prog else message)
145
+ return report