From 173559cc9173fc6362f2794049019d01c9f55a62 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 09:49:37 -0400 Subject: [PATCH 1/8] Read admitted field declarations and share native editor lifecycle (cherry picked from commit c5aed5fa723679ecc9f219a7a7d37a398e9016a0) --- src/toad/forms/declared_views.py | 66 ++++++++++++++++++------- src/toad/forms/editors.py | 83 +++++++++++++++++++------------- src/toad/forms/field_path.py | 38 ++------------- src/toad/forms/form_view.py | 65 ++++++++----------------- src/toad/preferences.py | 21 ++++---- src/toad/widgets/session_sort.py | 2 +- 6 files changed, 135 insertions(+), 140 deletions(-) diff --git a/src/toad/forms/declared_views.py b/src/toad/forms/declared_views.py index 5ee144919..f85a1989f 100644 --- a/src/toad/forms/declared_views.py +++ b/src/toad/forms/declared_views.py @@ -3,17 +3,26 @@ from __future__ import annotations from collections.abc import Iterable, Iterator -from dataclasses import MISSING, dataclass, replace +from dataclasses import dataclass from inspect import cleandoc -from typing import Annotated, ClassVar, Optional, Self, get_args, get_origin +from typing import Annotated, ClassVar, Optional, Self, TypeVar, get_args, get_origin +from formbridge.forms.ui_utils import FieldDisplayText from objectstate import ObjectState -from python_introspect import optional_member_type +from python_introspect import ( + ParameterInfo, + declared_field_label, + optional_member_type, + resolve_annotated, + resolve_optional, +) from python_introspect.validation import validate_annotation_value -from toad.forms.field_path import FieldPath, declared, declared_annotation, declared_field, label +from toad.forms.field_path import FieldPath from toad.forms.kinds import Placeholder +Metadata = TypeVar("Metadata") + class DeclaredViews: """A family of widget classes, each registered under what it shows. @@ -23,8 +32,9 @@ class DeclaredViews: of field is adding a class: nothing lists the members and nothing chooses between them by testing types. - Textual reads a widget's focus and CSS from its first base, so a member - names its Textual class first and its family after it. + Textual follows the first DOM base for native focus and CSS. A pure editor + capability may precede that base so its cooperative initializer binds the + field before the native control consumes it. """ shows: ClassVar[tuple[object, ...]] = () @@ -71,11 +81,23 @@ class DeclaredField: state: ObjectState path: FieldPath - annotation: object - @classmethod - def at(cls, state: ObjectState, path: FieldPath) -> DeclaredField: - return cls(state, path, declared_annotation(path)) + @property + def declaration(self) -> ParameterInfo: + return self.state.parameter_structure.declarations[str(self.path)] + + @property + def annotation(self) -> object: + return self.declaration.param_type + + def metadata(self, kind: type[Metadata]) -> Metadata | None: + """Metadata of the admitted field, including an optional member's metadata.""" + for annotation in (self.annotation, resolve_optional(resolve_annotated(self.annotation))): + if get_origin(annotation) is Annotated: + for item in get_args(annotation)[1:]: + if isinstance(item, kind): + return item + return None @property def value(self) -> object: @@ -83,30 +105,32 @@ def value(self) -> object: @property def default(self) -> object: - return self.state.signature_default(str(self.path)) + return self.declaration.default_value @property def has_default(self) -> bool: - field = declared_field(self.path) - return field.default is not MISSING or field.default_factory is not MISSING + return not self.declaration.is_required @property def placeholder(self) -> str: - hint = declared(self.path, Placeholder) + hint = self.metadata(Placeholder) return hint.text if hint is not None else "" @property def label(self) -> str: - return label(self.path) + return ( + declared_field_label(self.annotation) + or FieldDisplayText.from_field_name(self.declaration.name).display_name + ) @property def help(self) -> str: - return cleandoc(self.state.parameter_descriptions.get(str(self.path)) or "") + return cleandoc(self.declaration.description or "") @property def member(self) -> DeclaredField: """The same field as declared without `None`.""" - return replace(self, annotation=optional_member_type(self.annotation)) + return OptionalMemberField(self.state, self.path) def edit(self, value: object) -> None: """Give the field a value, unless its declaration does not accept it. @@ -118,3 +142,11 @@ def edit(self, value: object) -> None: # fails later, when the object is rebuilt (ObjectState issue #47). validate_annotation_value(self.annotation, value, path=self.label) self.state.update_parameter(str(self.path), value) + + +class OptionalMemberField(DeclaredField): + """The member editor reads a narrower annotation, not a copied declaration.""" + + @property + def annotation(self) -> object: + return optional_member_type(super().annotation) diff --git a/src/toad/forms/editors.py b/src/toad/forms/editors.py index e8965e2a3..31c9242c5 100644 --- a/src/toad/forms/editors.py +++ b/src/toad/forms/editors.py @@ -33,8 +33,8 @@ class ValueEditor(DeclaredViews, EditorWidget): """A control that shows one field's value and sends the user's edits to the state. The value lives in the ObjectState: the control is painted from it and - keeps nothing else. Each member is a Textual control first and an editor - second: `class BoolEditor(Checkbox, ValueEditor)`. + keeps nothing else. The editor capability binds the field before the native + control is initialized: `class BoolEditor(ValueEditor, Checkbox)`. """ states_default: ClassVar[bool] = True @@ -42,6 +42,19 @@ class ValueEditor(DeclaredViews, EditorWidget): field: DeclaredField + def __init__(self, field: DeclaredField) -> None: + self.field = field + super().__init__(**self.control_arguments()) + self.bind_control() + + def control_arguments(self) -> dict[str, object]: + """Arguments for the native control; containers need no initial value.""" + return {} + + def bind_control(self) -> None: + """Connect the native control's edits after it has been initialized.""" + raise NotImplementedError + @classmethod def declared_for(cls, field: DeclaredField) -> ValueEditor: return cls.showing(declarations(field.annotation))(field) @@ -68,12 +81,13 @@ def refuse(self, error: Exception) -> None: self.paint() -class BoolEditor(Checkbox, ValueEditor): +class BoolEditor(ValueEditor, Checkbox): shows = (bool,) - def __init__(self, field: DeclaredField) -> None: - self.field = field - super().__init__(value=bool(field.value)) + def control_arguments(self) -> dict[str, object]: + return {"value": bool(self.field.value)} + + def bind_control(self) -> None: self.listen(Checkbox.Changed, self.changed) def changed(self, message: Checkbox.Changed) -> None: @@ -85,16 +99,19 @@ def paint(self) -> None: self.value = bool(self.field.value) -class LineEditor(Input, ValueEditor): +class LineEditor(ValueEditor, Input): """One line of text, read as the field's type when the user leaves it or presses enter.""" accepts: ClassVar[InputType] - def __init__(self, field: DeclaredField) -> None: - self.field = field - super().__init__( - self.value_text(field.value), type=self.accepts, validators=[Function(self.acceptable)] - ) + def control_arguments(self) -> dict[str, object]: + return { + "value": self.value_text(self.field.value), + "type": self.accepts, + "validators": [Function(self.acceptable)], + } + + def bind_control(self) -> None: self.listen(Input.Blurred, self.finished) self.listen(Input.Submitted, self.finished) @@ -143,7 +160,7 @@ class StrEditor(LineEditor): accepts = "text" -class MultilineEditor(TextArea, ValueEditor): +class MultilineEditor(ValueEditor, TextArea): shows = (Multiline,) DEFAULT_CSS = """ @@ -154,9 +171,10 @@ class MultilineEditor(TextArea, ValueEditor): states_default = False - def __init__(self, field: DeclaredField) -> None: - self.field = field - super().__init__(str(field.value or "")) + def control_arguments(self) -> dict[str, object]: + return {"text": str(self.field.value or "")} + + def bind_control(self) -> None: self.listen(TextArea.Changed, self.changed) def changed(self, message: TextArea.Changed) -> None: @@ -170,20 +188,21 @@ def paint(self) -> None: self.load_text(value) -class SelectEditor(Select[object], ValueEditor): +class SelectEditor(ValueEditor, Select[object]): """One of a list of values. A value that is not on the list shows as its own text.""" - def __init__(self, field: DeclaredField) -> None: - self.field = field + def control_arguments(self) -> dict[str, object]: self.offered = tuple(self.options()) - value = field.value + value = self.field.value listed = self.lists(value) - super().__init__( - self.offered, - value=value if listed else Select.NULL, - allow_blank=not listed, - prompt=self.value_text(value), - ) + return { + "options": self.offered, + "value": value if listed else Select.NULL, + "allow_blank": not listed, + "prompt": self.value_text(value), + } + + def bind_control(self) -> None: self.listen(Select.Changed, self.changed) def options(self) -> Iterable[tuple[str, object]]: @@ -232,18 +251,16 @@ def options(self) -> Iterable[tuple[str, object]]: return ((format_enum_display(member), member) for member in members) -class OptionalEditor(containers.VerticalGroup, ValueEditor): +class OptionalEditor(ValueEditor, containers.VerticalGroup): """A field that may hold nothing: a box says whether it holds a value, and the editor of that value is shown while it does.""" shows = (Optional,) - def __init__(self, field: DeclaredField) -> None: - self.field = field - super().__init__() - self.holds_value = Checkbox("Set", value=field.value is not None) - self.member = ValueEditor.declared_for(field.member) - self.member.display = field.value is not None + def bind_control(self) -> None: + self.holds_value = Checkbox("Set", value=self.field.value is not None) + self.member = ValueEditor.declared_for(self.field.member) + self.member.display = self.field.value is not None self.holds_value.listen(Checkbox.Changed, self.held_changed) def compose(self) -> ComposeResult: diff --git a/src/toad/forms/field_path.py b/src/toad/forms/field_path.py index 710e4f690..2a3b221b1 100644 --- a/src/toad/forms/field_path.py +++ b/src/toad/forms/field_path.py @@ -3,14 +3,7 @@ from __future__ import annotations from dataclasses import Field, fields, is_dataclass -from functools import lru_cache -from typing import Annotated, TypeVar, get_args, get_origin - -from formbridge.forms.ui_utils import FieldDisplayText -from python_introspect import declared_field_label, resolve_annotated, resolve_optional -from python_introspect.annotation_types import resolved_class_annotations - -Declared = TypeVar("Declared") +from python_introspect import UnifiedParameterAnalyzer, resolve_annotated, resolve_optional class FieldPath: @@ -48,12 +41,6 @@ def __hash__(self) -> int: return hash((self._root, self._names)) -@lru_cache(maxsize=None) -def _annotations(owner: type) -> dict[str, object]: - # Declarations do not change while the process runs. - return resolved_class_annotations(owner) - - def parent(path: FieldPath) -> FieldPath: return FieldPath(path._root, path._names[:-1]) @@ -62,7 +49,9 @@ def declared_annotation(path: FieldPath) -> object: """The field's annotation as written, `Annotated` metadata included.""" if not path._names: return path._root - return _annotations(declared_type(parent(path)))[path._names[-1]] + return UnifiedParameterAnalyzer.analyze(declared_type(parent(path)))[ + path._names[-1] + ].param_type def declared_type(path: FieldPath) -> object: @@ -76,25 +65,6 @@ def declared_field(path: FieldPath) -> Field[object]: return next(field for field in fields(declared_type(parent(path))) if field.name == name) -def declared(path: FieldPath, kind: type[Declared]) -> Declared | None: - """The `Annotated` metadata of one kind declared on the field, if any.""" - annotation = declared_annotation(path) - for written in (annotation, resolve_optional(resolve_annotated(annotation))): - if get_origin(written) is Annotated: - for item in get_args(written)[1:]: - if isinstance(item, kind): - return item - return None - - -def label(path: FieldPath) -> str: - """What the field is called on screen: its declared label, or its name spelled out.""" - return ( - declared_field_label(declared_annotation(path)) - or FieldDisplayText.from_field_name(path._names[-1]).display_name - ) - - def steps(path: FieldPath) -> tuple[FieldPath, ...]: """The fields passed on the way down to this one, itself last.""" return tuple( diff --git a/src/toad/forms/form_view.py b/src/toad/forms/form_view.py index 26c5aaaba..4d14e76a5 100644 --- a/src/toad/forms/form_view.py +++ b/src/toad/forms/form_view.py @@ -4,14 +4,9 @@ from collections.abc import Iterator -from formbridge.forms.parameter_form_service import ( - FormStructure, - ParameterAnalysisInput, - ParameterFormService, -) +from formbridge.forms.parameter_form_service import ParameterFormService from formbridge.forms.parameter_info_types import DirectDataclassInfo, GenericInfo from objectstate import ObjectState -from python_introspect import UnifiedParameterAnalyzer, resolve_annotated from textual import containers, events, lazy from textual.app import ComposeResult from textual.content import Content @@ -19,30 +14,7 @@ from toad.forms.declared_views import DeclaredField, DeclaredViews from toad.forms.editors import ValueEditor -from toad.forms.field_path import FieldPath, label, steps - - -def form_structure(state: ObjectState) -> FormStructure: - """What formbridge finds in the object the state holds. - - formbridge is given each field's type without its `Annotated` metadata: - it does not see a nested dataclass through `Annotated` (formbridge issue #7). - The views read labels, choices and bounds from the declaration itself. - """ - held = state.object_instance - declared = UnifiedParameterAnalyzer.analyze(held) - return ParameterFormService().analyze_parameters( - ParameterAnalysisInput( - default_value={name: state.parameters[name] for name in declared}, - param_type={ - name: resolve_annotated(parameter.param_type) - for name, parameter in declared.items() - }, - field_id="", - description=state.parameter_descriptions, - parent_obj_type=type(held), - ) - ) +from toad.forms.field_path import FieldPath, steps class FieldTitle(Static): @@ -76,21 +48,25 @@ class GroupTitle(Static): class FieldView(containers.VerticalGroup, DeclaredViews): """One parameter of a form, shown the way its formbridge kind asks for.""" - def __init__(self, field: DeclaredField, structure: FormStructure, + def __init__(self, field: DeclaredField, included: frozenset[FieldPath] | None = None) -> None: super().__init__() self.field = field @staticmethod - def of(state: ObjectState, parent: FieldPath, structure: FormStructure, + def of(state: ObjectState, parent: FieldPath, included: frozenset[FieldPath] | None = None) -> list[FieldView]: - """A view for each parameter formbridge lists, in its order.""" + """Show ObjectState's direct children; FormBridge supplies each field's kind. + + ObjectState already extracted this topology. Reading it does not rebuild + a second form tree or re-analyze the held object's values. + """ views = [] - for parameter in structure.parameters: + for parameter in ParameterFormService().parameters_for_state(state, str(parent)): path = getattr(parent, parameter.name) if included is None or path in included: - field = DeclaredField.at(state, path) - views.append(FieldView.showing(type(parameter).__mro__)(field, structure, included)) + field = DeclaredField(state, path) + views.append(FieldView.showing(type(parameter).__mro__)(field, included)) return views def values(self) -> Iterator[ValueView]: @@ -118,12 +94,14 @@ class ValueView(FieldView): CHANGED_MARK = " ●" - def __init__(self, field: DeclaredField, structure: FormStructure, + def __init__(self, field: DeclaredField, included: frozenset[FieldPath] | None = None) -> None: - super().__init__(field, structure, included) + super().__init__(field, included) self.editor = ValueEditor.declared_for(field) self.title = FieldTitle(field.label) - self.search_text = " ".join(label(step) for step in steps(field.path)).lower() + self.search_text = " ".join( + DeclaredField(field.state, step).label for step in steps(field.path) + ).lower() def compose(self) -> ComposeResult: yield self.title @@ -175,11 +153,10 @@ class GroupView(FieldView): } """ - def __init__(self, field: DeclaredField, structure: FormStructure, + def __init__(self, field: DeclaredField, included: frozenset[FieldPath] | None = None) -> None: - super().__init__(field, structure, included) - name = str(field.path).rpartition(".")[2] - self.parts = FieldView.of(field.state, field.path, structure.nested_forms[name], included) + super().__init__(field, included) + self.parts = FieldView.of(field.state, field.path, included) def compose(self) -> ComposeResult: yield GroupTitle(self.field.label) @@ -218,7 +195,7 @@ def __init__(self, state: ObjectState, *, paths: tuple[FieldPath, ...] | None = included = (None if paths is None else frozenset(step for path in paths for step in steps(path))) self.parts = FieldView.of( - state, FieldPath(type(state.object_instance)), form_structure(state), included + state, FieldPath(type(state.object_instance)), included ) self.values = { str(view.field.path): view for part in self.parts for view in part.values() diff --git a/src/toad/preferences.py b/src/toad/preferences.py index 40de36100..da4547785 100644 --- a/src/toad/preferences.py +++ b/src/toad/preferences.py @@ -26,7 +26,6 @@ from toad.forms.declared_views import DeclaredField from toad.forms.field_path import ( FieldPath, - declared, declared_field, fields_below, leaves, @@ -327,7 +326,7 @@ def raise_save_error(error: atomic.AtomicWriteError) -> None: raise error -def no_effect(path: FieldPath, value: object) -> None: +def no_effect(field: DeclaredField) -> None: """Settings held outside an app change nothing but themselves.""" @@ -345,7 +344,7 @@ def __init__( raw: object = None, *, root: type[ToadSettings] = ToadSettings, - applied: Callable[[FieldPath, object], None] = no_effect, + applied: Callable[[DeclaredField], None] = no_effect, report_error: Callable[[atomic.AtomicWriteError], None] = raise_save_error, ) -> None: from toad.core.events import CoreEventStream @@ -369,10 +368,10 @@ def current(self) -> ToadSettings: return self.state.to_object() def set(self, path: FieldPath, value: object) -> None: - DeclaredField.at(self.state, path).edit(value) + DeclaredField(self.state, path).edit(value) def toggle(self, path: FieldPath) -> None: - setting = DeclaredField.at(self.state, path) + setting = DeclaredField(self.state, path) setting.edit(not setting.value) def changed(self, changed: set[str]) -> None: @@ -381,7 +380,7 @@ def changed(self, changed: set[str]) -> None: for name in changed & self.named.keys(): path = self.named[name] self.user_set.add(path) - self.applied(path, self.state.get_resolved_value(name)) + self.applied(DeclaredField(self.state, path)) self.events.publish(PreferenceChanged(path)) def discard(self) -> None: @@ -476,9 +475,9 @@ def open(cls, app: ToadApp) -> SavedSettings: path = cls.file_path() raw = json.loads(path.read_text("utf-8")) if path.exists() else {} - def applied(setting: FieldPath, value: object) -> None: - if (effect := declared(setting, effects.Effect)) is not None: - effect.apply(app, value) + def applied(field: DeclaredField) -> None: + if (effect := field.metadata(effects.Effect)) is not None: + effect.apply(app, field.value) def report(error: atomic.AtomicWriteError) -> None: app.notify(str(error), title="Settings", severity="error") @@ -487,8 +486,8 @@ def report(error: atomic.AtomicWriteError) -> None: def apply_all(self) -> None: """Bring a starting app in line with every setting.""" - for name, path in self.named.items(): - self.applied(path, self.state.get_resolved_value(name)) + for path in self.named.values(): + self.applied(DeclaredField(self.state, path)) def ensure_file(self, app: ToadApp) -> None: path = self.file_path() diff --git a/src/toad/widgets/session_sort.py b/src/toad/widgets/session_sort.py index c0ef7cf62..4b1a6dfe3 100644 --- a/src/toad/widgets/session_sort.py +++ b/src/toad/widgets/session_sort.py @@ -52,7 +52,7 @@ def __init__(self, control: SortControl, form: ThreadSortForm | ChannelSortForm) super().__init__() self.control = control self.state = ObjectState(form) - self.order_field = DeclaredField.at(self.state, FieldPath(type(form)).order) + self.order_field = DeclaredField(self.state, FieldPath(type(form)).order) self.form = FormView(self.state, focus=self.order_field.path) paths = control.visibility_fields() self.visibility_form = (FormView(self.app.saved_settings.state, paths=paths) From 3d40f5a27ec90df93eaa5792ce05dad156b95cc5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 10:15:54 -0400 Subject: [PATCH 2/8] Finish bound field migration in sidebar consumers (cherry picked from commit 698006a715a48fd8c25c636aa01d4b2844a41909) --- src/toad/comms_root.py | 4 ++-- src/toad/widgets/side_bar.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/toad/comms_root.py b/src/toad/comms_root.py index 1eafb4baa..73342492a 100644 --- a/src/toad/comms_root.py +++ b/src/toad/comms_root.py @@ -332,8 +332,8 @@ def sidebar_request(self) -> ObserveSidebar: state = self.app.saved_settings.state return ObserveSidebar( str(self.app.project_dir), - DeclaredField.at(state, SETTINGS.sidebar.show_stopped).value, - DeclaredField.at(state, SETTINGS.sidebar.show_archived).value, + DeclaredField(state, SETTINGS.sidebar.show_stopped).value, + DeclaredField(state, SETTINGS.sidebar.show_archived).value, ) def update_sidebar_interest(self) -> None: diff --git a/src/toad/widgets/side_bar.py b/src/toad/widgets/side_bar.py index 1f67e26f2..3e94bd334 100644 --- a/src/toad/widgets/side_bar.py +++ b/src/toad/widgets/side_bar.py @@ -664,7 +664,7 @@ def on_mount(self) -> None: app = cast("ToadApp", self.app) if self._navigation is None: self.observe_core(app.saved_settings.events) - self.collapsed = DeclaredField.at(app.saved_settings.state, SETTINGS.sidebar.hide).value + self.collapsed = DeclaredField(app.saved_settings.state, SETTINGS.sidebar.hide).value else: self.collapsed = self.hide self.watch_collapsed(self.collapsed) @@ -899,7 +899,7 @@ def render(self) -> str: @handles(PreferenceChanged) async def _settings_changed(self, message: CoreEventMessage) -> None: if message.event.path == SETTINGS.sidebar.hide: - self.collapsed = DeclaredField.at(self.app.saved_settings.state, message.event.path).value + self.collapsed = DeclaredField(self.app.saved_settings.state, message.event.path).value @on(SideBarToggle.Pressed) def on_toggle_pressed(self, event: SideBarToggle.Pressed) -> None: @@ -934,7 +934,7 @@ def toggle(self, *, focus: bool = True) -> None: app.saved_settings.set(SETTINGS.sidebar.hide, collapsed) # Project this input on its visible owner immediately. The shared # settings signal still updates other views, idempotently. - self.collapsed = DeclaredField.at(app.saved_settings.state, SETTINGS.sidebar.hide).value + self.collapsed = DeclaredField(app.saved_settings.state, SETTINGS.sidebar.hide).value else: self.collapsed = collapsed if collapsed: @@ -956,7 +956,7 @@ def reveal(self) -> None: self.collapsed = False def action_dismiss(self) -> None: - if DeclaredField.at(self.app.saved_settings.state, SETTINGS.sidebar.hide).value: + if DeclaredField(self.app.saved_settings.state, SETTINGS.sidebar.hide).value: self.collapsed = True self.post_message(self.Dismiss()) From 9371269d556989fbf25f8e7fa9ad5822fe17ada6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 10:51:12 -0400 Subject: [PATCH 3/8] Use native geometry and paint throughout settings and sort forms (cherry picked from commit 907840a70796fe76adba44b507dcfeea7dd29836) --- pyproject.toml | 4 +- src/toad/forms/editors.py | 32 +++--- src/toad/forms/form_view.py | 101 ++++++++---------- src/toad/forms/presentation.py | 175 +++++++++++++++++++++++++++++++ src/toad/screens/settings.py | 26 ++++- src/toad/screens/settings.tcss | 35 ------- src/toad/widgets/session_sort.py | 29 ++--- 7 files changed, 280 insertions(+), 122 deletions(-) create mode 100644 src/toad/forms/presentation.py delete mode 100644 src/toad/screens/settings.tcss diff --git a/pyproject.toml b/pyproject.toml index f5b87ceeb..167d63a12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,8 +30,8 @@ dependencies = [ "agent-comms[acp]", "agent-client-protocol==0.12.1", "ui-theme", - "formbridge==0.1.0", - "objectstate==2.1.1", + "formbridge==0.1.5", + "objectstate==4.0.1", ] [tool.uv.workspace] diff --git a/src/toad/forms/editors.py b/src/toad/forms/editors.py index 31c9242c5..23c94fe5f 100644 --- a/src/toad/forms/editors.py +++ b/src/toad/forms/editors.py @@ -16,12 +16,16 @@ from textual import containers from textual.app import ComposeResult from textual.validation import Function +from textual.geometry import Spacing +from textual.css.scalar import Scalar +from textual.color import TRANSPARENT from textual.widget import Widget from textual.widgets import Checkbox, Input, Select, TextArea from textual.widgets._input import InputType from toad.forms.declared_views import DeclaredField, DeclaredViews, declarations from toad.forms.kinds import Multiline +from toad.forms.presentation import FormCheckbox, FormInput, FormPart, FormSelectCurrent, FramedControl if TYPE_CHECKING: EditorWidget = Widget @@ -81,7 +85,7 @@ def refuse(self, error: Exception) -> None: self.paint() -class BoolEditor(ValueEditor, Checkbox): +class BoolEditor(ValueEditor, FormCheckbox): shows = (bool,) def control_arguments(self) -> dict[str, object]: @@ -99,7 +103,7 @@ def paint(self) -> None: self.value = bool(self.field.value) -class LineEditor(ValueEditor, Input): +class LineEditor(ValueEditor, FormInput): """One line of text, read as the field's type when the user leaves it or presses enter.""" accepts: ClassVar[InputType] @@ -160,14 +164,11 @@ class StrEditor(LineEditor): accepts = "text" -class MultilineEditor(ValueEditor, TextArea): +class MultilineEditor(ValueEditor, FramedControl, TextArea): shows = (Multiline,) - - DEFAULT_CSS = """ - MultilineEditor { - height: 10; - } - """ + height = Scalar.from_number(10) + padding = Spacing(0, 1, 0, 1) + background_tint = TRANSPARENT states_default = False @@ -188,9 +189,16 @@ def paint(self) -> None: self.load_text(value) -class SelectEditor(ValueEditor, Select[object]): +class SelectEditor(ValueEditor, FormPart, Select[object]): """One of a list of values. A value that is not on the list shows as its own text.""" + CURRENT = FormSelectCurrent + + def watch_has_focus(self, has_focus: bool) -> None: + super().watch_has_focus(has_focus) + if self.is_mounted: + self.query_one(FormSelectCurrent).refresh_paint() + def control_arguments(self) -> dict[str, object]: self.offered = tuple(self.options()) value = self.field.value @@ -251,14 +259,14 @@ def options(self) -> Iterable[tuple[str, object]]: return ((format_enum_display(member), member) for member in members) -class OptionalEditor(ValueEditor, containers.VerticalGroup): +class OptionalEditor(ValueEditor, FormPart, containers.VerticalGroup): """A field that may hold nothing: a box says whether it holds a value, and the editor of that value is shown while it does.""" shows = (Optional,) def bind_control(self) -> None: - self.holds_value = Checkbox("Set", value=self.field.value is not None) + self.holds_value = FormCheckbox("Set", value=self.field.value is not None) self.member = ValueEditor.declared_for(self.field.member) self.member.display = self.field.value is not None self.holds_value.listen(Checkbox.Changed, self.held_changed) diff --git a/src/toad/forms/form_view.py b/src/toad/forms/form_view.py index 4d14e76a5..2d3295131 100644 --- a/src/toad/forms/form_view.py +++ b/src/toad/forms/form_view.py @@ -11,41 +11,41 @@ from textual.app import ComposeResult from textual.content import Content from textual.widgets import Static +from textual.css._style_properties import Edges +from textual.geometry import Spacing +from textual.layouts.stream import StreamLayout +from rich.style import Style +from ui_theme.scheme import BackgroundColour, ForegroundColour, MutedColour, PrimaryColour, SecondaryColour from toad.forms.declared_views import DeclaredField, DeclaredViews from toad.forms.editors import ValueEditor from toad.forms.field_path import FieldPath, steps +from toad.forms.presentation import AUTO, FormPart -class FieldTitle(Static): - DEFAULT_CSS = """ - FieldTitle { - padding: 0 0 0 1; - text-style: bold; - } - """ +class FieldTitle(FormPart, Static): + padding = Spacing(0, 0, 0, 1) + text_style = Style(bold=True) -class FieldHelp(Static): - DEFAULT_CSS = """ - FieldHelp { - color: $text-muted; - padding: 0 0 0 1; - margin-bottom: 1; - } - """ +class FieldHelp(FormPart, Static): + padding = Spacing(0, 0, 0, 1) + margin = Spacing(0, 0, 1, 0) + @property + def paint_foreground(self): + return self.app.colours.colour(MutedColour) -class GroupTitle(Static): - DEFAULT_CSS = """ - GroupTitle { - padding: 0 0 0 1; - color: $primary; - } - """ + +class GroupTitle(FormPart, Static): + padding = Spacing(0, 0, 0, 1) + + @property + def paint_foreground(self): + return self.app.colours.colour(PrimaryColour) -class FieldView(containers.VerticalGroup, DeclaredViews): +class FieldView(FormPart, containers.VerticalGroup, DeclaredViews): """One parameter of a form, shown the way its formbridge kind asks for.""" def __init__(self, field: DeclaredField, @@ -53,6 +53,10 @@ def __init__(self, field: DeclaredField, super().__init__() self.field = field + @property + def margin(self) -> Spacing: + return Spacing(0, 0, 0 if self.last_child else 1, 0) + @staticmethod def of(state: ObjectState, parent: FieldPath, included: frozenset[FieldPath] | None = None) -> list[FieldView]: @@ -83,15 +87,6 @@ class ValueView(FieldView): shows = (GenericInfo,) - DEFAULT_CSS = """ - ValueView { - margin-bottom: 1; - &:last-child { - margin-bottom: 0; - } - } - """ - CHANGED_MARK = " ●" def __init__(self, field: DeclaredField, @@ -136,22 +131,15 @@ class GroupView(FieldView): shows = (DirectDataclassInfo,) - DEFAULT_CSS = """ - GroupView { - border: tall $secondary-muted; - padding: 0 1; - margin-bottom: 1; - &:light { - border: tall $foreground 20%; - } - &:focus-within { - border: tall $secondary; - } - &:last-child { - margin-bottom: 0; - } - } - """ + padding = Spacing(0, 1, 0, 1) + + @property + def border(self) -> Edges: + colours = self.app.colours + colour = (colours.colour(SecondaryColour) if self.has_focus_within else + colours.mixed(BackgroundColour, SecondaryColour, 0.3) if colours.scheme.dark else + colours.colour(ForegroundColour).multiply_alpha(0.2)) + return Edges(*(("tall", colour),) * 4) def __init__(self, field: DeclaredField, included: frozenset[FieldPath] | None = None) -> None: @@ -172,7 +160,7 @@ def filter(self, term: str) -> bool: return bool(self.display) -class FormView(containers.VerticalScroll, can_focus=False): +class FormView(FormPart, containers.VerticalScroll, can_focus=False): """Every field of the object an ObjectState holds, edited in place. The form holds no values. An edit goes to the state; the state says which @@ -181,12 +169,9 @@ class FormView(containers.VerticalScroll, can_focus=False): long form does not hold up the frame it opens in. """ - DEFAULT_CSS = """ - FormView { - layout: stream; - overflow: hidden scroll; - } - """ + DEFAULT_LAYOUT = StreamLayout + height = None + overflow_y = "scroll" def __init__(self, state: ObjectState, *, paths: tuple[FieldPath, ...] | None = None, focus: FieldPath | None = None) -> None: @@ -234,3 +219,9 @@ def filter(self, term: str) -> None: """Show only the fields whose labels contain the term.""" for part in self.parts: part.filter(term.lower()) + + +class InlineFormView(FormView): + """An intrinsically sized form inside a bounded dialog panel.""" + + height = AUTO diff --git a/src/toad/forms/presentation.py b/src/toad/forms/presentation.py new file mode 100644 index 000000000..d3a51f1ea --- /dev/null +++ b/src/toad/forms/presentation.py @@ -0,0 +1,175 @@ +"""Native form geometry and paint, supplied directly to Textual's owners.""" +from __future__ import annotations + +from rich.style import NULL_STYLE +from textual import events +from textual._paint_state import PaintLayer +from textual._styles_cache import FramePaint +from textual.box_model import BoxGeometry +from textual.color import Color, TRANSPARENT +from textual.css._style_properties import Edges +from textual.css.scalar import Scalar, ScalarOffset, Unit +from textual.geometry import Spacing +from textual.layouts.horizontal import HorizontalLayout +from textual.widgets._select import SelectCurrent +from textual.widgets import Checkbox, Input +from ui_theme.scheme import BackgroundColour, ErrorColour, ForegroundColour, PrimaryColour, SurfaceColour + + +AUTO = Scalar(1.0, Unit.AUTO, Unit.AUTO) + + +class FormPart(BoxGeometry, PaintLayer, FramePaint): + """The widget itself supplies native inputs; there is no styles mirror. + + Mutable state remains on the native control. Non-versioned paint inputs + are read live instead of being cached under an unrelated CSS generation. + The existing line cache still retains rendered strips and checks its inputs. + """ + + width = None + height = AUTO + min_width = min_height = max_width = max_height = None + padding = margin = Spacing(0, 0, 0, 0) + border = outline = Edges(*(("", TRANSPARENT),) * 4) + box_sizing = "border-box" + width_policy = "greedy" + dock = split = "none" + overflow_x = overflow_y = "hidden" + scrollbar_policy = "auto" + vertical_scrollbar_size = 2 + horizontal_scrollbar_size = 1 + scrollbar_visibility = "visible" + align_horizontal = "left" + align_vertical = "top" + grid_rows = grid_columns = () + grid_size_columns = 1 + grid_size_rows = grid_gutter_horizontal = grid_gutter_vertical = 0 + row_span = column_span = 1 + keyline = ("none", TRANSPARENT) + position = "relative" + overlay = constrain_x = constrain_y = "none" + child_layout = None + placement_offset = None + layer_name = "default" + layer_order = None + + paint_foreground = paint_background = None + background_tint = tint = TRANSPARENT + text_style = NULL_STYLE + text_opacity = layer_opacity = 1.0 + auto_color = False + paint_key = None + paint_cacheable = False + hatch = None + border_title_align = "left" + border_subtitle_align = "right" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.listen(events.DescendantFocus, self.focus_changed) + self.listen(events.DescendantBlur, self.focus_changed) + + @property + def geometry(self) -> BoxGeometry: + return self + + @property + def paint_layer(self) -> PaintLayer: + return self + + @property + def frame_paint(self) -> FramePaint: + return self + + @property + def paint_gutter(self) -> Spacing: + return self.box_gutter + + def set_placement_offset(self, offset: ScalarOffset) -> None: + self.placement_offset = offset + self.refresh(layout=True) + + def focus_changed(self, event) -> None: + self.refresh_paint() + + +class FramedControl(FormPart): + height = Scalar.from_number(3) + padding = Spacing(0, 2, 0, 2) + horizontal_scrollbar_size = 0 + + @property + def control_focused(self) -> bool: + return self.has_focus or self.has_focus_within + + @property + def border_colour(self) -> Color: + return (self.app.colours.colour(PrimaryColour) if self.control_focused + else Color(0, 0, 0, 0.2)) + + @property + def border(self) -> Edges: + return Edges(*(("tall", self.border_colour),) * 4) + + @property + def background_tint(self) -> Color: + return (self.app.colours.colour(ForegroundColour).multiply_alpha(0.05) + if self.control_focused else TRANSPARENT) + + @property + def paint_background(self) -> Color: + return self.app.colours.colour(SurfaceColour) + + +class FormInput(FramedControl, Input): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.listen(Input.Changed, self.validation_changed) + + def validation_changed(self, message: Input.Changed) -> None: + self.refresh_paint() + + @property + def border_colour(self) -> Color: + return (super().border_colour if self.is_valid else + self.app.colours.colour(ErrorColour).multiply_alpha(0.6)) + + +class FormCheckbox(FramedControl, Checkbox): + width = AUTO + padding = Spacing(0, 1, 0, 1) + + +class FormSelectCurrent(FramedControl, SelectCurrent): + """The existing Select owns interaction; its current-value child owns its frame.""" + + DEFAULT_LAYOUT = HorizontalLayout + height = AUTO + + @property + def control_focused(self) -> bool: + return self.parent.has_focus or self.parent.has_focus_within + + +class FormModal(FormPart): + """Shared native modal presentation and theme invalidation lifecycle.""" + + height = None + align_horizontal = "center" + align_vertical = "middle" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.listen(events.Mount, self.follow_theme) + + @property + def paint_background(self) -> Color: + return self.app.colours.colour(BackgroundColour).multiply_alpha(0.6) + + def follow_theme(self, event: events.Mount) -> None: + self.app.theme_changed_signal.subscribe(self, self.theme_changed) + + def theme_changed(self, theme) -> None: + # A theme changes the entire visible modal, not each offscreen field. + self.refresh() diff --git a/src/toad/screens/settings.py b/src/toad/screens/settings.py index 87c4572d3..30cc7418f 100644 --- a/src/toad/screens/settings.py +++ b/src/toad/screens/settings.py @@ -9,10 +9,14 @@ from textual.binding import CallBinding from toad.screens.modal import ModalScreen from textual.widgets import Footer, Input +from textual.css.scalar import Scalar, Unit +from textual.geometry import Spacing +from textual.color import Color from toad.app import ToadApp from toad.forms.editors import SelectEditor from toad.forms.form_view import FormView +from toad.forms.presentation import FormInput, FormModal, FormPart from toad.setting_choices import SchemeName @@ -26,21 +30,33 @@ def options(self) -> Iterable[tuple[str, object]]: return ((name, name) for name in self.app.colours.schemes) -class SettingsPanel(containers.Vertical): +class SettingsPanel(FormPart, containers.Vertical): """The side of the screen the settings are shown in.""" + height = None + padding = Spacing(0, 1, 0, 1) + paint_background = Color(0, 0, 0, 0.1) -class SettingsSearch(containers.VerticalGroup): + @property + def width(self): + return None if self.screen.has_class("-narrow") else Scalar(50, Unit.PERCENT, Unit.WIDTH) + + +class SettingsSearch(FormPart, containers.VerticalGroup): """Stays above the settings while they scroll.""" + dock = "top" + padding = Spacing(1, 0, 1, 0) + -class SettingsScreen(ModalScreen[None]): - CSS_PATH = "settings.tcss" +class SettingsScreen(FormModal, ModalScreen[None]): + align_horizontal = "right" + align_vertical = "top" app = getters.app(ToadApp) def __init__(self) -> None: super().__init__() - self.search = Input(placeholder="Search settings") + self.search = FormInput(placeholder="Search settings") self.form = FormView(self.app.saved_settings.state) self.search.listen(Input.Changed, self.searched) self.listen(events.Mount, self.opened) diff --git a/src/toad/screens/settings.tcss b/src/toad/screens/settings.tcss deleted file mode 100644 index 2b2668356..000000000 --- a/src/toad/screens/settings.tcss +++ /dev/null @@ -1,35 +0,0 @@ -SettingsScreen { - overflow: hidden; - background: $background 60%; - align-horizontal: right; - - &.-narrow SettingsPanel { - width: 1fr; - } - - SettingsPanel { - width: 50%; - padding: 0 1; - background: black 10%; - } - - SettingsSearch { - dock: top; - padding: 1 0; - } - - Input, Select SelectCurrent, Checkbox, TextArea { - border: tall black 20%; - &:focus { - border: tall $primary; - } - } - - Select:focus > SelectCurrent { - border: tall $primary; - } - - Input.-invalid { - border: tall $error 60%; - } -} diff --git a/src/toad/widgets/session_sort.py b/src/toad/widgets/session_sort.py index 4b1a6dfe3..fd81e26a7 100644 --- a/src/toad/widgets/session_sort.py +++ b/src/toad/widgets/session_sort.py @@ -18,10 +18,13 @@ from toad.screens.modal import ModalScreen from textual.widgets import Static from textual.widget import PrimaryActivation +from textual.css.scalar import Scalar, Unit +from textual.geometry import Spacing from toad.forms.declared_views import DeclaredField from toad.forms.field_path import FieldPath -from toad.forms.form_view import FormView +from toad.forms.form_view import InlineFormView +from toad.forms.presentation import FormModal, FormPart from toad.forms.kinds import Placeholder from toad.preferences import SETTINGS from toad.setting_choices import FamilyChoices @@ -43,7 +46,15 @@ class ChannelSortForm: ] -class SortDialog(ModalScreen[type[DisplayOrder] | None]): +class SortPanel(FormPart, VerticalScroll): + width = Scalar.from_number(52) + max_width = Scalar(90, Unit.PERCENT, Unit.WIDTH) + max_height = Scalar(90, Unit.VIEW_HEIGHT, Unit.VIEW_HEIGHT) + padding = Spacing(1, 2, 1, 2) + overflow_y = "auto" + + +class SortDialog(FormModal, ModalScreen[type[DisplayOrder] | None]): """A short-lived edit form, not a second source of persisted ordering.""" BINDINGS = [("escape", "cancel", "Close")] @@ -53,27 +64,19 @@ def __init__(self, control: SortControl, form: ThreadSortForm | ChannelSortForm) self.control = control self.state = ObjectState(form) self.order_field = DeclaredField(self.state, FieldPath(type(form)).order) - self.form = FormView(self.state, focus=self.order_field.path) + self.form = InlineFormView(self.state, focus=self.order_field.path) paths = control.visibility_fields() - self.visibility_form = (FormView(self.app.saved_settings.state, paths=paths) + self.visibility_form = (InlineFormView(self.app.saved_settings.state, paths=paths) if paths else None) def compose(self): - with VerticalScroll() as panel: - panel.styles.width = 52 - panel.styles.max_width = "90%" - panel.styles.height = "auto" - panel.styles.max_height = "90vh" - panel.styles.padding = (1, 2) + with SortPanel(): yield Static(f"Sort {self.control.scope}") - self.form.styles.height = "auto" yield self.form if self.visibility_form is not None: - self.visibility_form.styles.height = "auto" yield self.visibility_form def on_mount(self): - self.styles.align = ("center", "middle") self.state.on_resolved_changed(self.order_changed) def order_changed(self, paths: set[str]): From 76af2d92d0b503ae84f191d311674b4a45ba6d86 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 15 Oct 2026 13:03:36 -0400 Subject: [PATCH 4/8] Bind forms and saved settings to admitted ObjectState paths (cherry picked from commit bff49a4fdbdb6fa8b91d82dc60dad5c0b926992d) --- src/toad/comms_root.py | 5 +-- src/toad/core/preference_events.py | 4 +- src/toad/forms/declared_views.py | 14 ++++--- src/toad/forms/field_path.py | 52 +++++++++++------------- src/toad/forms/form_view.py | 38 +++++++++--------- src/toad/preferences.py | 57 +++++++++++++-------------- src/toad/screens/store.py | 2 +- src/toad/widgets/conversation.py | 2 +- src/toad/widgets/recovery_view.py | 2 +- src/toad/widgets/session_sort.py | 5 +-- src/toad/widgets/side_bar.py | 11 +++--- tests/textual/test_settings_screen.py | 9 +++-- 12 files changed, 97 insertions(+), 104 deletions(-) diff --git a/src/toad/comms_root.py b/src/toad/comms_root.py index 73342492a..0e84bad41 100644 --- a/src/toad/comms_root.py +++ b/src/toad/comms_root.py @@ -32,7 +32,6 @@ from agent_comms.ui_model.sidebar import SidebarModel, SidebarObservation from agent_comms.ui_model.status import OpenThreadStatus from toad.core.events import CoreEventStream, CoordinationObserved, ExternalTranscriptChanged, OpenTabsChanged, LinkContextChanged -from toad.forms.declared_views import DeclaredField from toad.preferences import SETTINGS from agent_comms.display_references import PendingReferences, UnavailableReferences @@ -332,8 +331,8 @@ def sidebar_request(self) -> ObserveSidebar: state = self.app.saved_settings.state return ObserveSidebar( str(self.app.project_dir), - DeclaredField(state, SETTINGS.sidebar.show_stopped).value, - DeclaredField(state, SETTINGS.sidebar.show_archived).value, + SETTINGS.sidebar.show_stopped.bind(state).value, + SETTINGS.sidebar.show_archived.bind(state).value, ) def update_sidebar_interest(self) -> None: diff --git a/src/toad/core/preference_events.py b/src/toad/core/preference_events.py index cf01fe1be..d3fb98c38 100644 --- a/src/toad/core/preference_events.py +++ b/src/toad/core/preference_events.py @@ -2,7 +2,7 @@ from dataclasses import dataclass -from toad.forms.field_path import FieldPath +from toad.forms.declared_views import DeclaredField from .events import CoreEvent @@ -10,4 +10,4 @@ class PreferenceChanged(CoreEvent): """Read this setting again; what the app does on the change has been done.""" - path: FieldPath + field: DeclaredField diff --git a/src/toad/forms/declared_views.py b/src/toad/forms/declared_views.py index f85a1989f..c372fdff1 100644 --- a/src/toad/forms/declared_views.py +++ b/src/toad/forms/declared_views.py @@ -8,7 +8,7 @@ from typing import Annotated, ClassVar, Optional, Self, TypeVar, get_args, get_origin from formbridge.forms.ui_utils import FieldDisplayText -from objectstate import ObjectState +from objectstate import DottedFieldPath, ObjectState from python_introspect import ( ParameterInfo, declared_field_label, @@ -18,7 +18,6 @@ ) from python_introspect.validation import validate_annotation_value -from toad.forms.field_path import FieldPath from toad.forms.kinds import Placeholder Metadata = TypeVar("Metadata") @@ -80,11 +79,14 @@ class DeclaredField: """One field of an ObjectState: where its value lives and how it was declared.""" state: ObjectState - path: FieldPath + path: DottedFieldPath + + def __post_init__(self) -> None: + object.__setattr__(self, "path", self.state.parameter_structure.path(self.path.value)) @property def declaration(self) -> ParameterInfo: - return self.state.parameter_structure.declarations[str(self.path)] + return self.state.parameter_structure.declarations[self.path.value] @property def annotation(self) -> object: @@ -101,7 +103,7 @@ def metadata(self, kind: type[Metadata]) -> Metadata | None: @property def value(self) -> object: - return self.state.get_resolved_value(str(self.path)) + return self.state.get_resolved_value(self.path.value) @property def default(self) -> object: @@ -141,7 +143,7 @@ def edit(self, value: object) -> None: # ObjectState takes any value for a field of the root object and only # fails later, when the object is rebuilt (ObjectState issue #47). validate_annotation_value(self.annotation, value, path=self.label) - self.state.update_parameter(str(self.path), value) + self.state.update_parameter(self.path.value, value) class OptionalMemberField(DeclaredField): diff --git a/src/toad/forms/field_path.py b/src/toad/forms/field_path.py index 2a3b221b1..8d7c01d55 100644 --- a/src/toad/forms/field_path.py +++ b/src/toad/forms/field_path.py @@ -2,7 +2,13 @@ from __future__ import annotations -from dataclasses import Field, fields, is_dataclass +from dataclasses import fields, is_dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from objectstate import ObjectState + from toad.forms.declared_views import DeclaredField + from python_introspect import UnifiedParameterAnalyzer, resolve_annotated, resolve_optional @@ -40,6 +46,21 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash((self._root, self._names)) + def bind(self, state: ObjectState) -> DeclaredField: + """Bind a declaration reference through ObjectState's admitted topology.""" + from toad.forms.declared_views import DeclaredField + + if not issubclass(state.type_for_path(""), self._root): + raise TypeError(f"{self!r} does not name a field of this ObjectState") + return DeclaredField(state, state.parameter_structure.path(str(self))) + + def matches(self, field: DeclaredField) -> bool: + """Whether this declaration names the bound field, including its root type.""" + return ( + issubclass(field.state.type_for_path(""), self._root) + and field.path.parts == self._names + ) + def parent(path: FieldPath) -> FieldPath: return FieldPath(path._root, path._names[:-1]) @@ -57,32 +78,3 @@ def declared_annotation(path: FieldPath) -> object: def declared_type(path: FieldPath) -> object: """The type a value of the field has, without `Annotated` and `Optional`.""" return resolve_annotated(resolve_optional(resolve_annotated(declared_annotation(path)))) - - -def declared_field(path: FieldPath) -> Field[object]: - """The dataclass field the path names, with the metadata it was declared with.""" - name = path._names[-1] - return next(field for field in fields(declared_type(parent(path))) if field.name == name) - - -def steps(path: FieldPath) -> tuple[FieldPath, ...]: - """The fields passed on the way down to this one, itself last.""" - return tuple( - FieldPath(path._root, path._names[:count]) for count in range(1, len(path._names) + 1) - ) - - -def fields_below(path: FieldPath) -> tuple[FieldPath, ...]: - """The fields of the dataclass this path holds; none when it holds a plain value.""" - owner = declared_type(path) - if not is_dataclass(owner): - return () - return tuple(getattr(path, field.name) for field in fields(owner)) - - -def leaves(path: FieldPath) -> tuple[FieldPath, ...]: - """Every plain-valued field at or below this path.""" - below = fields_below(path) - if not below: - return (path,) - return tuple(leaf for child in below for leaf in leaves(child)) diff --git a/src/toad/forms/form_view.py b/src/toad/forms/form_view.py index 2d3295131..6176c8c29 100644 --- a/src/toad/forms/form_view.py +++ b/src/toad/forms/form_view.py @@ -6,7 +6,7 @@ from formbridge.forms.parameter_form_service import ParameterFormService from formbridge.forms.parameter_info_types import DirectDataclassInfo, GenericInfo -from objectstate import ObjectState +from objectstate import DottedFieldPath, ObjectState from textual import containers, events, lazy from textual.app import ComposeResult from textual.content import Content @@ -19,7 +19,7 @@ from toad.forms.declared_views import DeclaredField, DeclaredViews from toad.forms.editors import ValueEditor -from toad.forms.field_path import FieldPath, steps +from toad.forms.field_path import FieldPath from toad.forms.presentation import AUTO, FormPart @@ -49,7 +49,7 @@ class FieldView(FormPart, containers.VerticalGroup, DeclaredViews): """One parameter of a form, shown the way its formbridge kind asks for.""" def __init__(self, field: DeclaredField, - included: frozenset[FieldPath] | None = None) -> None: + included: frozenset[DottedFieldPath] | None = None) -> None: super().__init__() self.field = field @@ -58,16 +58,15 @@ def margin(self) -> Spacing: return Spacing(0, 0, 0 if self.last_child else 1, 0) @staticmethod - def of(state: ObjectState, parent: FieldPath, - included: frozenset[FieldPath] | None = None) -> list[FieldView]: + def of(state: ObjectState, parent: str = "", + included: frozenset[DottedFieldPath] | None = None) -> list[FieldView]: """Show ObjectState's direct children; FormBridge supplies each field's kind. ObjectState already extracted this topology. Reading it does not rebuild a second form tree or re-analyze the held object's values. """ views = [] - for parameter in ParameterFormService().parameters_for_state(state, str(parent)): - path = getattr(parent, parameter.name) + for path, parameter in ParameterFormService().parameters_for_state(state, parent): if included is None or path in included: field = DeclaredField(state, path) views.append(FieldView.showing(type(parameter).__mro__)(field, included)) @@ -90,12 +89,13 @@ class ValueView(FieldView): CHANGED_MARK = " ●" def __init__(self, field: DeclaredField, - included: frozenset[FieldPath] | None = None) -> None: + included: frozenset[DottedFieldPath] | None = None) -> None: super().__init__(field, included) self.editor = ValueEditor.declared_for(field) self.title = FieldTitle(field.label) self.search_text = " ".join( - DeclaredField(field.state, step).label for step in steps(field.path) + DeclaredField(field.state, step).label + for step in field.state.parameter_structure.ancestors(field.path) ).lower() def compose(self) -> ComposeResult: @@ -142,9 +142,9 @@ def border(self) -> Edges: return Edges(*(("tall", colour),) * 4) def __init__(self, field: DeclaredField, - included: frozenset[FieldPath] | None = None) -> None: + included: frozenset[DottedFieldPath] | None = None) -> None: super().__init__(field, included) - self.parts = FieldView.of(field.state, field.path, included) + self.parts = FieldView.of(field.state, field.path.value, included) def compose(self) -> ComposeResult: yield GroupTitle(self.field.label) @@ -174,19 +174,19 @@ class FormView(FormPart, containers.VerticalScroll, can_focus=False): overflow_y = "scroll" def __init__(self, state: ObjectState, *, paths: tuple[FieldPath, ...] | None = None, - focus: FieldPath | None = None) -> None: + focus: DottedFieldPath | None = None) -> None: super().__init__() self.state = state - included = (None if paths is None else - frozenset(step for path in paths for step in steps(path))) - self.parts = FieldView.of( - state, FieldPath(type(state.object_instance)), included - ) + included = (None if paths is None else frozenset( + step for path in paths + for step in state.parameter_structure.ancestors(path.bind(state).path) + )) + self.parts = FieldView.of(state, included=included) self.values = { - str(view.field.path): view for part in self.parts for view in part.values() + view.field.path.value: view for part in self.parts for view in part.values() } if focus is not None: - editor = self.values[str(focus)].editor + editor = self.values[focus.value].editor editor.listen(events.Mount, lambda _: editor.focus()) self.listen(events.Mount, self.follow_state) self.listen(events.Unmount, self.leave_state) diff --git a/src/toad/preferences.py b/src/toad/preferences.py index da4547785..3a12e4eb0 100644 --- a/src/toad/preferences.py +++ b/src/toad/preferences.py @@ -18,18 +18,13 @@ from agent_comms.field_codec import FieldCodec from annotated_types import Ge -from objectstate import ObjectState, set_base_config_type +from objectstate import DottedFieldPath, ObjectState, set_base_config_type from python_introspect import AnnotatedDataclassValidationMixin, FieldLabel from toad import atomic, paths from toad import setting_effects as effects from toad.forms.declared_views import DeclaredField -from toad.forms.field_path import ( - FieldPath, - declared_field, - fields_below, - leaves, -) +from toad.forms.field_path import FieldPath from toad.forms.kinds import Multiline from toad.setting_choices import ( AlwaysSessionBar, @@ -352,8 +347,7 @@ def __init__( raw = {} if raw is None else raw self.root = FieldPath(root) self.state = ObjectState(FieldCodec.decode(root, raw)) - self.named = {str(path): path for path in leaves(self.root)} - self.in_file = frozenset(self.keys_held(self.root, raw)) + self.in_file = frozenset(self.keys_held("", raw)) self.user_set = set(self.in_file) self.applied, self.report_error = applied, report_error self.events = CoreEventStream(self) @@ -368,20 +362,24 @@ def current(self) -> ToadSettings: return self.state.to_object() def set(self, path: FieldPath, value: object) -> None: - DeclaredField(self.state, path).edit(value) + setting = path.bind(self.state) + setting.edit(value) + self.user_set.add(setting.path) def toggle(self, path: FieldPath) -> None: - setting = DeclaredField(self.state, path) + setting = path.bind(self.state) setting.edit(not setting.value) def changed(self, changed: set[str]) -> None: from toad.core.preference_events import PreferenceChanged - for name in changed & self.named.keys(): - path = self.named[name] - self.user_set.add(path) - self.applied(DeclaredField(self.state, path)) - self.events.publish(PreferenceChanged(path)) + for name in changed: + if (name in self.state.parameter_structure.declarations + and not self.state.has_parameter_descendants(name)): + setting = DeclaredField(self.state, self.state.parameter_structure.path(name)) + self.user_set.add(setting.path) + self.applied(setting) + self.events.publish(PreferenceChanged(setting)) def discard(self) -> None: """Go back to what the file holds.""" @@ -394,28 +392,29 @@ def discard(self) -> None: def file_path() -> Path: return paths.get_config() / "toad.json" - @staticmethod - def saved_key(path: FieldPath) -> str: - declaration = declared_field(path) - return declaration.metadata.get("wire_name", declaration.name) + def saved_key(self, path: DottedFieldPath) -> str: + parent, _, _ = path.value.rpartition(".") + owner = self.state.type_for_path(parent) + return next(key for declaration, key in FieldCodec.record_fields(owner) + if declaration.name == path.field_name) - def keys_held(self, path: FieldPath, raw: object) -> list[FieldPath]: + def keys_held(self, path: str, raw: object) -> list[DottedFieldPath]: """The settings and groups a document read from the file has a key for.""" if not isinstance(raw, dict): return [] held = [] - for below in fields_below(path): + for below in self.state.direct_parameter_paths(path): if (key := self.saved_key(below)) in raw: - held += [below, *self.keys_held(below, raw[key])] + held += [below, *self.keys_held(below.value, raw[key])] return held - def kept(self, path: FieldPath, encoded: dict[str, Any]) -> dict[str, Any]: + def kept(self, path: str, encoded: dict[str, Any]) -> dict[str, Any]: """The part of the whole encoded tree that belongs in the file.""" document = {} - for below in fields_below(path): + for below in self.state.direct_parameter_paths(path): key = self.saved_key(below) - if fields_below(below): - part = self.kept(below, encoded[key]) + if self.state.has_parameter_descendants(below.value): + part = self.kept(below.value, encoded[key]) if part or below in self.user_set: document[key] = part elif below in self.user_set: @@ -423,7 +422,7 @@ def kept(self, path: FieldPath, encoded: dict[str, Any]) -> dict[str, Any]: return document def document(self) -> dict[str, Any]: - return self.kept(self.root, FieldCodec.encode(self.current)) + return self.kept("", FieldCodec.encode(self.current)) @property def json(self) -> str: @@ -486,7 +485,7 @@ def report(error: atomic.AtomicWriteError) -> None: def apply_all(self) -> None: """Bring a starting app in line with every setting.""" - for path in self.named.values(): + for path in self.state.parameter_structure.leaf_paths(): self.applied(DeclaredField(self.state, path)) def ensure_file(self, app: ToadApp) -> None: diff --git a/src/toad/screens/store.py b/src/toad/screens/store.py index 68550f14b..069e87bcc 100644 --- a/src/toad/screens/store.py +++ b/src/toad/screens/store.py @@ -548,7 +548,7 @@ async def on_mount(self) -> None: @handles(PreferenceChanged) async def _preferences_changed(self, message: CoreEventMessage) -> None: - if message.event.path == SETTINGS.launcher.agents: + if SETTINGS.launcher.agents.matches(message.event.field): await self.launcher.recompose() def focus_screen(): diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 5ba3e2e3b..b93d3e817 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -1812,7 +1812,7 @@ async def replace_queued(self, prompts: list[str]) -> None: @handles(PreferenceChanged) async def _settings_changed(self, message: CoreEventMessage) -> None: - if message.event.path == SETTINGS.shell.allow_commands: + if SETTINGS.shell.allow_commands.matches(message.event.field): self.input_histories.shell.complete.add_words( self.app.settings.shell.allow_commands.split() ) diff --git a/src/toad/widgets/recovery_view.py b/src/toad/widgets/recovery_view.py index 33d85a57b..2e34df6ec 100644 --- a/src/toad/widgets/recovery_view.py +++ b/src/toad/widgets/recovery_view.py @@ -58,7 +58,7 @@ def on_show(self) -> None: @handles(PreferenceChanged) async def _settings_changed(self, message: CoreEventMessage) -> None: - if message.event.path == SETTINGS.ui.recovery_view: + if SETTINGS.ui.recovery_view.matches(message.event.field): self._apply_settings() def _apply_settings(self) -> None: diff --git a/src/toad/widgets/session_sort.py b/src/toad/widgets/session_sort.py index fd81e26a7..40d615dc5 100644 --- a/src/toad/widgets/session_sort.py +++ b/src/toad/widgets/session_sort.py @@ -21,7 +21,6 @@ from textual.css.scalar import Scalar, Unit from textual.geometry import Spacing -from toad.forms.declared_views import DeclaredField from toad.forms.field_path import FieldPath from toad.forms.form_view import InlineFormView from toad.forms.presentation import FormModal, FormPart @@ -63,7 +62,7 @@ def __init__(self, control: SortControl, form: ThreadSortForm | ChannelSortForm) super().__init__() self.control = control self.state = ObjectState(form) - self.order_field = DeclaredField(self.state, FieldPath(type(form)).order) + self.order_field = FieldPath(type(form)).order.bind(self.state) self.form = InlineFormView(self.state, focus=self.order_field.path) paths = control.visibility_fields() self.visibility_form = (InlineFormView(self.app.saved_settings.state, paths=paths) @@ -80,7 +79,7 @@ def on_mount(self): self.state.on_resolved_changed(self.order_changed) def order_changed(self, paths: set[str]): - if str(self.order_field.path) in paths: + if self.order_field.path.value in paths: order = self.order_field.value if order is not None and order is not self.control.order: self.dismiss(order) diff --git a/src/toad/widgets/side_bar.py b/src/toad/widgets/side_bar.py index 3e94bd334..1da5c2797 100644 --- a/src/toad/widgets/side_bar.py +++ b/src/toad/widgets/side_bar.py @@ -3,7 +3,6 @@ from toad.core import events as core_events from toad.core.preference_events import PreferenceChanged from toad.preferences import SETTINGS -from toad.forms.declared_views import DeclaredField from dataclasses import dataclass import asyncio from typing import TYPE_CHECKING, ClassVar, cast @@ -664,7 +663,7 @@ def on_mount(self) -> None: app = cast("ToadApp", self.app) if self._navigation is None: self.observe_core(app.saved_settings.events) - self.collapsed = DeclaredField(app.saved_settings.state, SETTINGS.sidebar.hide).value + self.collapsed = SETTINGS.sidebar.hide.bind(app.saved_settings.state).value else: self.collapsed = self.hide self.watch_collapsed(self.collapsed) @@ -898,8 +897,8 @@ def render(self) -> str: @handles(PreferenceChanged) async def _settings_changed(self, message: CoreEventMessage) -> None: - if message.event.path == SETTINGS.sidebar.hide: - self.collapsed = DeclaredField(self.app.saved_settings.state, message.event.path).value + if SETTINGS.sidebar.hide.matches(message.event.field): + self.collapsed = message.event.field.value @on(SideBarToggle.Pressed) def on_toggle_pressed(self, event: SideBarToggle.Pressed) -> None: @@ -934,7 +933,7 @@ def toggle(self, *, focus: bool = True) -> None: app.saved_settings.set(SETTINGS.sidebar.hide, collapsed) # Project this input on its visible owner immediately. The shared # settings signal still updates other views, idempotently. - self.collapsed = DeclaredField(app.saved_settings.state, SETTINGS.sidebar.hide).value + self.collapsed = SETTINGS.sidebar.hide.bind(app.saved_settings.state).value else: self.collapsed = collapsed if collapsed: @@ -956,7 +955,7 @@ def reveal(self) -> None: self.collapsed = False def action_dismiss(self) -> None: - if DeclaredField(self.app.saved_settings.state, SETTINGS.sidebar.hide).value: + if SETTINGS.sidebar.hide.bind(self.app.saved_settings.state).value: self.collapsed = True self.post_message(self.Dismiss()) diff --git a/tests/textual/test_settings_screen.py b/tests/textual/test_settings_screen.py index af65a1c06..5c8967066 100644 --- a/tests/textual/test_settings_screen.py +++ b/tests/textual/test_settings_screen.py @@ -13,7 +13,7 @@ from ui_theme.scheme import PrimaryColour from toad.forms import editors -from toad.forms.field_path import FieldPath, leaves +from toad.forms.field_path import FieldPath from toad.forms.form_view import ValueView from toad.preferences import SETTINGS, SavedSettings, ToadSettings from toad.screens.settings import SchemeEditor, SettingsScreen @@ -100,8 +100,11 @@ async def test_opening_the_editors_changes_no_preference(workspace): async def test_every_setting_the_user_may_edit_has_an_editor(workspace): async with settings_screen(workspace) as (ui, app, _file): shown = app.screen.form.values - hidden = {"anon_id"} | {str(path) for path in leaves(SETTINGS.launcher)} - assert set(shown) == {str(path) for path in leaves(SETTINGS)} - hidden + structure = app.saved_settings.state.parameter_structure + hidden = {"anon_id"} | { + path.value for path in structure.leaf_paths(SETTINGS.launcher.bind(app.saved_settings.state).path.value) + } + assert set(shown) == {path.value for path in structure.leaf_paths()} - hidden assert isinstance(editor(app, SETTINGS.ui.footer), editors.BoolEditor) assert isinstance(editor(app, SETTINGS.ui.column_width), editors.IntEditor) assert isinstance(editor(app, SETTINGS.ui.theme), SchemeEditor) From 1584822b4bc381d1df3b18386de85e0c308371ca Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 18:10:34 -0400 Subject: [PATCH 5/8] Use native screen sizing modes for Settings (cherry picked from commit ea33717136d3b0e1ebd3fd3d73b8b49c7b2ce441, without its dependency pins) Co-Authored-By: Claude Opus 5.5 --- src/toad/app.py | 21 ++++++++++++++++++++- src/toad/screens/settings.py | 5 ++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/toad/app.py b/src/toad/app.py index 05e414e6c..5be6f741e 100644 --- a/src/toad/app.py +++ b/src/toad/app.py @@ -12,6 +12,7 @@ import sys import json import os +from enum import IntEnum from functools import cached_property, partial from pathlib import Path from time import monotonic @@ -24,6 +25,7 @@ from textual.app import App from textual.await_complete import AwaitComplete from textual.content import Content +from textual.css.scalar import Scalar, Unit from textual.notifications import Notify from textual.reactive import var from textual.screen import Screen @@ -248,6 +250,21 @@ class ApplicationNotRunningError(RuntimeError): """The application has no event loop to accept cross-thread work.""" +class ScreenWidth(IntEnum): + """Native sizing modes; legacy breakpoint classes are derived from them.""" + + side_panel_width: Scalar | None + + def __new__(cls, minimum_width: int, side_panel_width: Scalar | None): + mode = int.__new__(cls, minimum_width) + mode._value_ = minimum_width + mode.side_panel_width = side_panel_width + return mode + + NARROW = (0, None) + WIDE = (100, Scalar(50, Unit.PERCENT, Unit.WIDTH)) + + class ToadApp(CoreEventReceiver, DeclaredWidgetActions, WorkspaceSessionShutdown, ThemedApp, App, inherit_bindings=False): """The top level app.""" @@ -261,7 +278,9 @@ class ToadApp(CoreEventReceiver, DeclaredWidgetActions, WorkspaceSessionShutdown project_dir = var(Path) show_sessions = var(False, toggle_class="-show-sessions-bar") - HORIZONTAL_BREAKPOINTS = [(0, "-narrow"), (100, "-wide")] + HORIZONTAL_BREAKPOINTS: list[tuple[int, str]] = [ + (mode, f"-{mode.name.lower()}") for mode in ScreenWidth + ] PAUSE_GC_ON_SCROLL = True diff --git a/src/toad/screens/settings.py b/src/toad/screens/settings.py index 30cc7418f..0a602ca10 100644 --- a/src/toad/screens/settings.py +++ b/src/toad/screens/settings.py @@ -9,11 +9,10 @@ from textual.binding import CallBinding from toad.screens.modal import ModalScreen from textual.widgets import Footer, Input -from textual.css.scalar import Scalar, Unit from textual.geometry import Spacing from textual.color import Color -from toad.app import ToadApp +from toad.app import ScreenWidth, ToadApp from toad.forms.editors import SelectEditor from toad.forms.form_view import FormView from toad.forms.presentation import FormInput, FormModal, FormPart @@ -39,7 +38,7 @@ class SettingsPanel(FormPart, containers.Vertical): @property def width(self): - return None if self.screen.has_class("-narrow") else Scalar(50, Unit.PERCENT, Unit.WIDTH) + return ScreenWidth(self.screen.horizontal_breakpoint).side_panel_width class SettingsSearch(FormPart, containers.VerticalGroup): From f67305462ee2cf6687c6b7b2acc38936e5346260 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 18:10:41 -0400 Subject: [PATCH 6/8] Render ordinary fields through the admitted ParameterInfo root (cherry picked from commit 2a6760534, without its scratch file) Co-Authored-By: Claude Opus 5.5 --- src/toad/forms/form_view.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/toad/forms/form_view.py b/src/toad/forms/form_view.py index 6176c8c29..d74f63dfe 100644 --- a/src/toad/forms/form_view.py +++ b/src/toad/forms/form_view.py @@ -5,7 +5,7 @@ from collections.abc import Iterator from formbridge.forms.parameter_form_service import ParameterFormService -from formbridge.forms.parameter_info_types import DirectDataclassInfo, GenericInfo +from formbridge.forms.parameter_info_types import DirectDataclassInfo, ParameterInfo from objectstate import DottedFieldPath, ObjectState from textual import containers, events, lazy from textual.app import ComposeResult @@ -84,7 +84,7 @@ def filter(self, term: str) -> bool: class ValueView(FieldView): """A field with one value: its label, its help and the editor for its kind.""" - shows = (GenericInfo,) + shows = (ParameterInfo,) CHANGED_MARK = " ●" From f97fd65a8be26bd3dd3bdf36a30790b8544d034e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 18:10:48 -0400 Subject: [PATCH 7/8] Keep admitted addresses on declared fields and read parent addresses from the field (source parts of d19c492a4 and 9fbec1d8b, whose dependency pins are replaced below) Co-Authored-By: Claude Opus 5.5 --- src/toad/forms/declared_views.py | 3 --- src/toad/preferences.py | 3 +-- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/toad/forms/declared_views.py b/src/toad/forms/declared_views.py index c372fdff1..58c6a76f9 100644 --- a/src/toad/forms/declared_views.py +++ b/src/toad/forms/declared_views.py @@ -81,9 +81,6 @@ class DeclaredField: state: ObjectState path: DottedFieldPath - def __post_init__(self) -> None: - object.__setattr__(self, "path", self.state.parameter_structure.path(self.path.value)) - @property def declaration(self) -> ParameterInfo: return self.state.parameter_structure.declarations[self.path.value] diff --git a/src/toad/preferences.py b/src/toad/preferences.py index 3a12e4eb0..bc67bdd37 100644 --- a/src/toad/preferences.py +++ b/src/toad/preferences.py @@ -393,8 +393,7 @@ def file_path() -> Path: return paths.get_config() / "toad.json" def saved_key(self, path: DottedFieldPath) -> str: - parent, _, _ = path.value.rpartition(".") - owner = self.state.type_for_path(parent) + owner = self.state.type_for_path(path.parent.value) return next(key for declaration, key in FieldCodec.record_fields(owner) if declaration.name == path.field_name) From 844b53a6c6b42b31fda3fba125174bd001951019 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 18:35:14 -0400 Subject: [PATCH 8/8] Import DeclaredField at module level and describe binding plainly Co-Authored-By: Claude Opus 5.5 --- src/toad/forms/declared_views.py | 2 +- src/toad/forms/field_path.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/toad/forms/declared_views.py b/src/toad/forms/declared_views.py index 58c6a76f9..519c533a8 100644 --- a/src/toad/forms/declared_views.py +++ b/src/toad/forms/declared_views.py @@ -90,7 +90,7 @@ def annotation(self) -> object: return self.declaration.param_type def metadata(self, kind: type[Metadata]) -> Metadata | None: - """Metadata of the admitted field, including an optional member's metadata.""" + """Metadata of the bound field, including an optional member's metadata.""" for annotation in (self.annotation, resolve_optional(resolve_annotated(self.annotation))): if get_origin(annotation) is Annotated: for item in get_args(annotation)[1:]: diff --git a/src/toad/forms/field_path.py b/src/toad/forms/field_path.py index 8d7c01d55..f538a2035 100644 --- a/src/toad/forms/field_path.py +++ b/src/toad/forms/field_path.py @@ -7,10 +7,11 @@ if TYPE_CHECKING: from objectstate import ObjectState - from toad.forms.declared_views import DeclaredField from python_introspect import UnifiedParameterAnalyzer, resolve_annotated, resolve_optional +from toad.forms.declared_views import DeclaredField + class FieldPath: """One field of a dataclass tree: `FieldPath(Settings).sidebar.hide`. @@ -47,9 +48,7 @@ def __hash__(self) -> int: return hash((self._root, self._names)) def bind(self, state: ObjectState) -> DeclaredField: - """Bind a declaration reference through ObjectState's admitted topology.""" - from toad.forms.declared_views import DeclaredField - + """Bind this declaration to the field ObjectState declares at this path.""" if not issubclass(state.type_for_path(""), self._root): raise TypeError(f"{self!r} does not name a field of this ObjectState") return DeclaredField(state, state.parameter_structure.path(str(self)))