Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 5 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ dependencies = [
"agent-comms[acp]",
"agent-client-protocol==0.12.1",
"ui-theme",
"formbridge==0.1.0",
"objectstate==2.1.1",
"formbridge==0.2.0",
"objectstate==5.0.0",
]

[tool.uv.workspace]
Expand All @@ -40,7 +40,9 @@ members = ["toad"]
[tool.uv.sources]
ui-theme = { git = "https://github.com/OpenHCSDev/ui-theme.git", rev = "2f19daf21f9fb6f22aafb0e1f470a9bb949a3633" }
agent-comms = { git = "https://github.com/OpenHCSDev/agent-comms.git", rev = "9ed8e0560e8231dbe3d6380512b2c9e1c69fa365" }
textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "18f4e7a67403d56846ef5abf55c93e3e0639be33" }
textual = { git = "https://github.com/OpenHCSDev/textual.git", rev = "25aad306ec9a374604f5973a363705f7c8aa0d0a" }
formbridge = { git = "https://github.com/OpenHCSDev/formbridge.git", rev = "ebe2fa9173428e2bd7154a0de23698757677c3c2" }
objectstate = { git = "https://github.com/OpenHCSDev/objectstate.git", rev = "ad8727278d9e83f9c0d4bfa94334177d5ea8fdf3" }
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 = "8fa7d4d0db993ea3b761c2760ca9b6a56a6251e9" }

Expand Down
21 changes: 20 additions & 1 deletion src/toad/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import sys
import json
import os
from enum import IntEnum
from functools import cached_property, partial
from pathlib import Path
from time import monotonic
Expand All @@ -25,6 +26,7 @@
from textual.app import App
from textual.await_complete import AwaitComplete
from textual.content import Content
from textual.css.scalar import Scalar, Unit
from textual.notifications import Notify
from textual.reactive import var
from textual.screen import Screen
Expand Down Expand Up @@ -249,6 +251,21 @@ class ApplicationNotRunningError(RuntimeError):
"""The application has no event loop to accept cross-thread work."""


class ScreenWidth(IntEnum):
"""Native sizing modes; legacy breakpoint classes are derived from them."""

side_panel_width: Scalar | None

def __new__(cls, minimum_width: int, side_panel_width: Scalar | None):
mode = int.__new__(cls, minimum_width)
mode._value_ = minimum_width
mode.side_panel_width = side_panel_width
return mode

NARROW = (0, None)
WIDE = (100, Scalar(50, Unit.PERCENT, Unit.WIDTH))


class ToadApp(VisibleReadMarking, CoreEventReceiver, DeclaredWidgetActions, WorkspaceSessionShutdown, ThemedApp, App, inherit_bindings=False):
"""The top level app."""

