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.
- {backpack_backbone-0.3.1/backpack_backbone.egg-info → backpack_backbone-0.4.0}/PKG-INFO +24 -9
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/README.md +23 -8
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/__init__.py +0 -10
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/prompt/__init__.py +5 -1
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/prompt/chrome.py +34 -7
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/prompt/core.py +58 -21
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/prompt/keymap.py +13 -8
- backpack_backbone-0.4.0/backbone/prompt/settings.py +112 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/ui.py +28 -141
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0/backpack_backbone.egg-info}/PKG-INFO +24 -9
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backpack_backbone.egg-info/SOURCES.txt +1 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/pyproject.toml +1 -1
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_boxes.py +20 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_keys.py +48 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_ui_progress.py +10 -3
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/LICENSE +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/app.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/datetime_parse.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/deps.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/files.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/keyboard.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/keys.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/log.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/nav.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/notify.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/numbering.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/output.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/procs.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/prompt/audio.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/prompt/dates.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/prompt/list_edit.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/prompt/lists.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/prompt/text.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/prompt/timezone.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/prompt/values.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/terminal_input.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backbone/timefmt.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backpack_backbone.egg-info/dependency_links.txt +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backpack_backbone.egg-info/top_level.txt +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/setup.cfg +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_datetime_parse.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_deps.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_edit_line.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_hint_clicks.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_list_edit_render.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_log.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_multiline.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_overlay.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_procs.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_quietly.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_select_move.py +0 -0
- {backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/tests/test_tabs.py +0 -0
- {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
|
+
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,
|
|
54
|
-
|
|
55
|
-
the `SPIN` / `
|
|
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
|
|
65
|
-
|
|
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.
|
|
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.
|
|
@@ -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,
|
|
36
|
-
|
|
37
|
-
the `SPIN` / `
|
|
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
|
|
47
|
-
|
|
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.
|
|
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.
|
|
@@ -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
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
197
|
-
the
|
|
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
|
-
|
|
928
|
-
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
39
|
-
|
|
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
|
|
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,
|
|
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
|
-
#
|
|
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
|
|
1088
|
-
"""
|
|
1089
|
-
|
|
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
|
|
1097
|
-
"""
|
|
1098
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1118
|
-
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
|
|
1123
|
-
for
|
|
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
|
|
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
|
+
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,
|
|
54
|
-
|
|
55
|
-
the `SPIN` / `
|
|
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
|
|
65
|
-
|
|
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.
|
|
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.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "backpack-backbone"
|
|
7
|
-
version = "0.
|
|
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 + "
|
|
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}
|
|
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)
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backpack_backbone.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{backpack_backbone-0.3.1 → backpack_backbone-0.4.0}/backpack_backbone.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|