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.
Files changed (71) hide show
  1. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/PKG-INFO +1 -1
  2. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/pages.md +9 -2
  3. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/wizards.md +4 -0
  4. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/app.py +13 -0
  5. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/contracts/__init__.py +4 -0
  6. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/contracts/pages.py +8 -0
  7. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/contracts/views.py +28 -1
  8. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/demo.py +77 -0
  9. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/themes/groundskeeping.tcss +9 -0
  10. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/widgets/wizard.py +42 -29
  11. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/widgets/workbench.py +252 -2
  12. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/tests/test_app_smoke.py +244 -1
  13. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/tests/test_navigation_contracts.py +21 -0
  14. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/tests/test_wizard_contracts.py +92 -11
  15. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  16. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  17. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  18. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/.github/ISSUE_TEMPLATE/not_working.yml +0 -0
  19. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  20. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/.github/release-drafter.yml +0 -0
  21. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/.github/workflows/ci.yml +0 -0
  22. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/.github/workflows/docs.yml +0 -0
  23. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/.github/workflows/merge.yml +0 -0
  24. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/.github/workflows/publish.yml +0 -0
  25. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/.gitignore +0 -0
  26. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/CONTRIBUTING.md +0 -0
  27. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/README.md +0 -0
  28. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/actions.md +0 -0
  29. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/api/actions.md +0 -0
  30. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/api/app.md +0 -0
  31. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/api/configurator.md +0 -0
  32. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/api/jobs.md +0 -0
  33. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/api/pages.md +0 -0
  34. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/api/telemetry.md +0 -0
  35. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/api/views.md +0 -0
  36. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/api/widgets.md +0 -0
  37. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/api/wizards.md +0 -0
  38. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/configuration.md +0 -0
  39. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/contributing.md +0 -0
  40. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/index.md +0 -0
  41. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/ownership.md +0 -0
  42. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/quickstart.md +0 -0
  43. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/static/images/demo-layout-example.png +0 -0
  44. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/docs/telemetry.md +0 -0
  45. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/examples/demo_app.py +0 -0
  46. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/mkdocs.yml +0 -0
  47. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/pyproject.toml +0 -0
  48. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/__init__.py +0 -0
  49. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/configurator/__init__.py +0 -0
  50. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/configurator/adapter.py +0 -0
  51. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/configurator/models.py +0 -0
  52. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/contracts/actions.py +0 -0
  53. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/contracts/jobs.py +0 -0
  54. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/contracts/telemetry.py +0 -0
  55. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/contracts/wizards.py +0 -0
  56. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/navigation.py +0 -0
  57. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/py.typed +0 -0
  58. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/telemetry/__init__.py +0 -0
  59. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/telemetry/providers/__init__.py +0 -0
  60. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/telemetry/providers/fake.py +0 -0
  61. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/telemetry/runtime.py +0 -0
  62. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/theme.py +0 -0
  63. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/widgets/__init__.py +0 -0
  64. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/widgets/configurator.py +0 -0
  65. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/widgets/primitives.py +0 -0
  66. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/src/groundskeeping/widgets/telemetry.py +0 -0
  67. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/tests/test_actions_and_jobs.py +0 -0
  68. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/tests/test_configurator_adapter.py +0 -0
  69. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/tests/test_dependency_boundaries.py +0 -0
  70. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/tests/test_telemetry_core.py +0 -0
  71. {groundskeeping-0.2.2 → groundskeeping-0.3.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: groundskeeping
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Reusable Textual operator shell, workbench, configuration browser, and telemetry primitives.
5
5
  Author: UNSW CAVA platform tooling
6
6
  Requires-Python: >=3.12
@@ -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; it does not need to know
24
- what a Groundworkers resource or `cava-nlp-shard` evaluation object is.
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(self._field_widget(widget_id, field, snapshot.values.get(field.key)))
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(f"{choice.label}: {choice.description}", classes="wizard-field-help")
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 | OptionList:
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(value=bool(value if value is not None else field.default), id=widget_id, disabled=disabled)
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
- Option(field.placeholder or "No selection", id=self._blank_choice_id)
175
+ (field.placeholder or "No selection", self._blank_choice_id)
167
176
  )
168
177
  options.extend(
169
- Option(
178
+ (
170
179
  choice.label
171
180
  if choice.description is None
172
181
  else f"{choice.label} — {choice.description}",
173
- id=choice.value,
182
+ choice.value,
174
183
  )
175
184
  for choice in field.choices
176
185
  )
177
186
  if not options:
178
- options.append(Option(field.placeholder or "No choices", disabled=True))
179
- widget = OptionList(
180
- *options,
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
- return None if selected is Select.BLANK else selected
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