diff --git a/pyproject.toml b/pyproject.toml index 20bf5d2a7..03013fade 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", branch = 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", branch = "main" } -objectstate = { git = "https://github.com/OpenHCSDev/objectstate.git", rev = "b5dd9f78b9293df145d7629f21deb89b042b9400" } +objectstate = { git = "https://github.com/OpenHCSDev/objectstate.git", rev = "1f859f06f70accd8c524a69b196d2837b0d79c70" } 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/forms/editors.py b/src/toad/forms/editors.py index 0ec7b8ab6..f069be475 100644 --- a/src/toad/forms/editors.py +++ b/src/toad/forms/editors.py @@ -2,6 +2,7 @@ from __future__ import annotations +from abc import abstractmethod from collections.abc import Iterable from enum import Enum from typing import TYPE_CHECKING, ClassVar, Optional @@ -13,18 +14,18 @@ resolve_annotated, ) from python_introspect.validation import validate_annotation_value -from textual import containers -from textual.app import ComposeResult +from textual import containers, work from textual.message import Message from textual.reactive import var from textual.validation import Function from textual.geometry import Spacing from textual.css.scalar import Scalar from textual.widget import Widget -from textual.widgets import Checkbox, Input, Select, TextArea +from textual.widgets import Button, Checkbox, Input, Select, TextArea from textual.widgets._input import InputType from formbridge.forms.declared_fields import DeclaredField, DeclaredViews, Multiline, declarations +from toad.forms.member_editing import UnionMembers from toad.forms.presentation import FormCheckbox, FormInput, FormSelectCurrent, FramedControl from toad.widget_view import WidgetView from toad.tone_paint import UntintedTonePaint @@ -77,6 +78,10 @@ def paint(self) -> None: def finish(self) -> None: """Send an edit the user has not finished yet. Most controls have none.""" + @abstractmethod + def commit(self) -> None: + """Explicitly supply the displayed input, even when it is unchanged.""" + 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) @@ -114,6 +119,9 @@ def changed(self, message: Checkbox.Changed) -> None: message.stop() self.send(message.value) + def commit(self) -> None: + self.send(self.value) + def paint(self) -> None: with self.prevent(Checkbox.Changed): self.value = bool(self.field.value) @@ -154,6 +162,9 @@ def finished(self, message: Input.Blurred | Input.Submitted) -> None: def finish(self) -> None: if self.value == self.value_text(self.field.value): return + self.commit() + + def commit(self) -> None: try: value = self.read(self.value) except ValueError as error: @@ -199,6 +210,9 @@ def changed(self, message: TextArea.Changed) -> None: message.stop() self.send(self.text) + def commit(self) -> None: + self.send(self.text) + def paint(self) -> None: value = str(self.field.value or "") if self.text != value: @@ -248,6 +262,10 @@ def changed(self, message: Select.Changed) -> None: if message.value is not Select.NULL: self.send(message.value) + def commit(self) -> None: + if self.value is not Select.NULL: + self.send(self.value) + def paint(self) -> None: value = self.field.value with self.prevent(Select.Changed): @@ -305,12 +323,21 @@ def member_feedback(self, message: ValueEditor.Feedback) -> None: def held_changed(self, message: Checkbox.Changed) -> None: message.stop() self.member.display = message.value - if not message.value: + if message.value: + self.member.commit() + else: self.member.set_reactive(ValueEditor.feedback, "") self.send(None) def finish(self) -> None: - self.member.finish() + if self.holds_value.value: + self.member.finish() + + def commit(self) -> None: + if self.holds_value.value: + self.member.commit() + else: + self.send(None) def value_text(self, value: object) -> str: return "not set" if value is None else self.member.value_text(value) @@ -321,3 +348,42 @@ def paint(self) -> None: self.holds_value.value = held self.member.display = held self.member.paint() + + +class UnionEditor(ValueEditor, Button): + """Choose and edit a declared member without changing the parent until Apply.""" + + shows = (UnionMembers,) + + @property + def members(self) -> UnionMembers: + metadata = self.field.metadata(UnionMembers) + assert metadata is not None + return metadata + + def bind_control(self) -> None: + self.paint() + + def value_text(self, value: object) -> str: + return self.members.label(self.members.selected(value)) + + def paint(self) -> None: + self.label = self.value_text(self.field.value) + + def commit(self) -> None: + self.send(self.field.value) + + def on_button_pressed(self, event: Button.Pressed) -> None: + event.stop() + self.choose() + + @work(exclusive=True) + async def choose(self) -> None: + dialog = self.members.dialog(self.field) + try: + await self.app.push_screen_wait(dialog) + finally: + if dialog.is_attached: + dialog.dismiss() + if self.is_attached: + self.paint() diff --git a/src/toad/forms/form_view.py b/src/toad/forms/form_view.py index 18b1bead8..37adfd050 100644 --- a/src/toad/forms/form_view.py +++ b/src/toad/forms/form_view.py @@ -17,7 +17,8 @@ from rich.style import Style from ui_theme.scheme import BackgroundColour, ErrorColour, ForegroundColour, MutedColour, PrimaryColour, SecondaryColour -from formbridge.forms.declared_fields import DeclaredField, DeclaredViews, FormPage, form_pages +from formbridge.forms.declared_fields import DeclaredField, DeclaredViews, FormPage, declarations, form_pages +from toad.forms.member_editing import UnionMembers from toad.forms.editors import ValueEditor from toad.widget_view import AUTO, FocusWithinView, WidgetView @@ -68,8 +69,13 @@ def of(state: ObjectState, parent: str = "") -> list[FieldView]: ObjectState already extracted this topology. Reading it does not rebuild a second form tree or re-analyze the held object's values. """ - return [FieldView.showing(type(parameter).__mro__)(DeclaredField(state, path)) - for path, parameter in ParameterFormService().parameters_for_state(state, parent)] + views = [] + for path, parameter in ParameterFormService().parameters_for_state(state, parent): + field = DeclaredField(state, path) + views.append(FieldView.showing(( + *declarations(field.annotation), *type(parameter).__mro__, + ))(field)) + return views def values(self) -> Iterator[ValueView]: """The views below this one that hold a single value.""" @@ -83,7 +89,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 = (ParameterInfo,) + shows = (ParameterInfo, UnionMembers) CHANGED_MARK = " ●" @@ -206,14 +212,20 @@ def leave_state(self, event: events.Unmount) -> None: self.state.off_resolved_changed(self.values_changed) self.state.off_state_changed(self.marks_changed) + def observing_values(self, paths: set[str]) -> Iterator[ValueView]: + """Observe a changed path through its existing ancestor/descendant relation.""" + for view in self.values.values(): + if any(view.field.path.intersects_path(path) for path in paths): + yield view + def values_changed(self, paths: set[str]) -> None: - for path in paths & self.values.keys(): - self.values[path].editor.paint() + for view in self.observing_values(paths): + view.editor.paint() def marks_changed(self, paths: set[str]) -> None: differ = self.state.dirty_fields - for path in paths & self.values.keys(): - self.values[path].mark(path in differ) + for view in self.observing_values(paths): + view.mark(any(view.field.path.intersects_path(path) for path in differ)) def finish(self) -> None: """Send every edit the user has typed but not left yet.""" diff --git a/src/toad/forms/member_editing.py b/src/toad/forms/member_editing.py new file mode 100644 index 000000000..0371b19d0 --- /dev/null +++ b/src/toad/forms/member_editing.py @@ -0,0 +1,56 @@ +"""Uncommitted member transactions, independent of a backend's member types.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import make_dataclass +from typing import ClassVar + +from formbridge.forms.declared_fields import DeclaredField +from objectstate import ObjectState +from textual.screen import ModalScreen + + +class MemberDraft[Selection = object](ABC): + """Uncommitted inputs; the containing field remains the committed value.""" + + state: ObjectState + selection: Selection + refusal: ClassVar[type[Exception]] + + @abstractmethod + def complete(self) -> object: ... + + +class MemberNotChosen(ValueError): + """The transaction has not selected an original declaration yet.""" + + +class UnchosenMemberDraft[Selection = object](MemberDraft[Selection]): + refusal = MemberNotChosen + + def __init__(self, selection: Selection) -> None: + self.selection = selection + self.state = ObjectState(make_dataclass('UnchosenMember', [])()) + + def complete(self) -> object: + raise MemberNotChosen('Choose a member') + + +class UnionMembers[Selection = object](ABC): + """Backend declarations supply choices and construct a pending member.""" + + @abstractmethod + def choices(self) -> tuple[Selection, ...]: ... + + @abstractmethod + def label(self, choice: Selection) -> str: ... + + @abstractmethod + def selected(self, value: object) -> Selection: ... + + @abstractmethod + def draft(self, choice: Selection, value: object) -> MemberDraft[Selection]: ... + + @abstractmethod + def dialog(self, field: DeclaredField) -> ModalScreen[None]: ... diff --git a/src/toad/forms/union_dialog.py b/src/toad/forms/union_dialog.py new file mode 100644 index 000000000..1a07311a8 --- /dev/null +++ b/src/toad/forms/union_dialog.py @@ -0,0 +1,106 @@ +"""A pending native member form; Apply alone edits its containing field.""" + +from __future__ import annotations + +from formbridge.forms.declared_fields import DeclaredField +from textual import containers, on +from textual.app import ComposeResult +from textual.screen import ModalScreen +from textual.widgets import Button, Select, Static +from textual.widgets.select import NoSelection + +from toad.forms.form_view import InlineFormView +from toad.forms.member_editing import UnionMembers + + +class UnionMemberDialog[Selection = object](ModalScreen[None]): + """One pending member transaction, separate from the committed parent.""" + + DEFAULT_CSS = ''' + UnionMemberDialog { align: center middle; background: $background 40%; } + UnionMemberDialog #union-box { width: 68; max-width: 95%; height: 90%; + background: $surface; border: solid $primary; padding: 1 2; } + UnionMemberDialog #union-body { height: 1fr; } + UnionMemberDialog #union-buttons { height: 3; } + ''' + + def __init__(self, field: DeclaredField, members: UnionMembers[Selection]) -> None: + super().__init__() + self.field, self.members = field, members + choice = members.selected(field.value) + self.selector: Select[Selection] = Select( + [(members.label(member), member) for member in members.choices()], + value=choice, allow_blank=False, id='union-member', + ) + self.draft = members.draft(choice, field.value) + self.form = InlineFormView.whole(self.draft.state) + self.body = containers.VerticalScroll(self.form, id='union-body') + self.feedback = Static('', markup=False) + self.apply_button = Button('Apply', id='union-apply', variant='primary', disabled=True) + + def compose(self) -> ComposeResult: + with containers.Vertical(id='union-box'): + yield Static(self.field.label, markup=False) + yield self.selector + yield self.body + yield self.feedback + with containers.Horizontal(id='union-buttons'): + yield self.apply_button + yield Button('Cancel', id='union-cancel') + + def on_mount(self) -> None: + self.draft.state.on_resolved_changed(self.changed) + self.validate_draft() + + def on_unmount(self) -> None: + self.draft.state.off_resolved_changed(self.changed) + + def changed(self, paths: set[str]) -> None: + self.validate_draft() + + def validate_draft(self) -> None: + try: + self.draft.complete() + except self.draft.refusal as error: + self.feedback.update(str(error)) + self.apply_button.disabled = True + else: + self.feedback.update('') + self.apply_button.disabled = False + + @on(Select.Changed, '#union-member') + async def select_member(self, event: Select.Changed) -> None: + event.stop() + choice = self.selector.value + if isinstance(choice, NoSelection): + raise ValueError('A union selector requires a declared selection') + if choice == self.draft.selection: + return + self.apply_button.disabled = True + self.draft.state.off_resolved_changed(self.changed) + await self.form.remove() + if not self.is_attached: + return + self.draft = self.members.draft(choice, self.field.value) + self.form = InlineFormView.whole(self.draft.state) + await self.body.mount(self.form) + self.draft.state.on_resolved_changed(self.changed) + self.validate_draft() + + @on(Button.Pressed, '#union-apply') + def apply_member(self, event: Button.Pressed) -> None: + event.stop() + self.form.finish() + try: + value = self.draft.complete() + except self.draft.refusal as error: + self.feedback.update(str(error)) + self.apply_button.disabled = True + return + self.field.edit(value) + self.dismiss() + + @on(Button.Pressed, '#union-cancel') + def cancel_member(self, event: Button.Pressed) -> None: + event.stop() + self.dismiss() diff --git a/src/toad/widgets/comms_command_dialog.py b/src/toad/widgets/comms_command_dialog.py index 427b2e46a..2f69935b4 100644 --- a/src/toad/widgets/comms_command_dialog.py +++ b/src/toad/widgets/comms_command_dialog.py @@ -8,14 +8,17 @@ from agent_comms.cli_command import TargetAction from agent_comms.target_fields import ChoiceTargetField, TargetField from agent_comms.config_options import ModelConfigOption -from agent_comms.field_codec import compile_decoder +from agent_comms.field_codec import EditorMember, MalformedRecord, MemberDraftFactory, MemberSelection, UnchosenMember from agent_comms.login import run_login from objectstate import ObjectState -from python_introspect import AnnotationChoices, FieldLabel +from python_introspect import AnnotationChoices, FieldLabel, optional_member_type from textual import on, work from textual.app import ComposeResult from textual.containers import Horizontal, Vertical, VerticalScroll from textual.widgets import Button, Checkbox, Static +from formbridge.forms.declared_fields import DeclaredField +from toad.forms.member_editing import MemberDraft, UnchosenMemberDraft, UnionMembers +from toad.forms.union_dialog import UnionMemberDialog from toad.forms.editors import ValueEditor from toad.forms.form_view import InlineFormView from formbridge.forms.declared_fields import Multiline @@ -42,6 +45,77 @@ def label(self, choice): )(field) +class CommandMemberDialog(UnionMemberDialog[MemberSelection], DismissibleScreen[None]): + """The generic transaction uses Toad's existing mode and dismissal owners.""" + + +@dataclass(frozen=True) +class PreparedUnion(UnionMembers[MemberSelection], MemberDraftFactory[MemberDraft[MemberSelection]]): + """Decoder references are annotation metadata, never ObjectState values.""" + + parameter: TargetField + + def choices(self) -> tuple[MemberSelection, ...]: + return self.parameter.editor_selections + + def label(self, choice: MemberSelection) -> str: + return choice.label + + def selected(self, value: object) -> MemberSelection: + return self.parameter.editor_selection(value) + + def dialog(self, field: DeclaredField) -> CommandMemberDialog: + return CommandMemberDialog(field, self) + + def draft(self, choice: MemberSelection, value: object) -> MemberDraft[MemberSelection]: + return choice.draft(self, value) + + def unchosen(self, member: UnchosenMember) -> MemberDraft[MemberSelection]: + return UnchosenMemberDraft(member) + + def member(self, member: EditorMember, value: object) -> MemberDraft[MemberSelection]: + return PreparedMemberDraft(member, value, self.selected(value) == member) + + +class DeclaredDraft: + """One field-derived native input state, shared by command and member forms.""" + + def prepare_draft(self, parameters: tuple[TargetField, ...]) -> None: + self.parameters = parameters + self.choices: dict[str, PreparedChoices] = {} + declarations = [] + for parameter in parameters: + hints: list[object] = [FieldLabel(parameter.description)] + hints.extend(parameter.editor_hints( + lambda item: self.choices.setdefault(item.name, PreparedChoices(item)))) + if parameter.editor_members and optional_member_type(parameter.editor_annotation) is None: + hints.append(PreparedUnion(parameter)) + if parameter.multiline: + hints.append(Multiline()) + declarations.append((parameter.name, Annotated[parameter.editor_annotation, *hints], + field(default_factory=lambda initial=parameter.editor_initial: initial))) + self.state = ObjectState(make_dataclass('CommandDraft', declarations)()) + + def arguments(self) -> dict[str, object]: + return {key: value for parameter in self.parameters + for key, value in parameter.argument(self.state.get_resolved_value(parameter.name)).items()} + + +class PreparedMemberDraft(DeclaredDraft, MemberDraft[MemberSelection]): + refusal = MalformedRecord + selection: EditorMember + + def __init__(self, member: EditorMember, value: object, supplied: bool) -> None: + self.selection = member + self.prepare_draft(tuple( + TargetField.of(entry, member.current(entry, value)) if supplied else TargetField.omitted(entry) + for entry in member.fields + )) + + def complete(self) -> object: + return self.selection.complete(self.arguments()) + + class CatalogField(ValueEditor, Button): """The shared model selector edits the form's canonical draft value.""" shows = (ModelConfigOption,) @@ -53,6 +127,9 @@ def paint(self): self.label = next((choice.name for choice in self.field.metadata(PreparedChoices).parameter.options if choice.value == self.field.value), 'Choose / connect a provider…') + def commit(self) -> None: + self.send(self.field.value) + def on_button_pressed(self, event: Button.Pressed): event.stop() self.choose() @@ -141,7 +218,7 @@ async def apply(self, context: CommandDialog) -> None: context.dismiss(arguments) -class CommandDialog(DismissibleScreen[dict[str, str | None]], actions=(ApplyCommandAction,)): +class CommandDialog(DeclaredDraft, DismissibleScreen[dict[str, object]], actions=(ApplyCommandAction,)): DEFAULT_CSS = """ CommandDialog { align: center middle; background: $background 40%; } CommandDialog #command-box { width: 68; max-width: 95%; height: 90%; @@ -157,17 +234,7 @@ class CommandDialog(DismissibleScreen[dict[str, str | None]], actions=(ApplyComm def __init__(self, definition: TargetAction, target: str, fields: tuple[TargetField, ...]): super().__init__() self.definition, self.target = definition, target - self.choices = {} - declarations = [] - for parameter in fields: - hints = [FieldLabel(parameter.description)] - hints.extend(parameter.editor_hints( - lambda item: self.choices.setdefault(item.name, PreparedChoices(item)))) - if parameter.multiline: - hints.append(Multiline()) - 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.prepare_draft(fields) self.form = InlineFormView.whole(self.state) self.form.id = 'command-fields' @@ -201,14 +268,15 @@ async def apply_clicked(self, event: Button.Pressed): await ApplyCommandAction().apply(self) - def arguments(self): - return {name: self.state.get_resolved_value(name) for name in self.form.values} - async def refresh_catalogs(self, arguments): self.query_one('#command-apply', Button).disabled = True - edited = self.definition.edited(arguments) - fields = await self.app.preparation.run_thread( - lambda: asyncio.run(edited.prepare_fields())) + try: + fields = await self.app.preparation.run_thread( + lambda: asyncio.run(self.definition.prepare_fields(arguments))) + except MalformedRecord as error: + if self.is_attached and arguments == self.arguments(): + self.query_one('#command-confirmation', Static).update(str(error)) + return if not self.is_attached or arguments != self.arguments(): return for parameter in fields: @@ -225,15 +293,18 @@ def update_confirmation(self): async def prepare(): def read(): for name, choices in self.choices.items(): - if arguments[name] not in choices.choices(): + if name not in arguments or arguments[name] not in choices.choices(): return f'Choose {name.replace("_", " ")}', False - return self.definition.edited(arguments).confirmation, True + try: + return self.definition.edited(arguments).confirmation, True + except MalformedRecord as error: + return str(error), False confirmation, valid = await self.app.preparation.run_thread(read) if not self.is_attached or arguments != self.arguments(): return self.query_one('#command-confirmation', Static).update(confirmation) confirmed.display = valid and bool(confirmation) self.query_one('#command-apply', Button).disabled = not valid or any( - arguments[name] != choices.parameter.editor_default + name not in arguments or arguments[name] != choices.parameter.editor_default for name, choices in self.choices.items()) self.run_worker(prepare, group='command-confirmation', exclusive=True) diff --git a/tests/textual/test_command_native_members.py b/tests/textual/test_command_native_members.py new file mode 100644 index 000000000..ca88487d9 --- /dev/null +++ b/tests/textual/test_command_native_members.py @@ -0,0 +1,178 @@ +"""Actual Toad command/member dialogs; no command execution or invented state.""" + +import pytest +from textual.widgets import Button + +from agent_comms.cli_command import TargetAction +from agent_comms.cli_verbs.fork import ForkCliCommand +from agent_comms.cli_verbs.new import NewThreadCliCommand +from agent_comms.field_codec import MalformedRecord +from agent_comms.repo import Checkout +from agent_comms.thread_management import ChangedTags, GivenTags, InheritedTags +from support.ui import TextualUi +from support.workspace import Workspace +from toad.forms.editors import LineEditor, OptionalEditor, UnionEditor, ValueEditor +from toad.widgets.comms_command_dialog import CommandDialog, CommandMemberDialog + + +async def show_command(ui: TextualUi, definition: TargetAction) -> CommandDialog: + dialog = CommandDialog(definition, 'private project', await definition.prepare_fields()) + ui.app.push_screen(dialog) + await ui.until(lambda: dialog.is_attached and all( + view.editor.is_mounted for view in dialog.form.values.values() + ), why='the real command fields to mount') + return dialog + + +async def supply(ui: TextualUi, editor: ValueEditor, text: str) -> None: + assert isinstance(editor, LineEditor) + editor.value = text + editor.commit() + await ui.step() + + +async def open_member(ui: TextualUi, command: CommandDialog) -> CommandMemberDialog: + editor = command.form.values['tags'].editor + assert isinstance(editor, UnionEditor) + editor.scroll_visible(animate=False, immediate=True) + await ui.step() + assert await ui.pilot.click(editor) + await ui.until(lambda: isinstance(ui.app.screen, CommandMemberDialog), + why='the actual member transaction') + member = ui.app.screen + assert isinstance(member, CommandMemberDialog) + return member + + +@pytest.mark.parametrize('text,expected', [('', frozenset()), ('chosen', frozenset({'chosen'}))]) +async def test_new_dialog_keeps_missing_name_and_tags_distinct_from_explicit_empty( + workspace: Workspace, text: str, expected: frozenset[str] +) -> None: + binding, = NewThreadCliCommand.project_bindings(workspace.comms, Checkout(workspace.project)) + definition = TargetAction((binding,), (), NewThreadCliCommand) + async with workspace.app(session=False) as ui: + dialog = await show_command(ui, definition) + assert 'name' not in dialog.arguments() + assert 'tags' not in dialog.arguments() + assert dialog.query_one('#command-apply', Button).disabled + with pytest.raises(MalformedRecord): + definition.edited(dialog.arguments()) + name = dialog.form.values['name'].editor + assert isinstance(name, OptionalEditor) + name.holds_value.value = True + await ui.step() + await supply(ui, name.member, 'new-child') + assert 'tags' not in dialog.arguments() + with pytest.raises(MalformedRecord): + definition.edited(dialog.arguments()) + tags = dialog.form.values['tags'].editor + assert isinstance(tags, OptionalEditor) + tags.holds_value.value = True + await ui.step() + await supply(ui, tags.member, text) + completed, = definition.edited(dialog.arguments()).bound + assert completed.name == 'new-child' + assert completed.tags == GivenTags(expected) + await ui.until(lambda: not dialog.query_one('#command-apply', Button).disabled, + why='explicit name and membership to enable Apply') + # Completing a form must not create or start the command's thread. + assert 'new-child' not in workspace.comms.registry + dialog.dismiss() + + +async def test_fork_member_selection_apply_reopen_cancel_and_native_parent_refresh( + workspace: Workspace, +) -> None: + parent = workspace.thread('original-parent', 'old') + binding = ForkCliCommand.thread_binding(parent) + definition = TargetAction((binding,), (parent.name,), ForkCliCommand) + async with workspace.app(session=False) as ui: + command = await show_command(ui, definition) + assert 'parent' not in command.form.values + assert 'name' not in command.arguments() + name = command.form.values['name'].editor + assert isinstance(name, OptionalEditor) + name.holds_value.value = True + await ui.step() + await supply(ui, name.member, 'fork-child') + untouched, = definition.edited(command.arguments()).bound + assert untouched.tags == InheritedTags() + assert untouched.tags.for_child(frozenset({'later'})) == frozenset({'later'}) + + member = await open_member(ui, command) + given = next(choice for choice in member.members.choices() if choice.declaration is GivenTags) + member.selector.value = given + await ui.until(lambda: member.draft.selection is given and 'value' in member.form.values, + why='GivenTags text editor') + assert member.selector.value is given + await supply(ui, member.form.values['value'].editor, 'old') + assert await ui.pilot.click(member.apply_button) + await ui.until(lambda: ui.app.screen is command, why='one GivenTags commit') + assert command.state.get_resolved_value('tags') == GivenTags(frozenset({'old'})) + + member = await open_member(ui, command) + retired = member.draft.state + changed = next(choice for choice in member.members.choices() if choice.declaration is ChangedTags) + member.selector.value = changed + await ui.until(lambda: member.draft.selection is changed and set(member.form.values) == {'add', 'remove'}, + why='ChangedTags required record fields') + assert member.apply_button.disabled + assert member.draft.arguments() == {} + retired.update_parameter('value', 'retired-input') + await ui.step() + assert member.draft.arguments() == {} + assert member.apply_button.disabled + add = member.form.values['add'].editor + remove = member.form.values['remove'].editor + assert isinstance(add, OptionalEditor) + assert isinstance(remove, OptionalEditor) + add.holds_value.value = True + await ui.step() + await supply(ui, add.member, '["new"]') + assert member.apply_button.disabled + remove.holds_value.value = True + await ui.step() + await supply(ui, remove.member, '["old"]') + expected = ChangedTags(frozenset({'new'}), frozenset({'old'})) + assert member.draft.complete() == expected + assert await ui.pilot.click(member.apply_button) + await ui.until(lambda: ui.app.screen is command, why='one ChangedTags commit') + assert command.state.get_resolved_value('tags') == expected + assert set(command.state.direct_parameter_paths('tags')) == { + command.state.parameter_structure.path('tags.add'), + command.state.parameter_structure.path('tags.remove'), + } + assert 'tags.tags' not in command.state.parameters + await ui.step() + assert str(command.form.values['tags'].editor.label) == changed.label + assert command.form.values['tags'] in tuple(command.form.observing_values({'tags.add'})) + completed, = definition.edited(command.arguments()).bound + assert completed.tags == expected + assert completed.parent == parent.name + + member = await open_member(ui, command) + assert member.draft.selection.declaration is ChangedTags + assert member.draft.complete() == expected + await supply(ui, member.form.values['add'].editor, '["cancelled"]') + assert await ui.pilot.click(member.query_one('#union-cancel', Button)) + await ui.until(lambda: ui.app.screen is command, why='member transaction cancelled') + assert command.state.get_resolved_value('tags') == expected + assert 'fork-child' not in workspace.comms.registry + with pytest.raises(ValueError, match='Target-bound'): + await command.refresh_catalogs({'parent': 'not-the-captured-parent'}) + with pytest.raises(ValueError, match='not offered'): + await command.refresh_catalogs({'unoffered': 'not-a-command-field'}) + command.dismiss() + + +async def test_actual_settings_observes_a_changed_ancestor(workspace: Workspace) -> None: + from toad.screens.settings import SettingsScreen + + async with workspace.app(session=False) as ui: + settings = SettingsScreen() + ui.app.push_screen(settings) + await ui.until(lambda: settings.is_attached, why='the actual settings form') + width = settings.form.values['ui.column_width'] + assert width in tuple(settings.form.observing_values({'ui'})) + assert width in tuple(settings.form.observing_values({'ui.column_width'})) + settings.dismiss() diff --git a/uv.lock b/uv.lock index 31cb59c88..9bc1d5160 100644 --- a/uv.lock +++ b/uv.lock @@ -284,7 +284,7 @@ requires-dist = [ { name = "metaclass-registry", git = "https://github.com/OpenHCSDev/metaclass-registry?branch=main" }, { name = "msgpack", specifier = "==1.2.2" }, { name = "notify-py", specifier = "==0.3.43" }, - { name = "objectstate", git = "https://github.com/OpenHCSDev/objectstate.git?rev=b5dd9f78b9293df145d7629f21deb89b042b9400" }, + { name = "objectstate", git = "https://github.com/OpenHCSDev/objectstate.git?rev=1f859f06f70accd8c524a69b196d2837b0d79c70" }, { name = "packaging", specifier = "==26.2" }, { name = "pathspec", specifier = "==1.1.1" }, { name = "platformdirs", specifier = "==4.9.6" }, @@ -887,7 +887,7 @@ wheels = [ [[package]] name = "objectstate" version = "5.0.0" -source = { git = "https://github.com/OpenHCSDev/objectstate.git?rev=b5dd9f78b9293df145d7629f21deb89b042b9400#b5dd9f78b9293df145d7629f21deb89b042b9400" } +source = { git = "https://github.com/OpenHCSDev/objectstate.git?rev=1f859f06f70accd8c524a69b196d2837b0d79c70#1f859f06f70accd8c524a69b196d2837b0d79c70" } dependencies = [ { name = "dill" }, { name = "pycodify" },