diff --git a/pyproject.toml b/pyproject.toml index 0c7948e4b..f08445c1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ "metaclass-registry", "agent-client-protocol==0.12.1", "ui-theme", - "formbridge==0.2.0", + "formbridge", "objectstate==5.0.0", "msgpack==1.2.2", ] @@ -44,7 +44,7 @@ ui-theme = { git = "https://github.com/OpenHCSDev/ui-theme.git", branch = "main" agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", branch = "main" } metaclass-registry = { git = "https://github.com/OpenHCSDev/metaclass-registry", branch = "main" } textual = { git = "https://github.com/OpenHCSDev/textual.git", branch = "main" } -formbridge = { git = "https://github.com/OpenHCSDev/formbridge.git", rev = "ebe2fa9173428e2bd7154a0de23698757677c3c2" } +formbridge = { git = "https://github.com/OpenHCSDev/formbridge.git", branch = "main" } objectstate = { git = "https://github.com/OpenHCSDev/objectstate.git", rev = "b5dd9f78b9293df145d7629f21deb89b042b9400" } textual-image = { git = "https://github.com/OpenHCSDev/textual-image.git", rev = "1f1d8b8ea3c30b39da3b14ea0ac0786ce9c49a80" } textual-diff-view = { git = "https://github.com/OpenHCSDev/textual-diff-view.git", rev = "95db444b536cba27081dd2c1496f71e25670a446" } diff --git a/src/toad/core/preference_events.py b/src/toad/core/preference_events.py index d3fb98c38..7736b3ccd 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.declared_views import DeclaredField +from formbridge.forms.declared_fields import DeclaredField from .events import CoreEvent diff --git a/src/toad/forms/declared_views.py b/src/toad/forms/declared_views.py deleted file mode 100644 index 394caf1ec..000000000 --- a/src/toad/forms/declared_views.py +++ /dev/null @@ -1,163 +0,0 @@ -"""Widget families whose members say which declarations they show.""" - -from __future__ import annotations - -from collections.abc import Callable, Iterable, Iterator -from dataclasses import dataclass -from inspect import cleandoc -from typing import Annotated, ClassVar, Optional, Self, TypeVar, get_args, get_origin - -from formbridge.forms.ui_utils import FieldDisplayText -from objectstate import DottedFieldPath, ObjectState -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.kinds import Placeholder - -Metadata = TypeVar("Metadata") - - -class DeclaredViews: - """A family of widget classes, each registered under what it shows. - - A class directly below this one starts a family. A member names the - declarations it shows in its own body (`shows = (int,)`), so adding a kind - of field is adding a class: nothing lists the members and nothing chooses - between them by testing types. - - 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, ...]] = () - shown_by: ClassVar[dict[object, Callable]] - - def __init_subclass__(cls, **kwargs: object) -> None: - super().__init_subclass__(**kwargs) - if DeclaredViews in cls.__bases__: - cls.shown_by = {} - for declaration in vars(cls).get("shows", ()): - cls.shown_by[declaration] = cls - - @classmethod - def showing(cls, declarations: Iterable[object]) -> Callable[..., Self]: - """The member registered under the first of these declarations that has one.""" - declarations = tuple(declarations) - for declaration in declarations: - if declaration in cls.shown_by: - return cls.shown_by[declaration] - raise LookupError(f"No {cls.__name__} shows any of {declarations!r}") - - -def declarations(annotation: object) -> Iterator[object]: - """What an annotation declares, most specific first. - - `Annotated` metadata comes before the type it annotates, `T | None` is - `Optional`, and a class is followed by its bases. - """ - if get_origin(annotation) is Annotated: - base, *metadata = get_args(annotation) - for item in metadata: - yield from type(item).__mro__[:-1] - yield from declarations(base) - elif optional_member_type(annotation) is not None: - yield Optional - else: - declared = get_origin(annotation) or annotation - yield from declared.__mro__[:-1] if isinstance(declared, type) else (declared,) - - -@dataclass(frozen=True) -class DeclaredField: - """One field of an ObjectState: where its value lives and how it was declared.""" - - state: ObjectState - path: DottedFieldPath - - @property - def declaration(self) -> ParameterInfo: - return self.state.parameter_structure.declarations[self.path.value] - - @property - def annotation(self) -> object: - return self.declaration.param_type - - @property - def owner_type(self) -> type: - """The class of the resolved record containing this field. - - ObjectState's declaration owner may instead be a function. Record - serialization needs the containing value's actual class, including - the delegate returned by to_object at the root. - """ - parent = self.path.parent.value - owner = self.state.get_resolved_value(parent) if parent else self.state.to_object() - return type(owner) - - def metadata(self, kind: type[Metadata]) -> Metadata | None: - """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:]: - if isinstance(item, kind): - return item - return None - - @property - def value(self) -> object: - return self.state.get_resolved_value(self.path.value) - - @property - def default(self) -> object: - return self.declaration.default_value - - @property - def has_default(self) -> bool: - return not self.declaration.is_required - - @property - def placeholder(self) -> str: - hint = self.metadata(Placeholder) - return hint.text if hint is not None else "" - - @property - def label(self) -> str: - return ( - declared_field_label(self.annotation) - or FieldDisplayText.from_field_name(self.declaration.name).display_name - ) - - @property - def help(self) -> str: - return cleandoc(self.declaration.description or "") - - @property - def member(self) -> DeclaredField: - """The same field as declared without `None`.""" - return OptionalMemberField(self.state, self.path) - - def validate(self, value: object) -> None: - """Require this field's declared type and constraints before edit or save.""" - # 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) - - def edit(self, value: object) -> None: - """Give the field a value accepted by its declaration.""" - self.validate(value) - self.state.update_parameter(self.path.value, value) - - -class OptionalMemberField(DeclaredField): - """The member editor reads a narrower annotation, not a copied declaration.""" - - @property - def annotation(self) -> object: - return optional_member_type(resolve_annotated(super().annotation)) diff --git a/src/toad/forms/editors.py b/src/toad/forms/editors.py index a40dc41b0..0ec7b8ab6 100644 --- a/src/toad/forms/editors.py +++ b/src/toad/forms/editors.py @@ -20,13 +20,11 @@ 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 formbridge.forms.declared_fields import DeclaredField, DeclaredViews, Multiline, declarations from toad.forms.presentation import FormCheckbox, FormInput, FormSelectCurrent, FramedControl from toad.widget_view import WidgetView from toad.tone_paint import UntintedTonePaint @@ -45,9 +43,6 @@ class ValueEditor(DeclaredViews, EditorWidget): control is initialized: `class BoolEditor(ValueEditor, Checkbox)`. """ - states_default: ClassVar[bool] = True - """Whether the field's help should end with its default value.""" - field: DeclaredField feedback: var[str] = var("", init=False) @@ -86,6 +81,10 @@ def value_text(self, value: object) -> str: """How a value of this field reads.""" return self.field.placeholder if value is None else str(value) + def default_note(self) -> str: + """The line ending the field's help that names its default value.""" + return f"\ndefault: {self.value_text(self.field.default)}" if self.field.has_default else "" + def send(self, value: object) -> None: try: self.field.edit(value) @@ -186,7 +185,9 @@ class MultilineEditor(ValueEditor, UntintedTonePaint, FramedControl, TextArea): height = Scalar.from_number(10) padding = Spacing(0, 1, 0, 1) - states_default = False + def default_note(self) -> str: + """A text block's default is too long to restate under its help.""" + return "" def control_arguments(self) -> dict[str, object]: return {"text": str(self.field.value or "")} diff --git a/src/toad/forms/field_path.py b/src/toad/forms/field_path.py deleted file mode 100644 index f538a2035..000000000 --- a/src/toad/forms/field_path.py +++ /dev/null @@ -1,79 +0,0 @@ -"""A field of a dataclass tree, named through the declarations instead of a string.""" - -from __future__ import annotations - -from dataclasses import fields, is_dataclass -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from objectstate import ObjectState - -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`. - - Each step is checked against the dataclass fields when the path is built, - so a path that exists names a declared field. `str(path)` is the dotted - name ObjectState keeps the field under. - """ - - __slots__ = ("_root", "_names") - - def __init__(self, root: type, names: tuple[str, ...] = ()) -> None: - self._root, self._names = root, names - - def __getattr__(self, name: str) -> FieldPath: - owner = declared_type(self) - if not is_dataclass(owner) or name not in {field.name for field in fields(owner)}: - raise AttributeError(f"{owner!r} declares no field {name!r}") - return FieldPath(self._root, (*self._names, name)) - - def __str__(self) -> str: - return ".".join(self._names) - - def __repr__(self) -> str: - return f"FieldPath({self._root.__name__}, {str(self)!r})" - - def __eq__(self, other: object) -> bool: - return ( - isinstance(other, FieldPath) - and (self._root, self._names) == (other._root, other._names) - ) - - def __hash__(self) -> int: - return hash((self._root, self._names)) - - def bind(self, state: ObjectState) -> 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))) - - 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]) - - -def declared_annotation(path: FieldPath) -> object: - """The field's annotation as written, `Annotated` metadata included.""" - if not path._names: - return path._root - return UnifiedParameterAnalyzer.analyze(declared_type(parent(path)))[ - path._names[-1] - ].param_type - - -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)))) diff --git a/src/toad/forms/form_view.py b/src/toad/forms/form_view.py index d56175550..18b1bead8 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, ParameterInfo -from objectstate import DottedFieldPath, ObjectState +from objectstate import ObjectState from textual import containers, events, lazy, on from textual.app import ComposeResult from textual.content import Content @@ -17,9 +17,8 @@ from rich.style import Style from ui_theme.scheme import BackgroundColour, ErrorColour, ForegroundColour, MutedColour, PrimaryColour, SecondaryColour -from toad.forms.declared_views import DeclaredField, DeclaredViews +from formbridge.forms.declared_fields import DeclaredField, DeclaredViews, FormPage, form_pages from toad.forms.editors import ValueEditor -from toad.forms.field_path import FieldPath from toad.widget_view import AUTO, FocusWithinView, WidgetView @@ -54,8 +53,7 @@ def paint_foreground(self): class FieldView(WidgetView, containers.VerticalGroup, DeclaredViews): """One parameter of a form, shown the way its formbridge kind asks for.""" - def __init__(self, field: DeclaredField, - included: frozenset[DottedFieldPath] | None = None) -> None: + def __init__(self, field: DeclaredField) -> None: super().__init__() self.field = field @@ -64,19 +62,14 @@ def margin(self) -> Spacing: return Spacing(0, 0, 0 if self.last_child else 1, 0) @staticmethod - def of(state: ObjectState, parent: str = "", - included: frozenset[DottedFieldPath] | None = None) -> list[FieldView]: + def of(state: ObjectState, parent: str = "") -> 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 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)) - return views + return [FieldView.showing(type(parameter).__mro__)(DeclaredField(state, path)) + for path, parameter in ParameterFormService().parameters_for_state(state, parent)] def values(self) -> Iterator[ValueView]: """The views below this one that hold a single value.""" @@ -94,17 +87,12 @@ class ValueView(FieldView): CHANGED_MARK = " ●" - def __init__(self, field: DeclaredField, - included: frozenset[DottedFieldPath] | None = None) -> None: - super().__init__(field, included) + def __init__(self, field: DeclaredField) -> None: + super().__init__(field) self.editor = ValueEditor.declared_for(field) self.title = FieldTitle(field.label) self.refusal = FieldRefusal("", markup=False) self.refusal.display = False - self.search_text = " ".join( - DeclaredField(field.state, step).label - for step in field.state.parameter_structure.ancestors(field.path) - ).lower() def compose(self) -> ComposeResult: yield self.title @@ -120,17 +108,16 @@ def show_feedback(self, message: ValueEditor.Feedback) -> None: def description(self) -> Content: help = Content.from_markup(self.field.help) - if not self.editor.states_default or not self.field.has_default: - return help - default = self.editor.value_text(self.field.default) - return Content.assemble(help, (f"\ndefault: {default}", "$text-secondary")) + if bounds := self.field.bounds_text: + help = Content.assemble(help, (f"\naccepts: {bounds}", "$text-secondary")) + return Content.assemble(help, (self.editor.default_note(), "$text-secondary")) def values(self) -> Iterator[ValueView]: yield self def filter(self, term: str) -> bool: - self.display = term in self.search_text - return term in self.search_text + self.display = term in self.field.search_text + return bool(self.display) def mark(self, changed: bool) -> None: """Say on the title whether the value differs from the saved one.""" @@ -156,10 +143,9 @@ def border(self) -> Edges: colours.colour(ForegroundColour).multiply_alpha(0.2)) return Edges(*(("tall", colour),) * 4) - def __init__(self, field: DeclaredField, - included: frozenset[DottedFieldPath] | None = None) -> None: - super().__init__(field, included) - self.parts = FieldView.of(field.state, field.path.value, included) + def __init__(self, field: DeclaredField) -> None: + super().__init__(field) + self.parts = FieldView.of(field.state, field.path.value) def compose(self) -> ComposeResult: yield GroupTitle(self.field.label) @@ -188,24 +174,28 @@ class FormView(WidgetView, containers.VerticalScroll, can_focus=False): height = None overflow_y = "scroll" - def __init__(self, state: ObjectState, *, paths: tuple[FieldPath, ...] | None = None, - focus: DottedFieldPath | None = None) -> None: + def __init__(self, state: ObjectState, parts: list[FieldView]) -> None: super().__init__() self.state = state - 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.parts = parts self.values = { view.field.path.value: view for part in self.parts for view in part.values() } - if focus is not None: - 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) + @classmethod + def whole(cls, state: ObjectState) -> FormView: + """Every shown field of the state on one form.""" + return cls(state, FieldView.of(state)) + + @classmethod + def pages(cls, state: ObjectState) -> dict[FormPage, FormView]: + """One form per page the top-level fields declare, in declaration order.""" + parts = {part.field.path: part for part in FieldView.of(state)} + return {page: cls(state, [parts[path] for path in paths]) + for page, paths in form_pages(state, parts).items()} + def follow_state(self, event: events.Mount) -> None: lazy.Reveal.reveal(self, list(self.parts)) self.state.on_resolved_changed(self.values_changed) diff --git a/src/toad/forms/kinds.py b/src/toad/forms/kinds.py deleted file mode 100644 index 3dccc9e53..000000000 --- a/src/toad/forms/kinds.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Field kinds a type alone does not say, declared as `Annotated` metadata.""" - -from dataclasses import dataclass - - -@dataclass(frozen=True) -class Placeholder: - """Text for an empty field, not an offered or persisted value.""" - - text: str - - -@dataclass(frozen=True) -class Multiline: - """Text of several lines: `Annotated[str, Multiline()]`.""" diff --git a/src/toad/keymap.py b/src/toad/keymap.py index 81bae44fb..2f407cb6d 100644 --- a/src/toad/keymap.py +++ b/src/toad/keymap.py @@ -1,33 +1,32 @@ """Stored overrides and views of the keyboard declarations.""" from __future__ import annotations -from dataclasses import dataclass, replace +import re from collections.abc import Mapping +from dataclasses import dataclass, replace from functools import cache from importlib import import_module from pkgutil import walk_packages -import re from types import MappingProxyType -from typing import Annotated, TYPE_CHECKING - -import toad -from objectstate import mark_ui_hidden_config +from typing import TYPE_CHECKING, Annotated from agent_comms.declared_family import DeclaredFamily from agent_comms.field_codec import NominalSpelling, NominalText +from objectstate import mark_ui_hidden_config from textual.binding import Binding, BindingsMap from textual.dom import DOMNode from textual.keys import KeyInvocation from textual.screen import ModalScreen +import toad from toad.key_modes import KeyMode from toad.setting_declarations import Effect, FamilyChoices from toad.widget_actions import ApplicationAction - if TYPE_CHECKING: + from formbridge.forms.declared_fields import DeclaredField + from toad.app import ToadApp - from toad.forms.declared_views import DeclaredField @cache diff --git a/src/toad/preferences.py b/src/toad/preferences.py index aed4f1cbf..7dc507178 100644 --- a/src/toad/preferences.py +++ b/src/toad/preferences.py @@ -20,16 +20,14 @@ from agent_comms.field_codec import FieldCodec, WireName from agent_comms.locked_store import FilesRequest from agent_comms.typed_table import ExternalStore -from annotated_types import Ge +from annotated_types import Ge, MinLen from objectstate import DottedFieldPath, ObjectState, set_base_config_type from pydantic import JsonValue 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 -from toad.forms.kinds import Multiline +from formbridge.forms.declared_fields import DeclaredField, FieldPath, FormPage, Multiline from toad.key_modes import KeyMode, Standard from toad.keymap import Keymap from toad.patch import DiffSettings @@ -37,7 +35,9 @@ AlwaysSessionBar, BlurNotification, ColumnAlignment, + CompactInputBox, Expansion, + InputBox, FailExpansion, LoadingStyle, LeftColumnAlignment, @@ -49,12 +49,42 @@ SessionBar, ) from toad.setting_declarations import Effect, FamilyChoices +from toad.setting_widget import PromptSettingEffects from toad.widgets.presentation_window import PresentationBudget if TYPE_CHECKING: from toad.app import ToadApp +@dataclass(frozen=True) +class InputSettings(AnnotatedDataclassValidationMixin): + box: Annotated[ + type[InputBox], FieldLabel("Layout"), FamilyChoices(InputBox), + PromptSettingEffects.apply_input_layout, + ] = CompactInputBox + """Normal draws a padded border around the prompt. Compact keeps it flush with the conversation. Flush also removes the spacing around the label and the info bar.""" + + placeholder: Annotated[str, FieldLabel("Placeholder"), PromptSettingEffects.apply_input_text] = ( + "How can I help you today?" + ) + """Text shown in the empty prompt of an agent session.""" + + agent_symbol: Annotated[ + str, FieldLabel("Message symbol"), MinLen(1), PromptSettingEffects.apply_input_text, + ] = "❯" + """Shown before a one-line message to the agent.""" + + multiline_symbol: Annotated[ + str, FieldLabel("Multi-line message symbol"), MinLen(1), PromptSettingEffects.apply_input_text, + ] = "☰" + """Shown before a message of several lines or one holding a code fence.""" + + shell_symbol: Annotated[ + str, FieldLabel("Shell command symbol"), MinLen(1), PromptSettingEffects.apply_input_text, + ] = "$" + """Shown before a shell command.""" + + @dataclass(frozen=True) class UiSettings(AnnotatedDataclassValidationMixin): wheel_scroll_rows: Annotated[ @@ -84,14 +114,10 @@ class UiSettings(AnnotatedDataclassValidationMixin): keymap: Keymap = field(default_factory=Keymap) """Keyboard overrides, edited in the keybinding menu.""" - prompt_message: Annotated[str, FieldLabel("Prompt message")] = "How can I help you today?" - """Text shown as placeholder in prompt text area""" - - compact_input: Annotated[ - bool, FieldLabel("Compact text input?"), effects.visibility, - WireName("compact-input"), - ] = False - """Remove border and margin around the text area for additional space""" + input: Annotated[InputSettings, FieldLabel("Input box")] = field( + default_factory=InputSettings + ) + """The prompt you type messages and shell commands into.""" sessions_bar: Annotated[ type[SessionBar], @@ -259,52 +285,59 @@ async def collect(self, operation: Callable[[], Awaitable[None]]) -> None: await operation() +INTERFACE = FormPage("Interface") +"""How Toad looks and responds: layout, input box, sidebar, notifications and diffs.""" + +SESSIONS = FormPage("Agents and shell") +"""What Toad does in a session: agent output, tool calls, the shell, the launcher and usage data.""" + + @dataclass(frozen=True) class ToadSettings(AnnotatedDataclassValidationMixin): anon_id: str = field(default="", metadata={"ui_hidden": True}) """Anonymous installation ID""" - ui: Annotated[UiSettings, FieldLabel("User interface settings")] = field( + ui: Annotated[UiSettings, FieldLabel("User interface settings"), INTERFACE] = field( default_factory=UiSettings ) """The following settings allow you to customize the look and feel of the User Interface.""" notifications: Annotated[ - NotificationsSettings, FieldLabel("Notification (toasts) settings") + NotificationsSettings, FieldLabel("Notification (toasts) settings"), INTERFACE ] = field(default_factory=NotificationsSettings) """Customize how Toad displays notifications""" - sidebar: Annotated[SidebarSettings, FieldLabel("Sidebar settings")] = field( + sidebar: Annotated[SidebarSettings, FieldLabel("Sidebar settings"), INTERFACE] = field( default_factory=SidebarSettings ) """Customize how the sidebar is displayed.""" - agent: Annotated[AgentSettings, FieldLabel("Agent settings")] = field( + agent: Annotated[AgentSettings, FieldLabel("Agent settings"), SESSIONS] = field( default_factory=AgentSettings ) """Customize how you interact with agents""" - tools: Annotated[ToolsSettings, FieldLabel("Tool call settings")] = field( + tools: Annotated[ToolsSettings, FieldLabel("Tool call settings"), SESSIONS] = field( default_factory=ToolsSettings ) """Customize how Toad displays agent tool calls""" - shell: Annotated[ShellSettings, FieldLabel("Shell settings")] = field( + shell: Annotated[ShellSettings, FieldLabel("Shell settings"), SESSIONS] = field( default_factory=ShellSettings ) """Customize shell interactions.""" - diff: Annotated[DiffSettings, FieldLabel("Diff view settings")] = field( + diff: Annotated[DiffSettings, FieldLabel("Diff view settings"), INTERFACE] = field( default_factory=DiffSettings ) """Customize how diffs are displayed.""" - launcher: Annotated[LauncherSettings, FieldLabel("Launcher settings")] = field( + launcher: Annotated[LauncherSettings, FieldLabel("Launcher settings"), SESSIONS] = field( default_factory=LauncherSettings, metadata={"ui_hidden": True} ) """Customize the launcher""" - statistics: Annotated[StatisticsSettings, FieldLabel("Data collection")] = field( + statistics: Annotated[StatisticsSettings, FieldLabel("Data collection"), SESSIONS] = field( default_factory=StatisticsSettings ) """Preferences regarding data collection.""" diff --git a/src/toad/screens/settings.py b/src/toad/screens/settings.py index 215a5f1e1..c5c26f0da 100644 --- a/src/toad/screens/settings.py +++ b/src/toad/screens/settings.py @@ -1,25 +1,26 @@ -"""The settings form: every setting the user may edit, searched by its label.""" +"""The settings form: every setting the user may edit, one tab per declared page, searched by label.""" from __future__ import annotations +from collections import ChainMap from collections.abc import Iterable from textual import containers, events from textual.app import ComposeResult from textual.binding import ModeBinding from textual.document._editing import EditingMode -from textual.widgets import Input +from textual.widgets import ContentSwitcher, Input, Tab, Tabs from toad.widgets.footer import ToadFooter from textual.geometry import Spacing from textual.color import Color from toad.app import ScreenWidth from toad.forms.editors import SelectEditor -from toad.forms.form_view import FormView +from toad.forms.form_view import FormView, ValueView from toad.forms.presentation import FormInput, FormModal from toad.screens.modal import DismissibleScreen from toad.widget_actions import PriorityActions -from toad.widget_view import WidgetView +from toad.widget_view import FILL, WidgetView from toad.setting_choices import SchemeName from toad.app_node import ToadNode @@ -52,6 +53,12 @@ class SettingsSearch(WidgetView, containers.VerticalGroup): padding = Spacing(1, 0, 1, 0) +class SettingsPages(WidgetView, ContentSwitcher): + """The page of settings whose tab is active; the others stay mounted and hidden.""" + + height = FILL + + class SettingsScreen(PriorityActions, FormModal[None], DismissibleScreen[None], ToadNode): align_horizontal = "right" align_vertical = "top" @@ -59,7 +66,10 @@ class SettingsScreen(PriorityActions, FormModal[None], DismissibleScreen[None], def __init__(self) -> None: super().__init__() self.search = FormInput(placeholder="Search settings") - self.form = FormView(self.app.saved_settings.state) + self.pages = FormView.pages(self.app.saved_settings.state) + for number, form in enumerate(self.pages.values()): + form.id = f"settings-page-{number}" + self.listen(Tabs.TabActivated, self.page_chosen) self.search.listen(Input.Changed, self.searched) self.listen(events.Mount, self.opened) self.listen(events.Unmount, self.closed) @@ -68,22 +78,39 @@ def compose(self) -> ComposeResult: with SettingsPanel(): with SettingsSearch(): yield self.search - yield self.form + yield Tabs(*(Tab(page.title, id=form.id) for page, form in self.pages.items())) + with SettingsPages(initial=next(iter(self.pages.values())).id): + yield from self.pages.values() yield ToadFooter() def opened(self, event: events.Mount) -> None: - self.app.saved_settings.open_edits.append(self.form.finish) + self.app.saved_settings.open_edits.append(self.finish) def closed(self, event: events.Unmount) -> None: - self.app.saved_settings.open_edits.remove(self.form.finish) + self.app.saved_settings.open_edits.remove(self.finish) + + @property + def values(self) -> ChainMap[str, ValueView]: + """Every single-value view on every page, by dotted path.""" + return ChainMap(*(form.values for form in self.pages.values())) + + def page_chosen(self, message: Tabs.TabActivated) -> None: + message.stop() + self.query_one(SettingsPages).current = message.tab.id + + def finish(self) -> None: + """Send every edit typed on any page but not left yet.""" + for form in self.pages.values(): + form.finish() def searched(self, message: Input.Changed) -> None: message.stop() - self.form.filter(message.value) + for form in self.pages.values(): + form.filter(message.value) def dismiss_control(self) -> None: """Leave with every edit sent; the settings are saved once the screen is gone.""" - self.form.finish() + self.finish() super().dismiss_control() def focus_search(self) -> None: diff --git a/src/toad/setting_choices.py b/src/toad/setting_choices.py index cba8fbfed..163747e59 100644 --- a/src/toad/setting_choices.py +++ b/src/toad/setting_choices.py @@ -3,17 +3,23 @@ from __future__ import annotations from abc import abstractmethod +from collections.abc import Callable from dataclasses import dataclass -from typing import ClassVar, cast from random import choice from threading import Lock +from typing import ClassVar, cast + +from agent_comms.declared_choice import DeclaredChoice, TitledChoice +from textual.color import TRANSPARENT, Color from textual.content import Content +from textual.css._style_properties import Edges from textual.css.types import AlignHorizontal +from textual.geometry import Spacing from textual.widget import Widget -from toad.widgets.future_text import FutureText -from agent_comms.declared_choice import DeclaredChoice +from toad.acp.status import ToolCallStatus from toad.platform import DesktopService, host +from toad.widgets.future_text import FutureText @dataclass(frozen=True) @@ -26,15 +32,20 @@ class SchemeName: class Expansion(DeclaredChoice, affix="Expansion"): - patch_preview = True - @classmethod @abstractmethod def should_expand(cls, status) -> bool: ... + @classmethod + def expands_preview(cls, status: ToolCallStatus, preview_fits: Callable[[], bool]) -> bool: + """A completed call opens to show its patch preview when the preview fits.""" + return status.completed and preview_fits() + class NeverExpansion(Expansion): - patch_preview = False + @classmethod + def expands_preview(cls, status: ToolCallStatus, preview_fits: Callable[[], bool]) -> bool: + return False @classmethod def should_expand(cls, status) -> bool: @@ -47,7 +58,7 @@ def should_expand(cls, status) -> bool: return True -class SuccessExpansion(Expansion): +class SuccessExpansion(TitledChoice, Expansion): title = "Success only" @classmethod @@ -55,7 +66,7 @@ def should_expand(cls, status) -> bool: return status.completed -class FailExpansion(Expansion): +class FailExpansion(TitledChoice, Expansion): title = "Fail only" @classmethod @@ -85,7 +96,7 @@ def shown(cls, count: int) -> bool: return True -class MultipleSessionBar(SessionBar): +class MultipleSessionBar(TitledChoice, SessionBar): title = "When there are multiple sessions" @classmethod @@ -131,7 +142,7 @@ def enabled(cls, focused: bool) -> bool: return False -class BlurNotification(NotificationPolicy): +class BlurNotification(TitledChoice, NotificationPolicy): title = "When app is not focused" @classmethod @@ -161,6 +172,61 @@ class HiddenScrollbar(Scrollbar): pass +class InputBox(DeclaredChoice, affix="InputBox"): + """How much room the prompt takes around the text being typed. + + The base is the compact prompt; a member overrides only the room it changes. + """ + + label_padding: ClassVar[Spacing] = Spacing(0, 1, 0, 1) + agent_info_padding: ClassVar[Spacing] = Spacing(0, 1, 0, 1) + frame_margin: ClassVar[Spacing] = Spacing(0, 0, 0, 0) + frame_padding: ClassVar[Spacing] = Spacing(0, 0, 0, 0) + + @classmethod + def frame_border(cls, colour: Color) -> Edges: + """The edges drawn around the text and its label.""" + return Edges(*(("", TRANSPARENT),) * 4) + + @classmethod + def info_bar_margin(cls, footer: bool) -> Spacing: + """Room beside the info bar; it lines up with the footer's keys while one is shown.""" + space = int(footer) + return Spacing(0, space, 0, space) + + +class NormalInputBox(TitledChoice, InputBox): + """The classic prompt: a bordered, padded box above the info bar.""" + + title = "Normal (bordered and padded)" + frame_margin = Spacing(1, 1, 0, 1) + frame_padding = Spacing(0, 1, 0, 0) + + @classmethod + def frame_border(cls, colour: Color) -> Edges: + return Edges(*(("round", colour),) * 4) + + @classmethod + def info_bar_margin(cls, footer: bool) -> Spacing: + return Spacing(0, 2, 0, 2) + + +class CompactInputBox(TitledChoice, InputBox): + title = "Compact (no border)" + + +class FlushInputBox(TitledChoice, InputBox): + """No room at all: the label, the text and the info bar touch the screen edge.""" + + title = "Flush (no border or spacing)" + label_padding = Spacing(0, 1, 0, 0) + agent_info_padding = Spacing(0, 0, 0, 0) + + @classmethod + def info_bar_margin(cls, footer: bool) -> Spacing: + return Spacing(0, 0, 0, 0) + + class ColumnAlignment(DeclaredChoice, affix="ColumnAlignment"): """Place a width-constrained conversation using Textual's container layout.""" diff --git a/src/toad/setting_declarations.py b/src/toad/setting_declarations.py index 56c3e36e6..34fde6383 100644 --- a/src/toad/setting_declarations.py +++ b/src/toad/setting_declarations.py @@ -10,8 +10,9 @@ from python_introspect import AnnotationChoices if TYPE_CHECKING: + from formbridge.forms.declared_fields import DeclaredField + from toad.app import ToadApp - from toad.forms.declared_views import DeclaredField @dataclass(frozen=True) @@ -35,3 +36,24 @@ class Effect: def open_widgets(app, widget_type): roots = (*app.workspace_sessions.views.values(), *app.screen_stack) return {widget for root in roots for widget in root.walk_children(widget_type)} + + +class EachOpenWidget(Effect): + """A widget capability's own method as a setting effect. + + The class the method is declared on is the widget type: a change runs the + method on every open widget composing that capability. + """ + + owner: type + + def __init__(self, method: Callable[[object], None]) -> None: + super().__init__(self.on_each_open) + self.method = method + + def __set_name__(self, owner: type, name: str) -> None: + self.owner = owner + + def on_each_open(self, app: ToadApp, field: DeclaredField) -> None: + for widget in self.open_widgets(app, self.owner): + self.method(widget) diff --git a/src/toad/setting_effects.py b/src/toad/setting_effects.py index ac20bed94..fc592bfde 100644 --- a/src/toad/setting_effects.py +++ b/src/toad/setting_effects.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import replace from typing import TYPE_CHECKING -from toad.forms.declared_views import DeclaredField +from formbridge.forms.declared_fields import DeclaredField from toad.setting_widget import SettingVisibility from toad.widgets.history_anchor import HistoryWindow @@ -69,3 +69,4 @@ def sessions_bar(app: ToadApp, field: DeclaredField) -> None: @Effect def blink_title(app: ToadApp, field: DeclaredField) -> None: app.terminal_attention.update() + diff --git a/src/toad/setting_widget.py b/src/toad/setting_widget.py index 478fcc46a..129b80c8c 100644 --- a/src/toad/setting_widget.py +++ b/src/toad/setting_widget.py @@ -1,4 +1,9 @@ -"""Visibility belongs to its widget declaration, including the first mount.""" +"""Setting capabilities a widget composes: visibility, and the prompt's input settings.""" +from __future__ import annotations + +from toad.setting_declarations import EachOpenWidget + + class SettingVisibility: def __init_subclass__(cls, *, setting: str, **kwargs): super().__init_subclass__(**kwargs) @@ -6,3 +11,18 @@ def __init_subclass__(cls, *, setting: str, **kwargs): def on_mount(self): self.display = self.app.saved_settings.state.get_resolved_value(self.visibility_setting) + + +class PromptSettingEffects: + """A prompt follows the input box settings: these are their effects on every open prompt.""" + + @EachOpenWidget + def apply_input_layout(self) -> None: + """Prompt parts read their room from the input box setting when laid out.""" + for part in self.walk_children(): + part.refresh(layout=True) + self.refresh(layout=True) + + @EachOpenWidget + def apply_input_text(self) -> None: + self.update_prompt() diff --git a/src/toad/widgets/comms_command_dialog.py b/src/toad/widgets/comms_command_dialog.py index 8f72beca7..427b2e46a 100644 --- a/src/toad/widgets/comms_command_dialog.py +++ b/src/toad/widgets/comms_command_dialog.py @@ -18,7 +18,7 @@ from textual.widgets import Button, Checkbox, Static from toad.forms.editors import ValueEditor from toad.forms.form_view import InlineFormView -from toad.forms.kinds import Multiline +from formbridge.forms.declared_fields import Multiline from toad.screens.modal import ModalScreen, DismissibleScreen from toad.widget_actions import DismissAction, WidgetAction from textual.document._editing import EditingMode @@ -168,7 +168,7 @@ def __init__(self, definition: TargetAction, target: str, fields: tuple[TargetFi declarations.append((parameter.name, Annotated[compile_decoder(parameter.annotation).editor_annotation, *hints], field(default=parameter.editor_default))) self.state = ObjectState(make_dataclass('CommandDraft', declarations)()) - self.form = InlineFormView(self.state) + self.form = InlineFormView.whole(self.state) self.form.id = 'command-fields' def compose(self) -> ComposeResult: diff --git a/src/toad/widgets/prompt.py b/src/toad/widgets/prompt.py index fc2c49be3..f0296022f 100644 --- a/src/toad/widgets/prompt.py +++ b/src/toad/widgets/prompt.py @@ -30,7 +30,7 @@ from textual.widgets import Label, OptionList, TextArea from textual.widgets.option_list import Option from textual.widgets.text_area import Selection -from ui_theme.scheme import PrimaryColour, PromptToneColour +from ui_theme.scheme import MutedColour, PrimaryColour, PromptToneColour from toad import messages from toad.clipboard_image import read_clipboard_png, save_clipboard_png @@ -51,12 +51,12 @@ PreviousHistoryCursor, PromptCursor, ) -from toad.setting_widget import SettingVisibility +from toad.setting_widget import PromptSettingEffects, SettingVisibility from toad.slash_command import SlashCommand from toad.widget_actions import ( DismissibleControl, HiddenActions, LocalMethodKeys, NativeMethodKeys, ) -from toad.widget_view import AUTO, WidgetView +from toad.widget_view import AUTO, FocusWithinView, WidgetView from toad.widgets.condensed_path import CondensedPath from toad.widgets.highlighted_textarea import HighlightedTextArea from toad.widgets.model_switcher import ModelSwitcher @@ -109,11 +109,7 @@ class AgentInfo( @property def padding(self): - return ( - super().padding - if self.app.settings.ui.compact_input - else Spacing(0, 1, 0, 1) - ) + return self.app.settings.ui.input.box.agent_info_padding @property def primary_activation(self): @@ -157,25 +153,34 @@ class InfoBar( @property def margin(self): ui = self.app.settings.ui - space = 0 if ui.compact_input else int(ui.footer) - return Spacing(0, space, 0, space) + return ui.input.box.info_bar_margin(ui.footer) class PromptLabel(WidgetView, Label): width = AUTO - AI = Content.styled("❯", "$text-secondary") - SHELL = Content.styled("$", "$text-primary") - MULTILINE = Content.styled("☰", "$text-secondary") @property def padding(self): - return Spacing(0, 1, 0, 0 if self.app.settings.ui.compact_input else 1) + return self.app.settings.ui.input.box.label_padding def render(self): return self.query_ancestor(Prompt).label_glyph() -class PromptContainer(WidgetView, containers.HorizontalGroup): +class PromptContainer(FocusWithinView, containers.HorizontalGroup): + @property + def margin(self): + return self.app.settings.ui.input.box.frame_margin + + @property + def padding(self): + return self.app.settings.ui.input.box.frame_padding + + @property + def border(self): + colour = self.app.colours.colour(PrimaryColour if self.has_focus_within else MutedColour) + return self.app.settings.ui.input.box.frame_border(colour) + def on_mouse_down(self, event: events.MouseUp) -> None: self.query_ancestor(Prompt).focus() @@ -480,7 +485,8 @@ def can_enter_shell(self) -> bool: return False -class Prompt(NativeMethodKeys, HiddenActions, DismissibleControl, TonePaint, containers.VerticalGroup): +class Prompt(NativeMethodKeys, HiddenActions, DismissibleControl, PromptSettingEffects, TonePaint, + containers.VerticalGroup): tone = PromptToneColour DEFAULT_CSS = """ @@ -780,7 +786,7 @@ def update_prompt(self): "Enter shell command\t[r]▌esc▐[/r] prompt mode" ).expand_tabs(8) if shell - else "" + else self.app.settings.ui.input.placeholder ) editor.highlight_language = "shell" if shell else "markdown" self.prompt_label.refresh() @@ -838,12 +844,14 @@ def suggest(self, suggestion: str) -> None: self.prompt_text_area.suggestion = suggestion[len(self.text) :] def label_glyph(self) -> Content: + symbols = self.app.settings.ui.input if self.shell_mode: - return PromptLabel.SHELL - return ( - PromptLabel.MULTILINE + return Content.styled(symbols.shell_symbol, "$text-primary") + return Content.styled( + symbols.multiline_symbol if "\n" in self.text or "```" in self.text - else PromptLabel.AI + else symbols.agent_symbol, + "$text-secondary", ) def compose_completions(self) -> ComposeResult: @@ -947,7 +955,7 @@ def update_prompt(self) -> None: self.prompt_label.refresh() def label_glyph(self) -> Content: - return PromptLabel.AI + return Content.styled(self.app.settings.ui.input.agent_symbol, "$text-secondary") def compose_delivery_controls(self) -> ComposeResult: return () diff --git a/src/toad/widgets/session_sort.py b/src/toad/widgets/session_sort.py index dd3798021..e27836bf6 100644 --- a/src/toad/widgets/session_sort.py +++ b/src/toad/widgets/session_sort.py @@ -19,9 +19,8 @@ from toad.widget_actions import Activates from textual.geometry import Offset -from toad.forms.field_path import FieldPath from toad.widgets.comms_menu import ContextMenu -from toad.forms.kinds import Placeholder +from formbridge.forms.declared_fields import FieldPath, Placeholder from toad.preferences import SETTINGS from toad.setting_declarations import FamilyChoices diff --git a/src/toad/widgets/tool_call.py b/src/toad/widgets/tool_call.py index 2b27801fd..ef3509182 100644 --- a/src/toad/widgets/tool_call.py +++ b/src/toad/widgets/tool_call.py @@ -9,7 +9,6 @@ from textual.reactive import var from textual.css.query import NoMatches from textual import containers -from textual.widget import Widget from toad.tool_output import ToolOutput, ToolOutputPart from metaclass_registry.mro_dispatch import handles @@ -208,8 +207,7 @@ def check_expand(self) -> None: return tool_call_expand = self.app.settings.tools.expand status = tool_call - if (status.completed and tool_call_expand.patch_preview - and self.output.preview_fits()): + if tool_call_expand.expands_preview(status, self.output.preview_fits): self._auto_expanded = True self.expanded = True return diff --git a/tests/model/test_saved_settings.py b/tests/model/test_saved_settings.py index 1c50bc55d..ab81c5a37 100644 --- a/tests/model/test_saved_settings.py +++ b/tests/model/test_saved_settings.py @@ -14,7 +14,7 @@ from agent_comms.field_codec import FieldCodec from python_introspect import AnnotatedDataclassValidationMixin from toad.acp.status import ToolCallStatus -from toad.forms.kinds import Multiline +from formbridge.forms.declared_fields import Multiline from toad.preferences import SETTINGS, SavedSettings from toad.setting_choices import BothExpansion, Expansion, FailExpansion from toad.setting_declarations import FamilyChoices diff --git a/tests/textual/test_application_actions_in_the_running_app.py b/tests/textual/test_application_actions_in_the_running_app.py index 76870670c..023c48683 100644 --- a/tests/textual/test_application_actions_in_the_running_app.py +++ b/tests/textual/test_application_actions_in_the_running_app.py @@ -51,9 +51,9 @@ async def open_column_width_editor(ui, app): await ui.until(lambda: isinstance(app.screen, SettingsScreen), why="the settings screen") assert await ui.textual[1].click(app.screen.search) await ui.type('Width of the column') - await ui.until(lambda: app.screen.form.values["ui.column_width"].editor.is_mounted, + await ui.until(lambda: app.screen.values["ui.column_width"].editor.is_mounted, why="the column width editor") - width = app.screen.form.values["ui.column_width"].editor + width = app.screen.values["ui.column_width"].editor width.scroll_visible(animate=False, immediate=True) await ui.step() assert await ui.textual[1].click(width) @@ -196,9 +196,9 @@ def positioned(fraction): await ui.until(lambda: isinstance(app.screen, SettingsScreen), why="settings") assert await ui.textual[1].click(app.screen.search) await ui.type('Column alignment') - await ui.until(lambda: app.screen.form.values["ui.column_alignment"].editor.is_mounted, + await ui.until(lambda: app.screen.values["ui.column_alignment"].editor.is_mounted, why="alignment selector") - selector = app.screen.form.values["ui.column_alignment"].editor + selector = app.screen.values["ui.column_alignment"].editor selector.scroll_visible(animate=False, immediate=True) await ui.step() selector.focus() diff --git a/tests/textual/test_forms.py b/tests/textual/test_forms.py index 6638a6d52..a53950ff4 100644 --- a/tests/textual/test_forms.py +++ b/tests/textual/test_forms.py @@ -11,13 +11,11 @@ from objectstate import ObjectState from python_introspect import AnnotatedDataclassValidationMixin, AnnotationChoices, FieldLabel from textual.app import App, ComposeResult -from textual.widgets import Checkbox, Select import toad.preferences # noqa: F401 names the process-wide root ObjectState asks for (ObjectState issue #44) from toad.forms import editors -from toad.forms.field_path import FieldPath from toad.forms.form_view import FormView, GroupView, ValueView -from toad.forms.kinds import Multiline +from formbridge.forms.declared_fields import Multiline from toad.textual_colours import ThemedApp @@ -60,7 +58,7 @@ def __init__(self) -> None: self.state = ObjectState(Everything()) def compose(self) -> ComposeResult: - self.form = FormView(self.state) + self.form = FormView.whole(self.state) yield self.form @@ -130,7 +128,8 @@ async def test_a_value_the_declaration_refuses_is_reported_and_the_editor_shows_ async with open_form() as (app, pilot, shown): await leave(pilot, shown["count"], "0") assert app.state.to_object().count == 3 and shown["count"].value == "3" - assert any("at least 1" in notification.message for notification in app._notifications) + refusal = app.form.values["count"].refusal + assert refusal.display and "at least 1" in str(refusal.render()) await leave(pilot, shown["count"], "") assert app.state.to_object().count == 3 and shown["count"].value == "3" diff --git a/tests/textual/test_quit_with_unsaved_settings.py b/tests/textual/test_quit_with_unsaved_settings.py index 4da8de899..6ce0a0030 100644 --- a/tests/textual/test_quit_with_unsaved_settings.py +++ b/tests/textual/test_quit_with_unsaved_settings.py @@ -31,9 +31,9 @@ async def test_quit_that_cannot_save_stays_open_and_a_retry_saves_the_edit_and_e await ui.until(lambda: isinstance(app.screen, SettingsScreen), why="the settings screen") assert await pilot.click(app.screen.search) await ui.type('Width of the column') - await ui.until(lambda: app.screen.form.values["ui.column_width"].editor.is_mounted, + await ui.until(lambda: app.screen.values["ui.column_width"].editor.is_mounted, why="the column width editor") - editor = app.screen.form.values["ui.column_width"].editor + editor = app.screen.values["ui.column_width"].editor editor.scroll_visible(animate=False, immediate=True) await ui.step() assert await pilot.click(editor) diff --git a/tests/textual/test_settings_screen.py b/tests/textual/test_settings_screen.py index c4e8482d5..b3d811e7f 100644 --- a/tests/textual/test_settings_screen.py +++ b/tests/textual/test_settings_screen.py @@ -9,14 +9,14 @@ from python_introspect import AnnotatedDataclassValidationMixin, FieldLabel from ui_theme.catalog import SCHEMES +from objectstate import is_ui_hidden_config_type from ui_theme.colour import Rgb from ui_theme.scheme import PrimaryColour from toad.forms import editors -from toad.forms.declared_views import DeclaredField -from toad.forms.field_path import FieldPath +from formbridge.forms.declared_fields import DeclaredField, FieldPath from toad.forms.form_view import ValueView -from toad.preferences import SETTINGS, SavedSettings, ToadSettings +from toad.preferences import INTERFACE, SETTINGS, SavedSettings, ToadSettings from toad.screens.settings import SchemeEditor, SettingsScreen from toad.setting_effects import Effect from toad.widgets.footer import ToadFooter @@ -49,14 +49,14 @@ class ExperimentSettings(AnnotatedDataclassValidationMixin): @dataclass(frozen=True) class ExtendedSettings(ToadSettings): - experiment: Annotated[ExperimentSettings, FieldLabel("Experiment")] = field(default_factory=ExperimentSettings) + experiment: Annotated[ExperimentSettings, FieldLabel("Experiment"), INTERFACE] = field(default_factory=ExperimentSettings) EXTENDED = FieldPath(ExtendedSettings) async def form_shown(ui, app): - await ui.until(lambda: all(view.is_mounted for view in app.screen.form.values.values()), + await ui.until(lambda: all(view.is_mounted for view in app.screen.values.values()), seconds=5, why="an editor per setting") @@ -79,11 +79,11 @@ async def settings_screen(workspace): def editor(app, path): - return app.screen.form.values[str(path)].editor + return app.screen.values[str(path)].editor def marked(app, path): - return ValueView.CHANGED_MARK in str(app.screen.form.values[str(path)].title.render()) + return ValueView.CHANGED_MARK in str(app.screen.values[str(path)].title.render()) async def edit_text(ui, widget, value): @@ -101,14 +101,14 @@ 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 + shown = app.screen.values structure = app.saved_settings.state.parameter_structure hidden = set() for name in structure.declarations: declared = DeclaredField(app.saved_settings.state, structure.path(name)) definition = next(item for item in fields(declared.owner_type) if item.name == declared.path.field_name) - if definition.metadata.get("ui_hidden", False): + if definition.metadata.get("ui_hidden", False) or is_ui_hidden_config_type(declared.annotation): hidden.add(name) hidden.update(path.value for path in structure.leaf_paths(name)) assert set(shown) == {path.value for path in structure.leaf_paths()} - hidden diff --git a/uv.lock b/uv.lock index 17af5a2c9..17d3c4d58 100644 --- a/uv.lock +++ b/uv.lock @@ -278,7 +278,7 @@ requires-dist = [ { name = "bashlex", specifier = "==0.18" }, { name = "click", specifier = "==8.4.0" }, { name = "format-currency", specifier = ">=0.0.10" }, - { name = "formbridge", git = "https://github.com/OpenHCSDev/formbridge.git?rev=ebe2fa9173428e2bd7154a0de23698757677c3c2" }, + { name = "formbridge", git = "https://github.com/OpenHCSDev/formbridge.git?branch=main" }, { name = "httpx", specifier = "==0.28.1" }, { name = "jeepney", marker = "sys_platform == 'linux'", specifier = "==0.9.0" }, { name = "metaclass-registry", git = "https://github.com/OpenHCSDev/metaclass-registry?branch=main" }, @@ -421,8 +421,9 @@ wheels = [ [[package]] name = "formbridge" version = "0.2.0" -source = { git = "https://github.com/OpenHCSDev/formbridge.git?rev=ebe2fa9173428e2bd7154a0de23698757677c3c2#ebe2fa9173428e2bd7154a0de23698757677c3c2" } +source = { git = "https://github.com/OpenHCSDev/formbridge.git?branch=main#b67c97e5c3ad1eda5c06a464fdc578038d01e3e8" } dependencies = [ + { name = "annotated-types" }, { name = "docutils" }, { name = "metaclass-registry" }, { name = "objectstate" },