console-window 2.0.0.10__tar.gz → 2.0.2__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 (47) hide show
  1. {console_window-2.0.0.10 → console_window-2.0.2}/PKG-INFO +31 -1
  2. {console_window-2.0.0.10 → console_window-2.0.2}/README.md +30 -0
  3. {console_window-2.0.0.10 → console_window-2.0.2}/console_window/__init__.py +1 -1
  4. console_window-2.0.2/console_window/keys.py +37 -0
  5. {console_window-2.0.0.10 → console_window-2.0.2}/console_window/opts.py +70 -3
  6. {console_window-2.0.0.10 → console_window-2.0.2}/console_window/spinner.py +15 -5
  7. {console_window-2.0.0.10 → console_window-2.0.2}/console_window/window.py +122 -16
  8. {console_window-2.0.0.10 → console_window-2.0.2/tests}/CHANGELOG.md +48 -0
  9. console_window-2.0.2/tests/test_taken_nav_keys.py +251 -0
  10. console_window-2.0.0.10/console_window/keys.py +0 -18
  11. {console_window-2.0.0.10 → console_window-2.0.2}/.github/workflows/ci.yml +0 -0
  12. {console_window-2.0.0.10 → console_window-2.0.2}/.gitignore +0 -0
  13. {console_window-2.0.0.10 → console_window-2.0.2}/LICENSE +0 -0
  14. {console_window-2.0.0.10 → console_window-2.0.2}/SampleApp.py +0 -0
  15. {console_window-2.0.0.10 → console_window-2.0.2}/console_window/backend.py +0 -0
  16. {console_window-2.0.0.10 → console_window-2.0.2}/console_window/context.py +0 -0
  17. {console_window-2.0.0.10 → console_window-2.0.2}/console_window/geometry.py +0 -0
  18. {console_window-2.0.0.10 → console_window-2.0.2}/console_window/header.py +0 -0
  19. {console_window-2.0.0.10 → console_window-2.0.2}/console_window/screens.py +0 -0
  20. {console_window-2.0.0.10 → console_window-2.0.2}/console_window/signals.py +0 -0
  21. {console_window-2.0.0.10 → console_window-2.0.2}/console_window/textwidth.py +0 -0
  22. {console_window-2.0.0.10 → console_window-2.0.2}/console_window/theme.py +0 -0
  23. {console_window-2.0.0.10 → console_window-2.0.2}/console_window/widgets.py +0 -0
  24. {console_window-2.0.0.10 → console_window-2.0.2}/pyproject.toml +0 -0
  25. {console_window-2.0.0.10 → console_window-2.0.2}/runner +0 -0
  26. {console_window-2.0.0.10 → console_window-2.0.2/tests}/AUDIT.md +0 -0
  27. {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_backend.py +0 -0
  28. {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_context.py +0 -0
  29. {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_demo_mode.py +0 -0
  30. {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_dialogs.py +0 -0
  31. {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_flash.py +0 -0
  32. {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_geometry.py +0 -0
  33. {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_header.py +0 -0
  34. {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_interactive.py +0 -0
  35. {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_low_colour.py +0 -0
  36. {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_overflow.py +0 -0
  37. {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_public_api.py +0 -0
  38. {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_render_retry.py +0 -0
  39. {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_sample_app.py +0 -0
  40. {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_screens.py +0 -0
  41. {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_signals.py +0 -0
  42. {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_spinner.py +0 -0
  43. {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_textwidth.py +0 -0
  44. {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_theme.py +0 -0
  45. {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_viewport_pad.py +0 -0
  46. {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_widechars.py +0 -0
  47. {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_widgets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: console-window
3
- Version: 2.0.0.10
3
+ Version: 2.0.2
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
@@ -210,6 +210,36 @@ finally:
210
210
  Only keys the context actually added are removed on exit, so a pre-existing
211
211
  binding is left in place and nested uses are safe.
212
212
 
213
+ ### Taking navigation keys for the application
214
+
215
+ Navigation keys are reserved by default, but an app may take specific ones for
216
+ itself without monkeypatching the module global. Declare them on the window's
217
+ options; the mapping value is the label the help screen shows:
218
+
219
+ ```python
220
+ import curses
221
+ from console_window import ConsoleWindowOpts, OptionSpinner
222
+
223
+ opts = ConsoleWindowOpts(
224
+ taken_nav_keys={
225
+ curses.KEY_LEFT: 'prev page',
226
+ curses.KEY_RIGHT: 'next page',
227
+ },
228
+ )
229
+ win = ConsoleWindow(opts=opts)
230
+ spin = OptionSpinner(stack=stack, nav_keys=opts.nav_keys) # accept them in add_key()
231
+ spin.add_key('prev', '← - previous', genre='action', keys=[curses.KEY_LEFT])
232
+ ```
233
+
234
+ A taken key is withdrawn from navigation dispatch for that window only: it no
235
+ longer scrolls, it is no longer advertised as navigation, and it takes the
236
+ normal `handled_keys`/`relax_handled_keys` path on return. Text editing is
237
+ unchanged — dialogs and widgets still use LEFT/RIGHT/HOME/END for the caret.
238
+ Because ownership lives in `ConsoleWindowOpts`, two windows in one process can
239
+ have different bindings; `NAVIGATION_KEYS` itself is never mutated. Taking every
240
+ vertical key is allowed (and logged as "reduced navigation"), since the
241
+ application is then responsible for its own scrolling.
242
+
213
243
  `ScreenStack.perform_actions()` is the normal router: it walks the spinner
214
244
  options, honours their scopes, and calls the matching `<attr>_ACTION` method.
215
245
  For apps that dispatch an action themselves, `ScreenStack.act_in(action,
@@ -191,6 +191,36 @@ finally:
191
191
  Only keys the context actually added are removed on exit, so a pre-existing
192
192
  binding is left in place and nested uses are safe.
193
193
 
194
+ ### Taking navigation keys for the application
195
+
196
+ Navigation keys are reserved by default, but an app may take specific ones for
197
+ itself without monkeypatching the module global. Declare them on the window's
198
+ options; the mapping value is the label the help screen shows:
199
+
200
+ ```python
201
+ import curses
202
+ from console_window import ConsoleWindowOpts, OptionSpinner
203
+
204
+ opts = ConsoleWindowOpts(
205
+ taken_nav_keys={
206
+ curses.KEY_LEFT: 'prev page',
207
+ curses.KEY_RIGHT: 'next page',
208
+ },
209
+ )
210
+ win = ConsoleWindow(opts=opts)
211
+ spin = OptionSpinner(stack=stack, nav_keys=opts.nav_keys) # accept them in add_key()
212
+ spin.add_key('prev', '← - previous', genre='action', keys=[curses.KEY_LEFT])
213
+ ```
214
+
215
+ A taken key is withdrawn from navigation dispatch for that window only: it no
216
+ longer scrolls, it is no longer advertised as navigation, and it takes the
217
+ normal `handled_keys`/`relax_handled_keys` path on return. Text editing is
218
+ unchanged — dialogs and widgets still use LEFT/RIGHT/HOME/END for the caret.
219
+ Because ownership lives in `ConsoleWindowOpts`, two windows in one process can
220
+ have different bindings; `NAVIGATION_KEYS` itself is never mutated. Taking every
221
+ vertical key is allowed (and logged as "reduced navigation"), since the
222
+ application is then responsible for its own scrolling.
223
+
194
224
  `ScreenStack.perform_actions()` is the normal router: it walks the spinner
195
225
  options, honours their scopes, and calls the matching `<attr>_ACTION` method.
196
226
  For apps that dispatch an action themselves, `ScreenStack.act_in(action,
@@ -1,6 +1,6 @@
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.0.0.10"
3
+ __version__ = "2.0.2"
4
4
 
5
5
  from .context import Context
6
6
  from .header import HeaderLine, Span
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import curses
4
+
5
+ # Keys reserved for cursor movement and scrolling. By default
6
+ # OptionSpinner.add_key rejects these with ValueError and demo-mode
7
+ # highlighting ignores them. An application may take specific keys for its own
8
+ # use through ``ConsoleWindowOpts(taken_nav_keys=...)``; that withdraws them
9
+ # from navigation dispatch for that window only.
10
+ #
11
+ # The set is a plain ``set`` for 2.0 compatibility, but the library never
12
+ # mutates it. Per-window ownership lives in ``ConsoleWindowOpts``: pass
13
+ # ``taken_nav_keys`` to withdraw a key from *that* window's navigation
14
+ # dispatch, and read ``opts.nav_keys`` for the effective (immutable) set.
15
+ # Never monkeypatch this module global; two windows with different bindings
16
+ # would contaminate each other.
17
+ NAVIGATION_KEYS: set[int] = {
18
+ curses.KEY_UP, curses.KEY_DOWN, curses.KEY_LEFT, curses.KEY_RIGHT,
19
+ curses.KEY_HOME, curses.KEY_END, curses.KEY_PPAGE, curses.KEY_NPAGE,
20
+ ord('j'), ord('k'), # Vi-style up / down
21
+ ord('0'), ord('$'), # First / last row
22
+ ord('H'), ord('M'), ord('L'), # Top / middle / bottom of page
23
+ ord('\x15'), # Ctrl-U (half page up)
24
+ ord('\x04'), # Ctrl-D (half page down)
25
+ ord('\x02'), # Ctrl-B (page up)
26
+ ord('\x06'), # Ctrl-F (page down)
27
+ }
28
+
29
+ # The subset of NAVIGATION_KEYS that moves the viewport vertically. When an
30
+ # application takes all of them (often to implement its own scrolling), the
31
+ # window still constructs, but "reduced navigation" deserves a warning.
32
+ VERTICAL_NAV_KEYS: frozenset[int] = frozenset({
33
+ curses.KEY_UP, curses.KEY_DOWN,
34
+ curses.KEY_HOME, curses.KEY_END, curses.KEY_PPAGE, curses.KEY_NPAGE,
35
+ ord('j'), ord('k'), ord('0'), ord('$'), ord('H'), ord('M'), ord('L'),
36
+ ord('\x15'), ord('\x04'), ord('\x02'), ord('\x06'),
37
+ })
@@ -2,15 +2,62 @@ from __future__ import annotations
2
2
 
3
3
  import curses
4
4
  import difflib
5
+ import logging
6
+ from collections.abc import Iterable, Mapping
5
7
  from typing import Any, Callable
6
8
 
9
+ from .keys import NAVIGATION_KEYS, VERTICAL_NAV_KEYS
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def _normalize_taken_nav_keys(raw: object) -> dict[int, str]:
15
+ """Normalize ``taken_nav_keys`` to ``{key_code: app_label}``.
16
+
17
+ A mapping gives the help screen an app-facing label per key; a bare
18
+ iterable of key codes is accepted too and normalizes to an empty label.
19
+ Non-integer entries are rejected (they cannot be dispatched), and duplicate
20
+ entries collapse to one binding (the last label wins). Keys that are not in
21
+ ``NAVIGATION_KEYS`` are kept but warned about: they subtract nothing, so
22
+ silently accepting them would hide a typo.
23
+ """
24
+ if raw is None:
25
+ return {}
26
+ if isinstance(raw, Mapping):
27
+ items: Iterable[tuple[object, object]] = raw.items()
28
+ elif isinstance(raw, Iterable) and not isinstance(raw, (str, bytes)):
29
+ items = ((key, '') for key in raw)
30
+ else:
31
+ raise TypeError(
32
+ 'taken_nav_keys must be a mapping of {key_code: label} or an '
33
+ f'iterable of key codes, got {type(raw).__name__}'
34
+ )
35
+
36
+ normalized: dict[int, str] = {}
37
+ for key, label in items:
38
+ # bool is an int subclass; a True/False key code is always a mistake.
39
+ if isinstance(key, bool) or not isinstance(key, int):
40
+ raise ValueError(
41
+ f'taken_nav_keys entries must be integer key codes, got {key!r}'
42
+ )
43
+ normalized[key] = '' if label is None else str(label)
44
+
45
+ unknown = sorted(set(normalized) - NAVIGATION_KEYS)
46
+ if unknown:
47
+ logger.warning(
48
+ 'taken_nav_keys: %s not a navigation key; these entries have no effect',
49
+ ', '.join(str(key) for key in unknown),
50
+ )
51
+ return normalized
52
+
7
53
 
8
54
  class ConsoleWindowOpts:
9
55
  __slots__ = ['answer_show_redraws', 'body_cols', 'body_rows', 'ctrl_c_terminates',
10
56
  'demo_mode', 'dialog_abort', 'dialog_return', 'head_line', 'head_rows',
11
- 'keys', 'max_rows', 'min_cols_rows', 'mod_pick', 'pick_attr', 'pick_mode',
12
- 'pick_range', 'pick_size', 'relax_handled_keys', 'return_if_pos_change',
13
- 'single_cell_scroll_indicator', 'strip_attrs_in_pick_mode', 'theme']
57
+ 'keys', 'max_rows', 'min_cols_rows', 'mod_pick', 'nav_keys', 'pick_attr',
58
+ 'pick_mode', 'pick_range', 'pick_size', 'relax_handled_keys',
59
+ 'return_if_pos_change', 'single_cell_scroll_indicator',
60
+ 'strip_attrs_in_pick_mode', 'taken_nav_keys', 'theme']
14
61
 
15
62
  # Explicit attribute types: assignments below come from **kwargs, so without
16
63
  # these every option would be typed as ``object`` and every read would need a
@@ -29,6 +76,11 @@ class ConsoleWindowOpts:
29
76
  keys: set | list | None
30
77
  min_cols_rows: tuple[int, int]
31
78
  mod_pick: Callable[[str], str] | None
79
+ # Effective navigation set for this window: ``NAVIGATION_KEYS`` minus the
80
+ # keys the application took. Computed, never passed in.
81
+ nav_keys: frozenset[int]
82
+ # App-owned navigation keys: {key_code: label shown on the help screen}.
83
+ taken_nav_keys: dict[int, str]
32
84
  pick_attr: int
33
85
  pick_mode: bool
34
86
  pick_range: list[int] | None
@@ -59,6 +111,21 @@ class ConsoleWindowOpts:
59
111
  self.max_rows = kwargs.get('max_rows', 32767)
60
112
  self.body_cols = kwargs.get('body_cols', 200)
61
113
  self.keys = kwargs.get('keys')
114
+ if 'nav_keys' in kwargs:
115
+ raise TypeError(
116
+ 'ConsoleWindowOpts() computes nav_keys from taken_nav_keys; '
117
+ 'pass taken_nav_keys={key: label} instead of nav_keys.'
118
+ )
119
+ self.taken_nav_keys = _normalize_taken_nav_keys(kwargs.get('taken_nav_keys'))
120
+ self.nav_keys = frozenset(NAVIGATION_KEYS - set(self.taken_nav_keys))
121
+ if self.taken_nav_keys and not (self.nav_keys & VERTICAL_NAV_KEYS):
122
+ # Explicit user choice, not an error: the app has taken over
123
+ # scrolling. Still worth a diagnostic, because "the arrow keys do
124
+ # nothing" is otherwise indistinguishable from a bug.
125
+ logger.warning(
126
+ 'taken_nav_keys removes every vertical navigation key; this '
127
+ 'window has reduced (custom) navigation'
128
+ )
62
129
  self.pick_mode = kwargs.get('pick_mode', False)
63
130
  self.pick_size = kwargs.get('pick_size', 1)
64
131
  self.mod_pick = kwargs.get('mod_pick')
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import curses
4
+ from collections.abc import Iterable
4
5
  from types import SimpleNamespace
5
6
  from typing import TYPE_CHECKING
6
7
 
@@ -12,9 +13,18 @@ if TYPE_CHECKING: # pragma: no cover - typing only
12
13
 
13
14
 
14
15
  class OptionSpinner:
15
- def __init__(self, stack: ScreenStack | None = None) -> None:
16
+ def __init__(self, stack: ScreenStack | None = None,
17
+ nav_keys: Iterable[int] | None = None) -> None:
16
18
  self.options: list[SimpleNamespace] = []
17
19
  self.keys: set[int] = set()
20
+ # The navigation set this spinner validates against. Pass the owning
21
+ # window's ``opts.nav_keys`` when the window takes navigation keys for
22
+ # the app; otherwise the process default applies. Keeping this
23
+ # per-spinner (rather than reading/mutating the module global) lets two
24
+ # windows with different bindings coexist.
25
+ self.nav_keys: frozenset[int] = frozenset(
26
+ NAVIGATION_KEYS if nav_keys is None else nav_keys
27
+ )
18
28
  self.margin = 4 # + actual width (1st column right pos)
19
29
  self.align = self.margin # + actual width (1st column right pos)
20
30
  self._default_obj: object = SimpleNamespace() # if not given one (private)
@@ -125,7 +135,7 @@ class OptionSpinner:
125
135
  return chr(key) if 32 <= key < 127 else f'<{key}>'
126
136
 
127
137
  def _reject_navigation_key(self, key: int) -> None:
128
- if key in NAVIGATION_KEYS:
138
+ if key in self.nav_keys:
129
139
  raise ValueError(
130
140
  f'Key {self._key_label(key)} ({key}) conflicts with a navigation key. '
131
141
  f'Navigation keys are reserved for cursor movement and scrolling.'
@@ -253,9 +263,9 @@ class OptionSpinner:
253
263
 
254
264
  @staticmethod
255
265
  def show_help_nav_keys(win: ConsoleWindow) -> None:
256
- # Late import to avoid a circular import at module load time.
257
- from .window import ConsoleWindow
258
- for line in ConsoleWindow.get_nav_key_lines():
266
+ # Render from the live window so its own opts.taken_nav_keys are
267
+ # honoured: keys the app owns are not advertised as navigation.
268
+ for line in win.nav_help_lines():
259
269
  win.add_header(line)
260
270
 
261
271
  def show_help_body(self, win: ConsoleWindow, screen_filter: int | list | set | None = None) -> None:
@@ -40,23 +40,116 @@ if TYPE_CHECKING: # pragma: no cover - typing only
40
40
 
41
41
  logger = logging.getLogger(__name__)
42
42
 
43
- # Navigation help is stored as complete lines, not a dedented triple-quoted
44
- # string: editing the indentation can no longer change what the help screen
45
- # renders. ``ConsoleWindow.nav_keys`` remains the joined string for callers
46
- # that want the whole blurb at once.
47
- NAV_KEY_LINES: tuple[str, ...] = (
48
- 'Navigation: H/M/L: top/middle/end-of-page',
49
- ' k, UP: up one row 0, HOME: first row',
50
- 'j, DOWN: down one row $, END: last row',
51
- ' Ctrl-u: half-page up Ctrl-b, PPAGE: page up',
52
- ' Ctrl-d: half-page down Ctrl-f, NPAGE: page down',
53
- )
54
-
55
43
  # Control keys that drive the same movements as the page keys. They live here
56
- # rather than inside `prompt()` so the navigation map and the help text above
44
+ # rather than inside `prompt()` so the navigation map and the help text below
57
45
  # cannot drift apart silently.
58
46
  _CTRL_PAGE_UP, _CTRL_HALF_DOWN, _CTRL_PAGE_DOWN, _CTRL_HALF_UP = 2, 4, 6, 21
59
47
 
48
+ # Display labels for the keys that can appear in the navigation help. Arrow
49
+ # keys are listed too even though the default rows do not use them: an app that
50
+ # takes LEFT/RIGHT gets `←`/`→` on the "App:" line without supplying a label.
51
+ NAV_KEY_LABELS: dict[int, str] = {
52
+ curses.KEY_UP: 'UP',
53
+ curses.KEY_DOWN: 'DOWN',
54
+ curses.KEY_LEFT: '←',
55
+ curses.KEY_RIGHT: '→',
56
+ curses.KEY_HOME: 'HOME',
57
+ curses.KEY_END: 'END',
58
+ curses.KEY_PPAGE: 'PPAGE',
59
+ curses.KEY_NPAGE: 'NPAGE',
60
+ ord('j'): 'j',
61
+ ord('k'): 'k',
62
+ ord('0'): '0',
63
+ ord('$'): '$',
64
+ ord('H'): 'H',
65
+ ord('M'): 'M',
66
+ ord('L'): 'L',
67
+ _CTRL_HALF_UP: 'Ctrl-u',
68
+ _CTRL_HALF_DOWN: 'Ctrl-d',
69
+ _CTRL_PAGE_UP: 'Ctrl-b',
70
+ _CTRL_PAGE_DOWN: 'Ctrl-f',
71
+ }
72
+
73
+ # Structured navigation help: (key codes, description). The rendered lines are
74
+ # generated from this table and the window's effective key set, so a key the
75
+ # application took disappears from the help instead of being advertised and
76
+ # doing nothing. Keep this table in step with `_navigation_target()`; the two
77
+ # are the single sources of truth for "what scrolls" and "what we say scrolls".
78
+ NAV_HELP_ROWS: tuple[tuple[tuple[int, ...], str], ...] = (
79
+ ((ord('k'), curses.KEY_UP), 'up one row'),
80
+ ((ord('j'), curses.KEY_DOWN), 'down one row'),
81
+ ((ord('0'), curses.KEY_HOME), 'first row'),
82
+ ((ord('$'), curses.KEY_END), 'last row'),
83
+ ((_CTRL_HALF_UP,), 'half-page up'),
84
+ ((_CTRL_HALF_DOWN,), 'half-page down'),
85
+ ((_CTRL_PAGE_UP, curses.KEY_PPAGE), 'page up'),
86
+ ((_CTRL_PAGE_DOWN, curses.KEY_NPAGE), 'page down'),
87
+ ((ord('H'), ord('M'), ord('L')), 'top/middle/end-of-page'),
88
+ )
89
+
90
+ _NAV_HELP_COLUMN = 38
91
+ _NAV_HELP_PREFIX = 'Navigation: '
92
+ _NAV_APP_PREFIX = 'App: '
93
+
94
+
95
+ def _nav_key_label(key: int) -> str:
96
+ label = NAV_KEY_LABELS.get(key)
97
+ if label is not None:
98
+ return label
99
+ return chr(key) if 32 <= key < 127 else f'<{key}>'
100
+
101
+
102
+ def _pair_nav_entries(entries: list[str], prefix: str) -> list[str]:
103
+ """Render two entries per line under a fixed-width prefix."""
104
+ lines = []
105
+ pad = ' ' * len(prefix)
106
+ for index in range(0, len(entries), 2):
107
+ left = entries[index]
108
+ right = entries[index + 1] if index + 1 < len(entries) else ''
109
+ head = prefix if index == 0 else pad
110
+ lines.append(f'{head}{left:<{_NAV_HELP_COLUMN}}{right}'.rstrip())
111
+ return lines
112
+
113
+
114
+ def render_nav_help(effective: Iterable[int],
115
+ taken: dict[int, str] | None = None) -> tuple[str, ...]:
116
+ """Render the navigation help for one window's effective key set.
117
+
118
+ ``effective`` is the set of key codes still dispatched as navigation;
119
+ ``taken`` maps the app-owned keys to the label the app wants shown. The
120
+ default (no taken keys) output is the public ``NAV_KEY_LINES`` text.
121
+ """
122
+ effective = frozenset(effective)
123
+ entries = []
124
+ for keys, label in NAV_HELP_ROWS:
125
+ shown = [_nav_key_label(key) for key in keys if key in effective]
126
+ if shown:
127
+ entries.append(f'{", ".join(shown)}: {label}')
128
+
129
+ if entries:
130
+ lines = _pair_nav_entries(entries, _NAV_HELP_PREFIX)
131
+ else:
132
+ lines = [
133
+ _NAV_HELP_PREFIX
134
+ + '(no navigation keys: all are bound by the application)'
135
+ ]
136
+
137
+ app_entries = []
138
+ for key, label in (taken or {}).items():
139
+ name = _nav_key_label(key)
140
+ app_entries.append(f'{name}: {label}' if label else name)
141
+ if app_entries:
142
+ lines.extend(_pair_nav_entries(app_entries, _NAV_APP_PREFIX))
143
+ return tuple(lines)
144
+
145
+
146
+ # Navigation help for the default key set. Stored as complete lines, not a
147
+ # dedented triple-quoted string: editing the indentation can no longer change
148
+ # what the help screen renders. ``ConsoleWindow.nav_keys`` remains the joined
149
+ # string for callers that want the whole blurb at once; live windows render
150
+ # through ``nav_help_lines()`` so their own ``taken_nav_keys`` are honoured.
151
+ NAV_KEY_LINES: tuple[str, ...] = render_nav_help(NAVIGATION_KEYS)
152
+
60
153
  # Editing keys that passthrough mode hands to the application unchanged, in
61
154
  # addition to printable characters, navigation keys and `handled_keys`.
62
155
  # ESC and DELETE are editing keys too: an in-place widget such as
@@ -577,6 +670,10 @@ class ConsoleWindow:
577
670
  def get_nav_key_lines() -> tuple[str, ...]:
578
671
  return NAV_KEY_LINES
579
672
 
673
+ def nav_help_lines(self) -> tuple[str, ...]:
674
+ """Navigation help for *this* window's effective key set."""
675
+ return render_nav_help(self.opts.nav_keys, self.opts.taken_nav_keys)
676
+
580
677
  def _set_screen_dims(self) -> bool:
581
678
  rows, cols = self.scr.getmaxyx()
582
679
  same = bool(rows == self.rows and cols == self.cols)
@@ -2304,7 +2401,9 @@ class ConsoleWindow:
2304
2401
  return None
2305
2402
 
2306
2403
  def _record_demo_key(self, key: int) -> None:
2307
- if self.opts.demo_mode and key not in NAVIGATION_KEYS:
2404
+ # Taken navigation keys are app-owned, so demo mode highlights them
2405
+ # just like any other application key.
2406
+ if self.opts.demo_mode and key not in self.opts.nav_keys:
2308
2407
  self.last_demo_key = self._format_key_for_demo(key)
2309
2408
 
2310
2409
  def _clear_demo_indicator(self) -> None:
@@ -2313,7 +2412,8 @@ class ConsoleWindow:
2313
2412
 
2314
2413
  def _is_passthrough_key(self, key: int) -> bool:
2315
2414
  return (32 <= key <= 126 or key in _PASSTHROUGH_EDITING_KEYS
2316
- or key in NAVIGATION_KEYS or key in self.handled_keys)
2415
+ or key in NAVIGATION_KEYS or key in self.opts.taken_nav_keys
2416
+ or key in self.handled_keys)
2317
2417
 
2318
2418
  def _passthrough_result(self, key: int) -> int | None:
2319
2419
  if not self.passthrough_mode or not self._is_passthrough_key(key):
@@ -2334,12 +2434,18 @@ class ConsoleWindow:
2334
2434
  if key in self.handled_keys and not self.relax_handled_keys:
2335
2435
  self._record_demo_key(key)
2336
2436
  return True, key # return for handling
2337
- if key in NAVIGATION_KEYS:
2437
+ if key in self.opts.nav_keys:
2338
2438
  self._clear_demo_indicator()
2339
2439
  return False, None
2340
2440
 
2341
2441
  def _navigation_target(self, key: int) -> int | None:
2342
2442
  """Map a navigation key to the row it asks for, or None if it is not one."""
2443
+ # A key the application took is withdrawn from navigation dispatch.
2444
+ # This guard must come before the moves table: removing the key from
2445
+ # NAVIGATION_KEYS alone would still let UP/DOWN/page keys scroll,
2446
+ # because the table is what actually dispatches.
2447
+ if key in self.opts.taken_nav_keys:
2448
+ return None
2343
2449
  pos = self.pick_pos if self.pick_mode else self.scroll_pos
2344
2450
  delta = self.pick_size if self.pick_mode else 1
2345
2451
  half = self.scroll_view_size // 2
@@ -3,6 +3,53 @@
3
3
  All notable changes to `console-window` are documented here.
4
4
  This project follows [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [2.0.2]
7
+
8
+ ### Added
9
+ - `ConsoleWindowOpts(taken_nav_keys=...)`: hand specific navigation keys to the
10
+ application on a per-window basis. The value is a mapping of
11
+ `{key_code: help_label}` (a bare iterable of key codes is also accepted and
12
+ normalizes to empty labels), so the help screen can show what the app bound
13
+ them to (`←: prev`). The normalized mapping is available as
14
+ `opts.taken_nav_keys`, and the effective set as `opts.nav_keys` (a
15
+ `frozenset` of `NAVIGATION_KEYS` minus the taken keys).
16
+ - `OptionSpinner(nav_keys=...)`: the spinner validates `add_key()` against the
17
+ supplied set instead of the module global, so
18
+ `OptionSpinner(nav_keys=opts.nav_keys)` accepts a key the window took.
19
+ - `ConsoleWindow.nav_help_lines()`: renders navigation help for the window's
20
+ effective key set and appends a separate `App:` line for taken keys with
21
+ their labels. `OptionSpinner.show_help_nav_keys(win)` now uses it.
22
+
23
+ ### Changed
24
+ - Navigation help is generated from the structured `NAV_HELP_ROWS` table, so a
25
+ key the app took disappears from the rendered help instead of being
26
+ advertised and doing nothing. `NAV_KEY_LINES`, `get_nav_key_lines()` and
27
+ `get_nav_keys_blurb()` remain the static default output, and the default
28
+ rendering is unchanged.
29
+ - `_navigation_target()` refuses a taken key before consulting its movement
30
+ table. Subtracting from `NAVIGATION_KEYS` alone was not enough: UP/DOWN,
31
+ page and HOME/END movements are dispatched by that hardcoded table, so a
32
+ taken key would still have scrolled.
33
+ - Demo-mode highlighting and passthrough classification treat taken keys as
34
+ application keys, and `_handle_prompt_app_key()` no longer treats them as
35
+ navigation.
36
+ - `NAVIGATION_KEYS` is still a `set` for 2.0 compatibility, but the library no
37
+ longer mutates it. Applications should pass `taken_nav_keys` (and can delete
38
+ their `NAVIGATION_KEYS.remove(...)` monkeypatches); two windows with
39
+ different bindings no longer contaminate each other.
40
+
41
+ ### Notes
42
+ - A taken key is withdrawn from *navigation dispatch* only. Text editing
43
+ (`_DIALOG_SIMPLE_MOVES`, widget carets, dialogs) still uses
44
+ LEFT/RIGHT/HOME/END normally.
45
+ - Return behaviour follows the existing `handled_keys`/`relax_handled_keys`
46
+ rules: a taken key bound through the spinner is returned; with
47
+ `relax_handled_keys=False` and no binding it may still be swallowed.
48
+ - Invalid entries (non-integer key codes) raise `ValueError`; duplicate codes
49
+ collapse to one binding. Codes that are not navigation keys are kept but
50
+ logged as having no effect, and taking every vertical key logs a
51
+ "reduced navigation" warning instead of failing.
52
+
6
53
  ## [Unreleased]
7
54
 
8
55
  ### Added
@@ -446,4 +493,5 @@ fully backward compatible (`from console_window import ...` and
446
493
  - `Development Status` classifier aligned with the release; version bumped to
447
494
  `2.0.1`.
448
495
 
496
+ [2.0.2]: https://github.com/joedefen/console-window
449
497
  [2.0.1]: https://github.com/joedefen/console-window
@@ -0,0 +1,251 @@
1
+ """Per-window navigation-key ownership (``opts.taken_nav_keys``).
2
+
3
+ The trap these tests exist for: subtracting a key from ``NAVIGATION_KEYS`` is
4
+ not enough, because movement is dispatched by a separate hardcoded table in
5
+ ``_navigation_target()``. UP/DOWN, the page keys and HOME/END must therefore
6
+ be tested specifically: they are the keys that would still scroll if only the
7
+ reservation set were changed.
8
+ """
9
+
10
+ import curses
11
+ import logging
12
+
13
+ import pytest
14
+
15
+ import console_window.window as window_mod
16
+ from console_window import ConsoleWindow, ConsoleWindowOpts, OptionSpinner
17
+ from console_window.backend import HeadlessBackend
18
+ from console_window.keys import NAVIGATION_KEYS, VERTICAL_NAV_KEYS
19
+ from console_window.window import NAV_KEY_LINES
20
+
21
+ _ACS_STANDINS = {
22
+ 'ACS_HLINE': '-',
23
+ 'ACS_VLINE': '|',
24
+ 'ACS_ULCORNER': '+',
25
+ 'ACS_URCORNER': '+',
26
+ 'ACS_LLCORNER': '+',
27
+ 'ACS_LRCORNER': '+',
28
+ }
29
+
30
+
31
+ @pytest.fixture(autouse=True)
32
+ def _headless_curses(monkeypatch):
33
+ for name, value in _ACS_STANDINS.items():
34
+ if not hasattr(window_mod.curses, name):
35
+ monkeypatch.setattr(window_mod.curses, name, value, raising=False)
36
+
37
+
38
+ @pytest.fixture
39
+ def make():
40
+ created = []
41
+
42
+ def _make(backend=None, **opts):
43
+ backend = backend if backend is not None else HeadlessBackend(rows=24, cols=80)
44
+ win = ConsoleWindow(opts=ConsoleWindowOpts(**opts), backend=backend,
45
+ singleton=False)
46
+ created.append(win)
47
+ return win, backend
48
+
49
+ yield _make
50
+ for win in created:
51
+ if win.scr is not None:
52
+ win.stop_curses()
53
+
54
+
55
+ # --- opts normalization and validation --------------------------------------
56
+
57
+ def test_default_opts_keeps_every_navigation_key():
58
+ opts = ConsoleWindowOpts()
59
+ assert opts.taken_nav_keys == {}
60
+ assert opts.nav_keys == frozenset(NAVIGATION_KEYS)
61
+
62
+
63
+ def test_mapping_labels_are_preserved_and_iterables_normalize_to_empty():
64
+ opts = ConsoleWindowOpts(taken_nav_keys={
65
+ curses.KEY_LEFT: 'prev',
66
+ curses.KEY_RIGHT: 'next',
67
+ })
68
+ assert opts.taken_nav_keys == {
69
+ curses.KEY_LEFT: 'prev',
70
+ curses.KEY_RIGHT: 'next',
71
+ }
72
+ assert curses.KEY_LEFT not in opts.nav_keys
73
+
74
+ bare = ConsoleWindowOpts(taken_nav_keys=[curses.KEY_UP, curses.KEY_DOWN])
75
+ assert bare.taken_nav_keys == {curses.KEY_UP: '', curses.KEY_DOWN: ''}
76
+
77
+
78
+ def test_duplicate_keys_collapse_to_the_last_label():
79
+ opts = ConsoleWindowOpts(taken_nav_keys={curses.KEY_LEFT: 'a',
80
+ curses.KEY_LEFT: 'b'})
81
+ assert opts.taken_nav_keys == {curses.KEY_LEFT: 'b'}
82
+
83
+
84
+ def test_non_integer_keys_are_rejected():
85
+ with pytest.raises(ValueError, match='integer key codes'):
86
+ ConsoleWindowOpts(taken_nav_keys={'LEFT': 'prev'})
87
+ with pytest.raises(ValueError, match='integer key codes'):
88
+ ConsoleWindowOpts(taken_nav_keys={True: 'yes'})
89
+
90
+
91
+ def test_nav_keys_is_computed_not_passed():
92
+ with pytest.raises(TypeError, match='taken_nav_keys'):
93
+ ConsoleWindowOpts(nav_keys=[ord('j')])
94
+
95
+
96
+ def test_unknown_key_is_kept_but_logged(caplog):
97
+ with caplog.at_level(logging.WARNING, logger='console_window.opts'):
98
+ opts = ConsoleWindowOpts(taken_nav_keys={ord('z'): 'zoom'})
99
+ assert opts.taken_nav_keys == {ord('z'): 'zoom'}
100
+ assert opts.nav_keys == frozenset(NAVIGATION_KEYS) # nothing was subtracted
101
+ assert any('not a navigation key' in record.message for record in caplog.records)
102
+
103
+
104
+ def test_taking_every_vertical_key_warns_but_constructs(caplog, make):
105
+ with caplog.at_level(logging.WARNING, logger='console_window.opts'):
106
+ win, _backend = make(taken_nav_keys=dict.fromkeys(VERTICAL_NAV_KEYS, ''))
107
+ assert not (win.opts.nav_keys & VERTICAL_NAV_KEYS)
108
+ assert any('reduced' in record.message for record in caplog.records)
109
+ # The help screen reports the reduced navigation instead of lying.
110
+ assert 'no navigation keys' in win.nav_help_lines()[0]
111
+
112
+
113
+ # --- dispatch: taken keys must not scroll -----------------------------------
114
+
115
+ @pytest.mark.parametrize('key', [
116
+ curses.KEY_UP, curses.KEY_DOWN,
117
+ curses.KEY_PPAGE, curses.KEY_NPAGE,
118
+ curses.KEY_HOME, curses.KEY_END,
119
+ ord('j'), ord('k'), ord('0'), ord('$'), ord('H'), ord('M'), ord('L'),
120
+ 2, 4, 6, 21,
121
+ ])
122
+ def test_taken_key_never_scrolls_and_is_returned_to_the_app(make, key):
123
+ win, backend = make(backend=HeadlessBackend(rows=12, cols=80),
124
+ body_rows=60, head_rows=2, taken_nav_keys={key: 'app'})
125
+ for i in range(40):
126
+ win.add_body(f'row {i}')
127
+ win.fix_positions()
128
+ win.scroll_pos = min(10, win.max_scroll_pos)
129
+ win.fix_positions()
130
+ before = win.scroll_pos
131
+
132
+ backend.queue_keys(key)
133
+ assert win.prompt(seconds=0.5) == key # relax_handled_keys default: app gets it
134
+ assert win.scroll_pos == before
135
+
136
+
137
+ def test_taken_key_without_binding_may_be_swallowed_when_not_relaxed(make):
138
+ # Existing handled_keys/relax_handled_keys rules apply; ownership does not
139
+ # implicitly add the key to handled_keys.
140
+ win, backend = make(relax_handled_keys=False, taken_nav_keys={curses.KEY_DOWN: ''})
141
+ for i in range(40):
142
+ win.add_body(f'row {i}')
143
+ win.fix_positions()
144
+ before = win.scroll_pos
145
+ backend.queue_keys(curses.KEY_DOWN)
146
+ assert win.prompt(seconds=0.02) is None
147
+ assert win.scroll_pos == before
148
+
149
+
150
+ def test_spinner_accepts_a_taken_key_and_the_action_fires(make):
151
+ opts = ConsoleWindowOpts(
152
+ taken_nav_keys={curses.KEY_RIGHT: 'next'},
153
+ relax_handled_keys=False,
154
+ )
155
+ win, backend = make(taken_nav_keys=opts.taken_nav_keys,
156
+ relax_handled_keys=False)
157
+ spin = OptionSpinner(nav_keys=opts.nav_keys)
158
+ spin.add_key('next', '→ - next', genre='action', keys=[curses.KEY_RIGHT])
159
+ win.set_handled_keys(spin)
160
+
161
+ backend.queue_keys(curses.KEY_RIGHT)
162
+ assert win.prompt(seconds=0.5) == curses.KEY_RIGHT
163
+ assert win.scroll_pos == 0
164
+
165
+ spin.do_key(curses.KEY_RIGHT, win)
166
+ assert spin.default_obj.next is True
167
+
168
+
169
+ def test_spinner_still_rejects_a_retained_navigation_key():
170
+ opts = ConsoleWindowOpts(taken_nav_keys={curses.KEY_LEFT: 'prev'})
171
+ spin = OptionSpinner(nav_keys=opts.nav_keys)
172
+ with pytest.raises(ValueError, match='navigation key'):
173
+ spin.add_key('jump', 'UP - reserved', genre='action', keys=[curses.KEY_UP])
174
+
175
+
176
+ # --- isolation --------------------------------------------------------------
177
+
178
+ def test_two_windows_with_different_bindings_do_not_affect_each_other(make):
179
+ global_keys = frozenset(NAVIGATION_KEYS)
180
+
181
+ left, _left_backend = make(taken_nav_keys={curses.KEY_LEFT: 'prev'})
182
+ for i in range(40):
183
+ left.add_body(f'row {i}')
184
+ left.fix_positions()
185
+
186
+ right, right_backend = make()
187
+ for i in range(40):
188
+ right.add_body(f'row {i}')
189
+ right.fix_positions()
190
+ right.scroll_pos = min(5, right.max_scroll_pos)
191
+ right.fix_positions()
192
+ before = right.scroll_pos
193
+
194
+ assert left.opts.nav_keys == global_keys - {curses.KEY_LEFT}
195
+ assert right.opts.nav_keys == global_keys
196
+ # The untouched window still navigates normally.
197
+ right_backend.queue_keys(curses.KEY_DOWN)
198
+ right.prompt()
199
+ assert right.scroll_pos == before + 1
200
+ # And the module global was never mutated.
201
+ assert frozenset(NAVIGATION_KEYS) == global_keys
202
+ assert left.opts.taken_nav_keys == {curses.KEY_LEFT: 'prev'}
203
+
204
+
205
+ # --- dialogs and help -------------------------------------------------------
206
+
207
+ def test_dialogs_still_edit_with_taken_arrow_and_home_end_keys(make):
208
+ win, backend = make(taken_nav_keys={
209
+ curses.KEY_LEFT: '', curses.KEY_RIGHT: '',
210
+ curses.KEY_HOME: '', curses.KEY_END: '',
211
+ })
212
+ backend.queue_keys(ord('h'), ord('i'), curses.KEY_LEFT,
213
+ curses.KEY_BACKSPACE, 10)
214
+ assert win.answer() == 'i'
215
+
216
+ backend.queue_keys(curses.KEY_HOME, curses.KEY_DC, 10)
217
+ assert win.answer(seed='ab') == 'b'
218
+
219
+
220
+ def test_default_help_is_generated_from_the_structured_table():
221
+ opts = ConsoleWindowOpts()
222
+ lines = window_mod.render_nav_help(opts.nav_keys, opts.taken_nav_keys)
223
+ assert lines == NAV_KEY_LINES
224
+ assert 'k, UP: up one row' in lines[0]
225
+
226
+
227
+ def test_help_omits_taken_nav_keys_and_shows_app_labels(make):
228
+ win, _backend = make(taken_nav_keys={
229
+ curses.KEY_UP: 'app up',
230
+ curses.KEY_LEFT: 'prev',
231
+ curses.KEY_RIGHT: 'next',
232
+ })
233
+ lines = win.nav_help_lines()
234
+ joined = '\n'.join(lines)
235
+
236
+ # UP is gone from the navigation rows, but its sibling k remains.
237
+ assert 'k, UP' not in joined
238
+ assert 'k: up one row' in joined
239
+ # Taken keys are advertised under App: with the caller's labels.
240
+ assert '←: prev' in joined
241
+ assert '→: next' in joined
242
+ assert 'UP: app up' in joined
243
+ assert lines[-2].startswith('App:') or lines[-1].startswith('App:')
244
+
245
+
246
+ def test_spinner_help_renders_from_the_window(make):
247
+ win, _backend = make(taken_nav_keys={curses.KEY_LEFT: 'prev'})
248
+ spin = OptionSpinner(nav_keys=win.opts.nav_keys)
249
+ spin.show_help_nav_keys(win)
250
+ header_text = '\n'.join(win.head.texts)
251
+ assert '←: prev' in header_text
@@ -1,18 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import curses
4
-
5
- # Keys reserved for cursor movement and scrolling. Applications cannot bind
6
- # these: OptionSpinner.add_key rejects them with ValueError, and demo-mode
7
- # highlighting ignores them.
8
- NAVIGATION_KEYS: set[int] = {
9
- curses.KEY_UP, curses.KEY_DOWN, curses.KEY_LEFT, curses.KEY_RIGHT,
10
- curses.KEY_HOME, curses.KEY_END, curses.KEY_PPAGE, curses.KEY_NPAGE,
11
- ord('j'), ord('k'), # Vi-style up / down
12
- ord('0'), ord('$'), # First / last row
13
- ord('H'), ord('M'), ord('L'), # Top / middle / bottom of page
14
- ord('\x15'), # Ctrl-U (half page up)
15
- ord('\x04'), # Ctrl-D (half page down)
16
- ord('\x02'), # Ctrl-B (page up)
17
- ord('\x06'), # Ctrl-F (page down)
18
- }
File without changes