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/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}")