skit-cli 0.0.1__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.
- skit/__init__.py +3 -0
- skit/analyzer.py +410 -0
- skit/argspec.py +528 -0
- skit/argstate.py +166 -0
- skit/atomic.py +101 -0
- skit/cli.py +1621 -0
- skit/config.py +255 -0
- skit/editor.py +77 -0
- skit/flows.py +596 -0
- skit/i18n.py +324 -0
- skit/inlineform.py +61 -0
- skit/launcher.py +326 -0
- skit/locales/zh_CN/LC_MESSAGES/skit.mo +0 -0
- skit/locales/zh_TW/LC_MESSAGES/skit.mo +0 -0
- skit/metawriter.py +267 -0
- skit/models.py +154 -0
- skit/paths.py +44 -0
- skit/pep723.py +292 -0
- skit/promptform.py +67 -0
- skit/reconcile.py +321 -0
- skit/shim.py +386 -0
- skit/store.py +566 -0
- skit/theme.py +123 -0
- skit/tokens.py +112 -0
- skit/tui.py +774 -0
- skit/tui_add.py +361 -0
- skit/tui_footer.py +38 -0
- skit/tui_form.py +586 -0
- skit/tui_health.py +150 -0
- skit/tui_prefs.py +175 -0
- skit/tui_settings.py +374 -0
- skit/uvman.py +288 -0
- skit_cli-0.0.1.dist-info/METADATA +199 -0
- skit_cli-0.0.1.dist-info/RECORD +37 -0
- skit_cli-0.0.1.dist-info/WHEEL +4 -0
- skit_cli-0.0.1.dist-info/entry_points.txt +3 -0
- skit_cli-0.0.1.dist-info/licenses/LICENSE +21 -0
skit/tui_form.py
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
"""The run form as Textual widgets: one FormPlan → one pokeable screen.
|
|
2
|
+
|
|
3
|
+
Shared by the TUI workbench (full screen) and the CLI's inline mini-form (M6): same
|
|
4
|
+
widgets, same validation, same keys — one flow, two frames. All logic stays in flows;
|
|
5
|
+
this module renders and collects.
|
|
6
|
+
|
|
7
|
+
Field widgets by kind:
|
|
8
|
+
- bool → Checkbox
|
|
9
|
+
- choice → horizontal RadioSet (←/→ or click)
|
|
10
|
+
- secret → password Input (+ "reads $NAME" note when an env source is set)
|
|
11
|
+
- anything else → Input, with live token-expansion preview and glob match count
|
|
12
|
+
- degraded → Input + "leave empty for the script's own default" hint
|
|
13
|
+
|
|
14
|
+
Type hints render in muted text and only turn loud on a validation error.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TYPE_CHECKING, override
|
|
21
|
+
|
|
22
|
+
from rich.markup import escape
|
|
23
|
+
from textual import on
|
|
24
|
+
from textual.app import ComposeResult
|
|
25
|
+
from textual.binding import Binding
|
|
26
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
27
|
+
from textual.screen import ModalScreen, Screen
|
|
28
|
+
from textual.widgets import Checkbox, Input, Label, OptionList, RadioButton, RadioSet, Static
|
|
29
|
+
from textual.widgets.option_list import Option
|
|
30
|
+
|
|
31
|
+
from . import argstate, flows, tokens, tui_footer
|
|
32
|
+
from .i18n import gettext
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from .models import Entry
|
|
36
|
+
|
|
37
|
+
# The submit result: (raw field values, extra passthrough args) or None on cancel.
|
|
38
|
+
FormResult = tuple[dict[str, str], list[str]] | None
|
|
39
|
+
|
|
40
|
+
_EXTRA_KEY = "__extra_args__"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _type_label(kind: str) -> str:
|
|
44
|
+
"""The dim type hint next to a field label, translated at render time. The msgids MUST be
|
|
45
|
+
gettext() string literals here, not a dict of labels fed to gettext(kind): a dict lookup is
|
|
46
|
+
invisible to Babel's extractor, so the strings never reach the catalog and silently fall
|
|
47
|
+
back to English in every locale. (That is the exact bug scripts/i18n_coverage.py's
|
|
48
|
+
dynamic-gettext check now guards against.)"""
|
|
49
|
+
return {
|
|
50
|
+
"int": gettext("whole number"),
|
|
51
|
+
"float": gettext("number"),
|
|
52
|
+
"str": gettext("text"),
|
|
53
|
+
"bool": gettext("on/off"),
|
|
54
|
+
}.get(kind, kind)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _degraded_notice(reason: str) -> str:
|
|
58
|
+
"""The honest line for a whole-parser degrade (the form still offers the
|
|
59
|
+
extra-arguments escape field)."""
|
|
60
|
+
if reason == "subparsers":
|
|
61
|
+
return gettext(
|
|
62
|
+
"This script has subcommands skit can't model — type everything into the "
|
|
63
|
+
"extra-arguments field."
|
|
64
|
+
)
|
|
65
|
+
return gettext(
|
|
66
|
+
"skit couldn't read this script's argument declarations — type everything into "
|
|
67
|
+
"the extra-arguments field."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class FieldRow(Vertical):
|
|
72
|
+
"""One form field: label row, control, help/feedback lines."""
|
|
73
|
+
|
|
74
|
+
DEFAULT_CSS = """
|
|
75
|
+
FieldRow { height: auto; margin: 0 0 1 0; max-width: 100; }
|
|
76
|
+
FieldRow .field-label { color: $foreground; }
|
|
77
|
+
FieldRow .field-help { color: $text-muted; }
|
|
78
|
+
FieldRow .field-error { color: $error; }
|
|
79
|
+
FieldRow .field-preview { color: $text-muted; }
|
|
80
|
+
/* Feedback lines materialize only when they have something to say. As permanent
|
|
81
|
+
empty Statics they cost 2 blank rows per field, which is what stretched the form
|
|
82
|
+
into a sparse, oddly-gapped page. */
|
|
83
|
+
FieldRow .field-preview { display: none; }
|
|
84
|
+
FieldRow .field-error { display: none; }
|
|
85
|
+
FieldRow Input { width: 1fr; }
|
|
86
|
+
FieldRow RadioSet { layout: horizontal; height: auto; border: none; padding: 0; }
|
|
87
|
+
FieldRow RadioSet:focus { border: none; }
|
|
88
|
+
/* Textual gives each RadioButton width:1fr, which scatters two choices across the
|
|
89
|
+
whole terminal row; options belong side by side, reading left to right. */
|
|
90
|
+
FieldRow RadioSet > RadioButton { width: auto; margin: 0 3 0 0; }
|
|
91
|
+
/* A bare Checkbox wears a tall border around a lone glyph — a cryptic floating
|
|
92
|
+
box. Borderless, with an on/off word as its label, it reads as the toggle it is. */
|
|
93
|
+
FieldRow Checkbox { border: none; padding: 0; }
|
|
94
|
+
FieldRow Checkbox:focus { border: none; }
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self, field: flows.FormField, prefill: str) -> None:
|
|
98
|
+
# Field keys are identifiers (argparse dests, [tool.skit] names, placeholders),
|
|
99
|
+
# so they are valid Textual ids; the ▾ link targets the row through this id.
|
|
100
|
+
super().__init__(id=f"fr-{field.key}")
|
|
101
|
+
self.field: flows.FormField = field
|
|
102
|
+
self._prefill: str = prefill
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def insertable(self) -> bool:
|
|
106
|
+
"""Whether the ▾ token menu applies: free-text-ish and not a secret (secret
|
|
107
|
+
values skip token expansion by design)."""
|
|
108
|
+
return not self.field.secret and self.field.kind not in ("bool", "choice")
|
|
109
|
+
|
|
110
|
+
@override
|
|
111
|
+
def compose(self) -> ComposeResult:
|
|
112
|
+
f = self.field
|
|
113
|
+
pieces = [escape(f.label)]
|
|
114
|
+
if f.required:
|
|
115
|
+
pieces.append(f"[$accent]{gettext('required')}[/]")
|
|
116
|
+
if f.kind in ("int", "float", "bool"):
|
|
117
|
+
pieces.append(f"[dim]{_type_label(f.kind)}[/dim]")
|
|
118
|
+
if f.secret:
|
|
119
|
+
pieces.append(f"[dim]🔒 {gettext('never saved to disk')}[/dim]")
|
|
120
|
+
if self.insertable:
|
|
121
|
+
pieces.append(
|
|
122
|
+
f"[$accent @click=screen.insert_token('{f.key}')]▾ {gettext('insert')}[/]"
|
|
123
|
+
)
|
|
124
|
+
yield Static(" ".join(pieces), classes="field-label", markup=True)
|
|
125
|
+
yield from self._compose_control()
|
|
126
|
+
if f.help:
|
|
127
|
+
yield Static(escape(f.help), classes="field-help")
|
|
128
|
+
if f.degraded:
|
|
129
|
+
yield Static(
|
|
130
|
+
gettext("Leave empty to use the script's own default."), classes="field-help"
|
|
131
|
+
)
|
|
132
|
+
if f.secret and f.env_source:
|
|
133
|
+
yield Static(
|
|
134
|
+
gettext("Leave empty to read it from the environment variable %(env)s.")
|
|
135
|
+
% {"env": escape(f.env_source)},
|
|
136
|
+
classes="field-help",
|
|
137
|
+
)
|
|
138
|
+
yield Static("", classes="field-preview")
|
|
139
|
+
yield Static("", classes="field-error")
|
|
140
|
+
|
|
141
|
+
def _compose_control(self) -> ComposeResult:
|
|
142
|
+
f = self.field
|
|
143
|
+
if f.kind == "bool":
|
|
144
|
+
on = self._prefill.strip().lower() in ("true", "1", "yes")
|
|
145
|
+
yield Checkbox(gettext("on") if on else gettext("off"), value=on)
|
|
146
|
+
elif f.kind == "choice" and f.choices:
|
|
147
|
+
with RadioSet():
|
|
148
|
+
for choice in f.choices:
|
|
149
|
+
yield RadioButton(escape(choice), value=(choice == self._prefill))
|
|
150
|
+
else:
|
|
151
|
+
yield Input(value=self._prefill, password=f.secret)
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def value(self) -> str:
|
|
155
|
+
f = self.field
|
|
156
|
+
if f.kind == "bool":
|
|
157
|
+
return "true" if self.query_one(Checkbox).value else "false"
|
|
158
|
+
if f.kind == "choice" and f.choices:
|
|
159
|
+
pressed = self.query_one(RadioSet).pressed_index
|
|
160
|
+
return f.choices[pressed] if 0 <= pressed < len(f.choices) else ""
|
|
161
|
+
return self.query_one(Input).value
|
|
162
|
+
|
|
163
|
+
def set_value(self, value: str) -> None:
|
|
164
|
+
f = self.field
|
|
165
|
+
if f.kind == "bool":
|
|
166
|
+
self.query_one(Checkbox).value = value.strip().lower() in ("true", "1", "yes")
|
|
167
|
+
elif f.kind == "choice" and f.choices:
|
|
168
|
+
if value in f.choices:
|
|
169
|
+
buttons = list(self.query(RadioButton))
|
|
170
|
+
buttons[f.choices.index(value)].value = True
|
|
171
|
+
else:
|
|
172
|
+
self.query_one(Input).value = value
|
|
173
|
+
|
|
174
|
+
def show_error(self, message: str | None) -> None:
|
|
175
|
+
error = self.query_one(".field-error", Static)
|
|
176
|
+
error.update(escape(message) if message else "")
|
|
177
|
+
error.display = bool(message)
|
|
178
|
+
|
|
179
|
+
@on(Checkbox.Changed)
|
|
180
|
+
def _bool_word(self, event: Checkbox.Changed) -> None:
|
|
181
|
+
"""The label tracks the state (on/off), btop-style — an unchecked box labeled
|
|
182
|
+
"on" would read as a lie."""
|
|
183
|
+
event.checkbox.label = gettext("on") if event.value else gettext("off")
|
|
184
|
+
|
|
185
|
+
@on(Input.Changed)
|
|
186
|
+
def _live_feedback(self, event: Input.Changed) -> None:
|
|
187
|
+
"""Token-expansion preview and glob match count, refreshed as the user types."""
|
|
188
|
+
f = self.field
|
|
189
|
+
preview = self.query_one(".field-preview", Static)
|
|
190
|
+
value = event.value
|
|
191
|
+
if f.secret or not value:
|
|
192
|
+
preview.update("")
|
|
193
|
+
preview.display = False
|
|
194
|
+
return
|
|
195
|
+
lines: list[str] = []
|
|
196
|
+
if tokens.has_tokens(value):
|
|
197
|
+
expanded, error = tokens.preview(value, cwd=Path.cwd())
|
|
198
|
+
lines.append(f"→ {escape(error if error else expanded)}")
|
|
199
|
+
count = flows.glob_feedback(value, Path.cwd())
|
|
200
|
+
if count is not None:
|
|
201
|
+
lines.append(
|
|
202
|
+
gettext("✓ matches %(count)s file(s)") % {"count": count}
|
|
203
|
+
if count
|
|
204
|
+
else gettext("⚠ matches no files yet")
|
|
205
|
+
)
|
|
206
|
+
preview.update(" ".join(lines))
|
|
207
|
+
preview.display = bool(lines)
|
|
208
|
+
self.show_error(None)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class PresetNameModal(ModalScreen[str | None]):
|
|
212
|
+
"""Ctrl+S: name (or overwrite) a preset from the current form values."""
|
|
213
|
+
|
|
214
|
+
AUTO_FOCUS = "Input" # type immediately; without this, Enter lands nowhere
|
|
215
|
+
BINDINGS = [Binding("escape", "cancel", gettext("Cancel"))]
|
|
216
|
+
DEFAULT_CSS = """
|
|
217
|
+
PresetNameModal { align: center middle; }
|
|
218
|
+
PresetNameModal > Vertical {
|
|
219
|
+
border: round $accent; padding: 1 2; width: 60; height: auto;
|
|
220
|
+
background: $background;
|
|
221
|
+
}
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
def __init__(self, existing: set[str]) -> None:
|
|
225
|
+
super().__init__()
|
|
226
|
+
self._existing: set[str] = existing
|
|
227
|
+
|
|
228
|
+
@override
|
|
229
|
+
def compose(self) -> ComposeResult:
|
|
230
|
+
with Vertical():
|
|
231
|
+
yield Label(gettext("Save as preset"))
|
|
232
|
+
yield Input(placeholder=gettext("Preset name"))
|
|
233
|
+
yield Static("", id="preset-hint", markup=True)
|
|
234
|
+
yield Static(
|
|
235
|
+
tui_footer.bar(
|
|
236
|
+
tui_footer.chip("screen.save_name", "Enter", gettext("Save")),
|
|
237
|
+
tui_footer.chip("screen.cancel", "Esc", gettext("Cancel")),
|
|
238
|
+
),
|
|
239
|
+
markup=True,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
@on(Input.Changed)
|
|
243
|
+
def _overwrite_hint(self, event: Input.Changed) -> None:
|
|
244
|
+
hint = self.query_one("#preset-hint", Static)
|
|
245
|
+
if event.value.strip() in self._existing:
|
|
246
|
+
hint.update(
|
|
247
|
+
f"[yellow]{gettext('This overwrites the existing preset %(name)s.') % {'name': escape(event.value.strip())}}[/yellow]"
|
|
248
|
+
)
|
|
249
|
+
else:
|
|
250
|
+
hint.update("")
|
|
251
|
+
|
|
252
|
+
@on(Input.Submitted)
|
|
253
|
+
def _save(self, event: Input.Submitted) -> None:
|
|
254
|
+
name = event.value.strip()
|
|
255
|
+
if name:
|
|
256
|
+
self.dismiss(name)
|
|
257
|
+
|
|
258
|
+
def action_save_name(self) -> None:
|
|
259
|
+
"""Click twin of Enter: save under the typed name (a blank name stays put)."""
|
|
260
|
+
name = self.query_one(Input).value.strip()
|
|
261
|
+
if name:
|
|
262
|
+
self.dismiss(name)
|
|
263
|
+
|
|
264
|
+
def action_cancel(self) -> None:
|
|
265
|
+
self.dismiss(None)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class TokenMenuModal(ModalScreen[str | None]):
|
|
269
|
+
"""The ▾insert menu: run-time value tokens, spelled out so users learn the syntax
|
|
270
|
+
passively (the same philosophy as showing the assembled command). The first two
|
|
271
|
+
entries ARE the design's intent-vs-frozen fork: "wherever I run from" ({cwd}) sits
|
|
272
|
+
right next to "this directory, as a fixed path"."""
|
|
273
|
+
|
|
274
|
+
AUTO_FOCUS = "OptionList"
|
|
275
|
+
BINDINGS = [Binding("escape", "cancel", gettext("Cancel"))]
|
|
276
|
+
DEFAULT_CSS = """
|
|
277
|
+
TokenMenuModal { align: center middle; }
|
|
278
|
+
TokenMenuModal > Vertical { border: round $accent; padding: 1 2; width: 64;
|
|
279
|
+
height: auto; background: $background; }
|
|
280
|
+
TokenMenuModal OptionList { height: auto; border: none; }
|
|
281
|
+
TokenMenuModal Static { width: auto; margin: 1 0 0 0; }
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
_ENV_SENTINEL = "__env__"
|
|
285
|
+
|
|
286
|
+
@override
|
|
287
|
+
def compose(self) -> ComposeResult:
|
|
288
|
+
entries: list[tuple[str, str, str]] = [
|
|
289
|
+
("{cwd}", gettext("Directory at run time (changes with where you run)"), "{cwd}"),
|
|
290
|
+
(str(Path.cwd()), gettext("This directory, as a fixed path"), str(Path.cwd())),
|
|
291
|
+
("{today}", gettext("Today's date"), "{today}"),
|
|
292
|
+
("{now}", gettext("Current time"), "{now}"),
|
|
293
|
+
("~", gettext("Home directory"), "~"),
|
|
294
|
+
]
|
|
295
|
+
options = [
|
|
296
|
+
Option(f"{escape(label)} [dim]{escape(shown)}[/dim]", id=insert)
|
|
297
|
+
for shown, label, insert in entries
|
|
298
|
+
]
|
|
299
|
+
options.append(
|
|
300
|
+
Option(
|
|
301
|
+
f"{gettext('Environment variable…')} [dim]{{env:NAME}}[/dim]",
|
|
302
|
+
id=self._ENV_SENTINEL,
|
|
303
|
+
)
|
|
304
|
+
)
|
|
305
|
+
with Vertical():
|
|
306
|
+
yield Label(gettext("Insert a run-time value"))
|
|
307
|
+
yield OptionList(*options)
|
|
308
|
+
yield Static(
|
|
309
|
+
tui_footer.bar(tui_footer.chip("screen.cancel", "Esc", gettext("Cancel"))),
|
|
310
|
+
markup=True,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
@on(OptionList.OptionSelected)
|
|
314
|
+
def _picked(self, event: OptionList.OptionSelected) -> None:
|
|
315
|
+
if event.option.id == self._ENV_SENTINEL:
|
|
316
|
+
|
|
317
|
+
def _env_done(token: str | None) -> None:
|
|
318
|
+
self.dismiss(token)
|
|
319
|
+
|
|
320
|
+
self.app.push_screen(EnvPickerModal(), _env_done)
|
|
321
|
+
return
|
|
322
|
+
self.dismiss(str(event.option.id))
|
|
323
|
+
|
|
324
|
+
def action_cancel(self) -> None:
|
|
325
|
+
self.dismiss(None)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class EnvPickerModal(ModalScreen[str | None]):
|
|
329
|
+
"""Pick an environment variable by name (type to filter os.environ)."""
|
|
330
|
+
|
|
331
|
+
AUTO_FOCUS = "Input"
|
|
332
|
+
BINDINGS = [Binding("escape", "cancel", gettext("Cancel"))]
|
|
333
|
+
DEFAULT_CSS = """
|
|
334
|
+
EnvPickerModal { align: center middle; }
|
|
335
|
+
EnvPickerModal > Vertical { border: round $accent; padding: 1 2; width: 64;
|
|
336
|
+
height: auto; max-height: 80%; background: $background; }
|
|
337
|
+
EnvPickerModal OptionList { border: none; max-height: 12; }
|
|
338
|
+
EnvPickerModal Static { width: auto; margin: 1 0 0 0; }
|
|
339
|
+
"""
|
|
340
|
+
|
|
341
|
+
@override
|
|
342
|
+
def compose(self) -> ComposeResult:
|
|
343
|
+
with Vertical():
|
|
344
|
+
yield Label(gettext("Environment variable"))
|
|
345
|
+
yield Input(placeholder=gettext("type to filter…"))
|
|
346
|
+
yield OptionList(*self._options(""))
|
|
347
|
+
yield Static(
|
|
348
|
+
tui_footer.bar(tui_footer.chip("screen.cancel", "Esc", gettext("Cancel"))),
|
|
349
|
+
markup=True,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
def _options(self, needle: str) -> list[Option]:
|
|
353
|
+
import os
|
|
354
|
+
|
|
355
|
+
names = sorted(n for n in os.environ if needle.lower() in n.lower())
|
|
356
|
+
return [Option(escape(n), id=n) for n in names[:200]]
|
|
357
|
+
|
|
358
|
+
@on(Input.Changed)
|
|
359
|
+
def _filter(self, event: Input.Changed) -> None:
|
|
360
|
+
option_list = self.query_one(OptionList)
|
|
361
|
+
option_list.clear_options()
|
|
362
|
+
option_list.add_options(self._options(event.value.strip()))
|
|
363
|
+
|
|
364
|
+
@on(Input.Submitted)
|
|
365
|
+
def _typed(self, event: Input.Submitted) -> None:
|
|
366
|
+
# A name typed in full is accepted even if it isn't set YET — the token resolves
|
|
367
|
+
# at run time, and assembly reports a missing variable by name.
|
|
368
|
+
name = event.value.strip()
|
|
369
|
+
if name.isidentifier():
|
|
370
|
+
self.dismiss("{env:" + name + "}")
|
|
371
|
+
|
|
372
|
+
@on(OptionList.OptionSelected)
|
|
373
|
+
def _picked(self, event: OptionList.OptionSelected) -> None:
|
|
374
|
+
self.dismiss("{env:" + str(event.option.id) + "}")
|
|
375
|
+
|
|
376
|
+
def action_cancel(self) -> None:
|
|
377
|
+
self.dismiss(None)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class RunFormScreen(Screen[FormResult]):
|
|
381
|
+
"""The full-size run form (title answers "where am I": Run <name>)."""
|
|
382
|
+
|
|
383
|
+
BINDINGS = [
|
|
384
|
+
Binding("escape", "cancel", gettext("Cancel")),
|
|
385
|
+
Binding("ctrl+s", "save_preset", gettext("Save as preset")),
|
|
386
|
+
# Enter runs the form from ANY field. Without priority, a focused Checkbox/RadioSet
|
|
387
|
+
# swallows Enter for its own toggle/select and the footer's "Enter Run" silently does
|
|
388
|
+
# nothing (and an inline flag/choice-only form has no Input at all, so Enter could
|
|
389
|
+
# never submit). priority makes the screen intercept Enter first; Space still toggles
|
|
390
|
+
# a checkbox. Ctrl+R stays as the explicit muscle-memory chord.
|
|
391
|
+
Binding("enter", "submit", gettext("Run"), priority=True, show=False),
|
|
392
|
+
Binding("ctrl+r", "submit", gettext("Run"), priority=True),
|
|
393
|
+
Binding("ctrl+t", "insert_token", gettext("Insert value")),
|
|
394
|
+
]
|
|
395
|
+
DEFAULT_CSS = """
|
|
396
|
+
RunFormScreen { background: $background; }
|
|
397
|
+
/* btop grammar: the form is one rounded panel with the "Run <name>" title ON the
|
|
398
|
+
border (proc-box maroon — this is the panel where things run). The border lives
|
|
399
|
+
on an inner container, not the Screen: a bordered Screen shifts its coordinate
|
|
400
|
+
space and clicks on the bottom-docked footer land "outside" the screen. */
|
|
401
|
+
RunFormScreen #form-panel {
|
|
402
|
+
border: round $skit-box-maroon;
|
|
403
|
+
border-title-color: ansi_bright_white;
|
|
404
|
+
border-title-style: bold;
|
|
405
|
+
}
|
|
406
|
+
RunFormScreen #drift-banner, RunFormScreen #degraded-notice { color: $warning; padding: 0 1; }
|
|
407
|
+
RunFormScreen #preset-row { height: auto; padding: 0 1; }
|
|
408
|
+
/* Widgets default to width:1fr; in a Horizontal that lets the "Preset:" caption
|
|
409
|
+
swallow the whole row and push the chips (or the empty-state hint) clean off the
|
|
410
|
+
screen. Everything in this row hugs its content. */
|
|
411
|
+
RunFormScreen #preset-row Static { width: auto; margin: 0 1 0 0; }
|
|
412
|
+
RunFormScreen #preset-row RadioSet { width: auto; }
|
|
413
|
+
/* The empty-state hint is a long sentence: let it take the row's remaining width and
|
|
414
|
+
wrap, rather than width:auto (its full content width) which overflows a narrow form.
|
|
415
|
+
Two ids outrank the `#preset-row Static` width:auto rule above. */
|
|
416
|
+
RunFormScreen #preset-row #preset-empty { color: $text-muted; width: 1fr; height: auto; }
|
|
417
|
+
RunFormScreen #preset-row RadioSet { layout: horizontal; height: auto; border: none; }
|
|
418
|
+
RunFormScreen #preset-row RadioSet > RadioButton { width: auto; margin: 0 2 0 0; }
|
|
419
|
+
RunFormScreen #form-body { padding: 0 1; }
|
|
420
|
+
RunFormScreen #form-keys { dock: bottom; height: 1; color: $text-muted; padding: 0 1; }
|
|
421
|
+
|
|
422
|
+
/* Inline mode (the CLI's `skit run` mini-window): an inline Screen is sized to its
|
|
423
|
+
content height, but the VerticalScroll body defaults to height:1fr, which collapses
|
|
424
|
+
to a single row in an auto-height parent — the whole form flattens to a 3-line stub
|
|
425
|
+
and the docked footer gets clipped. Give the panel and body an explicit auto height
|
|
426
|
+
so the screen measures its true content, and cap the window so a tall form scrolls
|
|
427
|
+
inside it (footer stays docked and visible) instead of taking over the terminal. In
|
|
428
|
+
full-screen mode the body keeps its 1fr fill and the footer pins to the bottom. */
|
|
429
|
+
RunFormScreen:inline { height: auto; max-height: 80%; }
|
|
430
|
+
RunFormScreen:inline #form-panel { height: auto; }
|
|
431
|
+
RunFormScreen:inline #form-body { height: auto; }
|
|
432
|
+
"""
|
|
433
|
+
|
|
434
|
+
def __init__(
|
|
435
|
+
self,
|
|
436
|
+
entry: Entry,
|
|
437
|
+
plan: flows.FormPlan,
|
|
438
|
+
prefill: dict[str, str],
|
|
439
|
+
include_extra: bool = True,
|
|
440
|
+
) -> None:
|
|
441
|
+
super().__init__()
|
|
442
|
+
self._entry: Entry = entry
|
|
443
|
+
self._plan: flows.FormPlan = plan
|
|
444
|
+
self._prefill: dict[str, str] = prefill
|
|
445
|
+
# The inline (CLI) frame hides the extra-args row: argv already owns passthrough
|
|
446
|
+
# args there, and two sources for the same thing would fight.
|
|
447
|
+
self._include_extra: bool = include_extra
|
|
448
|
+
self._presets: dict[str, dict[str, str]] = argstate.load_state(entry.slug)["presets"]
|
|
449
|
+
|
|
450
|
+
def on_mount(self) -> None:
|
|
451
|
+
self.query_one("#form-panel").border_title = gettext("Run %(name)s") % {
|
|
452
|
+
"name": escape(self._entry.meta.name)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
@override
|
|
456
|
+
def compose(self) -> ComposeResult:
|
|
457
|
+
with Vertical(id="form-panel"):
|
|
458
|
+
if self._plan.drift_lines:
|
|
459
|
+
yield Static(
|
|
460
|
+
"\n".join(escape(line) for line in self._plan.drift_lines), id="drift-banner"
|
|
461
|
+
)
|
|
462
|
+
if self._plan.degraded_reason:
|
|
463
|
+
yield Static(_degraded_notice(self._plan.degraded_reason), id="degraded-notice")
|
|
464
|
+
with Horizontal(id="preset-row"):
|
|
465
|
+
yield Static(gettext("Preset:"), markup=False)
|
|
466
|
+
if self._presets:
|
|
467
|
+
with RadioSet(id="preset-set"):
|
|
468
|
+
yield RadioButton(gettext("last values"), value=True)
|
|
469
|
+
for name in sorted(self._presets):
|
|
470
|
+
yield RadioButton(escape(name))
|
|
471
|
+
else:
|
|
472
|
+
# Empty state teaches the Ctrl+S affordance precisely when the user
|
|
473
|
+
# has no presets and most needs to learn it (spec §2).
|
|
474
|
+
yield Static(
|
|
475
|
+
gettext("none yet — fill the form and press Ctrl+S to save one"),
|
|
476
|
+
id="preset-empty",
|
|
477
|
+
)
|
|
478
|
+
with VerticalScroll(id="form-body"):
|
|
479
|
+
for f in self._plan.fields:
|
|
480
|
+
yield FieldRow(f, self._prefill.get(f.key, ""))
|
|
481
|
+
if self._include_extra:
|
|
482
|
+
extra_field = flows.FormField(
|
|
483
|
+
key=_EXTRA_KEY,
|
|
484
|
+
label=gettext("Extra arguments (passed to the script as-is)"),
|
|
485
|
+
source="flag",
|
|
486
|
+
)
|
|
487
|
+
last_extra = argstate.load_state(self._entry.slug)["extra_args"]
|
|
488
|
+
# shlex.join, because collect() shlex.split()s: an argument that
|
|
489
|
+
# contains spaces must survive the round trip as ONE argument.
|
|
490
|
+
import shlex
|
|
491
|
+
|
|
492
|
+
yield FieldRow(extra_field, shlex.join(last_extra))
|
|
493
|
+
yield Static(
|
|
494
|
+
tui_footer.bar(
|
|
495
|
+
tui_footer.chip("screen.submit", "Enter", gettext("Run")),
|
|
496
|
+
tui_footer.chip("screen.insert_token", "Ctrl+T", gettext("Insert value")),
|
|
497
|
+
tui_footer.chip("screen.save_preset", "Ctrl+S", gettext("Save as preset")),
|
|
498
|
+
tui_footer.chip("screen.cancel", "Esc", gettext("Cancel")),
|
|
499
|
+
),
|
|
500
|
+
id="form-keys",
|
|
501
|
+
markup=True,
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
@on(RadioSet.Changed, "#preset-set")
|
|
505
|
+
def _apply_preset(self, event: RadioSet.Changed) -> None:
|
|
506
|
+
"""Chip switch: overlay the whole preset onto the fields ("last values" restores)."""
|
|
507
|
+
index = event.radio_set.pressed_index
|
|
508
|
+
names = sorted(self._presets)
|
|
509
|
+
chosen = (
|
|
510
|
+
self._prefill if index == 0 else {**self._prefill, **self._presets[names[index - 1]]}
|
|
511
|
+
)
|
|
512
|
+
for row in self.query(FieldRow):
|
|
513
|
+
if row.field.key != _EXTRA_KEY:
|
|
514
|
+
row.set_value(chosen.get(row.field.key, ""))
|
|
515
|
+
|
|
516
|
+
def _rows(self) -> list[FieldRow]:
|
|
517
|
+
return [row for row in self.query(FieldRow) if row.field.key != _EXTRA_KEY]
|
|
518
|
+
|
|
519
|
+
def collect(self) -> tuple[dict[str, str], list[str]]:
|
|
520
|
+
import shlex
|
|
521
|
+
|
|
522
|
+
values = {row.field.key: row.value for row in self._rows()}
|
|
523
|
+
extra_row = next((row for row in self.query(FieldRow) if row.field.key == _EXTRA_KEY), None)
|
|
524
|
+
if extra_row is None:
|
|
525
|
+
return values, []
|
|
526
|
+
try:
|
|
527
|
+
extra = shlex.split(extra_row.value)
|
|
528
|
+
except ValueError:
|
|
529
|
+
extra = [extra_row.value] if extra_row.value else []
|
|
530
|
+
return values, extra
|
|
531
|
+
|
|
532
|
+
def action_submit(self) -> None:
|
|
533
|
+
values, extra = self.collect()
|
|
534
|
+
errors = flows.validate(self._plan, values)
|
|
535
|
+
if errors:
|
|
536
|
+
for row in self._rows():
|
|
537
|
+
row.show_error(errors.get(row.field.key))
|
|
538
|
+
return
|
|
539
|
+
self.dismiss((values, extra))
|
|
540
|
+
|
|
541
|
+
def action_cancel(self) -> None:
|
|
542
|
+
self.dismiss(None)
|
|
543
|
+
|
|
544
|
+
def action_insert_token(self, key: str = "") -> None:
|
|
545
|
+
"""Ctrl+T (or the ▾ link on a field): insert a run-time value token at the
|
|
546
|
+
cursor of the focused text field. Secrets are excluded — their values skip
|
|
547
|
+
token expansion by design."""
|
|
548
|
+
if key:
|
|
549
|
+
row = self.query_one(f"#fr-{key}", FieldRow)
|
|
550
|
+
else:
|
|
551
|
+
focused = self.focused
|
|
552
|
+
if not isinstance(focused, Input):
|
|
553
|
+
return
|
|
554
|
+
row = next((a for a in focused.ancestors if isinstance(a, FieldRow)), None)
|
|
555
|
+
if row is None:
|
|
556
|
+
return
|
|
557
|
+
if not row.insertable:
|
|
558
|
+
return
|
|
559
|
+
target = row.query_one(Input)
|
|
560
|
+
target.focus()
|
|
561
|
+
|
|
562
|
+
def _insert(token: str | None) -> None:
|
|
563
|
+
if token:
|
|
564
|
+
target.insert_text_at_cursor(token)
|
|
565
|
+
target.focus()
|
|
566
|
+
|
|
567
|
+
self.app.push_screen(TokenMenuModal(), _insert)
|
|
568
|
+
|
|
569
|
+
def action_save_preset(self) -> None:
|
|
570
|
+
values, _extra = self.collect()
|
|
571
|
+
|
|
572
|
+
def _named(name: str | None) -> None:
|
|
573
|
+
if not name:
|
|
574
|
+
return
|
|
575
|
+
argstate.save_preset(
|
|
576
|
+
self._entry.slug,
|
|
577
|
+
name,
|
|
578
|
+
{k: v for k, v in values.items() if v},
|
|
579
|
+
secret_names=self._plan.secret_names,
|
|
580
|
+
)
|
|
581
|
+
self._presets = argstate.load_state(self._entry.slug)["presets"]
|
|
582
|
+
self.notify(
|
|
583
|
+
gettext('Preset "%(preset)s" saved.') % {"preset": name}, severity="information"
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
self.app.push_screen(PresetNameModal(set(self._presets)), _named)
|