backpack-backbone 0.3.2__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.2/backpack_backbone.egg-info → backpack_backbone-0.4.0}/PKG-INFO +13 -13
  2. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/README.md +12 -12
  3. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/__init__.py +0 -10
  4. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/prompt/chrome.py +14 -4
  5. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/prompt/core.py +1 -1
  6. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/prompt/keymap.py +13 -8
  7. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/ui.py +4 -113
  8. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0/backpack_backbone.egg-info}/PKG-INFO +13 -13
  9. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/pyproject.toml +1 -1
  10. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/tests/test_keys.py +29 -0
  11. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/LICENSE +0 -0
  12. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/app.py +0 -0
  13. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/datetime_parse.py +0 -0
  14. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/deps.py +0 -0
  15. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/files.py +0 -0
  16. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/keyboard.py +0 -0
  17. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/keys.py +0 -0
  18. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/log.py +0 -0
  19. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/nav.py +0 -0
  20. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/notify.py +0 -0
  21. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/numbering.py +0 -0
  22. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/output.py +0 -0
  23. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/procs.py +0 -0
  24. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/prompt/__init__.py +0 -0
  25. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/prompt/audio.py +0 -0
  26. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/prompt/dates.py +0 -0
  27. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/prompt/list_edit.py +0 -0
  28. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/prompt/lists.py +0 -0
  29. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/prompt/settings.py +0 -0
  30. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/prompt/text.py +0 -0
  31. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/prompt/timezone.py +0 -0
  32. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/prompt/values.py +0 -0
  33. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/terminal_input.py +0 -0
  34. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backbone/timefmt.py +0 -0
  35. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backpack_backbone.egg-info/SOURCES.txt +0 -0
  36. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backpack_backbone.egg-info/dependency_links.txt +0 -0
  37. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/backpack_backbone.egg-info/top_level.txt +0 -0
  38. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/setup.cfg +0 -0
  39. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/tests/test_boxes.py +0 -0
  40. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/tests/test_datetime_parse.py +0 -0
  41. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/tests/test_deps.py +0 -0
  42. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/tests/test_edit_line.py +0 -0
  43. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/tests/test_hint_clicks.py +0 -0
  44. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/tests/test_list_edit_render.py +0 -0
  45. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/tests/test_log.py +0 -0
  46. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/tests/test_multiline.py +0 -0
  47. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/tests/test_overlay.py +0 -0
  48. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/tests/test_procs.py +0 -0
  49. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/tests/test_quietly.py +0 -0
  50. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/tests/test_select_move.py +0 -0
  51. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/tests/test_tabs.py +0 -0
  52. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/tests/test_terminal_input.py +0 -0
  53. {backpack_backbone-0.3.2 → backpack_backbone-0.4.0}/tests/test_ui_progress.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: backpack-backbone
3
- Version: 0.3.2
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
@@ -60,16 +60,9 @@ 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
-
70
63
  `from backbone import ...` exposes only a subset of `ui` (`Colors`, the
71
- margins and glyph sets, `spinner`, `content_width`, `rule`, `bar`,
72
- `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.
73
66
  `from backbone.ui import human_gb`.
74
67
 
75
68
  **`prompt`** - the widgets. `select` (single or multi), `confirm`, `text`,
@@ -79,6 +72,13 @@ values: `calendar_select`, `datetime_edit`, `time_edit`,
79
72
  `system_editor_edit`. All resize-aware, all mouse-aware, all rendered through
80
73
  the same painter.
81
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
+
82
82
  **`prompt.core`** - the primitives underneath: the screen-diff painter, key
83
83
  reading, the `Choice` and `Column` types, the footer hint bar (`hint`) and its
84
84
  click mapping, and `run_dashboard`.
@@ -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
@@ -42,16 +42,9 @@ 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
-
52
45
  `from backbone import ...` exposes only a subset of `ui` (`Colors`, the
53
- margins and glyph sets, `spinner`, `content_width`, `rule`, `bar`,
54
- `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.
55
48
  `from backbone.ui import human_gb`.
56
49
 
57
50
  **`prompt`** - the widgets. `select` (single or multi), `confirm`, `text`,
@@ -61,6 +54,13 @@ values: `calendar_select`, `datetime_edit`, `time_edit`,
61
54
  `system_editor_edit`. All resize-aware, all mouse-aware, all rendered through
62
55
  the same painter.
63
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
+
64
64
  **`prompt.core`** - the primitives underneath: the screen-diff painter, key
65
65
  reading, the `Choice` and `Column` types, the footer hint bar (`hint`) and its
66
66
  click mapping, and `run_dashboard`.
@@ -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
  ]
