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.
- {backpack_backbone-0.2.0/backpack_backbone.egg-info → backpack_backbone-0.3.2}/PKG-INFO +19 -4
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/README.md +18 -3
- backpack_backbone-0.3.2/backbone/nav.py +198 -0
- backpack_backbone-0.3.2/backbone/prompt/__init__.py +25 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/prompt/audio.py +24 -22
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/prompt/chrome.py +95 -11
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/prompt/core.py +1119 -65
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/prompt/dates.py +7 -17
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/prompt/list_edit.py +28 -18
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/prompt/lists.py +351 -86
- backpack_backbone-0.3.2/backbone/prompt/settings.py +112 -0
- backpack_backbone-0.3.2/backbone/prompt/text.py +1033 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/prompt/timezone.py +5 -3
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/prompt/values.py +6 -18
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/ui.py +310 -36
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2/backpack_backbone.egg-info}/PKG-INFO +19 -4
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backpack_backbone.egg-info/SOURCES.txt +6 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/pyproject.toml +1 -1
- backpack_backbone-0.3.2/tests/test_boxes.py +606 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/tests/test_hint_clicks.py +19 -1
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/tests/test_keys.py +22 -3
- backpack_backbone-0.3.2/tests/test_list_edit_render.py +40 -0
- backpack_backbone-0.3.2/tests/test_multiline.py +176 -0
- backpack_backbone-0.3.2/tests/test_overlay.py +106 -0
- backpack_backbone-0.3.2/tests/test_tabs.py +195 -0
- backpack_backbone-0.3.2/tests/test_ui_progress.py +123 -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.2}/LICENSE +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/__init__.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/app.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/datetime_parse.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/deps.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/files.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/keyboard.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/keys.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/log.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/notify.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/numbering.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/output.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/procs.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/prompt/keymap.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/terminal_input.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backbone/timefmt.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backpack_backbone.egg-info/dependency_links.txt +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/backpack_backbone.egg-info/top_level.txt +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/setup.cfg +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/tests/test_datetime_parse.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/tests/test_deps.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/tests/test_edit_line.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/tests/test_log.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/tests/test_procs.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/tests/test_quietly.py +0 -0
- {backpack_backbone-0.2.0 → backpack_backbone-0.3.2}/tests/test_select_move.py +0 -0
- {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
|
|
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.
|
|
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 ["
|
|
139
|
+
return box_lines(["line one", "line two"], content_width(), 4, "Status")
|
|
129
140
|
|
|
130
|
-
run_dashboard(render, interval=1.0,
|
|
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.
|
|
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 ["
|
|
121
|
+
return box_lines(["line one", "line two"], content_width(), 4, "Status")
|
|
111
122
|
|
|
112
|
-
run_dashboard(render, interval=1.0,
|
|
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
|
-
|
|
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
|
|
70
|
-
("next_band", ("RIGHT",), "next band
|
|
71
|
-
("add", ("a", "A"), "add a band
|
|
72
|
-
("delete", ("d", "D", "BACKSPACE", "DELETE"), "delete the band
|
|
73
|
-
("flat", ("f", "F"), "flatten every band
|
|
74
|
-
("preset", ("p", "P"), "next preset
|
|
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
|
-
|
|
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
|
|
@@ -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) +
|
|
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.
|
|
141
|
-
place_help_toggle(out, 1 + ui.
|
|
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
|
|
146
|
-
"""
|
|
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
|
|
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
|
|
214
|
-
|
|
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
|