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.
@@ -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)