Expand All @@ -262,7 +279,9 @@ class ToadApp(VisibleReadMarking, CoreEventReceiver, DeclaredWidgetActions, Work
project_dir = var(Path)
show_sessions = var(False, toggle_class="-show-sessions-bar")

HORIZONTAL_BREAKPOINTS = [(0, "-narrow"), (100, "-wide")]
HORIZONTAL_BREAKPOINTS: list[tuple[int, str]] = [
(mode, f"-{mode.name.lower()}") for mode in ScreenWidth
]

PAUSE_GC_ON_SCROLL = True

Expand Down
5 changes: 2 additions & 3 deletions src/toad/comms_root.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
from agent_comms.ui_model.sidebar import SidebarModel, SidebarObservation
from agent_comms.ui_model.status import OpenThreadStatus
from toad.core.events import CoreEventStream, CoordinationObserved, ExternalTranscriptChanged, OpenTabsChanged, LinkContextChanged
from toad.forms.declared_views import DeclaredField
from toad.preferences import SETTINGS
from agent_comms.display_references import PendingReferences, UnavailableReferences

Expand Down Expand Up @@ -330,8 +329,8 @@ def sidebar_request(self) -> ObserveSidebar:
state = self.app.saved_settings.state
return ObserveSidebar(
str(self.app.project_dir),
DeclaredField.at(state, SETTINGS.sidebar.show_stopped).value,
DeclaredField.at(state, SETTINGS.sidebar.show_archived).value,
SETTINGS.sidebar.show_stopped.bind(state).value,
SETTINGS.sidebar.show_archived.bind(state).value,
)

def update_sidebar_interest(self) -> None:
Expand Down
4 changes: 2 additions & 2 deletions src/toad/core/preference_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

from dataclasses import dataclass

from toad.forms.field_path import FieldPath
from toad.forms.declared_views import DeclaredField
from .events import CoreEvent


@dataclass(frozen=True)
class PreferenceChanged(CoreEvent):
"""Read this setting again; what the app does on the change has been done."""

path: FieldPath
field: DeclaredField
75 changes: 53 additions & 22 deletions src/toad/forms/declared_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,25 @@
from __future__ import annotations

from collections.abc import Iterable, Iterator
from dataclasses import MISSING, dataclass, replace
from dataclasses import dataclass
from inspect import cleandoc
from typing import Annotated, ClassVar, Optional, Self, get_args, get_origin

from objectstate import ObjectState
from python_introspect import optional_member_type
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.field_path import FieldPath, declared, declared_annotation, declared_field, label
from toad.forms.kinds import Placeholder

Metadata = TypeVar("Metadata")


class DeclaredViews:
"""A family of widget classes, each registered under what it shows.
Expand All @@ -23,8 +31,9 @@ class DeclaredViews:
of field is adding a class: nothing lists the members and nothing chooses
between them by testing types.

Textual reads a widget's focus and CSS from its first base, so a member
names its Textual class first and its family after it.
Textual follows the first DOM base for native focus and CSS. A pure editor
capability may precede that base so its cooperative initializer binds the
field before the native control consumes it.
"""

shows: ClassVar[tuple[object, ...]] = ()
Expand Down Expand Up @@ -70,43 +79,57 @@ class DeclaredField:
"""One field of an ObjectState: where its value lives and how it was declared."""

state: ObjectState
path: FieldPath
annotation: object
path: DottedFieldPath

@classmethod
def at(cls, state: ObjectState, path: FieldPath) -> DeclaredField:
return cls(state, path, declared_annotation(path))
@property
def declaration(self) -> ParameterInfo:
return self.state.parameter_structure.declarations[self.path.value]

@property
def annotation(self) -> object:
return self.declaration.param_type

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(str(self.path))
return self.state.get_resolved_value(self.path.value)

@property
def default(self) -> object:
return self.state.signature_default(str(self.path))
return self.declaration.default_value

@property
def has_default(self) -> bool:
field = declared_field(self.path)
return field.default is not MISSING or field.default_factory is not MISSING
return not self.declaration.is_required

@property
def placeholder(self) -> str:
hint = declared(self.path, Placeholder)
hint = self.metadata(Placeholder)
return hint.text if hint is not None else ""

@property
def label(self) -> str:
return label(self.path)
return (
declared_field_label(self.annotation)
or FieldDisplayText.from_field_name(self.declaration.name).display_name
)

@property
def help(self) -> str:
return cleandoc(self.state.parameter_descriptions.get(str(self.path)) or "")
return cleandoc(self.declaration.description or "")

@property
def member(self) -> DeclaredField:
"""The same field as declared without `None`."""
return replace(self, annotation=optional_member_type(self.annotation))
return OptionalMemberField(self.state, self.path)

def edit(self, value: object) -> None:
"""Give the field a value, unless its declaration does not accept it.
Expand All @@ -117,4 +140,12 @@ def edit(self, value: object) -> None:
# ObjectState takes any value for a field of the root object and only
# fails later, when the object is rebuilt (ObjectState issue #47).
validate_annotation_value(self.annotation, value, path=self.label)
self.state.update_parameter(str(self.path), value)
self.state.update_parameter(self.path.value, value)


class OptionalMemberField(DeclaredField):
"""The member editor reads a narrower annotation, not a copied declaration."""

@property
def annotation(self) -> object:
return optional_member_type(super().annotation)
Loading
Loading