backpack-backbone 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. {backpack_backbone-0.2.0/backpack_backbone.egg-info → backpack_backbone-0.3.0}/PKG-INFO +1 -1
  2. backpack_backbone-0.3.0/backbone/nav.py +198 -0
  3. backpack_backbone-0.3.0/backbone/prompt/__init__.py +21 -0
  4. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/prompt/audio.py +18 -16
  5. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/prompt/chrome.py +75 -10
  6. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/prompt/core.py +836 -36
  7. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/prompt/dates.py +7 -17
  8. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/prompt/list_edit.py +28 -18
  9. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/prompt/lists.py +329 -76
  10. backpack_backbone-0.3.0/backbone/prompt/text.py +1002 -0
  11. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/prompt/timezone.py +5 -3
  12. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/prompt/values.py +6 -18
  13. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/ui.py +289 -19
  14. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0/backpack_backbone.egg-info}/PKG-INFO +1 -1
  15. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backpack_backbone.egg-info/SOURCES.txt +5 -0
  16. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/pyproject.toml +1 -1
  17. backpack_backbone-0.3.0/tests/test_boxes.py +437 -0
  18. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/tests/test_hint_clicks.py +18 -0
  19. backpack_backbone-0.3.0/tests/test_list_edit_render.py +40 -0
  20. backpack_backbone-0.3.0/tests/test_multiline.py +176 -0
  21. backpack_backbone-0.3.0/tests/test_overlay.py +57 -0
  22. backpack_backbone-0.3.0/tests/test_tabs.py +185 -0
  23. backpack_backbone-0.3.0/tests/test_ui_progress.py +116 -0
  24. backpack_backbone-0.2.0/backbone/nav.py +0 -17
  25. backpack_backbone-0.2.0/backbone/prompt/__init__.py +0 -20
  26. backpack_backbone-0.2.0/backbone/prompt/text.py +0 -356
  27. backpack_backbone-0.2.0/tests/test_ui_progress.py +0 -61
  28. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/LICENSE +0 -0
  29. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/README.md +0 -0
  30. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/__init__.py +0 -0
  31. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/app.py +0 -0
  32. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/datetime_parse.py +0 -0
  33. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/deps.py +0 -0
  34. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/files.py +0 -0
  35. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/keyboard.py +0 -0
  36. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/keys.py +0 -0
  37. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/log.py +0 -0
  38. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/notify.py +0 -0
  39. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/numbering.py +0 -0
  40. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/output.py +0 -0
  41. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/procs.py +0 -0
  42. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/prompt/keymap.py +0 -0
  43. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/terminal_input.py +0 -0
  44. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/timefmt.py +0 -0
  45. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backpack_backbone.egg-info/dependency_links.txt +0 -0
  46. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backpack_backbone.egg-info/top_level.txt +0 -0
  47. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/setup.cfg +0 -0
  48. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/tests/test_datetime_parse.py +0 -0
  49. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/tests/test_deps.py +0 -0
  50. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/tests/test_edit_line.py +0 -0
  51. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/tests/test_keys.py +0 -0
  52. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/tests/test_log.py +0 -0
  53. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/tests/test_procs.py +0 -0
  54. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/tests/test_quietly.py +0 -0
  55. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/tests/test_select_move.py +0 -0
  56. {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/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.0
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
@@ -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,21 @@
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_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
@@ -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
 
@@ -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
@@ -74,6 +75,26 @@ def set_player_opener(fn) -> None:
74
75
  _player_opener = fn
75
76
 
76
77
 
78
+ _command_line = None
79
+
80
+
81
+ def set_command_line(fn) -> None:
82
+ """Register a ``callable()`` that `:` opens, where a screen leaves it free.
83
+ It returns whether it opened a screen of its own (the screen under it then
84
+ needs drawing again)."""
85
+ global _command_line
86
+ _command_line = fn
87
+
88
+
89
+ def open_command_line() -> bool | None:
90
+ """Run the registered command line: whether it opened a screen of its own;
91
+ None when there is none to run here (none registered, or a screen that
92
+ keeps you in it, nav.modal())."""
93
+ if _command_line is None or nav.is_modal():
94
+ return None
95
+ return bool(_command_line())
96
+
97
+
77
98
  # --- shared widget chrome -------------------------------------------------
78
99
  # Every screen owes the user the same four things: a hint bar pinned above the
79
100
  # now-playing box and status bar so its keys never move, those keys clickable, the
@@ -135,15 +156,43 @@ def append_chrome(out: list, pairs, cells: dict, *, extra: str = "",
135
156
  if hint_lines:
136
157
  start = len(out) - len(hint_lines)
137
158
  for k in range(len(hint_lines)):
138
- # `_Widget.render` lays line j at terminal row anchor(1) + MARGIN_V + j.
159
+ # `_Widget.render` lays line j at terminal row anchor(1) + top_margin() + j.
139
160
  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)
161
+ 1 + ui.top_margin() + (start + k), items)
162
+ place_help_toggle(out, 1 + ui.top_margin(), cells, help_key)
142
163
  return out
