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.
- {backpack_backbone-0.2.0/backpack_backbone.egg-info → backpack_backbone-0.3.0}/PKG-INFO +1 -1
- backpack_backbone-0.3.0/backbone/nav.py +198 -0
- backpack_backbone-0.3.0/backbone/prompt/__init__.py +21 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/prompt/audio.py +18 -16
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/prompt/chrome.py +75 -10
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/prompt/core.py +836 -36
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/prompt/dates.py +7 -17
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/prompt/list_edit.py +28 -18
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/prompt/lists.py +329 -76
- backpack_backbone-0.3.0/backbone/prompt/text.py +1002 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/prompt/timezone.py +5 -3
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/prompt/values.py +6 -18
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/ui.py +289 -19
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0/backpack_backbone.egg-info}/PKG-INFO +1 -1
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backpack_backbone.egg-info/SOURCES.txt +5 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/pyproject.toml +1 -1
- backpack_backbone-0.3.0/tests/test_boxes.py +437 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/tests/test_hint_clicks.py +18 -0
- backpack_backbone-0.3.0/tests/test_list_edit_render.py +40 -0
- backpack_backbone-0.3.0/tests/test_multiline.py +176 -0
- backpack_backbone-0.3.0/tests/test_overlay.py +57 -0
- backpack_backbone-0.3.0/tests/test_tabs.py +185 -0
- backpack_backbone-0.3.0/tests/test_ui_progress.py +116 -0
- backpack_backbone-0.2.0/backbone/nav.py +0 -17
- backpack_backbone-0.2.0/backbone/prompt/__init__.py +0 -20
- backpack_backbone-0.2.0/backbone/prompt/text.py +0 -356
- backpack_backbone-0.2.0/tests/test_ui_progress.py +0 -61
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/LICENSE +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/README.md +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/__init__.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/app.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/datetime_parse.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/deps.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/files.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/keyboard.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/keys.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/log.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/notify.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/numbering.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/output.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/procs.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/prompt/keymap.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/terminal_input.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backbone/timefmt.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backpack_backbone.egg-info/dependency_links.txt +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/backpack_backbone.egg-info/top_level.txt +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/setup.cfg +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/tests/test_datetime_parse.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/tests/test_deps.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/tests/test_edit_line.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/tests/test_keys.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/tests/test_log.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/tests/test_procs.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/tests/test_quietly.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.0}/tests/test_select_move.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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,
|
|
393
|
+
lines = _eq_render_lines(bands, cursor, message, status, _plot_cols(),
|
|
392
394
|
_hint_pin_target() - len(chrome_hint_lines(_pairs)))
|
|
393
|
-
|
|
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("
|
|
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,
|
|
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
|
-
|
|
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) +
|
|
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.
|
|
141
|
-
place_help_toggle(out, 1 + ui.
|
|
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
|
|
146
|
-
"""
|
|
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
|
|
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
|
|
214
|
-
|
|
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
|