Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
948a5d5
Keep missing command inputs unset until the creator supplies them
trissim Oct 29, 2026
1b7b76e
Make explicit value commit an editor operation instead of a mode flag
trissim Oct 29, 2026
9072a56
Consume declared input presence and require editor commits
trissim Oct 29, 2026
2047688
Catch only malformed editor data in command forms
trissim Oct 29, 2026
f8576dd
Edit generic union members as cancellable native transactions
trissim Oct 29, 2026
3420a16
Derive unchosen labels and drafts from the selection member
trissim Oct 29, 2026
cb3dd7c
Keep the actual member selection on its draft
trissim Oct 29, 2026
34ff900
Keep native member selections through the union control
trissim Oct 30, 2026
b6e7108
Observe member subtree edits through canonical field paths
trissim Oct 30, 2026
d051658
Keep member selection native and use actual editor commit controls
trissim Oct 30, 2026
3410ec4
Lock the published native topology provider
trissim Oct 30, 2026
5cc8e61
Keep missing command inputs unset until the creator supplies them
trissim Oct 29, 2026
5b991f6
Make explicit value commit an editor operation instead of a mode flag
trissim Oct 29, 2026
03397e2
Consume declared input presence and require editor commits
trissim Oct 29, 2026
37b4cc5
Catch only malformed editor data in command forms
trissim Oct 29, 2026
6065dda
Edit generic union members as cancellable native transactions
trissim Oct 29, 2026
fb3fb16
Derive unchosen labels and drafts from the selection member
trissim Oct 29, 2026
a5ecae1
Keep the actual member selection on its draft
trissim Oct 29, 2026
2420837
Keep native member selections through the union control
trissim Oct 30, 2026
f8cf7b9
Observe member subtree edits through canonical field paths
trissim Oct 30, 2026
3c56ae8
Keep member selection native and use actual editor commit controls
trissim Oct 30, 2026
82aa861
Lock the published native topology provider
trissim Oct 30, 2026
20e22aa
Merge remote-tracking branch 'origin/work/commander-layout--P927--toa…
trissim Oct 30, 2026
b603872
Merge remote-tracking branch 'origin/main' into work/commander-layout…
trissim Oct 30, 2026
87d4f9e
Merge remote-tracking branch 'origin/main' into work/commander-layout…
trissim Oct 30, 2026
131b6ef
Merge remote-tracking branch 'origin/main' into work/commander-layout…
trissim Oct 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }

Expand Down
76 changes: 71 additions & 5 deletions src/toad/forms/editors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand All @@ -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()
28 changes: 20 additions & 8 deletions src/toad/forms/form_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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."""
Expand All @@ -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 = " ●"

Expand Down Expand Up @@ -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."""
Expand Down
56 changes: 56 additions & 0 deletions src/toad/forms/member_editing.py
Original file line number Diff line number Diff line change
@@ -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]: ...
106 changes: 106 additions & 0 deletions src/toad/forms/union_dialog.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading