backpack-backbone 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backpack_backbone-0.2.0/LICENSE +21 -0
- backpack_backbone-0.2.0/PKG-INFO +177 -0
- backpack_backbone-0.2.0/README.md +159 -0
- backpack_backbone-0.2.0/backbone/__init__.py +30 -0
- backpack_backbone-0.2.0/backbone/app.py +26 -0
- backpack_backbone-0.2.0/backbone/datetime_parse.py +224 -0
- backpack_backbone-0.2.0/backbone/deps.py +98 -0
- backpack_backbone-0.2.0/backbone/files.py +61 -0
- backpack_backbone-0.2.0/backbone/keyboard.py +148 -0
- backpack_backbone-0.2.0/backbone/keys.py +271 -0
- backpack_backbone-0.2.0/backbone/log.py +61 -0
- backpack_backbone-0.2.0/backbone/nav.py +17 -0
- backpack_backbone-0.2.0/backbone/notify.py +36 -0
- backpack_backbone-0.2.0/backbone/numbering.py +182 -0
- backpack_backbone-0.2.0/backbone/output.py +216 -0
- backpack_backbone-0.2.0/backbone/procs.py +60 -0
- backpack_backbone-0.2.0/backbone/prompt/__init__.py +20 -0
- backpack_backbone-0.2.0/backbone/prompt/audio.py +496 -0
- backpack_backbone-0.2.0/backbone/prompt/chrome.py +238 -0
- backpack_backbone-0.2.0/backbone/prompt/core.py +1562 -0
- backpack_backbone-0.2.0/backbone/prompt/dates.py +555 -0
- backpack_backbone-0.2.0/backbone/prompt/keymap.py +143 -0
- backpack_backbone-0.2.0/backbone/prompt/list_edit.py +974 -0
- backpack_backbone-0.2.0/backbone/prompt/lists.py +1280 -0
- backpack_backbone-0.2.0/backbone/prompt/text.py +356 -0
- backpack_backbone-0.2.0/backbone/prompt/timezone.py +1543 -0
- backpack_backbone-0.2.0/backbone/prompt/values.py +581 -0
- backpack_backbone-0.2.0/backbone/terminal_input.py +60 -0
- backpack_backbone-0.2.0/backbone/timefmt.py +34 -0
- backpack_backbone-0.2.0/backbone/ui.py +1044 -0
- backpack_backbone-0.2.0/backpack_backbone.egg-info/PKG-INFO +177 -0
- backpack_backbone-0.2.0/backpack_backbone.egg-info/SOURCES.txt +45 -0
- backpack_backbone-0.2.0/backpack_backbone.egg-info/dependency_links.txt +1 -0
- backpack_backbone-0.2.0/backpack_backbone.egg-info/top_level.txt +1 -0
- backpack_backbone-0.2.0/pyproject.toml +27 -0
- backpack_backbone-0.2.0/setup.cfg +4 -0
- backpack_backbone-0.2.0/tests/test_datetime_parse.py +37 -0
- backpack_backbone-0.2.0/tests/test_deps.py +46 -0
- backpack_backbone-0.2.0/tests/test_edit_line.py +27 -0
- backpack_backbone-0.2.0/tests/test_hint_clicks.py +79 -0
- backpack_backbone-0.2.0/tests/test_keys.py +90 -0
- backpack_backbone-0.2.0/tests/test_log.py +47 -0
- backpack_backbone-0.2.0/tests/test_procs.py +58 -0
- backpack_backbone-0.2.0/tests/test_quietly.py +35 -0
- backpack_backbone-0.2.0/tests/test_select_move.py +147 -0
- backpack_backbone-0.2.0/tests/test_terminal_input.py +47 -0
- backpack_backbone-0.2.0/tests/test_ui_progress.py +61 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RedEraRrow
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: backpack-backbone
|
|
3
|
+
Version: 0.2.0
|
|
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
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/RedEraRrow/backbone
|
|
7
|
+
Project-URL: Issues, https://github.com/RedEraRrow/backbone/issues
|
|
8
|
+
Classifier: Environment :: Console :: Curses
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: MacOS
|
|
11
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# backbone
|
|
20
|
+
|
|
21
|
+
Everything the back* tools share, written once: colours, margins, box
|
|
22
|
+
drawing, meters, prompt widgets and a live-view loop, so a list in one tool
|
|
23
|
+
looks and behaves like a list in another, plus the plumbing underneath (logging,
|
|
24
|
+
dates, CLI output, atomic files, background processes, notifications). backtrack
|
|
25
|
+
and backcrack both build on it, and a new tool should too: anything that isn't
|
|
26
|
+
specific to one tool's job belongs here.
|
|
27
|
+
|
|
28
|
+
Python, stdlib only - no third-party packages at all. Requires Python 3.10+.
|
|
29
|
+
|
|
30
|
+
This is a library, not a tool. It has no entry points of its own; something
|
|
31
|
+
else imports it.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
On PyPI as `backpack-backbone` (the import name is still `backbone`).
|
|
36
|
+
backtrack and backcrack depend on it, so installing either one pulls it in;
|
|
37
|
+
there's no reason to install it on its own unless you're building a tool.
|
|
38
|
+
|
|
39
|
+
To work on it, install this checkout editable before the tools, so edits here
|
|
40
|
+
take effect immediately with no reinstall:
|
|
41
|
+
|
|
42
|
+
pip3 install -e .
|
|
43
|
+
|
|
44
|
+
pip then sees the requirement already satisfied and leaves it.
|
|
45
|
+
|
|
46
|
+
`backbone.deps` is how a tool says what it needs besides Python: each tool's
|
|
47
|
+
`doctor` command reports it, and `deps.require` stops a tool at startup with
|
|
48
|
+
how to install anything it can't run without.
|
|
49
|
+
|
|
50
|
+
## What's in it
|
|
51
|
+
|
|
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
|
|
56
|
+
measuring (`visual_len`, `truncate_text`, `clip_ansi`, `strip_ansi`) that
|
|
57
|
+
makes any of that survive colour codes and wide characters, and small
|
|
58
|
+
formatters: `plural`, `human_gb`, `dir_size_kb`. The accent colour is chosen
|
|
59
|
+
with `set_accent` (an `ACCENT_PRESETS` key or `#RRGGBB`); a tool calls it once
|
|
60
|
+
at startup from its own settings, and `accent_code` / `accent_label` let a
|
|
61
|
+
settings screen check and name a value.
|
|
62
|
+
|
|
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.
|
|
66
|
+
`from backbone.ui import human_gb`.
|
|
67
|
+
|
|
68
|
+
**`prompt`** - the widgets. `select` (single or multi), `confirm`, `text`,
|
|
69
|
+
`path`, `live_select`, `list_edit`, plus specialised editors for tag-style
|
|
70
|
+
values: `calendar_select`, `datetime_edit`, `time_edit`,
|
|
71
|
+
`fraction_edit`, `number_edit`, `rating_edit`, `equaliser_edit`, `rva2_edit`,
|
|
72
|
+
`system_editor_edit`. All resize-aware, all mouse-aware, all rendered through
|
|
73
|
+
the same painter.
|
|
74
|
+
|
|
75
|
+
**`prompt.core`** - the primitives underneath: the screen-diff painter, key
|
|
76
|
+
reading, the `Choice` and `Column` types, the footer hint bar (`hint`) and its
|
|
77
|
+
click mapping, and `run_dashboard`.
|
|
78
|
+
|
|
79
|
+
**`nav`** - `NAV_STACK`, the app-wide breadcrumb, and `QuitToTerminal`, which
|
|
80
|
+
derives from `BaseException` specifically so an editor's `except Exception`
|
|
81
|
+
can't swallow a quit.
|
|
82
|
+
|
|
83
|
+
**`datetime_parse`** - one date and time parser for everything that reads a
|
|
84
|
+
hand-typed date, keeping whatever precision was given and saying why when it
|
|
85
|
+
can't read something.
|
|
86
|
+
|
|
87
|
+
**`app`** - who is running. A tool calls `app.configure("name", config_dir)`
|
|
88
|
+
once at startup (from its config module); the log file and the hints toggle
|
|
89
|
+
are kept in that folder, named after the tool.
|
|
90
|
+
|
|
91
|
+
**`log`** - the diagnostics log, `<config_dir>/<name>.log`, off until the
|
|
92
|
+
tool calls `log.configure(True)`. `from backbone.log import log` to write to
|
|
93
|
+
it, and `with quietly():` to carry on past an error on purpose while still
|
|
94
|
+
logging it.
|
|
95
|
+
|
|
96
|
+
**`output`** - a CLI's one output path: `table`, `record`, `event`, `note`,
|
|
97
|
+
`fail`, drawn as human text on a terminal or JSON / NDJSON with `--json`, and
|
|
98
|
+
the exit codes that go with them.
|
|
99
|
+
|
|
100
|
+
**`files`** - `write_text_atomic`, `backup_copy`, `log_line` (a daemon's
|
|
101
|
+
timestamped line, printed and appended to its log), `disk_free`,
|
|
102
|
+
`count_entries`.
|
|
103
|
+
|
|
104
|
+
**`procs`** - a tool's own background processes: `spawn_module` starts one (`python -m`)
|
|
105
|
+
detached, `find_processes` / `stop_processes` find or SIGTERM them by name
|
|
106
|
+
however they were started (directly, through `python3`, or through a launcher
|
|
107
|
+
command given as `launcher=`), `ps_listing` for the raw process table.
|
|
108
|
+
|
|
109
|
+
**`notify`** - `ntfy(server, topic, title, message)` pushes to a phone in the
|
|
110
|
+
background, doing nothing without a topic; `chime()` plays a sound at the
|
|
111
|
+
machine.
|
|
112
|
+
|
|
113
|
+
**`timefmt`**, **`numbering`**, **`keyboard`**, **`terminal_input`** - clock
|
|
114
|
+
and SRT timestamps; arabic, roman or written-out numbers; the keyboard layout
|
|
115
|
+
family (for typo scoring); raw key reads and escape decoding.
|
|
116
|
+
|
|
117
|
+
**`prompt.timezone`** - a full-screen world-map timezone picker.
|
|
118
|
+
|
|
119
|
+
## Live views
|
|
120
|
+
|
|
121
|
+
`run_dashboard(render, interval=..., on_key=...)` drives a tick-driven view
|
|
122
|
+
through the same `_Widget` machinery every prompt widget uses, rather than each
|
|
123
|
+
tool hand-rolling a redraw loop:
|
|
124
|
+
|
|
125
|
+
from backbone.prompt.core import run_dashboard
|
|
126
|
+
|
|
127
|
+
def render() -> list:
|
|
128
|
+
return [" line one", " line two"]
|
|
129
|
+
|
|
130
|
+
run_dashboard(render, interval=1.0, quit_key="q")
|
|
131
|
+
|
|
132
|
+
`render()` returns the whole frame as a list of lines, each carrying its own
|
|
133
|
+
left indent, and only runs once per `interval`. Keypresses and resizes are
|
|
134
|
+
checked every 50ms regardless, so a resize repaints and a `q` quits at once
|
|
135
|
+
instead of waiting out the data-refresh cadence. `on_key` handles anything
|
|
136
|
+
other than the quit key and is free to open a `select()` or `confirm()` of its
|
|
137
|
+
own; the dashboard repaints from scratch when it returns.
|
|
138
|
+
|
|
139
|
+
When stdin isn't a terminal (run from a script, or with input redirected),
|
|
140
|
+
it falls back to a plain sleep loop and never reads keys, so such a view has
|
|
141
|
+
to be stopped from outside.
|
|
142
|
+
|
|
143
|
+
## Terminal size
|
|
144
|
+
|
|
145
|
+
Everything reads `get_terminal_width()`, one cached value invalidated by
|
|
146
|
+
SIGWINCH, rather than calling out to the OS per line. `consume_resize()` tells
|
|
147
|
+
a loop a resize has landed since it last asked, which is the signal to clear
|
|
148
|
+
and repaint rather than diff.
|
|
149
|
+
|
|
150
|
+
`content_width()` is that width minus the margins, with no artificial floor:
|
|
151
|
+
content is always sized against the real terminal, however narrow, so a hard
|
|
152
|
+
clip later can't cut an oversized frame apart mid-border.
|
|
153
|
+
|
|
154
|
+
## Status bar and background work
|
|
155
|
+
|
|
156
|
+
`set_status(task_id, message)` registers running background work, which shows
|
|
157
|
+
in the status line as a pulsing beacon until the id is cleared. `show_status`
|
|
158
|
+
is the transient one-line toast. `set_footer_provider(fn)` registers a
|
|
159
|
+
persistent box drawn above the status line, such as backtrack's now-playing
|
|
160
|
+
bar; register nothing and no box is drawn. `prompt` also has optional hooks
|
|
161
|
+
for a host app's playback keys and player view (`set_transport_handler`,
|
|
162
|
+
`set_player_opener`), unused unless registered.
|
|
163
|
+
|
|
164
|
+
## Self-checks
|
|
165
|
+
|
|
166
|
+
Two modules check their own pure logic, no terminal needed:
|
|
167
|
+
|
|
168
|
+
python3 -m backbone.ui
|
|
169
|
+
python3 -m backbone.prompt.core
|
|
170
|
+
|
|
171
|
+
Both print an OK line. They cover the parts that run without a terminal:
|
|
172
|
+
sizing, measuring, truncation, table widths, hint parsing. Raw mode,
|
|
173
|
+
key reading and screen painting need a real tty and are not covered.
|
|
174
|
+
|
|
175
|
+
The tests in `tests/` each run on their own:
|
|
176
|
+
|
|
177
|
+
for f in tests/test_*.py; do python3 "$f"; done
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# backbone
|
|
2
|
+
|
|
3
|
+
Everything the back* tools share, written once: colours, margins, box
|
|
4
|
+
drawing, meters, prompt widgets and a live-view loop, so a list in one tool
|
|
5
|
+
looks and behaves like a list in another, plus the plumbing underneath (logging,
|
|
6
|
+
dates, CLI output, atomic files, background processes, notifications). backtrack
|
|
7
|
+
and backcrack both build on it, and a new tool should too: anything that isn't
|
|
8
|
+
specific to one tool's job belongs here.
|
|
9
|
+
|
|
10
|
+
Python, stdlib only - no third-party packages at all. Requires Python 3.10+.
|
|
11
|
+
|
|
12
|
+
This is a library, not a tool. It has no entry points of its own; something
|
|
13
|
+
else imports it.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
On PyPI as `backpack-backbone` (the import name is still `backbone`).
|
|
18
|
+
backtrack and backcrack depend on it, so installing either one pulls it in;
|
|
19
|
+
there's no reason to install it on its own unless you're building a tool.
|
|
20
|
+
|
|
21
|
+
To work on it, install this checkout editable before the tools, so edits here
|
|
22
|
+
take effect immediately with no reinstall:
|
|
23
|
+
|
|
24
|
+
pip3 install -e .
|
|
25
|
+
|
|
26
|
+
pip then sees the requirement already satisfied and leaves it.
|
|
27
|
+
|
|
28
|
+
`backbone.deps` is how a tool says what it needs besides Python: each tool's
|
|
29
|
+
`doctor` command reports it, and `deps.require` stops a tool at startup with
|
|
30
|
+
how to install anything it can't run without.
|
|
31
|
+
|
|
32
|
+
## What's in it
|
|
33
|
+
|
|
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
|
|
38
|
+
measuring (`visual_len`, `truncate_text`, `clip_ansi`, `strip_ansi`) that
|
|
39
|
+
makes any of that survive colour codes and wide characters, and small
|
|
40
|
+
formatters: `plural`, `human_gb`, `dir_size_kb`. The accent colour is chosen
|
|
41
|
+
with `set_accent` (an `ACCENT_PRESETS` key or `#RRGGBB`); a tool calls it once
|
|
42
|
+
at startup from its own settings, and `accent_code` / `accent_label` let a
|
|
43
|
+
settings screen check and name a value.
|
|
44
|
+
|
|
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.
|
|
48
|
+
`from backbone.ui import human_gb`.
|
|
49
|
+
|
|
50
|
+
**`prompt`** - the widgets. `select` (single or multi), `confirm`, `text`,
|
|
51
|
+
`path`, `live_select`, `list_edit`, plus specialised editors for tag-style
|
|
52
|
+
values: `calendar_select`, `datetime_edit`, `time_edit`,
|
|
53
|
+
`fraction_edit`, `number_edit`, `rating_edit`, `equaliser_edit`, `rva2_edit`,
|
|
54
|
+
`system_editor_edit`. All resize-aware, all mouse-aware, all rendered through
|
|
55
|
+
the same painter.
|
|
56
|
+
|
|
57
|
+
**`prompt.core`** - the primitives underneath: the screen-diff painter, key
|
|
58
|
+
reading, the `Choice` and `Column` types, the footer hint bar (`hint`) and its
|
|
59
|
+
click mapping, and `run_dashboard`.
|
|
60
|
+
|
|
61
|
+
**`nav`** - `NAV_STACK`, the app-wide breadcrumb, and `QuitToTerminal`, which
|
|
62
|
+
derives from `BaseException` specifically so an editor's `except Exception`
|
|
63
|
+
can't swallow a quit.
|
|
64
|
+
|
|
65
|
+
**`datetime_parse`** - one date and time parser for everything that reads a
|
|
66
|
+
hand-typed date, keeping whatever precision was given and saying why when it
|
|
67
|
+
can't read something.
|
|
68
|
+
|
|
69
|
+
**`app`** - who is running. A tool calls `app.configure("name", config_dir)`
|
|
70
|
+
once at startup (from its config module); the log file and the hints toggle
|
|
71
|
+
are kept in that folder, named after the tool.
|
|
72
|
+
|
|
73
|
+
**`log`** - the diagnostics log, `<config_dir>/<name>.log`, off until the
|
|
74
|
+
tool calls `log.configure(True)`. `from backbone.log import log` to write to
|
|
75
|
+
it, and `with quietly():` to carry on past an error on purpose while still
|
|
76
|
+
logging it.
|
|
77
|
+
|
|
78
|
+
**`output`** - a CLI's one output path: `table`, `record`, `event`, `note`,
|
|
79
|
+
`fail`, drawn as human text on a terminal or JSON / NDJSON with `--json`, and
|
|
80
|
+
the exit codes that go with them.
|
|
81
|
+
|
|
82
|
+
**`files`** - `write_text_atomic`, `backup_copy`, `log_line` (a daemon's
|
|
83
|
+
timestamped line, printed and appended to its log), `disk_free`,
|
|
84
|
+
`count_entries`.
|
|
85
|
+
|
|
86
|
+
**`procs`** - a tool's own background processes: `spawn_module` starts one (`python -m`)
|
|
87
|
+
detached, `find_processes` / `stop_processes` find or SIGTERM them by name
|
|
88
|
+
however they were started (directly, through `python3`, or through a launcher
|
|
89
|
+
command given as `launcher=`), `ps_listing` for the raw process table.
|
|
90
|
+
|
|
91
|
+
**`notify`** - `ntfy(server, topic, title, message)` pushes to a phone in the
|
|
92
|
+
background, doing nothing without a topic; `chime()` plays a sound at the
|
|
93
|
+
machine.
|
|
94
|
+
|
|
95
|
+
**`timefmt`**, **`numbering`**, **`keyboard`**, **`terminal_input`** - clock
|
|
96
|
+
and SRT timestamps; arabic, roman or written-out numbers; the keyboard layout
|
|
97
|
+
family (for typo scoring); raw key reads and escape decoding.
|
|
98
|
+
|
|
99
|
+
**`prompt.timezone`** - a full-screen world-map timezone picker.
|
|
100
|
+
|
|
101
|
+
## Live views
|
|
102
|
+
|
|
103
|
+
`run_dashboard(render, interval=..., on_key=...)` drives a tick-driven view
|
|
104
|
+
through the same `_Widget` machinery every prompt widget uses, rather than each
|
|
105
|
+
tool hand-rolling a redraw loop:
|
|
106
|
+
|
|
107
|
+
from backbone.prompt.core import run_dashboard
|
|
108
|
+
|
|
109
|
+
def render() -> list:
|
|
110
|
+
return [" line one", " line two"]
|
|
111
|
+
|
|
112
|
+
run_dashboard(render, interval=1.0, quit_key="q")
|
|
113
|
+
|
|
114
|
+
`render()` returns the whole frame as a list of lines, each carrying its own
|
|
115
|
+
left indent, and only runs once per `interval`. Keypresses and resizes are
|
|
116
|
+
checked every 50ms regardless, so a resize repaints and a `q` quits at once
|
|
117
|
+
instead of waiting out the data-refresh cadence. `on_key` handles anything
|
|
118
|
+
other than the quit key and is free to open a `select()` or `confirm()` of its
|
|
119
|
+
own; the dashboard repaints from scratch when it returns.
|
|
120
|
+
|
|
121
|
+
When stdin isn't a terminal (run from a script, or with input redirected),
|
|
122
|
+
it falls back to a plain sleep loop and never reads keys, so such a view has
|
|
123
|
+
to be stopped from outside.
|
|
124
|
+
|
|
125
|
+
## Terminal size
|
|
126
|
+
|
|
127
|
+
Everything reads `get_terminal_width()`, one cached value invalidated by
|
|
128
|
+
SIGWINCH, rather than calling out to the OS per line. `consume_resize()` tells
|
|
129
|
+
a loop a resize has landed since it last asked, which is the signal to clear
|
|
130
|
+
and repaint rather than diff.
|
|
131
|
+
|
|
132
|
+
`content_width()` is that width minus the margins, with no artificial floor:
|
|
133
|
+
content is always sized against the real terminal, however narrow, so a hard
|
|
134
|
+
clip later can't cut an oversized frame apart mid-border.
|
|
135
|
+
|
|
136
|
+
## Status bar and background work
|
|
137
|
+
|
|
138
|
+
`set_status(task_id, message)` registers running background work, which shows
|
|
139
|
+
in the status line as a pulsing beacon until the id is cleared. `show_status`
|
|
140
|
+
is the transient one-line toast. `set_footer_provider(fn)` registers a
|
|
141
|
+
persistent box drawn above the status line, such as backtrack's now-playing
|
|
142
|
+
bar; register nothing and no box is drawn. `prompt` also has optional hooks
|
|
143
|
+
for a host app's playback keys and player view (`set_transport_handler`,
|
|
144
|
+
`set_player_opener`), unused unless registered.
|
|
145
|
+
|
|
146
|
+
## Self-checks
|
|
147
|
+
|
|
148
|
+
Two modules check their own pure logic, no terminal needed:
|
|
149
|
+
|
|
150
|
+
python3 -m backbone.ui
|
|
151
|
+
python3 -m backbone.prompt.core
|
|
152
|
+
|
|
153
|
+
Both print an OK line. They cover the parts that run without a terminal:
|
|
154
|
+
sizing, measuring, truncation, table widths, hint parsing. Raw mode,
|
|
155
|
+
key reading and screen painting need a real tty and are not covered.
|
|
156
|
+
|
|
157
|
+
The tests in `tests/` each run on their own:
|
|
158
|
+
|
|
159
|
+
for f in tests/test_*.py; do python3 "$f"; done
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""backbone - shared terminal UI: colours, meters, prompt widgets, live views."""
|
|
2
|
+
from .ui import (
|
|
3
|
+
Colors,
|
|
4
|
+
MARGIN_H,
|
|
5
|
+
MARGIN_V,
|
|
6
|
+
SPIN,
|
|
7
|
+
PARTS,
|
|
8
|
+
SPARK,
|
|
9
|
+
spinner,
|
|
10
|
+
content_width,
|
|
11
|
+
rule,
|
|
12
|
+
bar,
|
|
13
|
+
header_box,
|
|
14
|
+
wrap_margins,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Colors",
|
|
19
|
+
"MARGIN_H",
|
|
20
|
+
"MARGIN_V",
|
|
21
|
+
"SPIN",
|
|
22
|
+
"PARTS",
|
|
23
|
+
"SPARK",
|
|
24
|
+
"spinner",
|
|
25
|
+
"content_width",
|
|
26
|
+
"rule",
|
|
27
|
+
"bar",
|
|
28
|
+
"header_box",
|
|
29
|
+
"wrap_margins",
|
|
30
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Which program is running, so shared code can find its files: a name and a
|
|
2
|
+
config folder, set once at startup by the host program (its config module)."""
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _default_dir(name: str) -> Path:
|
|
8
|
+
"""The platform's usual config folder for `name`."""
|
|
9
|
+
if os.name == "nt":
|
|
10
|
+
base = os.getenv("APPDATA") or str(Path.home() / "AppData" / "Roaming")
|
|
11
|
+
return Path(base) / name.capitalize()
|
|
12
|
+
xdg = os.getenv("XDG_CONFIG_HOME")
|
|
13
|
+
return (Path(xdg) if xdg else Path.home() / ".config") / name
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
name = "backbone"
|
|
17
|
+
config_dir = _default_dir(name)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def configure(app_name: str, app_config_dir=None) -> None:
|
|
21
|
+
"""Name the running program and its config folder (the platform default for
|
|
22
|
+
that name when not given). The diagnostics log and the saved hints switch
|
|
23
|
+
live there."""
|
|
24
|
+
global name, config_dir
|
|
25
|
+
name = app_name
|
|
26
|
+
config_dir = Path(app_config_dir) if app_config_dir else _default_dir(app_name)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""One parser for every hand-typed date: the calendar, the date/time editor,
|
|
2
|
+
the schedule table, filename values.
|
|
3
|
+
|
|
4
|
+
Everything goes through :func:`parse_datetime`. It accepts what a person
|
|
5
|
+
plausibly types, keeps whatever precision was given, and says *why* when it can't
|
|
6
|
+
read something so the caller can show that rather than a bare failure.
|
|
7
|
+
|
|
8
|
+
Accepted, all with ``-``, ``/`` or ``.`` between the parts and zero-padding
|
|
9
|
+
optional::
|
|
10
|
+
|
|
11
|
+
2008 2008-07 2008-07-02 2008-7-2
|
|
12
|
+
2008/07/02 2008.7.2 20080702
|
|
13
|
+
2008-07-02 18:30 2008-07-02T18:30 2008-07-02 18:30:45
|
|
14
|
+
|
|
15
|
+
A time may follow the date after a ``T`` (either case) or a space, as ``HH:MM``
|
|
16
|
+
or ``HH:MM:SS``. A trailing timezone (``Z`` or ``±HH:MM``) is stripped: the
|
|
17
|
+
tags Backtrack writes are local wall-clock timestamps.
|
|
18
|
+
|
|
19
|
+
Day-first vs month-first (``02/07/2008``) is ambiguous and is resolved
|
|
20
|
+
only when the caller says how, via ``dayfirst``. Left unset, an ambiguous date
|
|
21
|
+
is refused rather than guessed, because guessing wrong writes a plausible-looking
|
|
22
|
+
wrong date that nobody notices.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import datetime
|
|
27
|
+
import re
|
|
28
|
+
from typing import NamedTuple, Optional
|
|
29
|
+
|
|
30
|
+
__all__ = ['ParsedDateTime', 'parse_datetime', 'parse_date', 'parse_time',
|
|
31
|
+
'format_datetime', 'PRECISIONS']
|
|
32
|
+
|
|
33
|
+
# Coarse → fine. A caller can compare precisions with `PRECISIONS.index(...)`
|
|
34
|
+
# to demand at least a given granularity.
|
|
35
|
+
PRECISIONS = ('year', 'month', 'day', 'minute', 'second')
|
|
36
|
+
|
|
37
|
+
# Year-first, any of - / . between parts, zero-padding optional.
|
|
38
|
+
_YEAR_FIRST_RE = re.compile(r'^(\d{4})(?:[-/.\s](\d{1,2})(?:[-/.\s](\d{1,2}))?)?$')
|
|
39
|
+
# ISO basic form, 20080702.
|
|
40
|
+
_COMPACT_RE = re.compile(r'^(\d{4})(\d{2})(\d{2})$')
|
|
41
|
+
# Day- or month-first, e.g. 02/07/2008, order decided by `dayfirst`.
|
|
42
|
+
_YEAR_LAST_RE = re.compile(r'^(\d{1,2})[-/.\s](\d{1,2})[-/.\s](\d{4})$')
|
|
43
|
+
# A trailing timezone we drop rather than try to honour.
|
|
44
|
+
_TZ_RE = re.compile(r'(Z|[+-]\d{2}:?\d{2})$', re.IGNORECASE)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ParsedDateTime(NamedTuple):
|
|
48
|
+
"""The result of reading a date/time a user typed.
|
|
49
|
+
|
|
50
|
+
``date`` is always a real ``datetime.date`` on success (a year- or
|
|
51
|
+
month-only input is completed to the 1st so callers that just need *a* date
|
|
52
|
+
have one), and ``precision`` records how much was actually given, so a caller
|
|
53
|
+
that needs a real day (a schedule counting in days, say) can insist on it
|
|
54
|
+
instead of silently scheduling from an invented 1 January.
|
|
55
|
+
|
|
56
|
+
``time`` is ``'HH:MM:SS'`` or None. ``error`` is '' on success and otherwise
|
|
57
|
+
a short phrase naming what was wrong, fit to show the user directly.
|
|
58
|
+
"""
|
|
59
|
+
date: Optional[datetime.date]
|
|
60
|
+
time: Optional[str]
|
|
61
|
+
precision: str
|
|
62
|
+
error: str
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def ok(self) -> bool:
|
|
66
|
+
"""True if the input parsed."""
|
|
67
|
+
return not self.error
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_time(raw) -> Optional[str]:
|
|
71
|
+
"""Normalise ``HH``/``HH:MM``/``HH:MM:SS`` to ``'HH:MM:SS'``; None if unreadable.
|
|
72
|
+
|
|
73
|
+
Rejects out-of-range parts (``25:00``, ``18:75``) rather than rolling them
|
|
74
|
+
over, so a typo surfaces instead of quietly becoming a different time.
|
|
75
|
+
"""
|
|
76
|
+
if raw is None:
|
|
77
|
+
return None
|
|
78
|
+
s = str(raw).strip()
|
|
79
|
+
if not s:
|
|
80
|
+
return None
|
|
81
|
+
parts = s.split(':')
|
|
82
|
+
if len(parts) > 3 or not all(p.strip().isdigit() for p in parts if p.strip() != ''):
|
|
83
|
+
return None
|
|
84
|
+
try:
|
|
85
|
+
h = int(parts[0])
|
|
86
|
+
m = int(parts[1]) if len(parts) > 1 and parts[1].strip() else 0
|
|
87
|
+
sec = int(parts[2]) if len(parts) > 2 and parts[2].strip() else 0
|
|
88
|
+
except (ValueError, IndexError):
|
|
89
|
+
return None
|
|
90
|
+
if 0 <= h < 24 and 0 <= m < 60 and 0 <= sec < 60:
|
|
91
|
+
return f"{h:02d}:{m:02d}:{sec:02d}"
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _split_date_time(s: str) -> tuple:
|
|
96
|
+
"""Split a stamp into its date and time halves.
|
|
97
|
+
|
|
98
|
+
A ``T`` always separates them. A space only does when what follows it looks
|
|
99
|
+
like a clock time (it carries a ``:``) because a space is *also* a legal
|
|
100
|
+
separator inside the date itself: ``2008 07 02`` is a date, while
|
|
101
|
+
``2008-07-02 18:30`` is a date and a time.
|
|
102
|
+
"""
|
|
103
|
+
upper = s.upper()
|
|
104
|
+
if 'T' in upper:
|
|
105
|
+
cut = upper.index('T')
|
|
106
|
+
return s[:cut].strip(), s[cut + 1:].strip()
|
|
107
|
+
if ' ' in s:
|
|
108
|
+
head, _, tail = s.rpartition(' ')
|
|
109
|
+
if ':' in tail:
|
|
110
|
+
return head.strip(), tail.strip()
|
|
111
|
+
return s.strip(), ''
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def split_stamp(raw) -> tuple:
|
|
115
|
+
"""The date and time halves of a typed stamp, any trailing timezone dropped."""
|
|
116
|
+
return _split_date_time(_TZ_RE.sub('', str(raw or '').strip()).strip())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _read_date(part: str, dayfirst: Optional[bool]) -> tuple:
|
|
120
|
+
"""Parse the date half → ``(date, precision, error)``."""
|
|
121
|
+
m = _COMPACT_RE.match(part)
|
|
122
|
+
if m:
|
|
123
|
+
y, mo, d = (int(g) for g in m.groups())
|
|
124
|
+
return _build(y, mo, d, 'day')
|
|
125
|
+
|
|
126
|
+
m = _YEAR_FIRST_RE.match(part)
|
|
127
|
+
if m:
|
|
128
|
+
year, month, day = m.groups()
|
|
129
|
+
if month is None:
|
|
130
|
+
return _build(int(year), 1, 1, 'year')
|
|
131
|
+
if day is None:
|
|
132
|
+
return _build(int(year), int(month), 1, 'month')
|
|
133
|
+
return _build(int(year), int(month), int(day), 'day')
|
|
134
|
+
|
|
135
|
+
m = _YEAR_LAST_RE.match(part)
|
|
136
|
+
if m:
|
|
137
|
+
a, b, year = (int(g) for g in m.groups())
|
|
138
|
+
# Only one ordering can be right when a part exceeds 12 (13/07/2008 has
|
|
139
|
+
# to be day-first), so try both and see how many survive.
|
|
140
|
+
day_first_ok = 1 <= b <= 12
|
|
141
|
+
month_first_ok = 1 <= a <= 12
|
|
142
|
+
if day_first_ok and month_first_ok:
|
|
143
|
+
# Genuinely ambiguous: 02/07/2008 is 2 July or 2 February depending
|
|
144
|
+
# on where you live. Honour an explicit choice, else refuse rather
|
|
145
|
+
# than pick one and be silently wrong.
|
|
146
|
+
if dayfirst is None:
|
|
147
|
+
return None, '', (f"{part!r} could be day-first or month-first: "
|
|
148
|
+
"write it year-first (2008-07-02)")
|
|
149
|
+
day, month = (a, b) if dayfirst else (b, a)
|
|
150
|
+
elif day_first_ok:
|
|
151
|
+
day, month = a, b
|
|
152
|
+
elif month_first_ok:
|
|
153
|
+
month, day = a, b
|
|
154
|
+
else:
|
|
155
|
+
return None, '', f"{part!r} has no valid month"
|
|
156
|
+
return _build(year, month, day, 'day')
|
|
157
|
+
|
|
158
|
+
return None, '', f"{part!r} is not a date"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _build(year: int, month: int, day: int, precision: str) -> tuple:
|
|
162
|
+
"""Validate y/m/d into a real date, or report why it isn't one."""
|
|
163
|
+
if not 1 <= month <= 12:
|
|
164
|
+
return None, '', f"there is no month {month}"
|
|
165
|
+
try:
|
|
166
|
+
return datetime.date(year, month, day), precision, ''
|
|
167
|
+
except ValueError:
|
|
168
|
+
return None, '', (f"{year:04d}-{month:02d}-{day:02d} is not a real date")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def parse_datetime(raw, *, dayfirst: Optional[bool] = None) -> ParsedDateTime:
|
|
172
|
+
"""Read a date, optionally with a time, from something a user typed.
|
|
173
|
+
|
|
174
|
+
``dayfirst`` resolves ``02/07/2008``: True reads it day-first, False
|
|
175
|
+
month-first, and the default (None) refuses it and says to write the date
|
|
176
|
+
year-first. Year-first input is never ambiguous and never consults it.
|
|
177
|
+
"""
|
|
178
|
+
if raw is None:
|
|
179
|
+
return ParsedDateTime(None, None, '', 'no date given')
|
|
180
|
+
date_part, time_part = split_stamp(raw)
|
|
181
|
+
if not date_part:
|
|
182
|
+
return ParsedDateTime(None, None, '', 'no date given')
|
|
183
|
+
|
|
184
|
+
date, precision, err = _read_date(date_part, dayfirst)
|
|
185
|
+
if err:
|
|
186
|
+
return ParsedDateTime(None, None, '', err)
|
|
187
|
+
|
|
188
|
+
if not time_part:
|
|
189
|
+
return ParsedDateTime(date, None, precision, '')
|
|
190
|
+
|
|
191
|
+
tod = parse_time(time_part)
|
|
192
|
+
if tod is None:
|
|
193
|
+
return ParsedDateTime(None, None, '', f"{time_part!r} is not a valid 24-hour time")
|
|
194
|
+
# A time implies a full date; without one we would be timing an invented day.
|
|
195
|
+
if precision != 'day':
|
|
196
|
+
return ParsedDateTime(None, None, '', f"{date_part!r} needs a full year-month-day "
|
|
197
|
+
"to carry a time")
|
|
198
|
+
return ParsedDateTime(date, tod, 'second' if tod[-2:] != '00' else 'minute', '')
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def parse_date(raw, *, dayfirst: Optional[bool] = None) -> Optional[datetime.date]:
|
|
202
|
+
"""Just the date, or None if it won't parse. Any time given is ignored."""
|
|
203
|
+
return parse_datetime(raw, dayfirst=dayfirst).date
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def format_datetime(parsed: ParsedDateTime) -> str:
|
|
207
|
+
"""Render a parse back out at the precision it was given."""
|
|
208
|
+
if parsed.date is None:
|
|
209
|
+
return ''
|
|
210
|
+
if parsed.precision == 'year':
|
|
211
|
+
return f"{parsed.date.year:04d}"
|
|
212
|
+
if parsed.precision == 'month':
|
|
213
|
+
return f"{parsed.date.year:04d}-{parsed.date.month:02d}"
|
|
214
|
+
if parsed.time:
|
|
215
|
+
return f"{parsed.date.isoformat()} {parsed.time}"
|
|
216
|
+
return parsed.date.isoformat()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def parse_date_parts(raw, *, dayfirst: Optional[bool] = None) -> Optional[tuple]:
|
|
220
|
+
"""``(year, month, day)`` for a typed date, or None: the shape the calendar
|
|
221
|
+
and date/time widgets work in. A year- or month-only input completes to the
|
|
222
|
+
1st, as those widgets have always done."""
|
|
223
|
+
d = parse_datetime(raw, dayfirst=dayfirst).date
|
|
224
|
+
return (d.year, d.month, d.day) if d else None
|