@@ -115,20 +115,30 @@ CHROME_HANDLED = object() # the key was consumed; carry on with the loop
115
115
  CHROME_REDRAW = object() # consumed, and the caller should repaint fully
116
116
 
117
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
+
118
129
  def chrome_hint_pairs(pairs) -> list:
119
130
  """A widget's hint pairs plus the transport keys, while audio is playing.
120
131
 
121
- Only keys that will actually do something are advertised: the transport trio
122
- 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
123
133
  a terminal too narrow to draw the now-playing box: the keys are still live,
124
134
  so they are still listed.
125
135
  """
126
136
  items = list(pairs.items()) if isinstance(pairs, dict) else [tuple(p) for p in pairs]
127
137
  if ui.footer_active() or ui.footer_unboxed():
128
- if _transport_handler is not None:
138
+ if has_owner("global.playpause"):
129
139
  items += [(keys.label("global.playpause"), "play/pause"),
130
140
  (keys.label("global.next", "global.prev"), "next/prev")]
131
- if _player_opener is not None:
141
+ if has_owner("global.player"):
132
142
  items += [(keys.label("global.player"), "player")]
133
143
  return items
134
144
 
@@ -2499,7 +2499,7 @@ def run_dashboard(render, interval: float = 1.0, quit_action: str = "list.quit",
2499
2499
  `quit_action` binding (see backbone.keys) is pressed.
2500
2500
 
2501
2501
  `render()` takes no arguments and returns the whole frame as a list of
2502
- 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
2503
2503
  runs once every `interval` seconds; keypresses and resizes are checked
2504
2504
  every `poll` seconds regardless, and a resize renders at once.
2505
2505
 
@@ -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"},
@@ -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
 
@@ -1128,8 +1118,6 @@ C = Colors # the short name every widget uses
1128
1118
 
1129
1119
  SPIN = list("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
1130
1120
 
1131
- PARTS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"] # eighth-block fill steps
1132
-
1133
1121
  SPARK = "▁▂▃▄▅▆▇█"
1134
1122
 
1135
1123
  def content_width(min_width: int = 1) -> int:
@@ -1140,21 +1128,6 @@ def content_width(min_width: int = 1) -> int:
1140
1128
  """
1141
1129
  return max(min_width, get_terminal_width() - 2 * MARGIN_H)
1142
1130
 
1143
- def rule(n: int) -> str:
1144
- return "─" * n
1145
-
1146
- def bar(pct: int, width: int, color: str = "") -> str:
1147
- color = color or Colors.TEAL
1148
- pct = max(0, min(100, pct))
1149
- eighths = pct * width * 8 // 100
1150
- full, rem = divmod(eighths, 8)
1151
- out = color + "█" * full
1152
- if rem and full < width:
1153
- out += PARTS[rem]
1154
- full += 1
1155
- out += Colors.MUTE + "─" * (width - full) + Colors.R
1156
- return out
1157
-
1158
1131
  def rate_of_change(history: list, now: float, value: float, window: float = 20.0, max_len: int = 60):
1159
1132
  """Tracks `value` over time in `history` (a list of (ts, value) pairs,
1160
1133
  mutated in place and capped to `max_len` entries) and returns its rate
@@ -1189,68 +1162,6 @@ def sparkline(rate_history: list, rate: float, max_len: int = 14) -> str:
1189
1162
  mx = max(rate_history, default=1) or 1
1190
1163
  return "".join(SPARK[max(0, min(7, int(r / mx * 7.99)))] for r in rate_history)
1191
1164
 
1192
- def header_box(left: str, right: str, cols: int, spin: str = "") -> list:
1193
- """The 3-line rounded header frame (top rule, title row, bottom rule)
1194
- for a live view - `cols` is total frame width, i.e. content_width()'s
1195
- return value. The title row's padding is computed from the *actual*
1196
- rendered pieces (left, right, spin), so the right border always lands
1197
- exactly under the corners no matter how any of the three are sized -
1198
- this single spot is the only place that math needs to be right.
1199
-
1200
- Truncates `left` (then `right`, if even that isn't enough) so the row
1201
- never runs past `cols` regardless of terminal width - `right` (typically
1202
- a short, fixed-format clock) is kept whole for as long as it can be;
1203
- `left` (the variable, more compressible piece - a title/library name)
1204
- gives way first.
1205
-
1206
- Bakes in its own MARGIN_H left indent (matching every hand-written
1207
- widget line in backbone/prompt/ - e.g. confirm()'s
1208
- f" {message}") rather than relying on a wrapper to add it: a caller
1209
- driving its view through _Widget.render() (prompt/core.py) gets no
1210
- such wrapper, since _Widget only manages the vertical margin itself.
1211
- """
1212
- interior = cols - 2
1213
- # Reserve the spinner plus one pad column *before* sizing left/right, so
1214
- # truncating to fit `budget` always leaves room for pad >= 1 - flooring
1215
- # pad afterward instead (max(1, ...)) can push the row a column past the
1216
- # border once left+right already exactly fill the interior.
1217
- budget = max(0, interior - len(spin) - 1)
1218
- if visual_len(left) + visual_len(right) > budget:
1219
- right = truncate_text(right, min(visual_len(right), budget))
1220
- left = truncate_text(left, max(0, budget - visual_len(right)))
1221
- pad = max(0, interior - visual_len(left) - visual_len(right) - len(spin))
1222
- C = Colors
1223
- hpad = " " * MARGIN_H
1224
- return [
1225
- f"{hpad}{C.FRAME}╭{rule(interior)}╮{C.R}",
1226
- f"{hpad}{C.FRAME}│{C.B}{left}{C.R}{' ' * pad}{C.TXT}{right}{C.R}{spin}{C.FRAME}│{C.R}",
1227
- f"{hpad}{C.FRAME}╰{rule(interior)}╯{C.R}",
1228
- ]
1229
-
1230
- def wrap_margins(lines: list, width: int = None) -> str:
1231
- """Applies the global MARGIN_H/MARGIN_V inset plus per-line
1232
- clear-to-end-of-line, ready for one `sys.stdout.write` - the standard
1233
- back* frame render (pair with an `ESC[H` cursor-home beforehand).
1234
-
1235
- Joins with \\r\\n, not \\n: raw terminal mode (tty.setraw, used by
1236
- backbone.prompt.core for key reading) clears OPOST, so the terminal
1237
- stops translating a bare \\n into a carriage return - every line after
1238
- the first would otherwise start wherever the previous one ended instead
1239
- of column 1.
1240
-
1241
- `width` (typically content_width()'s return value), if given, clips
1242
- every line to it first - a safety net so one field a caller forgot to
1243
- size itself can't overflow the whole frame. Hand-tuned per-field
1244
- truncation still reads better (an ellipsis where it makes sense, not a
1245
- hard cut mid-word); this is the guarantee behind it, not a replacement.
1246
- """
1247
- if width is not None:
1248
- lines = [clip_ansi(line, width) for line in lines]
1249
- hpad = " " * MARGIN_H
1250
- vpad = ["\033[K"] * MARGIN_V
1251
- out = vpad + [hpad + line + "\033[K" for line in lines] + vpad
1252
- return "\r\n".join(out)
1253
-
1254
1165
  def spinner(frame: int) -> str:
1255
1166
  return SPIN[frame % len(SPIN)]
1256
1167
 
@@ -1276,33 +1187,13 @@ _ANSI_DEMO = re.compile(r"\033\[[0-9;]*[a-zA-Z]")
1276
1187
 
1277
1188
 
1278
1189
  def _demo() -> None:
1279
- """Self-check for the pure logic here, header_box's alignment above all.
1190
+ """Self-check for the pure logic here.
1280
1191
  Runs without a terminal: `python3 -m backbone.ui`.
1281
1192
  """
1282
- for cols in (70, 100, 137):
1283
- for left, right, spin in ((" SHORT", "12:00:00 ", "X"), ("", "", ""), ("a" * 20, "b", "Y")):
1284
- top, mid, bot = header_box(left, right, cols, spin)
1285
- widths = {len(strip_ansi(top)), len(strip_ansi(mid)), len(strip_ansi(bot))}
1286
- assert len(widths) == 1, (cols, left, right, spin, widths)
1287
- # A left piece far longer than the frame must truncate, not overflow -
1288
- # the border still lines up at a width too narrow for it whole.
1289
- for cols in (10, 20, 40):
1290
- top, mid, bot = header_box("a" * 200, "12:00:00 ", cols, "X")
1291
- widths = {len(strip_ansi(top)), len(strip_ansi(mid)), len(strip_ansi(bot))}
1292
- assert len(widths) == 1, (cols, widths)
1293
- assert len(strip_ansi(mid)) == cols + MARGIN_H, (cols, len(strip_ansi(mid)))
1294
1193
  assert visual_len("plain") == 5
1295
1194
  assert visual_len(f"{Colors.BOLD}x{Colors.RESET}") == 1
1296
1195
  assert truncate_text("abcdefgh", 4) == "abc…"
1297
1196
  assert plural(1, "disc") == "1 disc" and plural(2, "disc") == "2 discs"
1298
- # Regression guard: wrap_margins must join with \r\n, not \n - under raw
1299
- # terminal mode (OPOST cleared) a bare \n never returns to column 1, and
1300
- # every line after the first starts wherever the previous one ended.
1301
- assert "\r\n" in wrap_margins(["a", "b"])
1302
- # wrap_margins(width=...) must clip an oversized line rather than let it
1303
- # overflow - the safety net behind every tool's own per-field sizing.
1304
- clipped = wrap_margins(["a" * 200], width=10).split("\r\n")[1]
1305
- assert len(strip_ansi(clipped)) <= 10 + MARGIN_H, clipped
1306
1197
  assert human_gb(1048576) == "1.0"
1307
1198
  hist = []
1308
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.2
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
@@ -60,16 +60,9 @@ 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
-
70
63
  `from backbone import ...` exposes only a subset of `ui` (`Colors`, the
71
- margins and glyph sets, `spinner`, `content_width`, `rule`, `bar`,
72
- `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.
73
66
  `from backbone.ui import human_gb`.
74
67
 
75
68
  **`prompt`** - the widgets. `select` (single or multi), `confirm`, `text`,
@@ -79,6 +72,13 @@ values: `calendar_select`, `datetime_edit`, `time_edit`,
79
72
  `system_editor_edit`. All resize-aware, all mouse-aware, all rendered through
80
73
  the same painter.
81
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
+
82
82
  **`prompt.core`** - the primitives underneath: the screen-diff painter, key
83
83
  reading, the `Choice` and `Column` types, the footer hint bar (`hint`) and its
84
84
  click mapping, and `run_dashboard`.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "backpack-backbone"
7
- version = "0.3.2"
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"
@@ -105,5 +105,34 @@ class VolumeKeysTest(unittest.TestCase):
105
105
  self.assertEqual(got, ["vol_up", "vol_down"])
106
106
 
107
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
+
108
137
  if __name__ == "__main__":
109
138
  unittest.main()