settings-panel 0.1.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.
- settings_panel-0.1.0/PKG-INFO +8 -0
- settings_panel-0.1.0/pyproject.toml +14 -0
- settings_panel-0.1.0/pyproject.toml.orig +10 -0
- settings_panel-0.1.0/src/settings_panel/__init__.py +20 -0
- settings_panel-0.1.0/src/settings_panel/model.py +272 -0
- settings_panel-0.1.0/src/settings_panel/screen.py +473 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: settings-panel
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A game-style settings panel for Textual apps: fixed choices, tiles or list, written straight to JSON or TOML config files
|
|
5
|
+
Requires-Dist: textual>=8
|
|
6
|
+
Requires-Dist: tomlkit>=0.13
|
|
7
|
+
Requires-Dist: keyline>=0.1,<0.2
|
|
8
|
+
Requires-Python: >=3.11
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "settings-panel"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A game-style settings panel for Textual apps: fixed choices, tiles or list, written straight to JSON or TOML config files"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"textual>=8",
|
|
8
|
+
"tomlkit>=0.13",
|
|
9
|
+
"keyline>=0.1,<0.2",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["uv_build>=0.12.19,<0.13.0"]
|
|
14
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "settings-panel"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A game-style settings panel for Textual apps: fixed choices, tiles or list, written straight to JSON or TOML config files"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = ["textual>=8", "tomlkit>=0.13", "keyline>=0.1,<0.2"]
|
|
7
|
+
|
|
8
|
+
[build-system]
|
|
9
|
+
requires = ["uv_build>=0.12.19,<0.13.0"]
|
|
10
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""A game-style settings panel for Textual apps, and the small model behind
|
|
2
|
+
it: settings with fixed choices, grouped, stored in JSON or TOML files;
|
|
3
|
+
and lists to pick from, as tabs beside them."""
|
|
4
|
+
|
|
5
|
+
from .model import OFF_ON, Entry, Group, JsonStore, Listing, OverlayStore, Setting, StoreError, TomlStore, panel_settings
|
|
6
|
+
from .screen import SettingsScreen
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"OFF_ON",
|
|
10
|
+
"Entry",
|
|
11
|
+
"Group",
|
|
12
|
+
"JsonStore",
|
|
13
|
+
"Listing",
|
|
14
|
+
"OverlayStore",
|
|
15
|
+
"Setting",
|
|
16
|
+
"SettingsScreen",
|
|
17
|
+
"StoreError",
|
|
18
|
+
"TomlStore",
|
|
19
|
+
"panel_settings",
|
|
20
|
+
]
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Settings, grouped, and the files they live in.
|
|
2
|
+
|
|
3
|
+
Every setting has a fixed list of values to step through, so changing one
|
|
4
|
+
never needs typing. Values are written straight into each program's own
|
|
5
|
+
config file, so the program stays the owner of its settings.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Callable
|
|
15
|
+
|
|
16
|
+
import tomlkit
|
|
17
|
+
from tomlkit.exceptions import TOMLKitError
|
|
18
|
+
|
|
19
|
+
OFF_ON = [False, True]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class StoreError(Exception):
|
|
23
|
+
"""A settings file can't be read, so it won't be written either."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _write_atomically(path: Path, text: str) -> None:
|
|
27
|
+
# Write through symlinks (e.g. from a dotfiles manager) rather than
|
|
28
|
+
# replacing them.
|
|
29
|
+
target = path.resolve()
|
|
30
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
tmp = target.with_name(f".{target.name}.settings-tmp")
|
|
32
|
+
tmp.write_text(text)
|
|
33
|
+
os.replace(tmp, target)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class JsonStore:
|
|
37
|
+
"""A JSON settings file, such as micro's settings.json.
|
|
38
|
+
|
|
39
|
+
Keys are tuples naming the path to a value, because keys can contain
|
|
40
|
+
dots ("claude.autosave") and settings can be nested ("ft:markdown" ->
|
|
41
|
+
"softwrap"). By default the file only keeps values that differ from the
|
|
42
|
+
default, as micro does. With `keep_defaults`, a value set back to its
|
|
43
|
+
default is written out too, so a program that re-reads the file while
|
|
44
|
+
running (micro, through a plugin) sees the change.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, path: Path, keep_defaults: bool = False) -> None:
|
|
48
|
+
self.path = path
|
|
49
|
+
self.keep_defaults = keep_defaults
|
|
50
|
+
|
|
51
|
+
def _load(self) -> dict:
|
|
52
|
+
try:
|
|
53
|
+
text = self.path.read_text()
|
|
54
|
+
except FileNotFoundError:
|
|
55
|
+
return {}
|
|
56
|
+
except OSError as e:
|
|
57
|
+
raise StoreError(f"can't read {self.path}: {e.strerror}") from e
|
|
58
|
+
if not text.strip():
|
|
59
|
+
return {}
|
|
60
|
+
try:
|
|
61
|
+
data = json.loads(text)
|
|
62
|
+
except json.JSONDecodeError as e:
|
|
63
|
+
raise StoreError(f"{self.path} isn't valid JSON (line {e.lineno})") from e
|
|
64
|
+
if not isinstance(data, dict):
|
|
65
|
+
raise StoreError(f"{self.path} doesn't hold a JSON object")
|
|
66
|
+
return data
|
|
67
|
+
|
|
68
|
+
def get(self, key: tuple[str, ...]) -> Any:
|
|
69
|
+
node: Any = self._load()
|
|
70
|
+
for part in key:
|
|
71
|
+
if not isinstance(node, dict) or part not in node:
|
|
72
|
+
return None
|
|
73
|
+
node = node[part]
|
|
74
|
+
return node
|
|
75
|
+
|
|
76
|
+
def set(self, key: tuple[str, ...], value: Any, default: Any) -> None:
|
|
77
|
+
data = self._load()
|
|
78
|
+
*parents, last = key
|
|
79
|
+
node = data
|
|
80
|
+
chain = []
|
|
81
|
+
for part in parents:
|
|
82
|
+
child = node.get(part)
|
|
83
|
+
if not isinstance(child, dict):
|
|
84
|
+
child = node[part] = {}
|
|
85
|
+
chain.append((node, part))
|
|
86
|
+
node = child
|
|
87
|
+
if value is None or (value == default and not self.keep_defaults):
|
|
88
|
+
node.pop(last, None)
|
|
89
|
+
for parent, part in reversed(chain):
|
|
90
|
+
if parent[part] == {}:
|
|
91
|
+
del parent[part]
|
|
92
|
+
else:
|
|
93
|
+
node[last] = value
|
|
94
|
+
_write_atomically(self.path, json.dumps(data, indent=4, sort_keys=True) + "\n")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class TomlStore:
|
|
98
|
+
"""A TOML settings file.
|
|
99
|
+
|
|
100
|
+
Edits keep the file's comments and layout. Values are set even when they
|
|
101
|
+
equal the default, since many programs write out every value; None
|
|
102
|
+
removes the key.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, path: Path) -> None:
|
|
106
|
+
self.path = path
|
|
107
|
+
|
|
108
|
+
def _load(self) -> tomlkit.TOMLDocument:
|
|
109
|
+
try:
|
|
110
|
+
text = self.path.read_text()
|
|
111
|
+
except FileNotFoundError:
|
|
112
|
+
return tomlkit.document()
|
|
113
|
+
except OSError as e:
|
|
114
|
+
raise StoreError(f"can't read {self.path}: {e.strerror}") from e
|
|
115
|
+
try:
|
|
116
|
+
return tomlkit.parse(text)
|
|
117
|
+
except TOMLKitError as e:
|
|
118
|
+
raise StoreError(f"{self.path} isn't valid TOML ({e})") from e
|
|
119
|
+
|
|
120
|
+
def get(self, key: tuple[str, ...]) -> Any:
|
|
121
|
+
node: Any = self._load()
|
|
122
|
+
for part in key:
|
|
123
|
+
if not hasattr(node, "keys") or part not in node:
|
|
124
|
+
return None
|
|
125
|
+
node = node[part]
|
|
126
|
+
return node.unwrap() if hasattr(node, "unwrap") else node
|
|
127
|
+
|
|
128
|
+
def set(self, key: tuple[str, ...], value: Any, default: Any) -> None:
|
|
129
|
+
doc = self._load()
|
|
130
|
+
*parents, last = key
|
|
131
|
+
node: Any = doc
|
|
132
|
+
for part in parents:
|
|
133
|
+
if part not in node:
|
|
134
|
+
node[part] = tomlkit.table()
|
|
135
|
+
node = node[part]
|
|
136
|
+
if value is None:
|
|
137
|
+
if last in node:
|
|
138
|
+
del node[last]
|
|
139
|
+
else:
|
|
140
|
+
node[last] = value
|
|
141
|
+
_write_atomically(self.path, tomlkit.dumps(doc))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class OverlayStore:
|
|
145
|
+
"""A store with some of its keys kept in a JSON file of their own, such
|
|
146
|
+
as what one instance of a program keeps apart from the others.
|
|
147
|
+
|
|
148
|
+
`defaults` maps each of those keys to its default. They're read from
|
|
149
|
+
`path`, and a change to one is written there and to `base` too, for a
|
|
150
|
+
new file to start from: a key `path` doesn't have yet takes `base`'s
|
|
151
|
+
value, else its default, once, so that later changes to `base` don't
|
|
152
|
+
reach it. The other keys are `base`'s.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
def __init__(self, base: JsonStore | TomlStore, path: Path, defaults: dict[tuple[str, ...], Any]) -> None:
|
|
156
|
+
self.base = base
|
|
157
|
+
self.path = path
|
|
158
|
+
self.own = JsonStore(path, keep_defaults=True)
|
|
159
|
+
self.defaults = dict(defaults)
|
|
160
|
+
try:
|
|
161
|
+
for key, default in self.defaults.items():
|
|
162
|
+
if self.own.get(key) is None:
|
|
163
|
+
value = base.get(key)
|
|
164
|
+
self.own.set(key, default if value is None else value, default)
|
|
165
|
+
except (StoreError, OSError):
|
|
166
|
+
pass # read from base, then, until a change writes path
|
|
167
|
+
|
|
168
|
+
def get(self, key: tuple[str, ...]) -> Any:
|
|
169
|
+
if key in self.defaults:
|
|
170
|
+
value = self.own.get(key)
|
|
171
|
+
return self.base.get(key) if value is None else value
|
|
172
|
+
return self.base.get(key)
|
|
173
|
+
|
|
174
|
+
def set(self, key: tuple[str, ...], value: Any, default: Any) -> None:
|
|
175
|
+
if key in self.defaults:
|
|
176
|
+
self.own.set(key, value, default)
|
|
177
|
+
self.base.set(key, value, default)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
Store = JsonStore | TomlStore | OverlayStore
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass
|
|
184
|
+
class Setting:
|
|
185
|
+
key: tuple[str, ...]
|
|
186
|
+
label: str
|
|
187
|
+
help: str
|
|
188
|
+
choices: list[Any] | Callable[[], list[Any]]
|
|
189
|
+
default: Any
|
|
190
|
+
names: dict[Any, str] = field(default_factory=dict)
|
|
191
|
+
"""Display names for values that aren't clear on their own."""
|
|
192
|
+
|
|
193
|
+
def options(self) -> list[Any]:
|
|
194
|
+
return self.choices() if callable(self.choices) else self.choices
|
|
195
|
+
|
|
196
|
+
def show(self, value: Any) -> str:
|
|
197
|
+
if isinstance(value, bool):
|
|
198
|
+
return "on" if value else "off"
|
|
199
|
+
if value in self.names:
|
|
200
|
+
return self.names[value]
|
|
201
|
+
return "off" if value is None else str(value)
|
|
202
|
+
|
|
203
|
+
def step(self, current: Any, delta: int) -> Any:
|
|
204
|
+
"""The value `delta` places from `current`, wrapping around."""
|
|
205
|
+
options = self.options()
|
|
206
|
+
if current in options:
|
|
207
|
+
return options[(options.index(current) + delta) % len(options)]
|
|
208
|
+
# A value set by hand that isn't offered: start from an end.
|
|
209
|
+
return options[0] if delta > 0 else options[-1]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass
|
|
213
|
+
class Group:
|
|
214
|
+
id: str
|
|
215
|
+
name: str
|
|
216
|
+
where: str
|
|
217
|
+
"""Which file the group's settings live in, and when they apply."""
|
|
218
|
+
store: Store
|
|
219
|
+
settings: list[Setting]
|
|
220
|
+
on_change: Callable[[Setting, Any], None] | None = None
|
|
221
|
+
"""Called after a setting in the group changes, to apply it right away."""
|
|
222
|
+
|
|
223
|
+
def value(self, setting: Setting) -> Any:
|
|
224
|
+
value = self.store.get(setting.key)
|
|
225
|
+
return setting.default if value is None else value
|
|
226
|
+
|
|
227
|
+
def change(self, setting: Setting, delta: int) -> Any:
|
|
228
|
+
value = setting.step(self.value(setting), delta)
|
|
229
|
+
self.store.set(setting.key, value, setting.default)
|
|
230
|
+
if self.on_change is not None:
|
|
231
|
+
self.on_change(setting, value)
|
|
232
|
+
return value
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclass
|
|
236
|
+
class Entry:
|
|
237
|
+
"""A line of a `Listing`: what it says, a string or Rich Text; what the
|
|
238
|
+
description under the list says of it; and an `id` for whoever picks it.
|
|
239
|
+
A `separator` is a rule across the list instead, never selected."""
|
|
240
|
+
|
|
241
|
+
text: Any
|
|
242
|
+
help: str = ""
|
|
243
|
+
id: str = ""
|
|
244
|
+
separator: bool = False
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@dataclass
|
|
248
|
+
class Listing:
|
|
249
|
+
"""A group of lines to pick from rather than settings, such as what's
|
|
250
|
+
due across projects: a tab of its own, always a list. Ret, or a click
|
|
251
|
+
on the selected line, picks it."""
|
|
252
|
+
|
|
253
|
+
id: str
|
|
254
|
+
name: str
|
|
255
|
+
where: str
|
|
256
|
+
"""What the lines are, shown under one that has no help of its own."""
|
|
257
|
+
entries: list[Entry]
|
|
258
|
+
on_pick: Callable[[Entry], None] | None = None
|
|
259
|
+
"""Called with the line picked."""
|
|
260
|
+
verb: str = "open"
|
|
261
|
+
"""What picking a line does, for its key."""
|
|
262
|
+
empty: str = "Nothing here."
|
|
263
|
+
"""What the list says while it has no lines."""
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def panel_settings() -> list[Setting]:
|
|
267
|
+
"""Settings for the panel itself. Put them in a group whose store is the
|
|
268
|
+
panel's `prefs` store, and they apply as soon as they change."""
|
|
269
|
+
return [
|
|
270
|
+
Setting(("panel_height",), "Panel height", "How much of the window this panel covers.", ["1/4", "1/3", "1/2"], "1/3"),
|
|
271
|
+
Setting(("tabs",), "Group tabs", "Show the group tabs above the settings, or below them.", ["top", "bottom"], "top"),
|
|
272
|
+
]
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""A settings panel that rises over the bottom of the screen, like a game's
|
|
2
|
+
options menu: numbered group tabs, and each group as tiles or as a list,
|
|
3
|
+
with the selected setting's description right under them. A tab can hold
|
|
4
|
+
a list of lines to pick from instead (a Listing)."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from rich.rule import Rule
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
from textual import events
|
|
12
|
+
from textual.app import ComposeResult
|
|
13
|
+
from textual.binding import Binding
|
|
14
|
+
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
|
|
15
|
+
from textual.screen import ModalScreen
|
|
16
|
+
from textual.widgets import Static
|
|
17
|
+
|
|
18
|
+
from keyline import Key, Keyline
|
|
19
|
+
|
|
20
|
+
from .model import Entry, Group, JsonStore, Listing, Setting, StoreError
|
|
21
|
+
|
|
22
|
+
TILE_WIDTH = 22 # columns per tile, gutter included
|
|
23
|
+
PANEL_FRACTIONS = {"1/4": 1 / 4, "1/3": 1 / 3, "1/2": 1 / 2}
|
|
24
|
+
PANEL_KEYS = {"panel_height", "tabs"}
|
|
25
|
+
|
|
26
|
+
TILES_KEYS = [
|
|
27
|
+
Key("←↑↓→", "move"),
|
|
28
|
+
Key("Ret", "change", "change(1)"),
|
|
29
|
+
Key("⌫", "back", "change(-1)"),
|
|
30
|
+
Key("Tab", "group", "next_group(1)"),
|
|
31
|
+
Key("v", "list", "toggle_view"),
|
|
32
|
+
Key("Esc", "close", "close"),
|
|
33
|
+
]
|
|
34
|
+
LIST_KEYS = [
|
|
35
|
+
Key("↑↓", "move"),
|
|
36
|
+
Key("←→", "change", "change(1)"),
|
|
37
|
+
Key("Tab", "group", "next_group(1)"),
|
|
38
|
+
Key("v", "tiles", "toggle_view"),
|
|
39
|
+
Key("Esc", "close", "close"),
|
|
40
|
+
]
|
|
41
|
+
def listing_keys(verb: str) -> list[Key]:
|
|
42
|
+
"""The keys of a Listing, whose Ret does `verb`."""
|
|
43
|
+
return [
|
|
44
|
+
Key("↑↓", "move"),
|
|
45
|
+
Key("Ret", verb, "change(1)"),
|
|
46
|
+
Key("Tab", "group", "next_group(1)"),
|
|
47
|
+
Key("Esc", "close", "close"),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
TILE_ACTIONS = {"move", "change", "toggle_view", "select_group"}
|
|
52
|
+
"""The panel's actions on the tiles: their keys go to a widget with the
|
|
53
|
+
focus instead, such as an input a subclass adds."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Tab(Static):
|
|
57
|
+
def __init__(self, index: int, name: str) -> None:
|
|
58
|
+
super().__init__(Text.assemble((str(index + 1), "bold"), f" {name}"), classes="tab")
|
|
59
|
+
self.index = index
|
|
60
|
+
|
|
61
|
+
def on_click(self) -> None:
|
|
62
|
+
self.screen.select_group(self.index)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Item(Static):
|
|
66
|
+
"""A setting, as a tile or a list row. Clicking selects it; clicking it
|
|
67
|
+
again changes it, like tapping a button in a game's menu."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, index: int, kind: str) -> None:
|
|
70
|
+
super().__init__(classes=kind)
|
|
71
|
+
self.index = index
|
|
72
|
+
|
|
73
|
+
def on_click(self) -> None:
|
|
74
|
+
self.screen.item_clicked(self.index)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SettingsScreen(ModalScreen[None]):
|
|
78
|
+
"""Settings over the bottom of whatever screen is below.
|
|
79
|
+
|
|
80
|
+
`groups` are Groups of settings, and Listings, lines to pick from.
|
|
81
|
+
`prefs` is a JSON store for the panel's own state: its height and tab
|
|
82
|
+
position (see `panel_settings`) and whether each group shows as tiles or
|
|
83
|
+
a list. `start` names the group to open on, e.g. the one for the part of
|
|
84
|
+
the app that had focus. With `full=True` the panel fills the screen, for
|
|
85
|
+
running it on its own.
|
|
86
|
+
|
|
87
|
+
A subclass can add widgets under the settings, above their keys, with
|
|
88
|
+
`compose_under()`, and keys of its own with `panel_keys()`. While one of
|
|
89
|
+
its widgets has the focus, such as an input, the keys that act on the
|
|
90
|
+
tiles are that widget's.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
DEFAULT_CSS = """
|
|
94
|
+
SettingsScreen { background: transparent; }
|
|
95
|
+
SettingsScreen #bottom { dock: bottom; height: auto; }
|
|
96
|
+
SettingsScreen.-full #bottom { height: 100%; }
|
|
97
|
+
SettingsScreen #panel { height: 12; background: $surface; border-top: solid $accent; }
|
|
98
|
+
SettingsScreen.-full #panel { height: 1fr; }
|
|
99
|
+
SettingsScreen #tabs { height: 1; background: $panel; }
|
|
100
|
+
SettingsScreen #tabs.-bottom { dock: bottom; }
|
|
101
|
+
SettingsScreen .tab { width: auto; padding: 0 1; }
|
|
102
|
+
SettingsScreen .tab.-current { background: $accent; color: $text; text-style: bold; }
|
|
103
|
+
SettingsScreen #body { height: 1fr; padding: 0 1; }
|
|
104
|
+
SettingsScreen #tiles { layout: grid; grid-size: 1; grid-gutter: 1 1; grid-rows: 2; height: auto; }
|
|
105
|
+
SettingsScreen .tile { height: 2; padding: 0 1; background: $boost; }
|
|
106
|
+
SettingsScreen .row { height: 1; padding: 0 1; max-width: 60; }
|
|
107
|
+
SettingsScreen .row.-entry { max-width: 100%; }
|
|
108
|
+
SettingsScreen .row.-empty { color: $text-muted; }
|
|
109
|
+
SettingsScreen .row.-separator { padding: 0; }
|
|
110
|
+
SettingsScreen Item.-selected { background: $accent 50%; }
|
|
111
|
+
SettingsScreen #help { height: auto; max-height: 3; padding: 0 1; }
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
BINDINGS = [
|
|
115
|
+
Binding("escape", "close", priority=True),
|
|
116
|
+
Binding("up", "move('up')", priority=True),
|
|
117
|
+
Binding("down", "move('down')", priority=True),
|
|
118
|
+
Binding("left", "move('left')", priority=True),
|
|
119
|
+
Binding("right", "move('right')", priority=True),
|
|
120
|
+
Binding("enter,space", "change(1)", priority=True),
|
|
121
|
+
Binding("backspace", "change(-1)", priority=True),
|
|
122
|
+
Binding("tab", "next_group(1)", priority=True),
|
|
123
|
+
Binding("shift+tab", "next_group(-1)", priority=True),
|
|
124
|
+
Binding("v", "toggle_view", priority=True),
|
|
125
|
+
*[Binding(str(n), f"select_group({n - 1})", priority=True) for n in range(1, 10)],
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
def __init__(
|
|
129
|
+
self,
|
|
130
|
+
groups: list[Group | Listing],
|
|
131
|
+
prefs: JsonStore,
|
|
132
|
+
*,
|
|
133
|
+
full: bool = False,
|
|
134
|
+
close_keys: tuple[str, ...] = (),
|
|
135
|
+
start: str | None = None,
|
|
136
|
+
) -> None:
|
|
137
|
+
super().__init__(classes="-full" if full else None)
|
|
138
|
+
self.groups = groups
|
|
139
|
+
self.prefs = prefs
|
|
140
|
+
self.full = full
|
|
141
|
+
self.close_keys = close_keys
|
|
142
|
+
self.group_index = next((i for i, g in enumerate(groups) if g.id == start), 0)
|
|
143
|
+
self.selected = {group.id: 0 for group in groups}
|
|
144
|
+
self.cols = 1
|
|
145
|
+
|
|
146
|
+
# -- the panel's own state, read fresh so changes apply right away --
|
|
147
|
+
|
|
148
|
+
def pref(self, name: str, default: str) -> str:
|
|
149
|
+
try:
|
|
150
|
+
value = self.prefs.get((name,))
|
|
151
|
+
except StoreError:
|
|
152
|
+
value = None
|
|
153
|
+
return value if isinstance(value, str) else default
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def group(self) -> Group | Listing:
|
|
157
|
+
"""The group shown."""
|
|
158
|
+
return self.groups[self.group_index]
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def setting(self) -> Setting:
|
|
162
|
+
"""The group's selected setting; a Listing has none."""
|
|
163
|
+
assert isinstance(self.group, Group)
|
|
164
|
+
return self.group.settings[self.selected[self.group.id]]
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def entry(self) -> Entry | None:
|
|
168
|
+
"""A Listing's selected line, if it's shown and has lines."""
|
|
169
|
+
group = self.group
|
|
170
|
+
if not isinstance(group, Listing) or not group.entries:
|
|
171
|
+
return None
|
|
172
|
+
entry = group.entries[min(self.selected[group.id], len(group.entries) - 1)]
|
|
173
|
+
return None if entry.separator else entry
|
|
174
|
+
|
|
175
|
+
def _pickable(self, index: int, step: int) -> int | None:
|
|
176
|
+
"""The first line of a Listing from `index` on, going by `step`,
|
|
177
|
+
that isn't a separator; None past the end."""
|
|
178
|
+
entries = self.group.entries if isinstance(self.group, Listing) else []
|
|
179
|
+
while 0 <= index < len(entries) and entries[index].separator:
|
|
180
|
+
index += step
|
|
181
|
+
return index if 0 <= index < len(entries) else None
|
|
182
|
+
|
|
183
|
+
def count(self) -> int:
|
|
184
|
+
"""How many settings, or lines, the group shown has."""
|
|
185
|
+
group = self.group
|
|
186
|
+
return len(group.entries if isinstance(group, Listing) else group.settings)
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def view(self) -> str:
|
|
190
|
+
if isinstance(self.group, Listing):
|
|
191
|
+
return "list"
|
|
192
|
+
try:
|
|
193
|
+
view = self.prefs.get(("views", self.group.id))
|
|
194
|
+
except StoreError:
|
|
195
|
+
view = None
|
|
196
|
+
return view if view in ("tiles", "list") else "tiles"
|
|
197
|
+
|
|
198
|
+
# -- layout --
|
|
199
|
+
|
|
200
|
+
def compose(self) -> ComposeResult:
|
|
201
|
+
# Nothing may sit above the panel: any widget there, even a
|
|
202
|
+
# transparent one, would blank out the screen behind it.
|
|
203
|
+
with Vertical(id="bottom"):
|
|
204
|
+
yield Vertical(id="panel")
|
|
205
|
+
yield from self.compose_under()
|
|
206
|
+
yield Keyline(*TILES_KEYS, id="settings-keys", classes="-active")
|
|
207
|
+
|
|
208
|
+
def compose_under(self) -> ComposeResult:
|
|
209
|
+
"""Widgets under the settings, above their keys: none here, for a
|
|
210
|
+
subclass to add its own."""
|
|
211
|
+
yield from ()
|
|
212
|
+
|
|
213
|
+
async def on_mount(self) -> None:
|
|
214
|
+
await self.build_panel()
|
|
215
|
+
|
|
216
|
+
def on_click(self, event: events.Click) -> None:
|
|
217
|
+
# A click above the panel lands on the screen itself.
|
|
218
|
+
if event.widget is self:
|
|
219
|
+
self.dismiss()
|
|
220
|
+
|
|
221
|
+
def on_key(self, event: events.Key) -> None:
|
|
222
|
+
if event.key in self.close_keys:
|
|
223
|
+
event.stop()
|
|
224
|
+
self.dismiss()
|
|
225
|
+
|
|
226
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
227
|
+
# The keys for the tiles go to a widget with the focus, if any.
|
|
228
|
+
return not (action in TILE_ACTIONS and self.focused is not None)
|
|
229
|
+
|
|
230
|
+
async def build_panel(self) -> None:
|
|
231
|
+
panel = self.query_one("#panel")
|
|
232
|
+
await panel.remove_children()
|
|
233
|
+
tabs = Horizontal(*[Tab(i, g.name) for i, g in enumerate(self.groups)], id="tabs")
|
|
234
|
+
body = VerticalScroll(id="body", can_focus=False)
|
|
235
|
+
help_line = Static(id="help")
|
|
236
|
+
top = self.pref("tabs", "top") == "top"
|
|
237
|
+
# Tabs below the settings stay at the bottom, under the description,
|
|
238
|
+
# which sits right under the tiles.
|
|
239
|
+
tabs.set_class(not top, "-bottom")
|
|
240
|
+
await panel.mount_all([tabs, body, help_line] if top else [body, help_line, tabs])
|
|
241
|
+
self.size_panel()
|
|
242
|
+
await self.show_group()
|
|
243
|
+
|
|
244
|
+
def size_panel(self) -> None:
|
|
245
|
+
if self.full:
|
|
246
|
+
return # the panel takes the screen, but what's under it
|
|
247
|
+
fraction = PANEL_FRACTIONS.get(self.pref("panel_height", "1/3"), 1 / 3)
|
|
248
|
+
self.query_one("#panel").styles.height = max(9, round(self.size.height * fraction))
|
|
249
|
+
|
|
250
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
251
|
+
self.size_panel()
|
|
252
|
+
if self.view == "tiles":
|
|
253
|
+
self.set_columns()
|
|
254
|
+
|
|
255
|
+
def set_columns(self) -> None:
|
|
256
|
+
width = self.query_one("#body").size.width or self.size.width
|
|
257
|
+
self.cols = max(1, width // TILE_WIDTH)
|
|
258
|
+
for grid in self.query("#tiles"):
|
|
259
|
+
grid.styles.grid_size_columns = self.cols
|
|
260
|
+
self._fit_body()
|
|
261
|
+
|
|
262
|
+
def _fit_body(self) -> None:
|
|
263
|
+
"""The tiles, or the list, take the rows they need and no more, so the
|
|
264
|
+
description sits right under them; with less room they scroll."""
|
|
265
|
+
count = max(1, self.count())
|
|
266
|
+
rows = -(-count // self.cols) * 3 - 1 if self.view == "tiles" else count
|
|
267
|
+
body = self.query_one("#body")
|
|
268
|
+
body.styles.max_height = max(1, rows)
|
|
269
|
+
body.styles.min_height = max(1, min(2, rows))
|
|
270
|
+
|
|
271
|
+
async def show_group(self) -> None:
|
|
272
|
+
for tab in self.query(Tab):
|
|
273
|
+
tab.set_class(tab.index == self.group_index, "-current")
|
|
274
|
+
body = self.query_one("#body", VerticalScroll)
|
|
275
|
+
await body.remove_children()
|
|
276
|
+
listing = isinstance(self.group, Listing)
|
|
277
|
+
kind = "tile" if self.view == "tiles" else "row -entry" if listing else "row"
|
|
278
|
+
items = [Item(i, kind) for i in range(self.count())]
|
|
279
|
+
if listing and not items:
|
|
280
|
+
await body.mount(Static(self.group.empty, classes="row -empty"))
|
|
281
|
+
self._fit_body()
|
|
282
|
+
elif kind == "tile":
|
|
283
|
+
await body.mount(Container(*items, id="tiles"))
|
|
284
|
+
self.set_columns()
|
|
285
|
+
else:
|
|
286
|
+
await body.mount_all(items)
|
|
287
|
+
self._fit_body()
|
|
288
|
+
self.show_keys()
|
|
289
|
+
for item in items:
|
|
290
|
+
self.render_item(item)
|
|
291
|
+
if items:
|
|
292
|
+
self.select(self.selected[self.group.id])
|
|
293
|
+
else:
|
|
294
|
+
self.show_help()
|
|
295
|
+
|
|
296
|
+
def panel_keys(self) -> list[Key]:
|
|
297
|
+
"""The keys shown under the panel: the tiles' or the list's; a
|
|
298
|
+
subclass can add its own."""
|
|
299
|
+
if isinstance(self.group, Listing):
|
|
300
|
+
return listing_keys(self.group.verb)
|
|
301
|
+
return TILES_KEYS if self.view == "tiles" else LIST_KEYS
|
|
302
|
+
|
|
303
|
+
def show_keys(self) -> None:
|
|
304
|
+
"""Show `panel_keys()` under the panel, e.g. once what has the focus
|
|
305
|
+
has changed."""
|
|
306
|
+
self.query_one("#settings-keys", Keyline).set_keys(*self.panel_keys())
|
|
307
|
+
|
|
308
|
+
# -- items --
|
|
309
|
+
|
|
310
|
+
def render_item(self, item: Item) -> None:
|
|
311
|
+
if isinstance(self.group, Listing):
|
|
312
|
+
entry = self.group.entries[item.index]
|
|
313
|
+
if entry.separator:
|
|
314
|
+
# A Rule takes the width it's drawn at, so it follows a resize.
|
|
315
|
+
item.add_class("-separator")
|
|
316
|
+
item.update(Rule(style=""))
|
|
317
|
+
return
|
|
318
|
+
text = entry.text
|
|
319
|
+
item.update(text if isinstance(text, Text) else Text(str(text)))
|
|
320
|
+
return
|
|
321
|
+
group, setting = self.group, self.group.settings[item.index]
|
|
322
|
+
try:
|
|
323
|
+
value = group.value(setting)
|
|
324
|
+
except StoreError:
|
|
325
|
+
item.update(Text(setting.label, style="dim"))
|
|
326
|
+
return
|
|
327
|
+
shown = setting.show(value)
|
|
328
|
+
style = "bold" if value != setting.default else ""
|
|
329
|
+
if item.has_class("tile"):
|
|
330
|
+
item.update(Text.assemble((setting.label, "bold"), "\n", (shown, style)))
|
|
331
|
+
return
|
|
332
|
+
if item.index == self.selected[group.id]:
|
|
333
|
+
shown = f"◀ {shown} ▶"
|
|
334
|
+
row = Table.grid(expand=True)
|
|
335
|
+
row.add_column(ratio=1)
|
|
336
|
+
row.add_column(justify="right")
|
|
337
|
+
row.add_row(setting.label, Text(shown, style=style))
|
|
338
|
+
item.update(row)
|
|
339
|
+
|
|
340
|
+
def items(self) -> list[Item]:
|
|
341
|
+
return list(self.query(Item))
|
|
342
|
+
|
|
343
|
+
def select(self, index: int) -> None:
|
|
344
|
+
items = self.items()
|
|
345
|
+
if not items:
|
|
346
|
+
return
|
|
347
|
+
index = max(0, min(index, len(items) - 1))
|
|
348
|
+
if isinstance(self.group, Listing):
|
|
349
|
+
index = next((i for i in (self._pickable(index, 1), self._pickable(index, -1)) if i is not None), index)
|
|
350
|
+
previous = self.selected[self.group.id]
|
|
351
|
+
self.selected[self.group.id] = index
|
|
352
|
+
for item in items:
|
|
353
|
+
item.set_class(item.index == index, "-selected")
|
|
354
|
+
if items[0].has_class("row") and not items[0].has_class("-entry"):
|
|
355
|
+
for i in {previous, index}:
|
|
356
|
+
if i < len(items):
|
|
357
|
+
self.render_item(items[i])
|
|
358
|
+
self.query_one("#body", VerticalScroll).scroll_to_widget(items[index], animate=False)
|
|
359
|
+
self.show_help()
|
|
360
|
+
|
|
361
|
+
def show_help(self, note: str | None = None, error: bool = False) -> None:
|
|
362
|
+
"""The selected setting's help, then `note`, or where the group is
|
|
363
|
+
saved and the default; `error` shows the note as a problem. In a
|
|
364
|
+
Listing: the selected line's help, or what the lines are."""
|
|
365
|
+
if isinstance(self.group, Listing):
|
|
366
|
+
entry = self.entry
|
|
367
|
+
text = Text(entry.help if entry is not None and entry.help else "")
|
|
368
|
+
if not text:
|
|
369
|
+
text.append(self.group.where, style="dim")
|
|
370
|
+
if note:
|
|
371
|
+
text.append(f" {note}", style="bold red" if error else "dim")
|
|
372
|
+
self.query_one("#help", Static).update(text)
|
|
373
|
+
return
|
|
374
|
+
setting = self.setting
|
|
375
|
+
text = Text(f"{setting.help} " if setting.help else "")
|
|
376
|
+
if error:
|
|
377
|
+
text.append(note or "", style="bold red")
|
|
378
|
+
else:
|
|
379
|
+
text.append(note or f"{self.group.where} · default {setting.show(setting.default)}", style="dim")
|
|
380
|
+
self.query_one("#help", Static).update(text)
|
|
381
|
+
|
|
382
|
+
def item_clicked(self, index: int) -> None:
|
|
383
|
+
if isinstance(self.group, Listing) and self.group.entries[index].separator:
|
|
384
|
+
return
|
|
385
|
+
if index == self.selected[self.group.id]:
|
|
386
|
+
self.action_change(1)
|
|
387
|
+
else:
|
|
388
|
+
self.select(index)
|
|
389
|
+
|
|
390
|
+
# -- actions --
|
|
391
|
+
|
|
392
|
+
def action_close(self) -> None:
|
|
393
|
+
self.dismiss()
|
|
394
|
+
|
|
395
|
+
def action_move(self, direction: str) -> None:
|
|
396
|
+
index, count = self.selected[self.group.id], self.count()
|
|
397
|
+
if isinstance(self.group, Listing):
|
|
398
|
+
step = 1 if direction == "down" else -1
|
|
399
|
+
if direction in ("up", "down") and (to := self._pickable(index + step, step)) is not None:
|
|
400
|
+
self.select(to)
|
|
401
|
+
return
|
|
402
|
+
if self.view == "list":
|
|
403
|
+
if direction in ("left", "right"):
|
|
404
|
+
self.action_change(1 if direction == "right" else -1)
|
|
405
|
+
return
|
|
406
|
+
self.select(index + (1 if direction == "down" else -1))
|
|
407
|
+
return
|
|
408
|
+
cols = self.cols
|
|
409
|
+
if direction == "left" and index % cols:
|
|
410
|
+
index -= 1
|
|
411
|
+
elif direction == "right" and (index + 1) % cols and index + 1 < count:
|
|
412
|
+
index += 1
|
|
413
|
+
elif direction == "up" and index >= cols:
|
|
414
|
+
index -= cols
|
|
415
|
+
elif direction == "down" and index // cols < (count - 1) // cols:
|
|
416
|
+
index = min(index + cols, count - 1)
|
|
417
|
+
self.select(index)
|
|
418
|
+
|
|
419
|
+
def action_change(self, delta: int) -> None:
|
|
420
|
+
group = self.group
|
|
421
|
+
if isinstance(group, Listing):
|
|
422
|
+
if delta > 0 and (entry := self.entry) is not None and group.on_pick is not None:
|
|
423
|
+
group.on_pick(entry)
|
|
424
|
+
return
|
|
425
|
+
setting = group.settings[self.selected[group.id]]
|
|
426
|
+
try:
|
|
427
|
+
group.change(setting, delta)
|
|
428
|
+
except (StoreError, OSError) as e:
|
|
429
|
+
self.show_help(f"Not saved: {e}", error=True)
|
|
430
|
+
return
|
|
431
|
+
self.render_item(self.items()[self.selected[group.id]])
|
|
432
|
+
self.show_help(f"Saved. {group.where}")
|
|
433
|
+
if group.store is self.prefs and setting.key[0] in PANEL_KEYS:
|
|
434
|
+
if setting.key[0] == "tabs":
|
|
435
|
+
self.run_worker(self.build_panel(), exclusive=True)
|
|
436
|
+
else:
|
|
437
|
+
self.size_panel()
|
|
438
|
+
|
|
439
|
+
async def action_next_group(self, delta: int) -> None:
|
|
440
|
+
await self.action_select_group((self.group_index + delta) % len(self.groups))
|
|
441
|
+
|
|
442
|
+
async def action_select_group(self, index: int) -> None:
|
|
443
|
+
if 0 <= index < len(self.groups) and index != self.group_index:
|
|
444
|
+
self.group_index = index
|
|
445
|
+
await self.show_group()
|
|
446
|
+
|
|
447
|
+
def select_group(self, index: int) -> None:
|
|
448
|
+
self.run_worker(self.action_select_group(index), exclusive=True)
|
|
449
|
+
|
|
450
|
+
async def action_toggle_view(self) -> None:
|
|
451
|
+
if isinstance(self.group, Listing):
|
|
452
|
+
return # always a list
|
|
453
|
+
new = "list" if self.view == "tiles" else "tiles"
|
|
454
|
+
try:
|
|
455
|
+
self.prefs.set(("views", self.group.id), new, "tiles")
|
|
456
|
+
except (StoreError, OSError) as e:
|
|
457
|
+
self.show_help(f"Not saved: {e}", error=True)
|
|
458
|
+
return
|
|
459
|
+
await self.show_group()
|
|
460
|
+
|
|
461
|
+
def show_entries(self, listing: str, entries: list[Entry]) -> None:
|
|
462
|
+
"""Give the Listing with the id `listing` new lines, and show them if
|
|
463
|
+
it's shown, the same line selected (by its `id`) if it's still
|
|
464
|
+
there."""
|
|
465
|
+
group = next((g for g in self.groups if isinstance(g, Listing) and g.id == listing), None)
|
|
466
|
+
if group is None:
|
|
467
|
+
return
|
|
468
|
+
old = self.entry if self.group is group else None
|
|
469
|
+
group.entries = entries
|
|
470
|
+
at = next((i for i, e in enumerate(entries) if old is not None and old.id and e.id == old.id), None)
|
|
471
|
+
self.selected[listing] = at if at is not None else min(self.selected[listing], max(0, len(entries) - 1))
|
|
472
|
+
if self.group is group:
|
|
473
|
+
self.run_worker(self.show_group(), exclusive=True)
|