backpack-backbone 0.2.0__tar.gz → 0.3.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 (57) hide show
  1. {backpack_backbone-0.2.0/backpack_backbone.egg-info → backpack_backbone-0.3.2}/PKG-INFO +19 -4
  2. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/README.md +18 -3
  3. backpack_backbone-0.3.2/backbone/nav.py +198 -0
  4. backpack_backbone-0.3.2/backbone/prompt/__init__.py +25 -0
  5. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/prompt/audio.py +24 -22
  6. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/prompt/chrome.py +95 -11
  7. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/prompt/core.py +1119 -65
  8. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/prompt/dates.py +7 -17
  9. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/prompt/list_edit.py +28 -18
  10. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/prompt/lists.py +351 -86
  11. backpack_backbone-0.3.2/backbone/prompt/settings.py +112 -0
  12. backpack_backbone-0.3.2/backbone/prompt/text.py +1033 -0
  13. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/prompt/timezone.py +5 -3
  14. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/prompt/values.py +6 -18
  15. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/ui.py +310 -36
  16. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2/backpack_backbone.egg-info}/PKG-INFO +19 -4
  17. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backpack_backbone.egg-info/SOURCES.txt +6 -0
  18. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/pyproject.toml +1 -1
  19. backpack_backbone-0.3.2/tests/test_boxes.py +606 -0
  20. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/tests/test_hint_clicks.py +19 -1
  21. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/tests/test_keys.py +22 -3
  22. backpack_backbone-0.3.2/tests/test_list_edit_render.py +40 -0
  23. backpack_backbone-0.3.2/tests/test_multiline.py +176 -0
  24. backpack_backbone-0.3.2/tests/test_overlay.py +106 -0
  25. backpack_backbone-0.3.2/tests/test_tabs.py +195 -0
  26. backpack_backbone-0.3.2/tests/test_ui_progress.py +123 -0
  27. backpack_backbone-0.2.0/backbone/nav.py +0 -17
  28. backpack_backbone-0.2.0/backbone/prompt/__init__.py +0 -20
  29. backpack_backbone-0.2.0/backbone/prompt/text.py +0 -356
  30. backpack_backbone-0.2.0/tests/test_ui_progress.py +0 -61
  31. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/LICENSE +0 -0
  32. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/__init__.py +0 -0
  33. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/app.py +0 -0
  34. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/datetime_parse.py +0 -0
  35. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/deps.py +0 -0
  36. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/files.py +0 -0
  37. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/keyboard.py +0 -0
  38. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/keys.py +0 -0
  39. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/log.py +0 -0
  40. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/notify.py +0 -0
  41. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/numbering.py +0 -0
  42. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/output.py +0 -0
  43. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/procs.py +0 -0
  44. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/prompt/keymap.py +0 -0
  45. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/terminal_input.py +0 -0
  46. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/timefmt.py +0 -0
  47. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backpack_backbone.egg-info/dependency_links.txt +0 -0
  48. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backpack_backbone.egg-info/top_level.txt +0 -0
  49. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/setup.cfg +0 -0
  50. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/tests/test_datetime_parse.py +0 -0
  51. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/tests/test_deps.py +0 -0
  52. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/tests/test_edit_line.py +0 -0
  53. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/tests/test_log.py +0 -0
  54. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/tests/test_procs.py +0 -0
  55. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/tests/test_quietly.py +0 -0
  56. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/tests/test_select_move.py +0 -0
  57. {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/tests/test_terminal_input.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: backpack-backbone
3
- Version: 0.2.0
3
+ Version: 0.3.2
4
4
  Summary: Shared code for the back* tools: terminal UI, prompt widgets, live views, logging, dates, settings plumbing and small file helpers, implemented once.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/RedEraRrow/backbone
@@ -60,6 +60,13 @@ with `set_accent` (an `ACCENT_PRESETS` key or `#RRGGBB`); a tool calls it once
60
60
  at startup from its own settings, and `accent_code` / `accent_label` let a
61
61
  settings screen check and name a value.
62
62
 
63
+ **`prompt.settings`** - what a Settings screen is built from, so every
64
+ tool's reads the same: `SETTINGS_COLUMNS` (a name, then its state),
65
+ `state_glyph` (● / ○), `space_toggles` (space flips the on/off rows),
66
+ `index_of` (the cursor back on the row just changed), `pick_option` (one of
67
+ a fixed set of values) and `pick_accent` (the accent colour picker, each
68
+ colour shown in itself).
69
+
63
70
  `from backbone import ...` exposes only a subset of `ui` (`Colors`, the
64
71
  margins and glyph sets, `spinner`, `content_width`, `rule`, `bar`,
65
72
  `header_box`, `wrap_margins`); import anything else from its module, e.g.
@@ -122,12 +129,16 @@ family (for typo scoring); raw key reads and escape decoding.
122
129
  through the same `_Widget` machinery every prompt widget uses, rather than each
123
130
  tool hand-rolling a redraw loop:
124
131
 
125
- from backbone.prompt.core import run_dashboard
132
+ from backbone.prompt.chrome import chrome_room
133
+ from backbone.prompt.core import box_lines, run_dashboard
134
+ from backbone.ui import content_width
135
+
136
+ HINTS = [("q", "quit")]
126
137
 
127
138
  def render() -> list:
128
- return [" line one", " line two"]
139
+ return box_lines(["line one", "line two"], content_width(), 4, "Status")
129
140
 
130
- run_dashboard(render, interval=1.0, quit_key="q")
141
+ run_dashboard(render, interval=1.0, quit_action="list.quit", hints=HINTS)
131
142
 
132
143
  `render()` returns the whole frame as a list of lines, each carrying its own
133
144
  left indent, and only runs once per `interval`. Keypresses and resizes are
@@ -136,6 +147,10 @@ instead of waiting out the data-refresh cadence. `on_key` handles anything
136
147
  other than the quit key and is free to open a `select()` or `confirm()` of its
137
148
  own; the dashboard repaints from scratch when it returns.
138
149
 
150
+ With `hints`, the hint bar goes under the frame as every other screen's does,
151
+ with the help toggle and clickable keys; `chrome_room(hints)` is the rows the
152
+ frame has above it. A window too small for boxes gets backbone's own notice.
153
+
139
154
  When stdin isn't a terminal (run from a script, or with input redirected),
140
155
  it falls back to a plain sleep loop and never reads keys, so such a view has
141
156
  to be stopped from outside.
@@ -42,6 +42,13 @@ with `set_accent` (an `ACCENT_PRESETS` key or `#RRGGBB`); a tool calls it once
42
42
  at startup from its own settings, and `accent_code` / `accent_label` let a
43
43
  settings screen check and name a value.
44
44
 
45
+ **`prompt.settings`** - what a Settings screen is built from, so every
46
+ tool's reads the same: `SETTINGS_COLUMNS` (a name, then its state),
47
+ `state_glyph` (● / ○), `space_toggles` (space flips the on/off rows),
48
+ `index_of` (the cursor back on the row just changed), `pick_option` (one of
49
+ a fixed set of values) and `pick_accent` (the accent colour picker, each
50
+ colour shown in itself).
51
+
45
52
  `from backbone import ...` exposes only a subset of `ui` (`Colors`, the
46
53
  margins and glyph sets, `spinner`, `content_width`, `rule`, `bar`,
47
54
  `header_box`, `wrap_margins`); import anything else from its module, e.g.
@@ -104,12 +111,16 @@ family (for typo scoring); raw key reads and escape decoding.
104
111
  through the same `_Widget` machinery every prompt widget uses, rather than each
105
112
  tool hand-rolling a redraw loop:
106
113
 
107
- from backbone.prompt.core import run_dashboard
114
+ from backbone.prompt.chrome import chrome_room
115
+ from backbone.prompt.core import box_lines, run_dashboard
116
+ from backbone.ui import content_width
117
+
118
+ HINTS = [("q", "quit")]
108
119
 
109
120
  def render() -> list:
110
- return [" line one", " line two"]
121
+ return box_lines(["line one", "line two"], content_width(), 4, "Status")
111
122
 
112
- run_dashboard(render, interval=1.0, quit_key="q")
123
+ run_dashboard(render, interval=1.0, quit_action="list.quit", hints=HINTS)
113
124
 
114
125
  `render()` returns the whole frame as a list of lines, each carrying its own
115
126
  left indent, and only runs once per `interval`. Keypresses and resizes are
@@ -118,6 +129,10 @@ instead of waiting out the data-refresh cadence. `on_key` handles anything
118
129
  other than the quit key and is free to open a `select()` or `confirm()` of its
119
130
  own; the dashboard repaints from scratch when it returns.
120
131
 
132
+ With `hints`, the hint bar goes under the frame as every other screen's does,
133
+ with the help toggle and clickable keys; `chrome_room(hints)` is the rows the
134
+ frame has above it. A window too small for boxes gets backbone's own notice.
135
+
121
136
  When stdin isn't a terminal (run from a script, or with input redirected),
122
137
  it falls back to a plain sleep loop and never reads keys, so such a view has
123
138
  to be stopped from outside.
@@ -0,0 +1,198 @@
1
+ """nav.py - app-wide navigation breadcrumb and the quit-to-terminal signal,
2
+ shared by every back* tool that uses backbone's prompt widgets.
3
+ """
4
+ NAV_STACK = ["Home"]
5
+
6
+
7
+ class QuitToTerminal(BaseException):
8
+ """Raised to unwind the entire menu stack and exit straight to the
9
+ terminal. Derives from BaseException (not Exception) so it bypasses
10
+ ``except Exception`` handlers in editors/widgets and propagates cleanly
11
+ up to the app's main(), where the alt-screen is restored in a finally.
12
+
13
+ `q` quits an app from anywhere by raising this on the spot - there is
14
+ deliberately no "leave this widget and quit later" flag: `q` is never a
15
+ way out of a widget; Esc (or <-/b where a widget has no other use for
16
+ them) is what backs out.
17
+ """
18
+
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Tabs. Each tab's screen loop runs in its own thread, but only one at a time:
22
+ # switching hands the turn to the other tab's thread and parks this one where it
23
+ # is, so every tab comes back exactly as it was left, however deep its menus go.
24
+ import queue as _queue
25
+ import sys as _sys
26
+ import threading as _threading
27
+ from contextlib import contextmanager as _contextmanager
28
+
29
+ TABS: list = [] # (name, run) pairs while an app runs as tabs
30
+ _active = [0] # the tab on screen
31
+ _turns: dict = {} # tab index → Event set while it's that tab's turn
32
+ _visits: list = [] # tabs most recently shown last, for going back
33
+ _stacks: dict = {} # each tab's breadcrumb, kept while another shows
34
+ _ended: _queue.Queue = _queue.Queue()
35
+ _mine = _threading.local() # .tab: the tab this thread runs
36
+ _modal = [0] # screens inside nav.modal(): no switching
37
+ _base_tty = [None] # the terminal as run_tabs found it: each tab starts so
38
+ _fresh: set = set() # tabs to start again from the top when next shown
39
+
40
+
41
+ class _StartAfresh(BaseException):
42
+ """Unwinds a parked tab back to its start (go_to_tab's `fresh`)."""
43
+
44
+
45
+ def _tty():
46
+ """The terminal's attributes, or None (Windows, or not a terminal)."""
47
+ try:
48
+ import termios
49
+ return termios.tcgetattr(_sys.stdin.fileno())
50
+ except Exception:
51
+ return None
52
+
53
+
54
+ def _set_tty(attrs) -> None:
55
+ if attrs is not None:
56
+ import termios
57
+ termios.tcsetattr(_sys.stdin.fileno(), termios.TCSADRAIN, attrs)
58
+
59
+
60
+ def run_tabs(tabs: list, home: int = 0) -> None:
61
+ """Run an app as tabs, `tabs` being (name, run) pairs, starting on `home`,
62
+ until a tab raises (QuitToTerminal to quit, or a crash), which is raised here.
63
+
64
+ A tab whose run() returns (backed out of its top level) goes back to the tab
65
+ shown before it, or, with none, `home` starts again."""
66
+ _base_tty[0] = _tty()
67
+ crumb = NAV_STACK[:]
68
+ TABS[:] = tabs
69
+ try:
70
+ _show(home)
71
+ while True:
72
+ try: # a timeout, so signals (a resize) still reach this, the main thread
73
+ i, exc = _ended.get(timeout=0.2)
74
+ except _queue.Empty:
75
+ continue
76
+ _turns.pop(i, None)
77
+ _stacks.pop(i, None)
78
+ if i in _visits:
79
+ _visits.remove(i)
80
+ if exc is not None:
81
+ raise exc
82
+ _show(_visits[-1] if _visits else home)
83
+ finally:
84
+ # Tabs still parked stay parked: their threads die with the app.
85
+ TABS.clear()
86
+ _turns.clear(); _visits.clear(); _stacks.clear(); _fresh.clear()
87
+ _active[0] = 0
88
+ NAV_STACK[:] = crumb
89
+ _set_tty(_base_tty[0]) # parked tabs never get to restore it themselves
90
+
91
+
92
+ def _show(i: int) -> None:
93
+ """Make tab `i` the one on screen and give it the turn, starting its thread
94
+ on its first visit."""
95
+ if _active[0] in _turns: # still running: keep its breadcrumb
96
+ _stacks[_active[0]] = NAV_STACK[:]
97
+ _active[0] = i
98
+ NAV_STACK[:] = _stacks.get(i) or [TABS[i][0]]
99
+ if i in _visits:
100
+ _visits.remove(i)
101
+ _visits.append(i)
102
+ if i not in _turns:
103
+ _turns[i] = _threading.Event()
104
+ _threading.Thread(target=_tab_thread, args=(i,), daemon=True,
105
+ name=f"tab-{TABS[i][0]}").start()
106
+ _turns[i].set()
107
+
108
+
109
+ def _tab_thread(i: int) -> None:
110
+ _mine.tab = i
111
+ _turns[i].wait()
112
+ _set_tty(_base_tty[0])
113
+ _fresh.discard(i)
114
+ exc = None
115
+ while True:
116
+ try:
117
+ TABS[i][1]()
118
+ except _StartAfresh:
119
+ NAV_STACK[:] = [TABS[i][0]]
120
+ continue
121
+ except BaseException as e: # handed to run_tabs, which raises it
122
+ exc = e
123
+ break
124
+ _ended.put((i, exc))
125
+
126
+
127
+ @_contextmanager
128
+ def modal():
129
+ """For a screen that mustn't be left for another tab (one playing its own
130
+ audio): inside it, tab keys and clicks do nothing and the bar is dimmed."""
131
+ _modal[0] += 1
132
+ try:
133
+ yield
134
+ finally:
135
+ _modal[0] -= 1
136
+
137
+
138
+ def is_modal() -> bool:
139
+ return _modal[0] > 0
140
+
141
+
142
+ def active_tab() -> int:
143
+ """The tab on screen."""
144
+ return _active[0]
145
+
146
+
147
+ def current_tab() -> int | None:
148
+ """The tab this thread runs, or None outside tabs."""
149
+ return getattr(_mine, 'tab', None) if TABS else None
150
+
151
+
152
+ def switch_to(j: int) -> None:
153
+ """From a tab's thread, show tab `j`; returns once this tab is shown again,
154
+ with the terminal as it was."""
155
+ i = current_tab()
156
+ if i is None or j == i:
157
+ return
158
+ attrs = _tty()
159
+ _turns[i].clear()
160
+ _show(j)
161
+ _turns[i].wait()
162
+ _set_tty(attrs)
163
+ if i in _fresh:
164
+ _fresh.discard(i)
165
+ raise _StartAfresh()
166
+
167
+
168
+ def go_to_tab(name: str, fresh: bool = False) -> bool:
169
+ """Show the tab called `name` from another tab, returning (True) once that
170
+ one is left. False, doing nothing, outside tabs or already on it.
171
+ `fresh`: that tab starts again from the top rather than where it was left
172
+ (for a tab told where to open)."""
173
+ names = [n for n, _run in TABS]
174
+ if current_tab() is None or name not in names or names.index(name) == current_tab():
175
+ return False
176
+ if fresh:
177
+ _fresh.add(names.index(name))
178
+ switch_to(names.index(name))
179
+ return True
180
+
181
+
182
+ def tab_for(key: str, use_keys: bool = True) -> int | None:
183
+ """The tab a key or click asks for: a click on the tab bar, F1, F2… (keys
184
+ nothing types, so they work on any screen), and with `use_keys` Tab /
185
+ Shift-Tab. None when it isn't one, or is the tab already showing."""
186
+ if current_tab() is None or is_modal() or not isinstance(key, str):
187
+ return None
188
+ n, i, j = len(TABS), _active[0], None
189
+ if key.startswith('MOUSE_CLICK:'):
190
+ parts = key.split(':')
191
+ from backbone import ui
192
+ if len(parts) > 3 and parts[2].isdigit() and int(parts[2]) <= ui.tab_rows():
193
+ j = ui.tab_at(int(parts[3]))
194
+ elif use_keys and key in ('TAB', 'BACKTAB'):
195
+ j = (i + (1 if key == 'TAB' else -1)) % n
196
+ elif key[:1] == 'F' and key[1:].isdigit() and 1 <= int(key[1:]) <= n:
197
+ j = int(key[1:]) - 1
198
+ return j if j != i else None
@@ -0,0 +1,25 @@
1
+ """The prompt widgets, one import: lists (select, live_select, confirm,
2
+ ListPlace), text (text, path, multiline, system_editor_edit), list_edit, dates, values,
3
+ audio, and the shared chrome."""
4
+ from backbone.prompt.core import ( # noqa: F401
5
+ Choice, Column, JumpTo, Pane, PanelTitle, Preview, Trail, box_fits, box_lines, separator, HINTS_CLICK, add_help_corner, add_hint_click_cells,
6
+ help_corner_text, path_box, help_toggle_shown, help_toggle_width, hints_visible, is_hints_key, place_help_toggle, rounded_header,
7
+ set_help_toggle_shown, toggle_hints, columns_shown, set_columns_shown,
8
+ )
9
+ from backbone.prompt.chrome import ( # noqa: F401
10
+ CHROME_HANDLED, CHROME_REDRAW, MODE_TOGGLE, move_hint,
11
+ append_chrome, boxed_chrome, chrome_hint_lines, chrome_room, chrome_hint_pairs, consume_chrome, inner_rule,
12
+ disable_mouse, enable_mouse,
13
+ open_command_line, set_activity_opener, set_command_line, set_player_opener, set_transport_handler,
14
+ )
15
+ from backbone.prompt.text import multiline, overlay_checklist, overlay_text, path, system_editor_edit, text, token_completions # noqa: F401
16
+ from backbone.prompt.lists import ListPlace, confirm, live_select, options_menu, select # noqa: F401
17
+ from backbone.prompt.list_edit import list_edit # noqa: F401
18
+ from backbone.prompt.dates import calendar_select, datetime_edit # noqa: F401
19
+ from backbone.prompt.values import fraction_edit, number_edit, rating_edit, time_edit # noqa: F401
20
+ from backbone.prompt.audio import equaliser_edit, rva2_edit # noqa: F401
21
+ from backbone.prompt.keymap import keys_editor # noqa: F401
22
+ from backbone.prompt.settings import ( # noqa: F401
23
+ ACCENT_COLUMNS, OFF_GLYPH, ON_GLYPH, SETTINGS_COLUMNS, accent_name, accent_swatch, index_of,
24
+ pick_accent, pick_option, space_toggles, state_glyph,
25
+ )
@@ -8,8 +8,9 @@ from backbone.prompt.core import (
8
8
  from backbone import keys, ui
9
9
  from backbone.nav import QuitToTerminal
10
10
  from backbone.prompt.chrome import (
11
- append_chrome, CHROME_HANDLED, chrome_hint_lines, CHROME_REDRAW, consume_chrome, disable_mouse, enable_mouse,
11
+ boxed_chrome, CHROME_HANDLED, chrome_hint_lines, CHROME_REDRAW, consume_chrome, disable_mouse, enable_mouse,
12
12
  )
13
+ from backbone.prompt.core import box_fits
13
14
  from backbone.prompt.text import text
14
15
  from backbone.prompt.core import C
15
16
 
@@ -66,12 +67,12 @@ keys.define("levels", "Volume and equaliser", [
66
67
  ("up_big", ("PGUP",), "raise by 3"),
67
68
  ("down_big", ("PGDN",), "lower by 3"),
68
69
  ("zero", ("0",), "back to zero"),
69
- ("prev_band", ("LEFT",), "previous band (equaliser)"),
70
- ("next_band", ("RIGHT",), "next band (equaliser)"),
71
- ("add", ("a", "A"), "add a band (equaliser)"),
72
- ("delete", ("d", "D", "BACKSPACE", "DELETE"), "delete the band (equaliser)"),
73
- ("flat", ("f", "F"), "flatten every band (equaliser)"),
74
- ("preset", ("p", "P"), "next preset (equaliser)"),
70
+ ("prev_band", ("LEFT",), "previous band"),
71
+ ("next_band", ("RIGHT",), "next band"),
72
+ ("add", ("a", "A"), "add a band"),
73
+ ("delete", ("d", "D", "BACKSPACE", "DELETE"), "delete the band"),
74
+ ("flat", ("f", "F"), "flatten every band"),
75
+ ("preset", ("p", "P"), "next preset"),
75
76
  ("save", ("ENTER",), "save"),
76
77
  ("back", ("ESC",), "back"),
77
78
  ], within=("list", "global"))
@@ -89,11 +90,9 @@ def _eq_fmt_freq(freq: float) -> str:
89
90
  def _eq_render_lines(bands: list, cursor: int, message: str, status: str,
90
91
  cols: int, rows: int, show_curve: bool = True) -> list[str]:
91
92
  """Render the graphic-EQ plot: vertical bands from a 0 dB baseline, a dim
92
- response curve through the band tops, dB axis and frequency labels."""
93
- out = [
94
- f" {C.DIM}{message}{C.RESET}",
95
- f"{C.DIM}{'─' * ui.get_terminal_width()}{C.RESET}",
96
- ]
93
+ response curve through the band tops, dB axis and frequency labels. Two
94
+ rows of `rows` are left for the box's borders (boxed_chrome titles it)."""
95
+ out = []
97
96
  n = len(bands)
98
97
  plot_w = max(10, cols - 5) # 4 cols for the dB label + 1 gap
99
98
  avail = rows - 9
@@ -208,11 +207,9 @@ def _rva2_render_lines(gain: float, message: str, avail: int | None = None) -> l
208
207
  Cut rows (db < 0): bar fills downward; ▀ lights first (top half, at gain ≤ db+0.5),
209
208
  then █ when gain ≤ db.
210
209
  Every 0.5 dB step changes a visible half-block, so no increment is invisible.
210
+ The box around it (boxed_chrome) carries `message`.
211
211
  """
212
- out = [
213
- f" {C.DIM}{message}{C.RESET}",
214
- f"{C.DIM}{'─' * 20}{C.RESET}",
215
- ]
212
+ out = []
216
213
 
217
214
  # One row per dB is the ideal, but the meter must still fit above the hint
218
215
  # bar and the now-playing box: on a short terminal it would otherwise run off the
@@ -280,7 +277,7 @@ def rva2_edit(message: str = "Volume adjustment:", gain: float = 0.0) -> float |
280
277
  _avail = _hint_pin_target() - 4 - len(chrome_hint_lines(_pairs))
281
278
  lines = _rva2_render_lines(gain, message, avail=_avail)
282
279
  lines.append(f" {C.ACCENT}▸{C.RESET} {C.BOLD}{gain:+.1f} dB{C.RESET}")
283
- append_chrome(lines, _pairs, _hint_cells)
280
+ lines, _dx = boxed_chrome(lines, message, _pairs, _hint_cells)
284
281
  w.render(lines)
285
282
 
286
283
  result = None
@@ -362,6 +359,11 @@ def equaliser_edit(message: str = "Equalisation:", adjustments: list | None = No
362
359
  old = _get_term_attrs(fd)
363
360
  w = _Widget(fd)
364
361
  _hint_cells: dict = {} # clickable hint keys, filled by append_chrome
362
+ _dx = [0] # how far right the box moved the plot (boxed_chrome)
363
+
364
+ def _plot_cols() -> int:
365
+ """The width the plot is laid out in: inside the box, when there is one."""
366
+ return _cols() - (4 if box_fits() else 0)
365
367
 
366
368
  def _clamp(g: float) -> float:
367
369
  return max(-_EQ_GAIN_MAX, min(_EQ_GAIN_MAX, g))
@@ -388,9 +390,9 @@ def equaliser_edit(message: str = "Equalisation:", adjustments: list | None = No
388
390
  (L("levels.delete", most=1), "delete"), (L("levels.zero"), "zero"), (L("levels.flat"), "flat"),
389
391
  (L("levels.preset"), "preset"), (L("levels.save"), "save"), (L("levels.back"), "back"),
390
392
  (L("list.quit"), "quit app")]
391
- lines = _eq_render_lines(bands, cursor, message, status, _cols(),
393
+ lines = _eq_render_lines(bands, cursor, message, status, _plot_cols(),
392
394
  _hint_pin_target() - len(chrome_hint_lines(_pairs)))
393
- append_chrome(lines, _pairs, _hint_cells, help_key=True)
395
+ lines, _dx[0] = boxed_chrome(lines, message, _pairs, _hint_cells, help_key=True)
394
396
  w.render(lines)
395
397
 
396
398
  result = None
@@ -458,7 +460,7 @@ def equaliser_edit(message: str = "Equalisation:", adjustments: list | None = No
458
460
  elif act == 'levels.add':
459
461
  _restore_term_attrs(fd, old)
460
462
  disable_mouse()
461
- freq_str = text("Add band frequency (Hz):")
463
+ freq_str = text("Band frequency in Hz:")
462
464
  _set_raw(fd)
463
465
  enable_mouse()
464
466
  screen_takeover_next() # paint over the previous screen, no flash
@@ -481,8 +483,8 @@ def equaliser_edit(message: str = "Equalisation:", adjustments: list | None = No
481
483
  elif key.startswith('MOUSE_CLICK:') and n:
482
484
  parts = key.split(':')
483
485
  col = int(parts[3]) if len(parts) > 3 else 1
484
- plot_w = max(10, _cols() - 5)
485
- x = col - 5 # the 3-col dB label + a space; the plot starts at col 5
486
+ plot_w = max(10, _plot_cols() - 5)
487
+ x = col - 5 - _dx[0] # the 3-col dB label + a space; the plot starts at col 5, then the box's shift
486
488
  if 0 <= x < plot_w:
487
489
  bx = _eq_band_x(n, plot_w)
488
490
  cursor = min(range(n), key=lambda i: abs(bx[i] - x))
@@ -3,10 +3,11 @@ player and transport keys, mouse reporting, and the per-edit raw-text toggle."""
3
3
  from __future__ import annotations
4
4
  import sys
5
5
  from backbone.prompt.core import (
6
- _IS_WINDOWS, _hint, add_hint_click_cells, footer_click_action, _hint_pin_target,
6
+ box_fits, boxed_frame, render_status_bar, C,
7
+ _IS_WINDOWS, FOOTER_ACTIONS, _hint, add_hint_click_cells, footer_click_action, _hint_pin_target,
7
8
  screen_invalidate, HINTS_CLICK, is_hints_key, toggle_hints, place_help_toggle,
8
9
  )
9
- from backbone import keys, ui
10
+ from backbone import keys, nav, ui
10
11
 
11
12
 
12
13
  # Live on every screen: the background-audio transport (routed through
@@ -19,6 +20,13 @@ keys.define("global", "Everywhere", [
19
20
  ("player", ("\x0f",), "open the player"),
20
21
  ("raw_text", ("\x14",), "switch a value between its editor and raw text"),
21
22
  ], within=())
23
+ # The volume, on any screen that leaves these keys free (a list, the player,
24
+ # the miniplayer; not a text field, where they're typed). Its own group, so a
25
+ # key a screen binds for itself isn't taken from it.
26
+ keys.define("volume", "Volume", [
27
+ ("up", ("+", "="), "volume up"),
28
+ ("down", ("-", "_"), "volume down"),
29
+ ], within=())
22
30
 
23
31
  # The one key pair that moves a row up or down, wherever a list's order can be
24
32
  # changed (select's on_move, list_edit).
@@ -74,6 +82,28 @@ def set_player_opener(fn) -> None:
74
82
  _player_opener = fn
75
83
 
76
84
 
85
+ _command_line = None
86
+
87
+
88
+ def set_command_line(fn) -> None:
89
+ """Register a ``callable(around=None)`` that `:` opens, where a screen
90
+ leaves it free. It returns whether it opened a screen of its own (the
91
+ screen under it then needs drawing again), opening it inside `around()`
92
+ when given."""
93
+ global _command_line
94
+ _command_line = fn
95
+
96
+
97
+ def open_command_line(around=None) -> bool | None:
98
+ """Run the registered command line: whether it opened a screen of its own
99
+ (inside the context `around()`, when given: the player steps out of view
100
+ only for that); None when there is none to run here (none registered, or
101
+ a screen that keeps you in it, nav.modal())."""
102
+ if _command_line is None or nav.is_modal():
103
+ return None
104
+ return bool(_command_line(around=around) if around else _command_line())
105
+
106
+
77
107
  # --- shared widget chrome -------------------------------------------------
78
108
  # Every screen owes the user the same four things: a hint bar pinned above the
79
109
  # now-playing box and status bar so its keys never move, those keys clickable, the
@@ -109,6 +139,12 @@ def chrome_hint_lines(pairs, *, extra: str = "") -> list:
109
139
  return _hint(*chrome_hint_pairs(pairs), extra=extra).splitlines()
110
140
 
111
141
 
142
+ def chrome_room(pairs, *, extra: str = "") -> int:
143
+ """The rows a widget's frame has above its hint bar (append_chrome), for a
144
+ screen laying out its own boxes."""
145
+ return _hint_pin_target() - len(chrome_hint_lines(pairs, extra=extra))
146
+
147
+
112
148
  def append_chrome(out: list, pairs, cells: dict, *, extra: str = "",
113
149
  pin: bool = True, help_key: bool = False) -> list:
114
150
  """Append the hint bar to a widget's rendered `out` lines, in place.
@@ -121,7 +157,7 @@ def append_chrome(out: list, pairs, cells: dict, *, extra: str = "",
121
157
 
122
158
  The bar is empty unless hints are switched on; either way the top line
123
159
  (`out[0]`) carries the corner toggle. `help_key`: this screen leaves `?` free,
124
- so `?` toggles and the corner says so; otherwise it names Ctrl-/.
160
+ so `?` toggles and the corner says so; otherwise it names Ctrl-/ (or Ctrl-G).
125
161
  """
126
162
  items = chrome_hint_pairs(pairs)
127
163
  hint_lines = _hint(*items, extra=extra).splitlines()
@@ -135,15 +171,43 @@ def append_chrome(out: list, pairs, cells: dict, *, extra: str = "",
135
171
  if hint_lines:
136
172
  start = len(out) - len(hint_lines)
137
173
  for k in range(len(hint_lines)):
138
- # `_Widget.render` lays line j at terminal row anchor(1) + MARGIN_V + j.
174
+ # `_Widget.render` lays line j at terminal row anchor(1) + top_margin() + j.
139
175
  add_hint_click_cells(cells, out[start + k],
140
- 1 + ui.MARGIN_V + (start + k), items)
141
- place_help_toggle(out, 1 + ui.MARGIN_V, cells, help_key)
176
+ 1 + ui.top_margin() + (start + k), items)
177
+ place_help_toggle(out, 1 + ui.top_margin(), cells, help_key)
142
178
  return out
143
179
 
144
180
 
145
- def consume_chrome(key: str, cells: dict):
146
- """Handle a transport key, a now-playing box click, or a click on a hint key.
181
+ def inner_rule() -> str:
182
+ """A divider across the inside of a widget's box (boxed_chrome)."""
183
+ return f" {C.DIM}{'─' * max(1, ui.get_terminal_width() - 2 * ui.MARGIN_H - 4)}{C.RESET}"
184
+
185
+
186
+ def boxed_chrome(body: list, title: str, pairs, cells: dict, *, extra: str = "",
187
+ help_key: bool = False, header: list | None = None) -> tuple[list, int]:
188
+ """A widget's whole frame: any `header` lines, then `body` (lines with the
189
+ usual left margin) in a box titled `title` that reaches down to the hint
190
+ bar, then the bar (append_chrome). In a window too small for a box, `title`
191
+ is a line above the body instead. Either way the body starts one line under
192
+ the header; returns the lines and how many columns right the body moved (2
193
+ inside the box, for "│ " where the margin was), for a widget that maps
194
+ clicks on its body."""
195
+ title, header = title.strip().rstrip(":"), list(header or [])
196
+ if not box_fits():
197
+ out = header + [f" {C.DIM}{title}{C.RESET}"] + list(body)
198
+ append_chrome(out, pairs, cells, extra=extra, help_key=help_key)
199
+ return out, 0
200
+ box_h = max(3, chrome_room(pairs, extra=extra) - len(header))
201
+ out = boxed_frame(header, list(body), title, box_h, help_key and not header)
202
+ append_chrome(out, pairs, cells, extra=extra, help_key=help_key)
203
+ return out, 2
204
+
205
+
206
+ def consume_chrome(key: str, cells: dict, free_keys: bool = False):
207
+ """Handle a transport key, a now-playing box click, a click on a hint key,
208
+ or a click on the tab bar. With `free_keys` (a screen that has no other use
209
+ for them), also Tab / Shift-Tab and the digits for the tabs, `:` for the
210
+ command line, and the volume (+ / -).
147
211
 
148
212
  Returns :data:`CHROME_HANDLED` when the key is fully dealt with,
149
213
  :data:`CHROME_REDRAW` when the caller should also repaint, the synthesised
@@ -155,6 +219,21 @@ def consume_chrome(key: str, cells: dict):
155
219
  # background track change went by), so repaint everything.
156
220
  screen_invalidate()
157
221
  return CHROME_REDRAW
222
+ if free_keys and key == ':' and (opened := open_command_line()) is not None:
223
+ if not opened: # just the overlay, put back: only the toast is new
224
+ render_status_bar()
225
+ return CHROME_HANDLED
226
+ screen_invalidate()
227
+ if not _IS_WINDOWS:
228
+ sys.stdout.write("\033[?1000h\033[?1006h")
229
+ return CHROME_REDRAW
230
+ tab = nav.tab_for(key, free_keys)
231
+ if tab is not None:
232
+ nav.switch_to(tab) # back here once this tab shows again
233
+ screen_invalidate()
234
+ if not _IS_WINDOWS:
235
+ sys.stdout.write("\033[?1000h\033[?1006h") # the other tab may have had the mouse off
236
+ return CHROME_REDRAW
158
237
  if is_hints_key(key, bool(cells.get('__help_key__'))):
159
238
  toggle_hints()
160
239
  return CHROME_REDRAW # the bar appeared or went: re-lay the screen
@@ -168,6 +247,10 @@ def consume_chrome(key: str, cells: dict):
168
247
  if act in ("global.playpause", "global.next", "global.prev") and _transport_handler is not None:
169
248
  _transport_handler(act.split(".")[1])
170
249
  return CHROME_HANDLED
250
+ vol = keys.action(key, "volume") if free_keys and isinstance(key, str) else None
251
+ if vol in ("volume.up", "volume.down") and _transport_handler is not None:
252
+ _transport_handler("vol_up" if vol == "volume.up" else "vol_down")
253
+ return CHROME_HANDLED
171
254
 
172
255
  if isinstance(key, str) and key.startswith('MOUSE_CLICK:'):
173
256
  parts = key.split(':')
@@ -183,7 +266,7 @@ def consume_chrome(key: str, cells: dict):
183
266
  sys.stdout.write("\033[?1000h\033[?1006h")
184
267
  sys.stdout.flush()
185
268
  return CHROME_REDRAW
186
- if act in ('playpause', 'next', 'prev') and _transport_handler is not None:
269
+ if act in FOOTER_ACTIONS and _transport_handler is not None:
187
270
  _transport_handler(act)
188
271
  return CHROME_HANDLED
189
272
  hit = cells.get((row, col))
@@ -210,8 +293,9 @@ def disable_mouse() -> None:
210
293
 
211
294
 
212
295
  def set_transport_handler(fn) -> None:
213
- """Register ``callable(action)`` for the global transport hotkeys, where
214
- action is 'playpause', 'next', or 'prev'. Kept as a registered callback so
296
+ """Register ``callable(action)`` for the global transport hotkeys and clicks
297
+ on the now-playing box, where action is one of FOOTER_ACTIONS ('time': its
298
+ clock was clicked). Kept as a registered callback so
215
299
  prompt need not import the playback layer (mirrors set_player_opener)."""
216
300
  global _transport_handler
217
301
  _transport_handler = fn