jumble-cli 0.1.0__py3-none-any.whl
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.
- jumble/__init__.py +6 -0
- jumble/__main__.py +3 -0
- jumble/cli.py +271 -0
- jumble/config.py +182 -0
- jumble/generate.py +287 -0
- jumble/tui.py +481 -0
- jumble_cli-0.1.0.dist-info/METADATA +282 -0
- jumble_cli-0.1.0.dist-info/RECORD +11 -0
- jumble_cli-0.1.0.dist-info/WHEEL +4 -0
- jumble_cli-0.1.0.dist-info/entry_points.txt +2 -0
- jumble_cli-0.1.0.dist-info/licenses/LICENSE +22 -0
jumble/tui.py
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""The interactive generator (docs/spec.md §7): controls beside results, live validation."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import ClassVar
|
|
8
|
+
|
|
9
|
+
from textual.app import App, ComposeResult
|
|
10
|
+
from textual.binding import Binding, BindingType
|
|
11
|
+
from textual.containers import Grid, Horizontal, Vertical, VerticalScroll
|
|
12
|
+
from textual.dom import DOMNode
|
|
13
|
+
from textual.screen import ModalScreen
|
|
14
|
+
from textual.widget import Widget
|
|
15
|
+
from textual.widgets import (
|
|
16
|
+
Button,
|
|
17
|
+
Checkbox,
|
|
18
|
+
Footer,
|
|
19
|
+
Header,
|
|
20
|
+
Input,
|
|
21
|
+
OptionList,
|
|
22
|
+
SelectionList,
|
|
23
|
+
Static,
|
|
24
|
+
)
|
|
25
|
+
from textual.widgets.option_list import Option
|
|
26
|
+
|
|
27
|
+
from jumble.cli import installed_version
|
|
28
|
+
from jumble.config import config_path, file_settings, load, save
|
|
29
|
+
from jumble.generate import CHARSETS, JumbleError, Settings, generate, strength, strength_line
|
|
30
|
+
|
|
31
|
+
DEFAULTS = Settings()
|
|
32
|
+
|
|
33
|
+
# The results pane lists at most this many strings (§7.1, DEC-11).
|
|
34
|
+
SHOWN = 1000
|
|
35
|
+
|
|
36
|
+
# The four options' checkboxes: widget ID, label (§7.1), and setting name.
|
|
37
|
+
OPTIONS = (
|
|
38
|
+
("no-ambiguous", "No look-alikes", "no_ambiguous"),
|
|
39
|
+
("each", "One from each set", "each"),
|
|
40
|
+
("no-repeat", "No repeats", "no_repeat"),
|
|
41
|
+
("unique", "All unique", "unique"),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_int(value: str) -> int:
|
|
46
|
+
"""An integer input's value; one that doesn't parse (empty, `-`) counts as 0 (§7.1)."""
|
|
47
|
+
try:
|
|
48
|
+
return int(value)
|
|
49
|
+
except ValueError:
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def titled(widget: Widget, title: str) -> Widget:
|
|
54
|
+
"""`widget`, labelled in its border."""
|
|
55
|
+
widget.border_title = title
|
|
56
|
+
return widget
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def characters(name: str) -> str:
|
|
60
|
+
"""Set `name`'s characters for display (§7.6): every one, in §2.1's order,
|
|
61
|
+
nothing between them; `space`, which can't be seen, as a phrase.
|
|
62
|
+
|
|
63
|
+
Nothing filters it, `no_ambiguous` included: it shows the set, not the
|
|
64
|
+
alphabet (DEC-22). Both the set preview and Help use it (§7.6).
|
|
65
|
+
"""
|
|
66
|
+
if name == "space":
|
|
67
|
+
return "(the space character)"
|
|
68
|
+
return CHARSETS[name]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def controls(start: Settings) -> list[Widget]:
|
|
72
|
+
"""The controls of §7.1, holding `start` as given: the main screen's pane and
|
|
73
|
+
the settings screen's form are both built here, so they match (§7.4).
|
|
74
|
+
|
|
75
|
+
Under the Sets list, the preview shows the set under its cursor, which
|
|
76
|
+
starts on the first set (§7.6).
|
|
77
|
+
"""
|
|
78
|
+
return [
|
|
79
|
+
Horizontal(
|
|
80
|
+
titled(Input(str(start.length), id="length", type="integer"), "Length"),
|
|
81
|
+
titled(Input(str(start.count), id="count", type="integer"), "Count"),
|
|
82
|
+
id="numbers",
|
|
83
|
+
),
|
|
84
|
+
titled(
|
|
85
|
+
SelectionList[str](
|
|
86
|
+
*((name, name, name in start.charsets) for name in CHARSETS), id="charsets"
|
|
87
|
+
),
|
|
88
|
+
"Sets",
|
|
89
|
+
),
|
|
90
|
+
# Sets can hold `[` and `\`, so the preview is text, not markup.
|
|
91
|
+
Static(characters(next(iter(CHARSETS))), id="preview", markup=False),
|
|
92
|
+
titled(Input(start.custom, id="custom"), "Custom"),
|
|
93
|
+
Grid(
|
|
94
|
+
*(Checkbox(label, getattr(start, setting), id=wid) for wid, label, setting in OPTIONS),
|
|
95
|
+
id="options",
|
|
96
|
+
),
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def read_controls(node: DOMNode) -> Settings:
|
|
101
|
+
"""The controls under `node` as one `Settings`, unvalidated and with no default
|
|
102
|
+
fill (§7.1); an integer input that doesn't parse counts as 0."""
|
|
103
|
+
selected = set(node.query_one("#charsets", SelectionList).selected)
|
|
104
|
+
options = {setting: node.query_one(f"#{wid}", Checkbox).value for wid, _, setting in OPTIONS}
|
|
105
|
+
return Settings(
|
|
106
|
+
length=parse_int(node.query_one("#length", Input).value),
|
|
107
|
+
count=parse_int(node.query_one("#count", Input).value),
|
|
108
|
+
charsets=tuple(name for name in CHARSETS if name in selected),
|
|
109
|
+
custom=node.query_one("#custom", Input).value,
|
|
110
|
+
**options,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def set_controls(node: DOMNode, settings: Settings) -> None:
|
|
115
|
+
"""Set the controls under `node` to `settings`, as `controls` builds them."""
|
|
116
|
+
node.query_one("#length", Input).value = str(settings.length)
|
|
117
|
+
node.query_one("#count", Input).value = str(settings.count)
|
|
118
|
+
sets = node.query_one("#charsets", SelectionList)
|
|
119
|
+
sets.deselect_all()
|
|
120
|
+
for name in settings.charsets:
|
|
121
|
+
sets.select(name)
|
|
122
|
+
node.query_one("#custom", Input).value = settings.custom
|
|
123
|
+
for wid, _, setting in OPTIONS:
|
|
124
|
+
node.query_one(f"#{wid}", Checkbox).value = getattr(settings, setting)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def show_highlighted(node: DOMNode, event: SelectionList.SelectionHighlighted) -> None:
|
|
128
|
+
"""Show the set under the cursor of the Sets list under `node` in its preview (§7.6)."""
|
|
129
|
+
node.query_one("#preview", Static).update(characters(event.selection.value))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class SettingsScreen(ModalScreen[tuple[Settings, Path] | None]):
|
|
133
|
+
"""The settings file as a form: `,` opens it (§7.4, DEC-20).
|
|
134
|
+
|
|
135
|
+
It holds the main screen's controls, filled with `start`, and the file's
|
|
136
|
+
`path` as it resolved on opening. Save validates the form as the banner
|
|
137
|
+
does and writes the file, closing with the settings and the path it
|
|
138
|
+
saved to; Cancel and `escape` close with None. The form's messages stop
|
|
139
|
+
here, so nothing in it reaches the main screen's handlers.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
# The form's only key: the main screen's don't act while it is open (DEC-20).
|
|
143
|
+
BINDINGS: ClassVar[list[BindingType]] = [Binding("escape", "cancel", show=False)]
|
|
144
|
+
# The first control. Left unset, the screen takes the App's "#results",
|
|
145
|
+
# which it lacks, and nothing has focus.
|
|
146
|
+
AUTO_FOCUS = "#length"
|
|
147
|
+
|
|
148
|
+
def __init__(self, start: Settings, path: Path) -> None:
|
|
149
|
+
super().__init__()
|
|
150
|
+
self.start = start
|
|
151
|
+
self.path = path
|
|
152
|
+
|
|
153
|
+
def compose(self) -> ComposeResult:
|
|
154
|
+
# The path, wrapped whole, and the buttons always show; the form takes
|
|
155
|
+
# the rows left between them and scrolls when they are too few (§7.4,
|
|
156
|
+
# DEC-23).
|
|
157
|
+
with Vertical(id="dialog") as dialog:
|
|
158
|
+
dialog.border_title = "Settings"
|
|
159
|
+
# A path can hold `[`, so it is shown as text, not markup.
|
|
160
|
+
yield Static(str(self.path), id="path", markup=False)
|
|
161
|
+
with VerticalScroll(id="form"):
|
|
162
|
+
yield from controls(self.start)
|
|
163
|
+
with Horizontal(id="buttons"):
|
|
164
|
+
yield Button("Save", id="save", variant="primary")
|
|
165
|
+
yield Button("Cancel", id="cancel")
|
|
166
|
+
|
|
167
|
+
# The App's handlers would check the main screen's controls, move its
|
|
168
|
+
# preview, and `enter` would jumble there (§7.2, §7.6): the form's
|
|
169
|
+
# messages go no further than here.
|
|
170
|
+
|
|
171
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
172
|
+
event.stop()
|
|
173
|
+
|
|
174
|
+
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
|
|
175
|
+
event.stop()
|
|
176
|
+
|
|
177
|
+
def on_selection_list_selected_changed(self, event: SelectionList.SelectedChanged) -> None:
|
|
178
|
+
event.stop()
|
|
179
|
+
|
|
180
|
+
def on_selection_list_selection_highlighted(
|
|
181
|
+
self, event: SelectionList.SelectionHighlighted
|
|
182
|
+
) -> None:
|
|
183
|
+
# The form's preview follows the form's list, and never moves the main
|
|
184
|
+
# screen's (§7.6).
|
|
185
|
+
event.stop()
|
|
186
|
+
show_highlighted(self, event)
|
|
187
|
+
|
|
188
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
189
|
+
# `enter` in a form input saves, as the Save button does (§7.4).
|
|
190
|
+
event.stop()
|
|
191
|
+
self.action_save()
|
|
192
|
+
|
|
193
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
194
|
+
event.stop()
|
|
195
|
+
if event.button.id == "save":
|
|
196
|
+
self.action_save()
|
|
197
|
+
else:
|
|
198
|
+
self.action_cancel()
|
|
199
|
+
|
|
200
|
+
def action_save(self) -> None:
|
|
201
|
+
"""Validate the form, write the file, and close with what was saved (§7.4)."""
|
|
202
|
+
settings = read_controls(self)
|
|
203
|
+
try:
|
|
204
|
+
# The banner's check, so the form and the main screen agree (§7.1, DEC-20).
|
|
205
|
+
strength(settings)
|
|
206
|
+
except JumbleError as e:
|
|
207
|
+
self.app.notify(str(e), severity="error")
|
|
208
|
+
return
|
|
209
|
+
# Resolved now, not on opening (§7.4).
|
|
210
|
+
path = config_path()
|
|
211
|
+
try:
|
|
212
|
+
save(path, settings)
|
|
213
|
+
except JumbleError as e:
|
|
214
|
+
# `save`'s own failure: the screen stays open, the main screen as it was.
|
|
215
|
+
self.app.notify(str(e), severity="error")
|
|
216
|
+
return
|
|
217
|
+
self.dismiss((settings, path))
|
|
218
|
+
|
|
219
|
+
def action_cancel(self) -> None:
|
|
220
|
+
self.dismiss(None)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class HelpScreen(ModalScreen[None]):
|
|
224
|
+
"""Every named set and the control notes, without changing the session (§7.6)."""
|
|
225
|
+
|
|
226
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
227
|
+
Binding("escape,question_mark", "close", show=False),
|
|
228
|
+
]
|
|
229
|
+
# Override the App's results selector: this scroll container must receive
|
|
230
|
+
# the arrow/page keys. ModalScreen isolates the main bindings (DEC-20).
|
|
231
|
+
AUTO_FOCUS = "#help-content"
|
|
232
|
+
|
|
233
|
+
def compose(self) -> ComposeResult:
|
|
234
|
+
with VerticalScroll(id="help-content") as dialog:
|
|
235
|
+
dialog.border_title = "Help"
|
|
236
|
+
for name, chars in CHARSETS.items():
|
|
237
|
+
unit = "character" if len(chars) == 1 else "characters"
|
|
238
|
+
yield Static(f"{name} · {len(chars)} {unit}", classes="set-heading")
|
|
239
|
+
yield Static(characters(name), classes="set-body", markup=False)
|
|
240
|
+
# Exact control notes and look-alikes from §7.6 and §3.1.
|
|
241
|
+
for note in (
|
|
242
|
+
"Length: characters per string, at least 1",
|
|
243
|
+
"Count: how many strings, at least 1",
|
|
244
|
+
"Custom: extra characters, drawn as one more set",
|
|
245
|
+
"No look-alikes: leave out 0 O o l 1 I | ` ' \" ; : . , -",
|
|
246
|
+
"One from each set: at least one character from every chosen set",
|
|
247
|
+
"No repeats: no character twice within a string",
|
|
248
|
+
"All unique: no string twice in the output",
|
|
249
|
+
):
|
|
250
|
+
yield Static(note, classes="control-note", markup=False)
|
|
251
|
+
|
|
252
|
+
def action_close(self) -> None:
|
|
253
|
+
self.dismiss(None)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class JumbleApp(App[None]):
|
|
257
|
+
# The results pane holds focus so letter keys reach the bindings (DEC-12).
|
|
258
|
+
AUTO_FOCUS = "#results"
|
|
259
|
+
CSS = """
|
|
260
|
+
#error { display: none; background: $error; color: $text; padding: 0 1; }
|
|
261
|
+
#error.shown { display: block; }
|
|
262
|
+
#controls { width: 54; border-right: solid $panel; }
|
|
263
|
+
SettingsScreen { align: center middle; }
|
|
264
|
+
HelpScreen { align: center middle; }
|
|
265
|
+
#help-content { width: 58; height: 100%; border: thick $primary; padding: 0 1; }
|
|
266
|
+
.set-heading { text-style: bold; }
|
|
267
|
+
.set-body { margin-bottom: 1; }
|
|
268
|
+
#dialog { width: 58; height: auto; max-height: 100%; border: thick $primary; padding: 0 1; }
|
|
269
|
+
#form { height: 1fr; }
|
|
270
|
+
#path { color: $text-muted; }
|
|
271
|
+
#buttons { height: auto; align-horizontal: right; }
|
|
272
|
+
#buttons Button { margin-left: 1; }
|
|
273
|
+
#numbers { height: auto; }
|
|
274
|
+
#numbers Input { width: 1fr; }
|
|
275
|
+
#charsets { height: 12; }
|
|
276
|
+
#preview { height: 4; }
|
|
277
|
+
#options { grid-size: 2; height: auto; }
|
|
278
|
+
#options Checkbox { width: 1fr; }
|
|
279
|
+
#results { width: 1fr; height: 1fr; border: none; }
|
|
280
|
+
"""
|
|
281
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
282
|
+
("g", "jumble", "Jumble"),
|
|
283
|
+
("c", "copy", "Copy"),
|
|
284
|
+
("a", "copy_all", "Copy all"),
|
|
285
|
+
("s", "save", "Save"),
|
|
286
|
+
("d", "save_defaults", "Save defaults"),
|
|
287
|
+
("comma", "settings", "Settings"),
|
|
288
|
+
Binding("question_mark", "help", "Help", key_display="?"),
|
|
289
|
+
("q", "quit", "Quit"),
|
|
290
|
+
Binding("escape", "focus_results", show=False),
|
|
291
|
+
]
|
|
292
|
+
|
|
293
|
+
def __init__(self, settings: Settings = DEFAULTS, file_error: str | None = None) -> None:
|
|
294
|
+
"""`settings` are the controls' starting values, as given: the caller fills
|
|
295
|
+
the default sets (§5.2, §7.5, DEC-9). `file_error` is a settings-file
|
|
296
|
+
message to show in an error toast at start (§7.4, DEC-17)."""
|
|
297
|
+
super().__init__()
|
|
298
|
+
self.initial = settings
|
|
299
|
+
self.file_error = file_error
|
|
300
|
+
self.title = f"jumble {installed_version()}"
|
|
301
|
+
self.strings: list[str] = []
|
|
302
|
+
self.valid = False
|
|
303
|
+
# The banner's §6 message while the controls are invalid (§7.1).
|
|
304
|
+
self.problem = ""
|
|
305
|
+
|
|
306
|
+
def compose(self) -> ComposeResult:
|
|
307
|
+
# The starting values go in here, not in `on_mount`, so the first
|
|
308
|
+
# `check` sees them (§7.5).
|
|
309
|
+
start = self.initial
|
|
310
|
+
yield Header()
|
|
311
|
+
yield Static(id="error")
|
|
312
|
+
with Horizontal():
|
|
313
|
+
with VerticalScroll(id="controls"):
|
|
314
|
+
yield from controls(start)
|
|
315
|
+
# Strings can hold `[`, so they are shown as text, not markup.
|
|
316
|
+
yield OptionList(id="results", markup=False)
|
|
317
|
+
yield Footer()
|
|
318
|
+
|
|
319
|
+
def on_mount(self) -> None:
|
|
320
|
+
# An invalid start shows the banner and jumbles nothing (§7.2, §7.5).
|
|
321
|
+
self.check()
|
|
322
|
+
self.action_jumble()
|
|
323
|
+
if self.file_error:
|
|
324
|
+
self.notify(self.file_error, severity="error")
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def main(self) -> DOMNode:
|
|
328
|
+
"""The main screen, under the settings screen while that is open."""
|
|
329
|
+
return self.screen_stack[0]
|
|
330
|
+
|
|
331
|
+
def settings(self) -> Settings:
|
|
332
|
+
"""The main screen's controls as one `Settings`, unvalidated, no default fill (§7.1)."""
|
|
333
|
+
return read_controls(self.main)
|
|
334
|
+
|
|
335
|
+
def check(self) -> None:
|
|
336
|
+
"""Validate the controls: set the subtitle, or show the §6 message (§7.1)."""
|
|
337
|
+
error = self.query_one("#error", Static)
|
|
338
|
+
try:
|
|
339
|
+
line = strength_line(strength(self.settings()))
|
|
340
|
+
except JumbleError as e:
|
|
341
|
+
self.valid = False
|
|
342
|
+
self.problem = str(e)
|
|
343
|
+
self.sub_title = ""
|
|
344
|
+
error.update(self.problem)
|
|
345
|
+
error.add_class("shown")
|
|
346
|
+
return
|
|
347
|
+
self.valid = True
|
|
348
|
+
self.problem = ""
|
|
349
|
+
self.sub_title = line
|
|
350
|
+
error.remove_class("shown")
|
|
351
|
+
|
|
352
|
+
def on_input_changed(self) -> None:
|
|
353
|
+
self.check()
|
|
354
|
+
|
|
355
|
+
def on_checkbox_changed(self) -> None:
|
|
356
|
+
self.check()
|
|
357
|
+
|
|
358
|
+
def on_selection_list_selected_changed(self) -> None:
|
|
359
|
+
self.check()
|
|
360
|
+
|
|
361
|
+
def on_selection_list_selection_highlighted(
|
|
362
|
+
self, event: SelectionList.SelectionHighlighted
|
|
363
|
+
) -> None:
|
|
364
|
+
# The settings screen stops its form's, so this is the main screen's list.
|
|
365
|
+
show_highlighted(self.main, event)
|
|
366
|
+
|
|
367
|
+
def on_input_submitted(self) -> None:
|
|
368
|
+
# `enter` in an input jumbles, as `g` would, and leaves the input (§7.2, DEC-12).
|
|
369
|
+
self.action_jumble()
|
|
370
|
+
self.action_focus_results()
|
|
371
|
+
|
|
372
|
+
def action_focus_results(self) -> None:
|
|
373
|
+
self.query_one("#results", OptionList).focus()
|
|
374
|
+
|
|
375
|
+
def action_jumble(self) -> None:
|
|
376
|
+
"""Generate with the controls and replace the results; nothing while invalid."""
|
|
377
|
+
if not self.valid:
|
|
378
|
+
return
|
|
379
|
+
try:
|
|
380
|
+
strings = generate(self.settings())
|
|
381
|
+
except JumbleError as e:
|
|
382
|
+
# Only `unique`'s give-up gets past `check`: a toast, and the last
|
|
383
|
+
# result, rows, and cursor stay; the banner is for the controls (§7.1, DEC-15).
|
|
384
|
+
self.notify(str(e), severity="error")
|
|
385
|
+
return
|
|
386
|
+
self.strings = strings
|
|
387
|
+
rows: list[str | Option] = list(self.strings[:SHOWN])
|
|
388
|
+
if len(self.strings) > SHOWN:
|
|
389
|
+
# Disabled, so the cursor skips it (§7.1, DEC-11).
|
|
390
|
+
more = len(self.strings) - SHOWN
|
|
391
|
+
rows.append(Option(f"… and {more} more (s saves all)", disabled=True))
|
|
392
|
+
pane = self.query_one("#results", OptionList)
|
|
393
|
+
pane.set_options(rows)
|
|
394
|
+
pane.highlighted = 0
|
|
395
|
+
|
|
396
|
+
# Copying and saving use the last jumble's strings, valid controls or
|
|
397
|
+
# not; before any jumble there is nothing to copy or save (§7.1, §7.2).
|
|
398
|
+
|
|
399
|
+
def action_copy(self) -> None:
|
|
400
|
+
index = self.query_one("#results", OptionList).highlighted
|
|
401
|
+
if not self.strings or index is None:
|
|
402
|
+
return
|
|
403
|
+
self.copy_to_clipboard(self.strings[index])
|
|
404
|
+
self.notify("Copied")
|
|
405
|
+
|
|
406
|
+
def action_copy_all(self) -> None:
|
|
407
|
+
"""Copy every string, those past the shown 1000 too (DEC-11)."""
|
|
408
|
+
if not self.strings:
|
|
409
|
+
return
|
|
410
|
+
self.copy_to_clipboard("\n".join(self.strings))
|
|
411
|
+
n = len(self.strings)
|
|
412
|
+
self.notify(f"Copied {n} string" if n == 1 else f"Copied {n} strings")
|
|
413
|
+
|
|
414
|
+
def action_save(self) -> None:
|
|
415
|
+
"""Write every string to a new `jumble-<epoch>-<count>.txt` here (§7.3, DEC-13)."""
|
|
416
|
+
if not self.strings:
|
|
417
|
+
return
|
|
418
|
+
name = f"jumble-{int(time.time())}-{len(self.strings)}.txt"
|
|
419
|
+
# The same bytes as the command line's lines output (§4.1).
|
|
420
|
+
data = "".join(f"{s}\n" for s in self.strings).encode()
|
|
421
|
+
try:
|
|
422
|
+
# One exclusive create, so an existing file is never replaced (§7.3).
|
|
423
|
+
f = open(name, "xb") # noqa: SIM115
|
|
424
|
+
except FileExistsError:
|
|
425
|
+
self.notify(f"{name} exists; nothing saved", severity="error")
|
|
426
|
+
return
|
|
427
|
+
except OSError as e:
|
|
428
|
+
self.notify(f"can't save {name}: {e.strerror}", severity="error")
|
|
429
|
+
return
|
|
430
|
+
try:
|
|
431
|
+
with f:
|
|
432
|
+
f.write(data)
|
|
433
|
+
except OSError as e:
|
|
434
|
+
# The exclusive create made this file, so removing it removes only
|
|
435
|
+
# the partial save (§7.3).
|
|
436
|
+
with contextlib.suppress(OSError):
|
|
437
|
+
os.remove(name)
|
|
438
|
+
self.notify(f"can't save {name}: {e.strerror}", severity="error")
|
|
439
|
+
return
|
|
440
|
+
self.notify(f"Saved {name}")
|
|
441
|
+
|
|
442
|
+
def action_save_defaults(self) -> None:
|
|
443
|
+
"""Save the controls as the settings file's defaults (§7.4, DEC-18)."""
|
|
444
|
+
if not self.valid:
|
|
445
|
+
# Never a file the command line would refuse (§7.4).
|
|
446
|
+
self.notify(self.problem, severity="error")
|
|
447
|
+
return
|
|
448
|
+
# Resolved now, so a TUI started with --no-config saves too (§7.4).
|
|
449
|
+
path = config_path()
|
|
450
|
+
try:
|
|
451
|
+
save(path, self.settings())
|
|
452
|
+
except JumbleError as e:
|
|
453
|
+
self.notify(str(e), severity="error")
|
|
454
|
+
return
|
|
455
|
+
self.notify(f"Saved defaults to {path}")
|
|
456
|
+
|
|
457
|
+
def action_settings(self) -> None:
|
|
458
|
+
"""Open the settings screen on the settings file (§7.4, DEC-20)."""
|
|
459
|
+
path = config_path()
|
|
460
|
+
try:
|
|
461
|
+
start = file_settings(load(path))
|
|
462
|
+
except JumbleError as e:
|
|
463
|
+
# The built-in defaults, so Save repairs the file (DEC-17).
|
|
464
|
+
start = DEFAULTS
|
|
465
|
+
self.notify(str(e), severity="error")
|
|
466
|
+
self.push_screen(SettingsScreen(start, path), self.saved)
|
|
467
|
+
|
|
468
|
+
def action_help(self) -> None:
|
|
469
|
+
"""Open Help even with invalid controls (§7.6, DEC-22)."""
|
|
470
|
+
self.push_screen(HelpScreen())
|
|
471
|
+
|
|
472
|
+
def saved(self, result: tuple[Settings, Path] | None) -> None:
|
|
473
|
+
"""After the settings screen: set the controls to what it saved (§7.4)."""
|
|
474
|
+
if result is None:
|
|
475
|
+
return
|
|
476
|
+
settings, path = result
|
|
477
|
+
set_controls(self.main, settings)
|
|
478
|
+
# Setting a control posts its change for later: check now, so the
|
|
479
|
+
# banner and the subtitle follow before the toast. No jumble (§7.1).
|
|
480
|
+
self.check()
|
|
481
|
+
self.notify(f"Saved defaults to {path}")
|