143
164
 
144
165
 
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.
166
+ def inner_rule() -> str:
167
+ """A divider across the inside of a widget's box (boxed_chrome)."""
168
+ return f" {C.DIM}{'─' * max(1, ui.get_terminal_width() - 2 * ui.MARGIN_H - 4)}{C.RESET}"
169
+
170
+
171
+ def boxed_chrome(body: list, title: str, pairs, cells: dict, *, extra: str = "",
172
+ help_key: bool = False, header: list | None = None) -> tuple[list, int]:
173
+ """A widget's whole frame: any `header` lines, then `body` (lines with the
174
+ usual left margin) in a box titled `title` that reaches down to the hint
175
+ bar, then the bar (append_chrome). In a window too small for a box, `title`
176
+ is a line above the body instead. Either way the body starts one line under
177
+ the header; returns the lines and how many columns right the body moved (2
178
+ inside the box, for "│ " where the margin was), for a widget that maps
179
+ clicks on its body."""
180
+ title, header = title.strip().rstrip(":"), list(header or [])
181
+ if not box_fits():
182
+ out = header + [f" {C.DIM}{title}{C.RESET}"] + list(body)
183
+ append_chrome(out, pairs, cells, extra=extra, help_key=help_key)
184
+ return out, 0
185
+ box_h = max(3, _hint_pin_target() - len(header) - len(chrome_hint_lines(pairs, extra=extra)))
186
+ out = boxed_frame(header, list(body), title, box_h, help_key and not header)
187
+ append_chrome(out, pairs, cells, extra=extra, help_key=help_key)
188
+ return out, 2
189
+
190
+
191
+ def consume_chrome(key: str, cells: dict, free_keys: bool = False):
192
+ """Handle a transport key, a now-playing box click, a click on a hint key,
193
+ or a click on the tab bar. With `free_keys` (a screen that has no other use
194
+ for them), also Tab / Shift-Tab and the digits for the tabs, and `:` for
195
+ the command line.
147
196
 
148
197
  Returns :data:`CHROME_HANDLED` when the key is fully dealt with,
149
198
  :data:`CHROME_REDRAW` when the caller should also repaint, the synthesised
@@ -155,6 +204,21 @@ def consume_chrome(key: str, cells: dict):
155
204
  # background track change went by), so repaint everything.
156
205
  screen_invalidate()
157
206
  return CHROME_REDRAW
207
+ if free_keys and key == ':' and (opened := open_command_line()) is not None:
208
+ if not opened: # just the overlay, put back: only the toast is new
209
+ render_status_bar()
210
+ return CHROME_HANDLED
211
+ screen_invalidate()
212
+ if not _IS_WINDOWS:
213
+ sys.stdout.write("\033[?1000h\033[?1006h")
214
+ return CHROME_REDRAW
215
+ tab = nav.tab_for(key, free_keys)
216
+ if tab is not None:
217
+ nav.switch_to(tab) # back here once this tab shows again
218
+ screen_invalidate()
219
+ if not _IS_WINDOWS:
220
+ sys.stdout.write("\033[?1000h\033[?1006h") # the other tab may have had the mouse off
221
+ return CHROME_REDRAW
158
222
  if is_hints_key(key, bool(cells.get('__help_key__'))):
159
223
  toggle_hints()
160
224
  return CHROME_REDRAW # the bar appeared or went: re-lay the screen
@@ -183,7 +247,7 @@ def consume_chrome(key: str, cells: dict):
183
247
  sys.stdout.write("\033[?1000h\033[?1006h")
184
248
  sys.stdout.flush()
185
249
  return CHROME_REDRAW
186
- if act in ('playpause', 'next', 'prev') and _transport_handler is not None:
250
+ if act in FOOTER_ACTIONS and _transport_handler is not None:
187
251
  _transport_handler(act)
188
252
  return CHROME_HANDLED
189
253
  hit = cells.get((row, col))
@@ -210,8 +274,9 @@ def disable_mouse() -> None:
210
274
 
211
275
 
212
276
  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
277
+ """Register ``callable(action)`` for the global transport hotkeys and clicks
278
+ on the now-playing box, where action is one of FOOTER_ACTIONS ('time': its
279
+ clock was clicked). Kept as a registered callback so
215
280
  prompt need not import the playback layer (mirrors set_player_opener)."""
216
281
  global _transport_handler
217
282
  _transport_handler = fn