groundskeeping 0.2.2__tar.gz → 0.3.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.
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/PKG-INFO +1 -1
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/pages.md +9 -2
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/wizards.md +4 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/app.py +13 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/contracts/__init__.py +4 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/contracts/pages.py +8 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/contracts/views.py +28 -1
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/demo.py +77 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/themes/groundskeeping.tcss +9 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/widgets/wizard.py +42 -29
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/widgets/workbench.py +252 -2
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/tests/test_app_smoke.py +244 -1
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/tests/test_navigation_contracts.py +21 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/tests/test_wizard_contracts.py +92 -11
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/.github/ISSUE_TEMPLATE/not_working.yml +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/.github/release-drafter.yml +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/.github/workflows/ci.yml +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/.github/workflows/docs.yml +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/.github/workflows/merge.yml +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/.github/workflows/publish.yml +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/.gitignore +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/CONTRIBUTING.md +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/README.md +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/actions.md +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/api/actions.md +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/api/app.md +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/api/configurator.md +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/api/jobs.md +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/api/pages.md +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/api/telemetry.md +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/api/views.md +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/api/widgets.md +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/api/wizards.md +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/configuration.md +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/contributing.md +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/index.md +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/ownership.md +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/quickstart.md +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/static/images/demo-layout-example.png +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/telemetry.md +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/examples/demo_app.py +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/mkdocs.yml +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/pyproject.toml +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/__init__.py +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/configurator/__init__.py +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/configurator/adapter.py +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/configurator/models.py +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/contracts/actions.py +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/contracts/jobs.py +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/contracts/telemetry.py +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/contracts/wizards.py +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/navigation.py +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/py.typed +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/telemetry/__init__.py +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/telemetry/providers/__init__.py +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/telemetry/providers/fake.py +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/telemetry/runtime.py +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/theme.py +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/widgets/__init__.py +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/widgets/configurator.py +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/widgets/primitives.py +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/widgets/telemetry.py +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/tests/test_actions_and_jobs.py +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/tests/test_configurator_adapter.py +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/tests/test_dependency_boundaries.py +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/tests/test_telemetry_core.py +0 -0
- {groundskeeping-0.2.2 → groundskeeping-0.3.0}/uv.lock +0 -0
|
@@ -20,8 +20,9 @@ Use `SectionNavigation` for peer areas such as Database, Embeddings, and Runtime
|
|
|
20
20
|
evaluation run with nested artefacts.
|
|
21
21
|
|
|
22
22
|
Translate domain objects before they reach the workbench. The workbench understands
|
|
23
|
-
`SectionItem`, `CatalogueItem`, `TableView`, `TreeView`, and friends;
|
|
24
|
-
what a Groundworkers resource or `cava-nlp-shard` evaluation object
|
|
23
|
+
`SectionItem`, `CatalogueItem`, `TableView`, `SelectionTableView`, `TreeView`, and friends;
|
|
24
|
+
it does not need to know what a Groundworkers resource or `cava-nlp-shard` evaluation object
|
|
25
|
+
is.
|
|
25
26
|
|
|
26
27
|
Use `OperatorAppSpec.workbench_labels` to rename shared pane chrome such as the result and
|
|
27
28
|
detail panel labels. Page-owned titles still live on navigation and view contracts: for
|
|
@@ -54,6 +55,12 @@ startup rather than when an operator clicks a tab.
|
|
|
54
55
|
Row events return to the active page. The workbench renders generic models; the page decides
|
|
55
56
|
what a highlighted row means.
|
|
56
57
|
|
|
58
|
+
Use `SelectionTableView` when rows are controls, not just data. It renders a
|
|
59
|
+
Groundskeeping-owned selection list with stable row keys, disabled row handling, and
|
|
60
|
+
selection modes for single, multiple, or all-vs-specific selection. Pages that need the full
|
|
61
|
+
selected-key state can implement the optional `SelectionAwareOperatorPage.selection_changed`
|
|
62
|
+
hook; older pages still receive `row_selected` for compatibility.
|
|
63
|
+
|
|
57
64
|
Detail panes can render `TextView`, `KeyValueView`, or `TableView`. Use a detail `TableView`
|
|
58
65
|
when the selected item has its own repeated data, such as available LLM models for a provider.
|
|
59
66
|
|
|
@@ -64,6 +64,10 @@ The contracts cover three step types:
|
|
|
64
64
|
secret, and multiline fields. It also carries presentation metadata such as placeholder text,
|
|
65
65
|
help text, disabled/read-only state, and secret behaviour.
|
|
66
66
|
|
|
67
|
+
Choice fields render as Textual `Select` controls. Keyboard and mouse selection both populate
|
|
68
|
+
the submitted field value, and long option lists open as scrollable menus while focus stays
|
|
69
|
+
with the active control.
|
|
70
|
+
|
|
67
71
|
## Secrets and revisions
|
|
68
72
|
|
|
69
73
|
Real candidate values belong in the controller. Snapshots should carry only values needed to
|
|
@@ -227,6 +227,19 @@ class OperatorApp(App[None]):
|
|
|
227
227
|
str(event.row_key.value), self._page_context
|
|
228
228
|
)
|
|
229
229
|
|
|
230
|
+
def on_workbench_selection_highlighted(
|
|
231
|
+
self, event: Workbench.SelectionHighlighted
|
|
232
|
+
) -> None:
|
|
233
|
+
self._active_widget().row_highlighted(event.row_key, self._page_context)
|
|
234
|
+
|
|
235
|
+
def on_workbench_selection_changed(self, event: Workbench.SelectionChanged) -> None:
|
|
236
|
+
page = self._active_widget()
|
|
237
|
+
handler = getattr(page, "selection_changed", None)
|
|
238
|
+
if callable(handler):
|
|
239
|
+
handler(event.row_key, event.selected_keys, self._page_context)
|
|
240
|
+
return
|
|
241
|
+
page.row_selected(event.row_key, self._page_context)
|
|
242
|
+
|
|
230
243
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
231
244
|
action_key = self._workbench.action_key(event.button.id)
|
|
232
245
|
if action_key is not None:
|
|
@@ -40,6 +40,7 @@ from groundskeeping.contracts.pages import (
|
|
|
40
40
|
PageRegistry,
|
|
41
41
|
PageRoute,
|
|
42
42
|
PageSurfacePort,
|
|
43
|
+
SelectionAwareOperatorPage,
|
|
43
44
|
)
|
|
44
45
|
from groundskeeping.contracts.telemetry import (
|
|
45
46
|
MetricValue,
|
|
@@ -60,6 +61,9 @@ from groundskeeping.contracts.views import (
|
|
|
60
61
|
PageNavigation,
|
|
61
62
|
SectionItem,
|
|
62
63
|
SectionNavigation,
|
|
64
|
+
SelectionMode,
|
|
65
|
+
SelectionTableRow,
|
|
66
|
+
SelectionTableView,
|
|
63
67
|
SemanticStatus,
|
|
64
68
|
SurfaceView,
|
|
65
69
|
TableRow,
|
|
@@ -114,6 +114,14 @@ class OperatorPage(Protocol):
|
|
|
114
114
|
def row_selected(self, row_key: str, context: PageContext) -> None: ...
|
|
115
115
|
|
|
116
116
|
|
|
117
|
+
class SelectionAwareOperatorPage(Protocol):
|
|
118
|
+
"""Optional hook for pages that consume workbench-owned selection tables."""
|
|
119
|
+
|
|
120
|
+
def selection_changed(
|
|
121
|
+
self, row_key: str, selected_keys: tuple[str, ...], context: PageContext
|
|
122
|
+
) -> None: ...
|
|
123
|
+
|
|
124
|
+
|
|
117
125
|
@dataclass(frozen=True)
|
|
118
126
|
class PageRegistration:
|
|
119
127
|
"""A page route plus the factory that creates the page widget."""
|
|
@@ -8,6 +8,7 @@ from enum import StrEnum
|
|
|
8
8
|
from typing import Literal
|
|
9
9
|
|
|
10
10
|
type ViewActionVariant = Literal["default", "primary", "success", "warning", "error"]
|
|
11
|
+
type SelectionMode = Literal["single", "multiple", "all_or_specific"]
|
|
11
12
|
|
|
12
13
|
MAX_VIEW_ACTIONS = 6
|
|
13
14
|
|
|
@@ -115,6 +116,32 @@ class TableView:
|
|
|
115
116
|
actions: tuple[ViewAction, ...] = ()
|
|
116
117
|
|
|
117
118
|
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class SelectionTableRow:
|
|
121
|
+
"""One selectable row in a workbench-owned selection table."""
|
|
122
|
+
|
|
123
|
+
key: str
|
|
124
|
+
cells: tuple[str, ...]
|
|
125
|
+
selected: bool = False
|
|
126
|
+
disabled: bool = False
|
|
127
|
+
selection_group: str | None = None
|
|
128
|
+
detail: object | None = None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class SelectionTableView:
|
|
133
|
+
"""A table-shaped surface where Groundskeeping owns row toggle semantics."""
|
|
134
|
+
|
|
135
|
+
title: str
|
|
136
|
+
columns: tuple[str, ...]
|
|
137
|
+
rows: tuple[SelectionTableRow, ...]
|
|
138
|
+
status: SemanticStatus = SemanticStatus.INFO
|
|
139
|
+
message: str | None = None
|
|
140
|
+
actions: tuple[ViewAction, ...] = ()
|
|
141
|
+
selection_mode: SelectionMode = "multiple"
|
|
142
|
+
all_row_key: str | None = None
|
|
143
|
+
|
|
144
|
+
|
|
118
145
|
@dataclass(frozen=True)
|
|
119
146
|
class TreeNode:
|
|
120
147
|
"""A tree-shaped detail node."""
|
|
@@ -176,4 +203,4 @@ class KeyValueView:
|
|
|
176
203
|
|
|
177
204
|
|
|
178
205
|
type DetailView = TextView | KeyValueView | TableView
|
|
179
|
-
type SurfaceView = TableView | TreeView | EmptyView | LoadingView
|
|
206
|
+
type SurfaceView = TableView | SelectionTableView | TreeView | EmptyView | LoadingView
|
|
@@ -32,6 +32,8 @@ from groundskeeping.contracts import (
|
|
|
32
32
|
ReviewStep,
|
|
33
33
|
SectionItem,
|
|
34
34
|
SectionNavigation,
|
|
35
|
+
SelectionTableRow,
|
|
36
|
+
SelectionTableView,
|
|
35
37
|
SemanticStatus,
|
|
36
38
|
SurfaceView,
|
|
37
39
|
TableRow,
|
|
@@ -116,12 +118,22 @@ TELEMETRY_ROUTE = PageRoute(
|
|
|
116
118
|
class OverviewPage(_DemoPage):
|
|
117
119
|
route = OVERVIEW_ROUTE
|
|
118
120
|
|
|
121
|
+
def __init__(self) -> None:
|
|
122
|
+
super().__init__()
|
|
123
|
+
self._selected_vocab_keys: tuple[str, ...] = ("__all__",)
|
|
124
|
+
|
|
119
125
|
def build_navigation(self, context: PageContext) -> SectionNavigation:
|
|
120
126
|
return SectionNavigation(
|
|
121
127
|
items=(
|
|
122
128
|
SectionItem(
|
|
123
129
|
"overview.shell", "Shell contracts", status=SemanticStatus.OK
|
|
124
130
|
),
|
|
131
|
+
SectionItem(
|
|
132
|
+
"overview.selection",
|
|
133
|
+
"Selection table",
|
|
134
|
+
status=SemanticStatus.RUNNING,
|
|
135
|
+
description="All-vs-specific row controls",
|
|
136
|
+
),
|
|
125
137
|
SectionItem("overview.boundaries", "Consumer boundaries"),
|
|
126
138
|
)
|
|
127
139
|
)
|
|
@@ -144,6 +156,71 @@ class OverviewPage(_DemoPage):
|
|
|
144
156
|
),
|
|
145
157
|
)
|
|
146
158
|
|
|
159
|
+
def navigation_selected(self, item: NavigationItem, context: PageContext) -> None:
|
|
160
|
+
if item.key == "overview.selection":
|
|
161
|
+
context.surface.show_view(self.route.key, self._selection_view())
|
|
162
|
+
context.surface.show_detail(
|
|
163
|
+
self.route.key,
|
|
164
|
+
KeyValueView(
|
|
165
|
+
title="Selection",
|
|
166
|
+
rows=(
|
|
167
|
+
("changed row", "-"),
|
|
168
|
+
("selected keys", ", ".join(self._selected_vocab_keys)),
|
|
169
|
+
),
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
return
|
|
173
|
+
context.surface.show_view(self.route.key, self.landing_view(context))
|
|
174
|
+
|
|
175
|
+
def selection_changed(
|
|
176
|
+
self, row_key: str, selected_keys: tuple[str, ...], context: PageContext
|
|
177
|
+
) -> None:
|
|
178
|
+
self._selected_vocab_keys = selected_keys or ("__all__",)
|
|
179
|
+
context.surface.show_view(self.route.key, self._selection_view())
|
|
180
|
+
context.surface.show_detail(
|
|
181
|
+
self.route.key,
|
|
182
|
+
KeyValueView(
|
|
183
|
+
title="Selection",
|
|
184
|
+
rows=(
|
|
185
|
+
("changed row", row_key),
|
|
186
|
+
("selected keys", ", ".join(self._selected_vocab_keys)),
|
|
187
|
+
),
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def _selection_view(self) -> SelectionTableView:
|
|
192
|
+
selected = set(self._selected_vocab_keys)
|
|
193
|
+
return SelectionTableView(
|
|
194
|
+
title="Vocabulary selection",
|
|
195
|
+
message="workbench-owned all-vs-specific controls",
|
|
196
|
+
status=SemanticStatus.RUNNING,
|
|
197
|
+
columns=("Vocabulary", "Coverage", "State"),
|
|
198
|
+
selection_mode="all_or_specific",
|
|
199
|
+
all_row_key="__all__",
|
|
200
|
+
rows=(
|
|
201
|
+
SelectionTableRow(
|
|
202
|
+
"__all__",
|
|
203
|
+
("All vocabularies", "default", "ready"),
|
|
204
|
+
selected="__all__" in selected,
|
|
205
|
+
),
|
|
206
|
+
SelectionTableRow(
|
|
207
|
+
"snomed",
|
|
208
|
+
("SNOMED CT", "100%", "complete"),
|
|
209
|
+
disabled=True,
|
|
210
|
+
),
|
|
211
|
+
SelectionTableRow(
|
|
212
|
+
"loinc",
|
|
213
|
+
("LOINC", "73%", "available"),
|
|
214
|
+
selected="loinc" in selected,
|
|
215
|
+
),
|
|
216
|
+
SelectionTableRow(
|
|
217
|
+
"rxnorm",
|
|
218
|
+
("RxNorm", "41%", "available"),
|
|
219
|
+
selected="rxnorm" in selected,
|
|
220
|
+
),
|
|
221
|
+
),
|
|
222
|
+
)
|
|
223
|
+
|
|
147
224
|
|
|
148
225
|
class ConfigPage(_DemoPage):
|
|
149
226
|
route = CONFIG_ROUTE
|
|
@@ -102,6 +102,15 @@ Screen {
|
|
|
102
102
|
color: $text-warning;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
Select.wizard-choice-list {
|
|
106
|
+
width: 1fr;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
Select.wizard-choice-list > SelectOverlay {
|
|
110
|
+
height: 10;
|
|
111
|
+
max-height: 10;
|
|
112
|
+
}
|
|
113
|
+
|
|
105
114
|
.section {
|
|
106
115
|
border: round $accent 40%;
|
|
107
116
|
border-title-align: left;
|
|
@@ -14,12 +14,10 @@ from textual.widgets import (
|
|
|
14
14
|
DataTable,
|
|
15
15
|
Input,
|
|
16
16
|
Label,
|
|
17
|
-
OptionList,
|
|
18
17
|
Select,
|
|
19
18
|
Static,
|
|
20
19
|
TextArea,
|
|
21
20
|
)
|
|
22
|
-
from textual.widgets.option_list import Option
|
|
23
21
|
|
|
24
22
|
from groundskeeping.contracts.actions import FieldKind, FieldSpec
|
|
25
23
|
from groundskeeping.contracts.wizards import (
|
|
@@ -45,6 +43,7 @@ class WizardScreen(ModalScreen[WizardResult]):
|
|
|
45
43
|
self._field_by_widget_id: dict[str, FieldSpec] = {}
|
|
46
44
|
self._choice_widget_id = "wizard-choice"
|
|
47
45
|
self._blank_choice_id = "__groundskeeping_blank_choice__"
|
|
46
|
+
self._no_choice_id = "__groundskeeping_no_choice__"
|
|
48
47
|
|
|
49
48
|
def compose(self) -> ComposeResult:
|
|
50
49
|
with Vertical(id="wizard-frame"):
|
|
@@ -116,7 +115,9 @@ class WizardScreen(ModalScreen[WizardResult]):
|
|
|
116
115
|
widget_id = f"wizard-field-{index}"
|
|
117
116
|
self._field_by_widget_id[widget_id] = field
|
|
118
117
|
await body.mount(Label(field.label, classes="wizard-field-label"))
|
|
119
|
-
await body.mount(
|
|
118
|
+
await body.mount(
|
|
119
|
+
self._field_widget(widget_id, field, snapshot.values.get(field.key))
|
|
120
|
+
)
|
|
120
121
|
if field.help:
|
|
121
122
|
await body.mount(Static(field.help, classes="wizard-field-help"))
|
|
122
123
|
return
|
|
@@ -130,11 +131,15 @@ class WizardScreen(ModalScreen[WizardResult]):
|
|
|
130
131
|
value=current if current is not None else Select.BLANK,
|
|
131
132
|
id=self._choice_widget_id,
|
|
132
133
|
allow_blank=False,
|
|
134
|
+
classes="wizard-choice-list",
|
|
133
135
|
)
|
|
134
136
|
)
|
|
135
137
|
for choice in step.choices:
|
|
136
138
|
await body.mount(
|
|
137
|
-
Static(
|
|
139
|
+
Static(
|
|
140
|
+
f"{choice.label}: {choice.description}",
|
|
141
|
+
classes="wizard-field-help",
|
|
142
|
+
)
|
|
138
143
|
)
|
|
139
144
|
return
|
|
140
145
|
if isinstance(step, ReviewStep):
|
|
@@ -154,43 +159,50 @@ class WizardScreen(ModalScreen[WizardResult]):
|
|
|
154
159
|
|
|
155
160
|
def _field_widget(
|
|
156
161
|
self, widget_id: str, field: FieldSpec, value: object
|
|
157
|
-
) -> Input | TextArea | Checkbox |
|
|
162
|
+
) -> Input | TextArea | Checkbox | Select[str]:
|
|
158
163
|
disabled = field.disabled or field.read_only
|
|
159
164
|
if field.kind is FieldKind.BOOLEAN:
|
|
160
|
-
return Checkbox(
|
|
165
|
+
return Checkbox(
|
|
166
|
+
value=bool(value if value is not None else field.default),
|
|
167
|
+
id=widget_id,
|
|
168
|
+
disabled=disabled,
|
|
169
|
+
)
|
|
161
170
|
if field.kind is FieldKind.CHOICE:
|
|
162
171
|
current = value if value is not None else field.default
|
|
163
|
-
options = []
|
|
172
|
+
options: list[tuple[str, str]] = []
|
|
164
173
|
if not field.required:
|
|
165
174
|
options.append(
|
|
166
|
-
|
|
175
|
+
(field.placeholder or "No selection", self._blank_choice_id)
|
|
167
176
|
)
|
|
168
177
|
options.extend(
|
|
169
|
-
|
|
178
|
+
(
|
|
170
179
|
choice.label
|
|
171
180
|
if choice.description is None
|
|
172
181
|
else f"{choice.label} — {choice.description}",
|
|
173
|
-
|
|
182
|
+
choice.value,
|
|
174
183
|
)
|
|
175
184
|
for choice in field.choices
|
|
176
185
|
)
|
|
177
186
|
if not options:
|
|
178
|
-
options.append(
|
|
179
|
-
|
|
180
|
-
|
|
187
|
+
options.append((field.placeholder or "No choices", self._no_choice_id))
|
|
188
|
+
option_values = tuple(option_value for _, option_value in options)
|
|
189
|
+
if current in option_values:
|
|
190
|
+
selected = str(current)
|
|
191
|
+
elif not field.required:
|
|
192
|
+
selected = self._blank_choice_id
|
|
193
|
+
elif field.choices:
|
|
194
|
+
selected = field.choices[0].value
|
|
195
|
+
else:
|
|
196
|
+
selected = self._no_choice_id
|
|
197
|
+
return Select(
|
|
198
|
+
options,
|
|
199
|
+
prompt=field.placeholder or "Choose an option",
|
|
200
|
+
value=selected,
|
|
201
|
+
allow_blank=False,
|
|
181
202
|
id=widget_id,
|
|
182
|
-
disabled=disabled,
|
|
203
|
+
disabled=disabled or not field.choices,
|
|
183
204
|
classes="wizard-choice-list",
|
|
184
205
|
)
|
|
185
|
-
widget.styles.height = min(max(len(options), 2), 8)
|
|
186
|
-
choice_ids = tuple(option.id for option in options)
|
|
187
|
-
if current is None and not field.required:
|
|
188
|
-
widget.highlighted = 0
|
|
189
|
-
elif current in choice_ids:
|
|
190
|
-
widget.highlighted = choice_ids.index(current)
|
|
191
|
-
elif field.required and field.choices:
|
|
192
|
-
widget.highlighted = 0
|
|
193
|
-
return widget
|
|
194
206
|
if field.kind is FieldKind.MULTILINE:
|
|
195
207
|
widget = TextArea("" if value is None else str(value), id=widget_id)
|
|
196
208
|
widget.read_only = disabled
|
|
@@ -265,14 +277,15 @@ def _widget_value(widget: object) -> object:
|
|
|
265
277
|
return widget.text
|
|
266
278
|
if isinstance(widget, Checkbox):
|
|
267
279
|
return widget.value
|
|
268
|
-
if isinstance(widget, OptionList):
|
|
269
|
-
if widget.highlighted is None:
|
|
270
|
-
return None
|
|
271
|
-
selected = widget.get_option_at_index(widget.highlighted)
|
|
272
|
-
return None if selected.id == "__groundskeeping_blank_choice__" else selected.id
|
|
273
280
|
if isinstance(widget, Select):
|
|
274
281
|
selected = widget.value
|
|
275
|
-
|
|
282
|
+
if selected in {
|
|
283
|
+
Select.BLANK,
|
|
284
|
+
"__groundskeeping_blank_choice__",
|
|
285
|
+
"__groundskeeping_no_choice__",
|
|
286
|
+
}:
|
|
287
|
+
return None
|
|
288
|
+
return selected
|
|
276
289
|
return None
|
|
277
290
|
|
|
278
291
|
|