backpack-backbone 0.3.1__tar.gz → 0.4.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 (53) hide show
  1. {backpack_backbone-0.3.1/backpack_backbone.egg-info → backpack_backbone-0.4.0}/PKG-INFO +24 -9
  2. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/README.md +23 -8
  3. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/__init__.py +0 -10
  4. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/prompt/__init__.py +5 -1
  5. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/prompt/chrome.py +34 -7
  6. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/prompt/core.py +58 -21
  7. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/prompt/keymap.py +13 -8
  8. backpack_backbone-0.4.0/backbone/prompt/settings.py +112 -0
  9. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/ui.py +28 -141
  10. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0/backpack_backbone.egg-info}/PKG-INFO +24 -9
  11. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backpack_backbone.egg-info/SOURCES.txt +1 -0
  12. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/pyproject.toml +1 -1
  13. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_boxes.py +20 -0
  14. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_keys.py +48 -0
  15. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_ui_progress.py +10 -3
  16. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/LICENSE +0 -0
  17. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/app.py +0 -0
  18. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/datetime_parse.py +0 -0
  19. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/deps.py +0 -0
  20. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/files.py +0 -0
  21. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/keyboard.py +0 -0
  22. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/keys.py +0 -0
  23. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/log.py +0 -0
  24. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/nav.py +0 -0
  25. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/notify.py +0 -0
  26. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/numbering.py +0 -0
  27. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/output.py +0 -0
  28. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/procs.py +0 -0
  29. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/prompt/audio.py +0 -0
  30. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/prompt/dates.py +0 -0
  31. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/prompt/list_edit.py +0 -0
  32. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/prompt/lists.py +0 -0
  33. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/prompt/text.py +0 -0
  34. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/prompt/timezone.py +0 -0
  35. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/prompt/values.py +0 -0
  36. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/terminal_input.py +0 -0
  37. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/timefmt.py +0 -0
  38. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backpack_backbone.egg-info/dependency_links.txt +0 -0
  39. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backpack_backbone.egg-info/top_level.txt +0 -0
  40. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/setup.cfg +0 -0
  41. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_datetime_parse.py +0 -0
  42. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_deps.py +0 -0
  43. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_edit_line.py +0 -0
  44. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_hint_clicks.py +0 -0
  45. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_list_edit_render.py +0 -0
  46. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_log.py +0 -0
  47. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_multiline.py +0 -0
  48. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_overlay.py +0 -0
  49. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_procs.py +0 -0
  50. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_quietly.py +0 -0
  51. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_select_move.py +0 -0
  52. {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_tabs.py +0 -0
  53. {backpack_backbone-0.3.1 → backpack_backbone-0.4.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.1
3
+ Version: 0.4.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
@@ -50,9 +50,9 @@ how to install anything it can't run without.
50
50
  ## What's in it
51
51
 
52
52
  **`ui`** - the visual layer. `Colors` (honours `NO_COLOR=1`), the global
53
- `MARGIN_H` / `MARGIN_V` inset every frame is drawn inside, `wrap_margins`,
54
- `rule`, `bar`, `header_box`, `sparkline`, `spinner`, `rate_of_change`, and
55
- the `SPIN` / `PARTS` / `SPARK` glyph sets. Also the ANSI-aware text
53
+ `MARGIN_H` / `MARGIN_V` inset every frame is drawn inside, the progress
54
+ bar (`progress_cells`, `get_progress_bar`), `sparkline`, `spinner`,
55
+ `rate_of_change`, and the `SPIN` / `SPARK` glyph sets. Also the ANSI-aware text
56
56
  measuring (`visual_len`, `truncate_text`, `clip_ansi`, `strip_ansi`) that
57
57
  makes any of that survive colour codes and wide characters, and small
58
58
  formatters: `plural`, `human_gb`, `dir_size_kb`. The accent colour is chosen
@@ -61,8 +61,8 @@ 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
63
  `from backbone import ...` exposes only a subset of `ui` (`Colors`, the
64
- margins and glyph sets, `spinner`, `content_width`, `rule`, `bar`,
65
- `header_box`, `wrap_margins`); import anything else from its module, e.g.
64
+ margins and glyph sets, `spinner`, `content_width`); import anything else
65
+ from its module, e.g.
66
66
  `from backbone.ui import human_gb`.
67
67
 
68
68
  **`prompt`** - the widgets. `select` (single or multi), `confirm`, `text`,
@@ -72,6 +72,13 @@ values: `calendar_select`, `datetime_edit`, `time_edit`,
72
72
  `system_editor_edit`. All resize-aware, all mouse-aware, all rendered through
73
73
  the same painter.
74
74
 
75
+ **`prompt.settings`** - what a Settings screen is built from, so every
76
+ tool's reads the same: `SETTINGS_COLUMNS` (a name, then its state),
77
+ `state_glyph` (● / ○), `space_toggles` (space flips the on/off rows),
78
+ `index_of` (the cursor back on the row just changed), `pick_option` (one of
79
+ a fixed set of values) and `pick_accent` (the accent colour picker, each
80
+ colour shown in itself).
81
+
75
82
  **`prompt.core`** - the primitives underneath: the screen-diff painter, key
76
83
  reading, the `Choice` and `Column` types, the footer hint bar (`hint`) and its
77
84
  click mapping, and `run_dashboard`.
@@ -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.
@@ -32,9 +32,9 @@ how to install anything it can't run without.
32
32
  ## What's in it
33
33
 
34
34
  **`ui`** - the visual layer. `Colors` (honours `NO_COLOR=1`), the global
35
- `MARGIN_H` / `MARGIN_V` inset every frame is drawn inside, `wrap_margins`,
36
- `rule`, `bar`, `header_box`, `sparkline`, `spinner`, `rate_of_change`, and
37
- the `SPIN` / `PARTS` / `SPARK` glyph sets. Also the ANSI-aware text
35
+ `MARGIN_H` / `MARGIN_V` inset every frame is drawn inside, the progress
36
+ bar (`progress_cells`, `get_progress_bar`), `sparkline`, `spinner`,
37
+ `rate_of_change`, and the `SPIN` / `SPARK` glyph sets. Also the ANSI-aware text
38
38
  measuring (`visual_len`, `truncate_text`, `clip_ansi`, `strip_ansi`) that
39
39
  makes any of that survive colour codes and wide characters, and small
40
40
  formatters: `plural`, `human_gb`, `dir_size_kb`. The accent colour is chosen
@@ -43,8 +43,8 @@ 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
45
  `from backbone import ...` exposes only a subset of `ui` (`Colors`, the
46
- margins and glyph sets, `spinner`, `content_width`, `rule`, `bar`,
47
- `header_box`, `wrap_margins`); import anything else from its module, e.g.
46
+ margins and glyph sets, `spinner`, `content_width`); import anything else
47
+ from its module, e.g.
48
48
  `from backbone.ui import human_gb`.
49
49
 
