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.
- {console_window-2.0.0.10 → console_window-2.0.2}/PKG-INFO +31 -1
- {console_window-2.0.0.10 → console_window-2.0.2}/README.md +30 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/console_window/__init__.py +1 -1
- console_window-2.0.2/console_window/keys.py +37 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/console_window/opts.py +70 -3
- {console_window-2.0.0.10 → console_window-2.0.2}/console_window/spinner.py +15 -5
- {console_window-2.0.0.10 → console_window-2.0.2}/console_window/window.py +122 -16
- {console_window-2.0.0.10 → console_window-2.0.2/tests}/CHANGELOG.md +48 -0
- console_window-2.0.2/tests/test_taken_nav_keys.py +251 -0
- console_window-2.0.0.10/console_window/keys.py +0 -18
- {console_window-2.0.0.10 → console_window-2.0.2}/.github/workflows/ci.yml +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/.gitignore +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/LICENSE +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/SampleApp.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/console_window/backend.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/console_window/context.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/console_window/geometry.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/console_window/header.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/console_window/screens.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/console_window/signals.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/console_window/textwidth.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/console_window/theme.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/console_window/widgets.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/pyproject.toml +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/runner +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2/tests}/AUDIT.md +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_backend.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_context.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_demo_mode.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_dialogs.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_flash.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_geometry.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_header.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_interactive.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_low_colour.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_overflow.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_public_api.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_render_retry.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_sample_app.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_screens.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_signals.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_spinner.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_textwidth.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_theme.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_viewport_pad.py +0 -0
- {console_window-2.0.0.10 → console_window-2.0.2}/tests/test_widechars.py +0 -0
- {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.
|
|
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.
|
|
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', '
|
|
12
|
-
'
|
|
13
|
-
'
|
|
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
|
|
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
|
|
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
|
-
#
|
|
257
|
-
|
|
258
|
-
for line in
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|