50
50
  **`prompt`** - the widgets. `select` (single or multi), `confirm`, `text`,
@@ -54,6 +54,13 @@ values: `calendar_select`, `datetime_edit`, `time_edit`,
54
54
  `system_editor_edit`. All resize-aware, all mouse-aware, all rendered through
55
55
  the same painter.
56
56
 
57
+ **`prompt.settings`** - what a Settings screen is built from, so every
58
+ tool's reads the same: `SETTINGS_COLUMNS` (a name, then its state),
59
+ `state_glyph` (● / ○), `space_toggles` (space flips the on/off rows),
60
+ `index_of` (the cursor back on the row just changed), `pick_option` (one of
61
+ a fixed set of values) and `pick_accent` (the accent colour picker, each
62
+ colour shown in itself).
63
+
57
64
  **`prompt.core`** - the primitives underneath: the screen-diff painter, key
58
65
  reading, the `Choice` and `Column` types, the footer hint bar (`hint`) and its
59
66
  click mapping, and `run_dashboard`.
@@ -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.
@@ -4,14 +4,9 @@ from .ui import (
4
4
  MARGIN_H,
5
5
  MARGIN_V,
6
6
  SPIN,
7
- PARTS,
8
7
  SPARK,
9
8
  spinner,
10
9
  content_width,
11
- rule,
12
- bar,
13
- header_box,
14
- wrap_margins,
15
10
  )
16
11
 
17
12
  __all__ = [
@@ -19,12 +14,7 @@ __all__ = [
19
14
  "MARGIN_H",
20
15
  "MARGIN_V",
21
16
  "SPIN",
22
- "PARTS",
23
17
  "SPARK",
24
18
  "spinner",
25
19
  "content_width",
26
- "rule",
27
- "bar",
28
- "header_box",
29
- "wrap_margins",
30
20
  ]
@@ -8,7 +8,7 @@ from backbone.prompt.core import ( # noqa: F401
8
8
  )
9
9
  from backbone.prompt.chrome import ( # noqa: F401
10
10
  CHROME_HANDLED, CHROME_REDRAW, MODE_TOGGLE, move_hint,
11
- append_chrome, boxed_chrome, chrome_hint_lines, chrome_hint_pairs, consume_chrome, inner_rule,
11
+ append_chrome, boxed_chrome, chrome_hint_lines, chrome_room, chrome_hint_pairs, consume_chrome, inner_rule,
12
12
  disable_mouse, enable_mouse,
13
13
  open_command_line, set_activity_opener, set_command_line, set_player_opener, set_transport_handler,
14
14
  )
@@ -19,3 +19,7 @@ from backbone.prompt.dates import calendar_select, datetime_edit # noqa: F401
19
19
  from backbone.prompt.values import fraction_edit, number_edit, rating_edit, time_edit # noqa: F401
20
20
  from backbone.prompt.audio import equaliser_edit, rva2_edit # noqa: F401
21
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
+ )
@@ -20,6 +20,13 @@ keys.define("global", "Everywhere", [
20
20
  ("player", ("\x0f",), "open the player"),
21
21
  ("raw_text", ("\x14",), "switch a value between its editor and raw text"),
22
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=())
23
30
 
24
31
  # The one key pair that moves a row up or down, wherever a list's order can be
25
32
  # changed (select's on_move, list_edit).
@@ -108,20 +115,30 @@ CHROME_HANDLED = object() # the key was consumed; carry on with the loop
108
115
  CHROME_REDRAW = object() # consumed, and the caller should repaint fully
109
116
 
110
117
 
118
+ def has_owner(action_id: str) -> bool:
119
+ """Whether an action does anything in this app: the transport and volume
120
+ keys need a transport handler installed, ^O a player to reopen. Every
121
+ other action belongs to a screen, so it does."""
122
+ if action_id in ("global.playpause", "global.next", "global.prev") or action_id.startswith("volume."):
123
+ return _transport_handler is not None
124
+ if action_id == "global.player":
125
+ return _player_opener is not None
126
+ return True
127
+
128
+
111
129
  def chrome_hint_pairs(pairs) -> list:
112
130
  """A widget's hint pairs plus the transport keys, while audio is playing.
113
131
 
114
- Only keys that will actually do something are advertised: the transport trio
115
- needs a handler installed and ^O needs a player to reopen. `unboxed` covers
132
+ Only keys that will actually do something are advertised (has_owner). `unboxed` covers
116
133
  a terminal too narrow to draw the now-playing box: the keys are still live,
117
134
  so they are still listed.
118
135
  """
119
136
  items = list(pairs.items()) if isinstance(pairs, dict) else [tuple(p) for p in pairs]
120
137
  if ui.footer_active() or ui.footer_unboxed():
121
- if _transport_handler is not None:
138
+ if has_owner("global.playpause"):
122
139
  items += [(keys.label("global.playpause"), "play/pause"),
123
140
  (keys.label("global.next", "global.prev"), "next/prev")]
124
- if _player_opener is not None:
141
+ if has_owner("global.player"):
125
142
  items += [(keys.label("global.player"), "player")]
126
143
  return items
127
144
 
@@ -132,6 +149,12 @@ def chrome_hint_lines(pairs, *, extra: str = "") -> list:
132
149
  return _hint(*chrome_hint_pairs(pairs), extra=extra).splitlines()
133
150
 
134
151
 
152
+ def chrome_room(pairs, *, extra: str = "") -> int:
153
+ """The rows a widget's frame has above its hint bar (append_chrome), for a
154
+ screen laying out its own boxes."""
155
+ return _hint_pin_target() - len(chrome_hint_lines(pairs, extra=extra))
156
+
157
+
135
158
  def append_chrome(out: list, pairs, cells: dict, *, extra: str = "",
136
159
  pin: bool = True, help_key: bool = False) -> list:
137
160
  """Append the hint bar to a widget's rendered `out` lines, in place.
@@ -184,7 +207,7 @@ def boxed_chrome(body: list, title: str, pairs, cells: dict, *, extra: str = "",
184
207
  out = header + [f" {C.DIM}{title}{C.RESET}"] + list(body)
185
208
  append_chrome(out, pairs, cells, extra=extra, help_key=help_key)
186
209
  return out, 0
187
- box_h = max(3, _hint_pin_target() - len(header) - len(chrome_hint_lines(pairs, extra=extra)))
210
+ box_h = max(3, chrome_room(pairs, extra=extra) - len(header))
188
211
  out = boxed_frame(header, list(body), title, box_h, help_key and not header)
189
212
  append_chrome(out, pairs, cells, extra=extra, help_key=help_key)
190
213
  return out, 2
@@ -193,8 +216,8 @@ def boxed_chrome(body: list, title: str, pairs, cells: dict, *, extra: str = "",
193
216
  def consume_chrome(key: str, cells: dict, free_keys: bool = False):
194
217
  """Handle a transport key, a now-playing box click, a click on a hint key,
195
218
  or a click on the tab bar. With `free_keys` (a screen that has no other use
196
- for them), also Tab / Shift-Tab and the digits for the tabs, and `:` for
197
- the command line.
219
+ for them), also Tab / Shift-Tab and the digits for the tabs, `:` for the
220
+ command line, and the volume (+ / -).
198
221
 
199
222
  Returns :data:`CHROME_HANDLED` when the key is fully dealt with,
200
223
  :data:`CHROME_REDRAW` when the caller should also repaint, the synthesised
@@ -234,6 +257,10 @@ def consume_chrome(key: str, cells: dict, free_keys: bool = False):
234
257
  if act in ("global.playpause", "global.next", "global.prev") and _transport_handler is not None:
235
258
  _transport_handler(act.split(".")[1])
236
259
  return CHROME_HANDLED
260
+ vol = keys.action(key, "volume") if free_keys and isinstance(key, str) else None
261
+ if vol in ("volume.up", "volume.down") and _transport_handler is not None:
262
+ _transport_handler("vol_up" if vol == "volume.up" else "vol_down")
263
+ return CHROME_HANDLED
237
264
 
238
265
  if isinstance(key, str) and key.startswith('MOUSE_CLICK:'):
239
266
  parts = key.split(':')
@@ -590,7 +590,7 @@ def progress_float(message: str, progress: float) -> None:
590
590
  w = min(cols, _PROGRESS_W)
591
591
  boxed = rows >= 3 and w >= 16
592
592
  room = w - 4 if boxed else cols
593
- bar = ui.get_progress_bar(progress, max(4, min(24, room // 3)))
593
+ bar = ui.get_progress_bar(progress, max(4, min(24, room // 3)), caps=not boxed)
594
594
  head = f"{ui.pulse_circle()} {bar} "
595
595
  text = head + C.DIM + ui.truncate_text(message, max(1, room - ui.visual_len(head))) + C.RESET
596
596
  text += " " * max(0, room - ui.visual_len(text))
@@ -914,6 +914,25 @@ def wheel_at() -> tuple | None:
914
914
  return _wheel_at[0]
915
915
 
916
916
 
917
+ def _idle_tick() -> None:
918
+ """What every screen's wait does at ~8 Hz, and at once on a state change
919
+ (a pulse): the now-playing box repainted, a floating box taken away when
920
+ its time is up, and the background-activity notice kept live (re-stamped
921
+ while a task runs so its ● pulses; once more after the last, to clear it).
922
+ One routine for both, so frequent pulses (music playing) can't starve any
923
+ of it. Each part on its own: one failing mustn't stop the rest."""
924
+ _footer_last_draw[0] = time.time()
925
+ with quietly():
926
+ _render_footer_bar()
927
+ with quietly():
928
+ float_tick()
929
+ active = ui.has_background_tasks()
930
+ if active or _status_prev_active[0]:
931
+ with quietly():
932
+ render_status_bar()
933
+ _status_prev_active[0] = active
934
+
935
+
917
936
  def _wait_for_keypress(timeout: float = 0.05) -> bool:
918
937
  """Block up to `timeout` seconds for a keypress; return whether one arrived.
919
938
 
@@ -924,20 +943,8 @@ def _wait_for_keypress(timeout: float = 0.05) -> bool:
924
943
  if _cramped():
925
944
  _run_cramped()
926
945
  return False
927
- now = time.time()
928
- if now - _footer_last_draw[0] >= 0.12:
929
- _footer_last_draw[0] = now
930
- with quietly():
931
- _render_footer_bar()
932
- float_tick()
933
- # Keep the background-activity notice live: while a task is running the
934
- # status bar is re-stamped each tick so it stays up for the whole job and
935
- # its cyan ● pulses; one extra redraw after the last task clears the bar.
936
- active = ui.has_background_tasks()
937
- if active or _status_prev_active[0]:
938
- with quietly():
939
- render_status_bar()
940
- _status_prev_active[0] = active
946
+ if time.time() - _footer_last_draw[0] >= 0.12:
947
+ _idle_tick()
941
948
  if _IS_WINDOWS:
942
949
  end = time.time() + timeout
943
950
  while time.time() < end:
@@ -952,9 +959,7 @@ def _wait_for_keypress(timeout: float = 0.05) -> bool:
952
959
  os.read(_wake_r, 4096) # drain all coalesced pulses
953
960
  except OSError:
954
961
  pass
955
- _footer_last_draw[0] = time.time() # this pulse counts as the tick
956
- with quietly():
957
- _render_footer_bar() # repaint immediately on a state change
962
+ _idle_tick() # a state change: the tick's work now (the box repainted at once)
958
963
  return sys.stdin in ready # a wake alone is not a keypress
959
964
 
960
965
 
@@ -2488,13 +2493,13 @@ hint = _hint # the public name for the hint bar
2488
2493
 
2489
2494
 
2490
2495
  def run_dashboard(render, interval: float = 1.0, quit_action: str = "list.quit", on_quit=None,
2491
- poll: float = 0.05, on_key=None) -> None:
2496
+ poll: float = 0.05, on_key=None, hints=None) -> None:
2492
2497
  """Runs a live, tick-driven view through a _Widget, so it resizes and
2493
2498
  paints like every other widget here. Returns when a key of the
2494
2499
  `quit_action` binding (see backbone.keys) is pressed.
2495
2500
 
2496
2501
  `render()` takes no arguments and returns the whole frame as a list of
2497
- lines, each carrying its own left-margin indent (see header_box()). It
2502
+ lines, each carrying its own left-margin indent (box_lines() adds it). It
2498
2503
  runs once every `interval` seconds; keypresses and resizes are checked
2499
2504
  every `poll` seconds regardless, and a resize renders at once.
2500
2505
 
@@ -2506,6 +2511,12 @@ def run_dashboard(render, interval: float = 1.0, quit_action: str = "list.quit",
2506
2511
  back, raw mode undone) - the place for a "stop the background work
2507
2512
  too?" confirm().
2508
2513
 
2514
+ `hints`, if given, are the view's (key, label) pairs, or a callable giving
2515
+ them (for keys that can be rebound while it runs): they go in the hint
2516
+ bar under the frame (append_chrome), which the help toggle and clicks on
2517
+ its keys work like every other screen's. `render()` then lays its boxes
2518
+ out in chrome_room(hints) rows.
2519
+
2509
2520
  When stdin is not a terminal, keys can't be read: it renders on a plain
2510
2521
  time.sleep(interval) loop and never checks for the quit key, so the
2511
2522
  caller has to be stopped from outside.
@@ -2516,8 +2527,20 @@ def run_dashboard(render, interval: float = 1.0, quit_action: str = "list.quit",
2516
2527
  if is_tty:
2517
2528
  _set_raw(fd)
2518
2529
 
2530
+ from backbone.prompt.chrome import (CHROME_HANDLED, CHROME_REDRAW, append_chrome,
2531
+ consume_chrome, disable_mouse, enable_mouse)
2532
+ cells: dict = {}
2533
+
2534
+ def frame() -> list:
2535
+ lines = render()
2536
+ if hints is None:
2537
+ return lines
2538
+ return append_chrome(lines, hints() if callable(hints) else hints, cells, help_key=True)
2539
+
2519
2540
  w = _Widget(fd)
2520
2541
  screen_takeover_next()
2542
+ if is_tty and hints is not None:
2543
+ enable_mouse()
2521
2544
  last_render = 0.0
2522
2545
  try:
2523
2546
  while True:
@@ -2527,21 +2550,35 @@ def run_dashboard(render, interval: float = 1.0, quit_action: str = "list.quit",
2527
2550
  w.anchor_reset()
2528
2551
  now = time.monotonic()
2529
2552
  if resized or now - last_render >= interval:
2530
- w.render(render())
2553
+ w.render(frame())
2531
2554
  last_render = now
2532
2555
  if is_tty:
2533
2556
  if _wait_for_keypress(poll):
2534
2557
  key = _read_key(fd)
2558
+ if hints is not None:
2559
+ chrome = consume_chrome(key, cells)
2560
+ if chrome is CHROME_HANDLED:
2561
+ continue
2562
+ if chrome is CHROME_REDRAW:
2563
+ w.anchor_reset()
2564
+ last_render = 0.0
2565
+ continue
2566
+ key = chrome or key # a clicked hint: its key
2535
2567
  if keys.pressed(key, quit_action):
2536
2568
  break
2537
2569
  if on_key is not None:
2538
2570
  on_key(key)
2539
2571
  ui.clear_screen()
2540
2572
  w.anchor_reset()
2573
+ last_render = 0.0
2574
+ if hints is not None:
2575
+ enable_mouse() # the screen it opened may have turned it off
2541
2576
  else:
2542
2577
  time.sleep(interval)
2543
2578
  finally:
2544
2579
  if is_tty:
2580
+ if hints is not None:
2581
+ disable_mouse()
2545
2582
  _restore_term_attrs(fd, old_settings)
2546
2583
  sys.stdout.write("\033[?25h\n")
2547
2584
 
@@ -30,13 +30,17 @@ def _row(a: keys.Action) -> Choice:
30
30
  "changed" if changed else ""])
31
31
 
32
32
 
33
- def _choices() -> list:
33
+ def _choices(scopes) -> list:
34
+ """Every action this app has a use for, by its screen: only `scopes`
35
+ (all, when None), and no transport or volume keys without a player."""
36
+ from backbone.prompt.chrome import has_owner
34
37
  out: list = []
35
38
  for scope, title in keys.scopes():
36
- if scope == "keys_editor":
37
- continue # listed last, below
38
- out.append(separator(title))
39
- out += [_row(a) for a in keys.actions(scope)]
39
+ if scope == "keys_editor" or (scopes is not None and scope not in scopes):
40
+ continue # this page's own are listed last, below
41
+ rows = [_row(a) for a in keys.actions(scope) if has_owner(a.id)]
42
+ if rows:
43
+ out += [separator(title), *rows]
40
44
  out.append(separator("This page"))
41
45
  out += [_row(a) for a in keys.actions("keys_editor")]
42
46
  return out
@@ -102,8 +106,9 @@ def _remove_key(aid: str) -> None:
102
106
  ui.show_status(f"{keys.glyph(key)} removed" + ("" if len(bound) > 1 else ": the action has no key now"))
103
107
 
104
108
 
105
- def keys_editor() -> None:
106
- """The Key bindings page. Returns when backed out of."""
109
+ def keys_editor(scopes: list | None = None) -> None:
110
+ """The Key bindings page, for the key groups (keys.define scopes) in
111
+ `scopes`, or every one defined. Returns when backed out of."""
107
112
  from backbone.prompt.lists import ListPlace, select
108
113
  place = ListPlace()
109
114
  while True:
@@ -124,7 +129,7 @@ def keys_editor() -> None:
124
129
  hints[keys.label(aid)] = {"remove": "remove a key", "reset": "reset",
125
130
  "reset_all": "reset all"}[name]
126
131
 
127
- choice = select("", _choices(), columns=_COLUMNS, place=place,
132
+ choice = select("", _choices(scopes), columns=_COLUMNS, place=place,
128
133
  header=lambda: rounded_header("Key bindings", "",
129
134
  f"{changed} changed" if changed else "all default"),
130
135
  extra_hints={"↵": "add a key"},
@@ -0,0 +1,112 @@
1
+ """What every back* Settings screen is built from: the two-column row (name,
2
+ then its state), the on/off glyphs and space to flip them, a pick from fixed
3
+ values, and the accent colour picker."""
4
+ from __future__ import annotations
5
+
6
+ from backbone import ui
7
+ from backbone.prompt.core import Choice, Column, PanelTitle, separator
8
+ from backbone.ui import Colors as C
9
+
10
+ SETTINGS_COLUMNS = [
11
+ Column(style='primary'), # name, sized to its content
12
+ Column(style='dynamic-dim', flex=True), # state, left-aligned just after
13
+ ]
14
+
15
+ # The on/off pair: filled and hollow, not a tick and a cross: ✘ reads as
16
+ # *invalid* rather than *off*. ● and ○ differ only in fill, which is exactly
17
+ # the difference.
18
+ ON_GLYPH, OFF_GLYPH = "●", "○"
19
+
20
+
21
+ def state_glyph(value) -> str:
22
+ """● or ○ for an on/off setting's current state."""
23
+ return ON_GLYPH if value else OFF_GLYPH
24
+
25
+
26
+ def space_toggles(values) -> dict:
27
+ """select() kwargs making space flip the rows in `values` (the on/off
28
+ ones): select() returns ("__space__", row), and space does nothing on any
29
+ other row."""
30
+ return {"on_inspect": lambda v: ("__space__", v) if v in values else None,
31
+ "inspect_key": "list.toggle"}
32
+
33
+
34
+ def index_of(choices: list, value, default: int = 0) -> int:
35
+ """Index of the choice whose value == `value` (for putting the cursor back
36
+ on the row just acted on)."""
37
+ for i, c in enumerate(choices):
38
+ if (c.value if isinstance(c, Choice) else c) == value:
39
+ return i
40
+ return default
41
+
42
+
43
+ def pick_option(question: str, options: dict, current, title: str):
44
+ """One of a setting's fixed values, `options` being value → label: the
45
+ one picked, or None when backed out of. The current one is marked."""
46
+ from backbone.prompt.lists import select
47
+ return select(question,
48
+ choices=[Choice(title=label, value=value, cells=[label, ON_GLYPH if value == current else ""])
49
+ for value, label in options.items()],
50
+ columns=SETTINGS_COLUMNS, index=index_of(list(options), current),
51
+ header=PanelTitle(title))
52
+
53
+
54
+ def accent_swatch(value) -> list:
55
+ """A block of the colour itself, or a hatched gap when there is none."""
56
+ code = ui.accent_code(value)
57
+ return [(f"{code}████{C.RESET}", 'normal')] if code else [("░░░░", 'dim')]
58
+
59
+
60
+ def accent_name(value, name: str):
61
+ """The colour's name, written in that colour."""
62
+ code = ui.accent_code(value)
63
+ return [(f"{code}{name}{C.RESET}", 'normal')] if code else name
64
+
65
+
66
+ ACCENT_COLUMNS = [
67
+ Column(style='normal'), # swatch
68
+ Column(style='normal'), # name, in its own colour
69
+ Column(style='dynamic-dim', flex=True), # ● on the one in use
70
+ ]
71
+
72
+
73
+ def pick_accent(title: str, current, on_pick) -> None:
74
+ """An accent colour picker: every colour shown in itself, the terminal's
75
+ own first, then fixed ones and a custom #RRGGBB. Picking one calls
76
+ `on_pick(value)`, which applies it, so the screen around the list is the
77
+ preview; it returns when backed out of."""
78
+ from backbone.prompt.lists import ListPlace, select
79
+ from backbone.prompt.text import text
80
+ place = ListPlace()
81
+ place.value = '__custom__' if str(current).startswith('#') else current
82
+ while True:
83
+ custom = current if str(current).startswith('#') else None
84
+
85
+ def _row(value, name: str, colour) -> Choice:
86
+ in_use = value == current or (value == '__custom__' and custom)
87
+ return Choice(title=name, value=value,
88
+ cells=[accent_swatch(colour), accent_name(colour, name), ON_GLYPH if in_use else ""])
89
+
90
+ choices: list = [separator("Your terminal's colours")]
91
+ for i, (key, name, _colour) in enumerate(ui.ACCENT_PRESETS):
92
+ if i == 6:
93
+ choices.append(separator("Fixed colours"))
94
+ choices.append(_row(key, name, key))
95
+ choices += [separator(), _row('__custom__', 'Custom colour…', custom)]
96
+
97
+ choice = select("", choices=choices, columns=ACCENT_COLUMNS, place=place,
98
+ header=PanelTitle(title, ui.accent_label(current)))
99
+ if not choice:
100
+ return
101
+ if choice == '__custom__':
102
+ typed = text("Hex colour:", default=custom or "", placeholder="#4FC3F7")
103
+ if typed is None:
104
+ continue
105
+ rgb = ui.parse_hex_colour(typed)
106
+ if rgb is None:
107
+ ui.show_status("That isn't a colour. Use #RRGGBB, like #4FC3F7.")
108
+ continue
109
+ choice = "#%02X%02X%02X" % rgb
110
+ current = choice
111
+ on_pick(choice)
112
+ ui.show_status(f"{title}: {ui.accent_label(choice)}")
@@ -103,8 +103,7 @@ MARGIN_V = 1 # rows reserved on each vertical side (top and bottom)
103
103
  FOOTER_GLYPH_COLS = ((0, 2), (4, 2))
104
104
 
105
105
  class Colors:
106
- """The named palette every widget uses, plus semantic colours for a tool's
107
- own views (FRAME, TEAL, AMBER, RED, TXT, MUTE) and the short aliases R and B.
106
+ """The named palette every widget uses, and RED for what failed.
108
107
  All empty when colour is off (NO_COLOR, or set_colour(False))."""
109
108
  PRIMARY = "\033[1;37m" # Bold white
110
109
  WHITE = "\033[37m" # Normal white
@@ -127,23 +126,14 @@ class Colors:
127
126
  BAR_DIM = "\033[48;5;235m"
128
127
  HIDE = "\033[?25l"
129
128
  SHOW = "\033[?25h"
130
- # semantic colours for a tool's own views (backcrack's watch uses RED for failures)
131
- FRAME = "\033[38;5;239m"
132
- TEAL = "\033[38;5;43m"
133
- AMBER = "\033[38;5;179m"
134
- RED = "\033[38;5;167m"
135
- TXT = "\033[38;5;252m"
136
- MUTE = "\033[38;5;243m"
137
- R = RESET # short aliases
138
- B = BOLD
129
+ RED = "\033[38;5;167m" # a failure: a text field's problem, backcrack's FAIL lines
139
130
 
140
131
 
141
132
  # The styling half of Colors: everything that paints rather than moves the
142
133
  # cursor. Suppressing colour must not suppress HIDE/SHOW, which are cursor
143
134
  # control and still needed on a pipe.
144
135
  _STYLE_NAMES = ('PRIMARY', 'WHITE', 'ACCENT', 'ACCENT2', 'CYAN', 'YELLOW', 'MAGENTA', 'GREEN',
145
- 'DIM', 'BOLD', 'ITALIC', 'UNDERLINE', 'RESET', 'BACK', 'INVERT', 'BAR', 'BAR_DIM',
146
- 'FRAME', 'TEAL', 'AMBER', 'RED', 'TXT', 'MUTE', 'R', 'B')
136
+ 'DIM', 'BOLD', 'ITALIC', 'UNDERLINE', 'RESET', 'BACK', 'INVERT', 'BAR', 'BAR_DIM', 'RED')
147
137
  _STYLE_CODES = {name: getattr(Colors, name) for name in _STYLE_NAMES}
148
138
 
149
139
 
@@ -1084,43 +1074,39 @@ def _get_breadcrumb_str(width: int) -> str:
1084
1074
 
1085
1075
 
1086
1076
 
1087
- def get_progress_bar(progress: float, width: int = 40, span: tuple | None = None) -> str:
1088
- """
1089
- A pip-style progress bar.
1090
- [━━━━━━━━━━━━━━━━━━━━━━━━╸ ]
1091
- `span`: a section to pick out (see progress_cells).
1092
- """
1093
- return f"{Colors.DIM}[{Colors.RESET}{progress_cells(progress, width, span)}{Colors.DIM}]{Colors.RESET}"
1077
+ def progress_caps(caps: bool) -> tuple[str, str]:
1078
+ """A progress bar's end caps: brackets on a bare bar, none on one in a box
1079
+ (the box already frames it)."""
1080
+ return ("[", "]") if caps else ("", "")
1094
1081
 
1095
1082
 
1096
- def progress_cells(progress: float, width: int, span: tuple | None = None, rest: str = " ") -> str:
1097
- """A progress bar's `width` cells, coloured: what's played bright, the rest
1098
- `rest` (dim). `span`: (start, end) fractions of one section (a chapter) to
1099
- pick out: from its start to now in the accent colour, from now to its end dim."""
1100
- progress = max(0, min(1, progress))
1083
+ def progress_caps_width(caps: bool) -> int:
1084
+ """Columns the caps add around a progress bar's cells."""
1085
+ return sum(map(len, progress_caps(caps)))
1101
1086
 
1102
- filled_width = progress * width
1103
- whole_blocks = int(filled_width)
1104
- remainder = filled_width - whole_blocks
1105
1087
 
1106
- bar = "━" * whole_blocks
1088
+ def get_progress_bar(progress: float, width: int = 40, span: tuple | None = None, caps: bool = True) -> str:
1089
+ """
1090
+ A pip-style progress bar.
1091
+ [━━━━━━━━━━━━━━━━━━━━━━━━━──────────]
1092
+ `span`: a section to pick out (see progress_cells). `caps`: see progress_caps.
1093
+ """
1094
+ left, right = progress_caps(caps)
1095
+ return f"{Colors.DIM}{left}{Colors.RESET}{progress_cells(progress, width, span)}{Colors.DIM}{right}{Colors.RESET}"
1107
1096
 
1108
- # half-cell tip
1109
- if whole_blocks < width:
1110
- if remainder > 0.6:
1111
- bar += "━" # Almost full
1112
- elif remainder > 0.2:
1113
- bar += "╸" # Partial tip
1114
- else:
1115
- bar += " " # Not enough for a tip yet
1116
1097
 
1117
- cells = [(Colors.PRIMARY, c) for c in bar.rstrip(" ")]
1118
- cells += [(Colors.DIM, rest)] * (width - len(cells))
1098
+ def progress_cells(progress: float, width: int, span: tuple | None = None) -> str:
1099
+ """A progress bar's `width` cells: what's played heavy and bright, whole
1100
+ cells only, the rest thin and dim. `span`: (start, end) fractions of one
1101
+ section (a chapter) to pick out: from its start to now in the accent
1102
+ colour, from now to its end dim."""
1103
+ played = round(max(0, min(1, progress)) * width)
1104
+ cells = [(Colors.PRIMARY, "━")] * played + [(Colors.DIM, "─")] * (width - played)
1119
1105
  if span:
1120
1106
  lo = max(0, min(width - 1, int(span[0] * width)))
1121
1107
  hi = max(lo + 1, min(width, round(span[1] * width)))
1122
- cells[lo:hi] = [(Colors.ACCENT, c) if colour == Colors.PRIMARY else (Colors.DIM, "━")
1123
- for colour, c in cells[lo:hi]]
1108
+ cells[lo:hi] = [(Colors.ACCENT, c) if i < played else (Colors.DIM, "━")
1109
+ for i, (_, c) in enumerate(cells[lo:hi], lo)]
1124
1110
  return "".join(f"{colour}{''.join(c for _, c in group)}{Colors.RESET}"
1125
1111
  for colour, group in groupby(cells, key=lambda cell: cell[0]))
1126
1112
 
@@ -1132,8 +1118,6 @@ C = Colors # the short name every widget uses
1132
1118
 
1133
1119
  SPIN = list("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
1134
1120
 
1135
- PARTS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"] # eighth-block fill steps
1136
-
1137
1121
  SPARK = "▁▂▃▄▅▆▇█"
1138
1122
 
1139
1123
  def content_width(min_width: int = 1) -> int:
@@ -1144,21 +1128,6 @@ def content_width(min_width: int = 1) -> int:
1144
1128
  """
1145
1129
  return max(min_width, get_terminal_width() - 2 * MARGIN_H)
1146
1130
 
1147
- def rule(n: int) -> str:
1148
- return "─" * n
1149
-
1150
- def bar(pct: int, width: int, color: str = "") -> str:
1151
- color = color or Colors.TEAL
1152
- pct = max(0, min(100, pct))
1153
- eighths = pct * width * 8 // 100
1154
- full, rem = divmod(eighths, 8)
1155
- out = color + "█" * full
1156
- if rem and full < width:
1157
- out += PARTS[rem]
1158
- full += 1
1159
- out += Colors.MUTE + "─" * (width - full) + Colors.R
1160
- return out
1161
-
1162
1131
  def rate_of_change(history: list, now: float, value: float, window: float = 20.0, max_len: int = 60):
1163
1132
  """Tracks `value` over time in `history` (a list of (ts, value) pairs,
1164
1133
  mutated in place and capped to `max_len` entries) and returns its rate
@@ -1193,68 +1162,6 @@ def sparkline(rate_history: list, rate: float, max_len: int = 14) -> str:
1193
1162
  mx = max(rate_history, default=1) or 1
1194
1163
  return "".join(SPARK[max(0, min(7, int(r / mx * 7.99)))] for r in rate_history)
1195
1164
 
1196
- def header_box(left: str, right: str, cols: int, spin: str = "") -> list:
1197
- """The 3-line rounded header frame (top rule, title row, bottom rule)
1198
- for a live view - `cols` is total frame width, i.e. content_width()'s
1199
- return value. The title row's padding is computed from the *actual*
1200
- rendered pieces (left, right, spin), so the right border always lands
1201
- exactly under the corners no matter how any of the three are sized -
1202
- this single spot is the only place that math needs to be right.
1203
-
1204
- Truncates `left` (then `right`, if even that isn't enough) so the row
1205
- never runs past `cols` regardless of terminal width - `right` (typically
1206
- a short, fixed-format clock) is kept whole for as long as it can be;
1207
- `left` (the variable, more compressible piece - a title/library name)
1208
- gives way first.
1209
-
1210
- Bakes in its own MARGIN_H left indent (matching every hand-written
1211
- widget line in backbone/prompt/ - e.g. confirm()'s
1212
- f" {message}") rather than relying on a wrapper to add it: a caller
1213
- driving its view through _Widget.render() (prompt/core.py) gets no
1214
- such wrapper, since _Widget only manages the vertical margin itself.
1215
- """
1216
- interior = cols - 2
1217
- # Reserve the spinner plus one pad column *before* sizing left/right, so
1218
- # truncating to fit `budget` always leaves room for pad >= 1 - flooring
1219
- # pad afterward instead (max(1, ...)) can push the row a column past the
1220
- # border once left+right already exactly fill the interior.
1221
- budget = max(0, interior - len(spin) - 1)
1222
- if visual_len(left) + visual_len(right) > budget:
1223
- right = truncate_text(right, min(visual_len(right), budget))
1224
- left = truncate_text(left, max(0, budget - visual_len(right)))
1225
- pad = max(0, interior - visual_len(left) - visual_len(right) - len(spin))
1226
- C = Colors
1227
- hpad = " " * MARGIN_H
1228
- return [
1229
- f"{hpad}{C.FRAME}╭{rule(interior)}╮{C.R}",
1230
- f"{hpad}{C.FRAME}│{C.B}{left}{C.R}{' ' * pad}{C.TXT}{right}{C.R}{spin}{C.FRAME}│{C.R}",
1231
- f"{hpad}{C.FRAME}╰{rule(interior)}╯{C.R}",
1232
- ]
1233
-
1234
- def wrap_margins(lines: list, width: int = None) -> str:
1235
- """Applies the global MARGIN_H/MARGIN_V inset plus per-line
1236
- clear-to-end-of-line, ready for one `sys.stdout.write` - the standard
1237
- back* frame render (pair with an `ESC[H` cursor-home beforehand).
1238
-
1239
- Joins with \\r\\n, not \\n: raw terminal mode (tty.setraw, used by
1240
- backbone.prompt.core for key reading) clears OPOST, so the terminal
1241
- stops translating a bare \\n into a carriage return - every line after
1242
- the first would otherwise start wherever the previous one ended instead
1243
- of column 1.
1244
-
1245
- `width` (typically content_width()'s return value), if given, clips
1246
- every line to it first - a safety net so one field a caller forgot to
1247
- size itself can't overflow the whole frame. Hand-tuned per-field
1248
- truncation still reads better (an ellipsis where it makes sense, not a
1249
- hard cut mid-word); this is the guarantee behind it, not a replacement.
1250
- """
1251
- if width is not None:
1252
- lines = [clip_ansi(line, width) for line in lines]
1253
- hpad = " " * MARGIN_H
1254
- vpad = ["\033[K"] * MARGIN_V
1255
- out = vpad + [hpad + line + "\033[K" for line in lines] + vpad
1256
- return "\r\n".join(out)
1257
-
1258
1165
  def spinner(frame: int) -> str:
1259
1166
  return SPIN[frame % len(SPIN)]
1260
1167
 
@@ -1280,33 +1187,13 @@ _ANSI_DEMO = re.compile(r"\033\[[0-9;]*[a-zA-Z]")
1280
1187
 
1281
1188
 
1282
1189
  def _demo() -> None:
1283
- """Self-check for the pure logic here, header_box's alignment above all.
1190
+ """Self-check for the pure logic here.
1284
1191
  Runs without a terminal: `python3 -m backbone.ui`.
1285
1192
  """
1286
- for cols in (70, 100, 137):
1287
- for left, right, spin in ((" SHORT", "12:00:00 ", "X"), ("", "", ""), ("a" * 20, "b", "Y")):
1288
- top, mid, bot = header_box(left, right, cols, spin)
1289
- widths = {len(strip_ansi(top)), len(strip_ansi(mid)), len(strip_ansi(bot))}
1290
- assert len(widths) == 1, (cols, left, right, spin, widths)
1291
- # A left piece far longer than the frame must truncate, not overflow -
1292
- # the border still lines up at a width too narrow for it whole.
1293
- for cols in (10, 20, 40):
1294
- top, mid, bot = header_box("a" * 200, "12:00:00 ", cols, "X")
1295
- widths = {len(strip_ansi(top)), len(strip_ansi(mid)), len(strip_ansi(bot))}
1296
- assert len(widths) == 1, (cols, widths)
1297
- assert len(strip_ansi(mid)) == cols + MARGIN_H, (cols, len(strip_ansi(mid)))
1298
1193
  assert visual_len("plain") == 5
1299
1194
  assert visual_len(f"{Colors.BOLD}x{Colors.RESET}") == 1
1300
1195
  assert truncate_text("abcdefgh", 4) == "abc…"
1301
1196
  assert plural(1, "disc") == "1 disc" and plural(2, "disc") == "2 discs"
1302
- # Regression guard: wrap_margins must join with \r\n, not \n - under raw
1303
- # terminal mode (OPOST cleared) a bare \n never returns to column 1, and
1304
- # every line after the first starts wherever the previous one ended.
1305
- assert "\r\n" in wrap_margins(["a", "b"])
1306
- # wrap_margins(width=...) must clip an oversized line rather than let it
1307
- # overflow - the safety net behind every tool's own per-field sizing.
1308
- clipped = wrap_margins(["a" * 200], width=10).split("\r\n")[1]
1309
- assert len(strip_ansi(clipped)) <= 10 + MARGIN_H, clipped
1310
1197
  assert human_gb(1048576) == "1.0"
1311
1198
  hist = []
1312
1199
  assert rate_of_change(hist, 0.0, 0) is None # first sample - no window yet
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: backpack-backbone
3
- Version: 0.3.1
3
+ Version: 0.4.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
@@ -50,9 +50,9 @@ how to install anything it can't run without.
50
50
  ## What's in it
51
51
 
52
52
  **`ui`** - the visual layer. `Colors` (honours `NO_COLOR=1`), the global
53
- `MARGIN_H` / `MARGIN_V` inset every frame is drawn inside, `wrap_margins`,
54
- `rule`, `bar`, `header_box`, `sparkline`, `spinner`, `rate_of_change`, and
55
- the `SPIN` / `PARTS` / `SPARK` glyph sets. Also the ANSI-aware text
53
+ `MARGIN_H` / `MARGIN_V` inset every frame is drawn inside, the progress
54
+ bar (`progress_cells`, `get_progress_bar`), `sparkline`, `spinner`,
55
+ `rate_of_change`, and the `SPIN` / `SPARK` glyph sets. Also the ANSI-aware text
56
56
  measuring (`visual_len`, `truncate_text`, `clip_ansi`, `strip_ansi`) that
57
57
  makes any of that survive colour codes and wide characters, and small
58
58
  formatters: `plural`, `human_gb`, `dir_size_kb`. The accent colour is chosen
@@ -61,8 +61,8 @@ 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
63
  `from backbone import ...` exposes only a subset of `ui` (`Colors`, the
64
- margins and glyph sets, `spinner`, `content_width`, `rule`, `bar`,
65
- `header_box`, `wrap_margins`); import anything else from its module, e.g.
64
+ margins and glyph sets, `spinner`, `content_width`); import anything else
65
+ from its module, e.g.
66
66
  `from backbone.ui import human_gb`.
67
67
 
68
68
  **`prompt`** - the widgets. `select` (single or multi), `confirm`, `text`,
@@ -72,6 +72,13 @@ values: `calendar_select`, `datetime_edit`, `time_edit`,
72
72
  `system_editor_edit`. All resize-aware, all mouse-aware, all rendered through
73
73
  the same painter.
74
74
 
75
+ **`prompt.settings`** - what a Settings screen is built from, so every
76
+ tool's reads the same: `SETTINGS_COLUMNS` (a name, then its state),
77
+ `state_glyph` (● / ○), `space_toggles` (space flips the on/off rows),
78
+ `index_of` (the cursor back on the row just changed), `pick_option` (one of
79
+ a fixed set of values) and `pick_accent` (the accent colour picker, each
80
+ colour shown in itself).
81
+
75
82
  **`prompt.core`** - the primitives underneath: the screen-diff painter, key
76
83
  reading, the `Choice` and `Column` types, the footer hint bar (`hint`) and its
77
84
  click mapping, and `run_dashboard`.
@@ -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.
@@ -25,6 +25,7 @@ backbone/prompt/dates.py
25
25
  backbone/prompt/keymap.py
26
26
  backbone/prompt/list_edit.py
27
27
  backbone/prompt/lists.py
28
+ backbone/prompt/settings.py
28
29
  backbone/prompt/text.py
29
30
  backbone/prompt/timezone.py
30
31
  backbone/prompt/values.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "backpack-backbone"
7
- version = "0.3.1"
7
+ version = "0.4.0"
8
8
  description = "Shared code for the back* tools: terminal UI, prompt widgets, live views, logging, dates, settings plumbing and small file helpers, implemented once."
9
9
  requires-python = ">=3.10"
10
10
  readme = "README.md"
@@ -1,5 +1,6 @@
1
1
  """Boxed panels: the box itself, and select() drawing its list inside one."""
2
2
  import io
3
+ import time
3
4
  import re
4
5
  import sys
5
6
  import unittest
@@ -405,6 +406,25 @@ class FloatTest(unittest.TestCase):
405
406
  self.assertTrue(out.getvalue().startswith("\r"))
406
407
  self.assertFalse(core._float)
407
408
 
409
+ def test_a_pulse_takes_it_away_on_time_too(self):
410
+ """With music playing, state-change pulses come faster than the idle
411
+ tick: each one must do the tick's work, or the box never goes."""
412
+ import os as _os
413
+ out = io.StringIO()
414
+ with patch.object(sys, 'stdout', out):
415
+ core.screen_float(["[ VOL ]"], 60)
416
+ core._float['until'] = 0 # its time is up
417
+ r, w = _os.pipe()
418
+ _os.write(w, b"x")
419
+ try:
420
+ with patch.object(core, '_wake_r', r), patch.object(core, '_footer_last_draw', [time.time()]), \
421
+ patch.object(core, '_render_footer_bar', lambda: None), \
422
+ patch.object(core._sel, 'select', lambda *a: ([r], [], [])), patch.object(sys, 'stdout', out):
423
+ core._wait_for_keypress(0)
424
+ finally:
425
+ _os.close(r); _os.close(w)
426
+ self.assertFalse(core._float) # gone, though no idle tick was due
427
+
408
428
  def test_rows_painted_under_it_leave_it_be_and_come_back_after(self):
409
429
  row = "x" * 40
410
430
  core.screen_row_paint(5, row)
@@ -86,5 +86,53 @@ class KeysTest(unittest.TestCase):
86
86
  self.assertTrue(keys.bindable("n"))
87
87
 
88
88
 
89
+
90
+ class VolumeKeysTest(unittest.TestCase):
91
+ """+ and - turn the volume on any screen that leaves them free, never in
92
+ a text field (where they're typed)."""
93
+
94
+ def test_free_screens_turn_it_typing_ones_do_not(self):
95
+ from backbone.prompt import chrome
96
+ got = []
97
+ was = chrome._transport_handler
98
+ chrome.set_transport_handler(got.append)
99
+ try:
100
+ self.assertIs(chrome.consume_chrome("+", {}, free_keys=True), chrome.CHROME_HANDLED)
101
+ self.assertIs(chrome.consume_chrome("-", {}, free_keys=True), chrome.CHROME_HANDLED)
102
+ self.assertIsNone(chrome.consume_chrome("+", {})) # typed here: not ours
103
+ finally:
104
+ chrome.set_transport_handler(was)
105
+ self.assertEqual(got, ["vol_up", "vol_down"])
106
+
107
+
108
+ class KeyBindingsPageTest(unittest.TestCase):
109
+ """The Key bindings page lists what the app has a use for: the screens it
110
+ names, and the transport and volume keys only with a player."""
111
+
112
+ def rows(self, scopes=None, handler=None, opener=None):
113
+ from backbone.prompt import chrome, keymap
114
+ with patch.object(chrome, "_transport_handler", handler), patch.object(chrome, "_player_opener", opener):
115
+ return [c.value or c.title for c in keymap._choices(scopes)]
116
+
117
+ def test_no_player_no_transport_keys(self):
118
+ rows = self.rows()
119
+ self.assertNotIn("global.playpause", rows)
120
+ self.assertNotIn("global.player", rows)
121
+ self.assertNotIn("Volume", rows) # its group goes with its keys
122
+ self.assertIn("global.help", rows)
123
+
124
+ def test_with_a_player_they_are_there(self):
125
+ rows = self.rows(handler=print, opener=print)
126
+ self.assertIn("global.playpause", rows)
127
+ self.assertIn("global.player", rows)
128
+ self.assertIn("volume.up", rows)
129
+
130
+ def test_only_the_named_groups(self):
131
+ rows = self.rows(["list"])
132
+ self.assertIn("list.up", rows)
133
+ self.assertNotIn("confirm.yes", rows)
134
+ self.assertEqual(rows[-4:], ["This page", "keys_editor.remove", "keys_editor.reset", "keys_editor.reset_all"])
135
+
136
+
89
137
  if __name__ == "__main__":
90
138
  unittest.main()
@@ -64,14 +64,21 @@ if __name__ == "__main__":
64
64
  class ProgressBarSpanTest(unittest.TestCase):
65
65
  def test_span_is_accent_up_to_now_then_dim(self):
66
66
  bar = ui.get_progress_bar(0.5, 20, (0.4, 0.7))
67
- self.assertEqual(ui.strip_ansi(bar), "[" + "━" * 14 + " " * 6 + "]")
67
+ self.assertEqual(ui.strip_ansi(bar), "[" + "━" * 14 + "─" * 6 + "]")
68
68
  self.assertIn(f"{ui.Colors.ACCENT}━━{ui.Colors.RESET}", bar) # cells 8-9: played
69
- self.assertIn(f"{ui.Colors.DIM}━━━━ ", bar) # cells 10-13: to come, then empty
69
+ self.assertIn(f"{ui.Colors.DIM}━━━━──", bar) # cells 10-13: to come, then the rest
70
70
 
71
71
  def test_cells_alone_take_a_rest_glyph(self):
72
- cells = ui.strip_ansi(ui.progress_cells(0.5, 10, (0.3, 0.8), rest="─"))
72
+ cells = ui.strip_ansi(ui.progress_cells(0.5, 10, (0.3, 0.8)))
73
73
  self.assertEqual(cells, "━" * 8 + "──")
74
74
 
75
+ def test_whole_cells_only(self):
76
+ self.assertEqual(ui.strip_ansi(ui.progress_cells(0.44, 10)), "━━━━──────")
77
+ self.assertEqual(ui.strip_ansi(ui.progress_cells(0.46, 10)), "━━━━━─────")
78
+
79
+ def test_a_boxed_bar_drops_its_caps(self):
80
+ self.assertEqual(ui.strip_ansi(ui.get_progress_bar(0.5, 4, caps=False)), "━━──")
81
+
75
82
  def test_no_span_is_unchanged(self):
76
83
  self.assertNotIn(ui.Colors.ACCENT, ui.get_progress_bar(0.5, 20))
77
84