diff --git a/src/toad/application_actions.py b/src/toad/application_actions.py index e1c1f9fc2..bdb9b988a 100644 --- a/src/toad/application_actions.py +++ b/src/toad/application_actions.py @@ -20,7 +20,7 @@ from toad.target_commands import TargetContext from toad.thread_actions import ThreadAction from toad.core.session_requests import SessionNavigate -from toad.sidebar_layout import Side +from toad.sidebar_layout import LeftSidebarEdge, RightSidebarEdge, SidebarEdge from agent_comms.errors import RelationViolationError from agent_comms.cli_verbs.new import NewThreadCliCommand from agent_comms.channel_targets import ProjectTarget @@ -234,7 +234,7 @@ class VimHistoryForwardAction(VimHistoryAction): class VimSidebarAction(VimWorkspaceAction, declared_name=None): - side: Side + side: type[SidebarEdge] async def apply(self, app: ToadApp) -> None: app.workspace_chrome.toggle_sidebar(self.side) @@ -243,13 +243,13 @@ async def apply(self, app: ToadApp) -> None: class VimLeftSidebarAction(VimSidebarAction): """Toggle the sidebars on the left edge.""" keys = {VimMode: ("ctrl+shift+h",)} - side = "left" + side = LeftSidebarEdge class VimRightSidebarAction(VimSidebarAction): """Toggle the sidebars on the right edge.""" keys = {VimMode: ("ctrl+shift+l",)} - side = "right" + side = RightSidebarEdge class VimMoveTabAction(VimWorkspaceAction, declared_name=None): diff --git a/src/toad/line_blocks.py b/src/toad/line_blocks.py index 2a884daf9..c912f8b8e 100644 --- a/src/toad/line_blocks.py +++ b/src/toad/line_blocks.py @@ -17,7 +17,7 @@ from agent_comms.display_references import DisplayReference from agent_comms.nominal.field_codec import FieldCodec -from typing import ClassVar +from typing import ClassVar, Self from ui_theme.scheme import ( AccentColour, ErrorColour, ForegroundColour, MutedColour, PrimaryColour, SecondaryColour, SuccessColour, @@ -303,6 +303,10 @@ def text(self) -> str: def selection_text(self) -> str: return self.text + @classmethod + def titled(cls, title: str, role: type[LineRole] = MutedRole) -> Self: + return cls((Span(title, role),)) + @dataclass(frozen=True) class Spans(SpanText): @@ -348,14 +352,6 @@ class Summary(SpanText): def text(self) -> str: return super().text + "".join(span.text for span in self.kept) - @classmethod - def titled(cls, title: str, role: type[LineRole] = MutedRole, - link: LineLink | None = None) -> Summary: - return cls((Span(title, role, link),)) - - @property - def selection_text(self) -> str: - return self.text @dataclass(frozen=True) diff --git a/src/toad/prompt_actions.py b/src/toad/prompt_actions.py index d12a7ede9..da1cb7886 100644 --- a/src/toad/prompt_actions.py +++ b/src/toad/prompt_actions.py @@ -3,6 +3,7 @@ from textual.document._editing import VimMode from textual.widgets.text_area import EditingMode from toad.widget_actions import Action +from toad.core.input_events import UserInputSubmitted from typing import TYPE_CHECKING @@ -47,6 +48,15 @@ class SubmitInputAction(PromptAction, declared_name=None): def available(self, editor): return editor.submission_allowed + async def apply(self, editor: PromptTextArea) -> None: + editor.schedule_submission(self) + + def coalesce(self, following: SubmitInputAction) -> SubmitInputAction: + return following + + def submission_event(self, text: str, shell: bool) -> UserInputSubmitted: + return UserInputSubmitted(text, shell) + class SubmitAction(SubmitInputAction): """Send the prompt to the agent.""" @@ -54,9 +64,6 @@ class SubmitAction(SubmitInputAction): title = "Send" key_display = "⏎" - async def apply(self, editor: PromptTextArea) -> None: - editor.schedule_submission() - class SubmitNowAction(SubmitInputAction): """Send the prompt now, ahead of the queue.""" @@ -68,8 +75,11 @@ def available(self, editor): return (super().available(editor) and editor.prompt.agent_ready and editor.agent_busy and editor.prompt.agent_binding.presentation.queue_supported) - async def apply(self, editor: PromptTextArea) -> None: - editor.schedule_submission(immediate=True) + def coalesce(self, following: SubmitInputAction) -> SubmitInputAction: + return self + + def submission_event(self, text: str, shell: bool) -> UserInputSubmitted: + return UserInputSubmitted(text, shell, immediate=True) class CopyOrClearInputAction(PromptAction): diff --git a/src/toad/screens/comms.py b/src/toad/screens/comms.py index 2a21c160b..c000c54f2 100644 --- a/src/toad/screens/comms.py +++ b/src/toad/screens/comms.py @@ -2,7 +2,7 @@ from toad.core_event_carrier import CoreEventMessage from toad.core import events as core_events from toad.core import session_requests -from toad.screens.session_view import SessionView +from toad.screens.session_view import SidebarSessionView from toad.project_path_owner import CheckoutRoot import asyncio from functools import partial @@ -24,7 +24,6 @@ from toad.widgets.comms_chat import CommsChatView from toad.core.input_events import SelectHistoricalIdentity from toad.widgets.comms_sidebar import CoordinationStatus, CommsSidebar -from toad.session_tracker import SidebarState from toad.navigation_context import NavigationContext from toad.navigation_target import HistoryTarget, NavigationTarget, SelectTarget from toad.widgets.session_tabs import SessionsTabs @@ -78,7 +77,7 @@ def apply(self, context: CommsScreen) -> None: chat.prepare_prompt() -class CommsScreen(TogglesIrc, CyclesSessions, CheckoutRoot, SessionView, ToadNode, can_focus=False, +class CommsScreen(TogglesIrc, CyclesSessions, CheckoutRoot, SidebarSessionView[ThreadSidebar], ToadNode, can_focus=False, actions=(ViewAction, ReturnToViewAction, CloseViewAction)): """A channel or DM represented as a native concurrent Toad session.""" @@ -102,7 +101,6 @@ def __init__( self._identity = SessionIdentity.for_thread(me, owner_root) self.conversation = conversation self.wire_root = wire_root - self._thread_sidebar_state = SidebarState() self._content_ready = asyncio.Event() self._content_error: BaseException | None = None self._content_loaded = False @@ -143,9 +141,21 @@ def rebind_project(self, project: Checkout) -> None: chat.project_path = project chat.working_directory = str(project) - def sidebars(self): - sidebar = self.query_one_optional(ThreadSidebar) - return (sidebar,) if sidebar is not None else () + def make_thread_sidebar(self) -> ThreadSidebar: + return ThreadSidebar( + SideBar.Panel( + "Connection", + CoordinationStatus(), + id="coordination-panel", + ), + SideBar.Panel( + "Comms", + ThreadCommsSidebar(), + id="thread-comms-panel", + header_control=RelationshipSort(), + ), + state=self._thread_sidebar_state, + ) def compose(self) -> ComposeResult: content = ( @@ -156,20 +166,7 @@ def compose(self) -> ComposeResult: ) ) yield containers.Center( - ThreadSidebar( - SideBar.Panel( - "Connection", - CoordinationStatus(), - id="coordination-panel", - ), - SideBar.Panel( - "Comms", - ThreadCommsSidebar(), - id="thread-comms-panel", - header_control=RelationshipSort(), - ), - state=self._thread_sidebar_state, - ), + self.thread_sidebar, containers.Vertical(*content, id="comms-content"), ).set_styles(layout=self.app.workspace_chrome) diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index 85b263577..18853344c 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -46,10 +46,10 @@ ThreadTarget, ) from toad.screens.mcp_inventory import MCPInventoryScreen -from toad.screens.session_view import SessionView +from toad.screens.session_view import SidebarSessionView from toad.session_presentation import OperationalSessionPresentation from toad.session_scope import AgentBinding, NoAgent, ViewSessionOrigin -from toad.session_tracker import SessionDetails, SidebarState +from toad.session_tracker import SessionDetails from toad.tab_identity.history_origin import SessionHistoryOrigin from toad.thread_identity import NoThreadIdentity, ThreadIdentity from toad.widgets.channels_sidebar import ChannelsSidebar @@ -294,7 +294,7 @@ async def attached(self, screen: "MainScreen") -> None: await screen.prepare_presentation() -class MainScreen(NativeMethodKeys, HiddenActions, TogglesIrc, CyclesSessions, ViewSessionOrigin, SessionView, ToadNode, +class MainScreen(NativeMethodKeys, HiddenActions, TogglesIrc, CyclesSessions, ViewSessionOrigin, SidebarSessionView[SessionThreadSidebar], ToadNode, scopes={HiddenActions: (SessionNavigationAction,)}, can_focus=False): footer_compact = True @@ -310,7 +310,6 @@ class MainScreen(NativeMethodKeys, HiddenActions, TogglesIrc, CyclesSessions, Vi def keyboard_scopes(self) -> tuple[DeclaredWidgetActions, ...]: return self.content.keyboard_scopes(self) - thread_sidebar = getters.query_one(SessionThreadSidebar) @property def side_bar(self): @@ -332,7 +331,6 @@ def __init__( self._identity = binding.initial_identity(NoThreadIdentity()) self._session_pk = session_pk self._initial_prompt = initial_prompt - self._thread_sidebar_state = SidebarState() self._project_panel: ProjectPanel | None = None self.content: ScreenContent = OpeningContent() self.presentation = OperationalSessionPresentation() @@ -436,12 +434,12 @@ def activate_session(self) -> None: if self._project_panel is not None: self.call_after_refresh(self._project_panel.refresh_if_visible) - def sidebars(self): - return (self.thread_sidebar,) + def make_thread_sidebar(self) -> SessionThreadSidebar: + return SessionThreadSidebar(self) def compose(self) -> ComposeResult: with containers.Center().set_styles(layout=self.app.workspace_chrome): - yield SessionThreadSidebar(self) + yield self.thread_sidebar with containers.Vertical(id="session-content"): yield self.content.render(self) yield self.presentation.compose_content(self) diff --git a/src/toad/screens/session_view.py b/src/toad/screens/session_view.py index af91b4bda..03a7a0ed9 100644 --- a/src/toad/screens/session_view.py +++ b/src/toad/screens/session_view.py @@ -1,5 +1,7 @@ """Logical session surfaces hosted by the one native WorkspaceScreen.""" +from abc import abstractmethod +from functools import cached_property from textual.containers import Vertical from textual.reactive import reactive from contextlib import contextmanager @@ -10,6 +12,8 @@ from toad.thread_identity import SessionIdentity, NoThreadIdentity from toad.session_scope import NoSessionOrigin, SessionOrigin, SessionScope from toad.navigation_target import NavigationTarget, NoTarget +from toad.session_tracker import SidebarState +from toad.widgets.side_bar import SideBar, ThreadSidebar from toad.widget_actions import DeclaredWidgetActions from toad.widgets.message_notifications import ( NothingToRead, @@ -24,9 +28,9 @@ class SessionView(DeclaredWidgetActions, VisibleReadMarking, CoreEventReceiver, DEFAULT_CSS = "SessionView { width: 1fr; height: 1fr; }" - def __init__(self, *args, **kwargs): - self._identity: SessionIdentity = NoThreadIdentity() - super().__init__(*args, **kwargs) + # The absent identity is an immutable value; concrete bindings replace it + # on the instance. Native construction stays with Vertical/Widget. + _identity: SessionIdentity = NoThreadIdentity() def close_core_events(self) -> None: """Retire this logical source's descendants before its tab entry disappears.""" @@ -89,7 +93,7 @@ async def open_direct_messages(self) -> None: "Direct messages are unavailable from this view", title="Direct messages" ) - def sidebars(self): + def sidebars(self) -> tuple[SideBar, ...]: """Supply this view's native sidebar resources through its declaration.""" return () @@ -158,3 +162,26 @@ async def close_presentation(self) -> None: def retained_native_presentations(self): """Declare accepted native trees; non-native views own no such trees.""" return () + + +class SidebarSessionView[SidebarT: ThreadSidebar](SessionView): + """One session-owned sidebar survives construction, mounting and retirement. + + Composition mounts this resource; it does not create its identity. Chrome + may ask for membership before the native tree exists, and close still owns + the same resource after it has left that tree. + """ + + @cached_property + def _thread_sidebar_state(self) -> SidebarState: + return SidebarState() + + @abstractmethod + def make_thread_sidebar(self) -> SidebarT: ... + + @cached_property + def thread_sidebar(self) -> SidebarT: + return self.make_thread_sidebar() + + def sidebars(self) -> tuple[SidebarT, ...]: + return (self.thread_sidebar,) diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py index 22e5d3731..02a40a697 100644 --- a/src/toad/session_scope.py +++ b/src/toad/session_scope.py @@ -38,7 +38,7 @@ from agent_comms.messages import Message from agent_comms.thread_presentation import ThreadPresentation from textual.command import Provider - from textual.dom import DOMNode + from toad.widgets.side_bar import SideBar from toad.agent_schema import AgentDefinition from toad.comms_root import CoordinationAccess @@ -362,7 +362,7 @@ def thread_identity(self) -> SessionIdentity: ... def belongs_to_wire(self, root: str | Path) -> bool: ... @abstractmethod - def sidebars(self) -> tuple[DOMNode, ...]: ... + def sidebars(self) -> tuple[SideBar, ...]: ... @abstractmethod def keyboard_scopes(self) -> tuple[DeclaredWidgetActions, ...]: ... diff --git a/src/toad/sidebar_layout.py b/src/toad/sidebar_layout.py index 3f8f73e01..1028ed326 100644 --- a/src/toad/sidebar_layout.py +++ b/src/toad/sidebar_layout.py @@ -1,137 +1,390 @@ -"""One app-owned placement policy for the two independently movable sidebars.""" +"""One app-owned placement policy for independently movable sidebar surfaces.""" from __future__ import annotations -from dataclasses import dataclass, replace -from typing import Literal from abc import abstractmethod +from collections.abc import Iterator +from dataclasses import dataclass, replace +from typing import ClassVar, Self from agent_comms.nominal.declared_family import DeclaredFamily +from textual.css.types import DockEdge +from textual.geometry import Offset, Region, Size, Spacing +from textual.widget import Widget +from textual.widgets import Static + + +class SidebarEdge(DeclaredFamily, affix="SidebarEdge"): + """An edge owns its native geometry, resize axis and control placement.""" + + dock: ClassVar[DockEdge] + symbol: ClassVar[str] + sign: ClassVar[int] + minimum: ClassVar[int] + conversation_minimum: ClassVar[int] + collapsed_extent: ClassVar[int] + + @classmethod + @abstractmethod + def axis(cls) -> type[SidebarEdge]: ... + + @classmethod + @abstractmethod + def extent(cls, size: Size) -> int: ... + + @classmethod + @abstractmethod + def coordinate(cls, point: Offset) -> int: ... + + @classmethod + @abstractmethod + def origin(cls, viewport: int, offset: int, extent: int) -> int: ... + + @classmethod + @abstractmethod + def region(cls, size: Size, span: SidebarGeometry, occupied: Spacing) -> Region: ... + + @classmethod + @abstractmethod + def gutter(cls, extent: int) -> Spacing: ... + + @classmethod + @abstractmethod + def size_toggle(cls, toggle: Widget) -> None: ... + + @classmethod + @abstractmethod + def size_handle(cls, handle: Widget) -> None: ... + + @classmethod + @abstractmethod + def handle_text(cls, size: Size) -> str: ... + + @classmethod + @abstractmethod + def control_offset(cls, distance: int) -> Offset: ... + + @classmethod + def opposite(cls) -> type[SidebarEdge]: + return next(edge for edge in SidebarEdge.members_with(cls.axis()) if edge is not cls) + + @classmethod + def content_padding(cls) -> Spacing: + return cls.opposite().gutter(cls.collapsed_extent + 1) + + @classmethod + def toggle_symbol(cls, collapsed: bool) -> str: + return (cls.opposite() if collapsed else cls).symbol + + @classmethod + def order_key(cls, order: int) -> int: + return cls.sign * order + + +class LeadingSidebarEdge: + sign = 1 + + @classmethod + def origin(cls, viewport: int, offset: int, extent: int) -> int: + return offset + + +class TrailingSidebarEdge: + sign = -1 + + @classmethod + def origin(cls, viewport: int, offset: int, extent: int) -> int: + return viewport - offset - extent + + +class WidthSidebarEdge(SidebarEdge): + minimum = 18 + conversation_minimum = 24 + collapsed_extent = 3 + + @classmethod + def axis(cls) -> type[SidebarEdge]: + return WidthSidebarEdge + + @classmethod + def extent(cls, size: Size) -> int: + return size.width + + @classmethod + def coordinate(cls, point: Offset) -> int: + return point.x + + @classmethod + def region(cls, size: Size, span: SidebarGeometry, occupied: Spacing) -> Region: + return Region(cls.origin(size.width, span.offset, span.extent), occupied.top, + span.extent, max(0, size.height - occupied.height)) + + @classmethod + def size_toggle(cls, toggle: Widget) -> None: + toggle.styles.width, toggle.styles.height = cls.collapsed_extent, "1fr" + + @classmethod + def size_handle(cls, handle: Widget) -> None: + handle.styles.width, handle.styles.height = 1, "1fr" + handle.styles.pointer = "ew-resize" + + @classmethod + def handle_text(cls, size: Size) -> str: + return "\n".join("│" for _ in range(size.height)) + + @classmethod + def control_offset(cls, distance: int) -> Offset: + return Offset(-cls.sign * distance, 0) + -type Side = Literal["left", "right"] +class HeightSidebarEdge(SidebarEdge): + minimum = 9 + conversation_minimum = 8 + collapsed_extent = 1 + + @classmethod + def axis(cls) -> type[SidebarEdge]: + return HeightSidebarEdge + + @classmethod + def extent(cls, size: Size) -> int: + return size.height + + @classmethod + def coordinate(cls, point: Offset) -> int: + return point.y + + @classmethod + def region(cls, size: Size, span: SidebarGeometry, occupied: Spacing) -> Region: + return Region(0, cls.origin(size.height, span.offset, span.extent), size.width, span.extent) + + @classmethod + def size_toggle(cls, toggle: Widget) -> None: + toggle.styles.width, toggle.styles.height = "1fr", cls.collapsed_extent + + @classmethod + def size_handle(cls, handle: Widget) -> None: + handle.styles.width, handle.styles.height = "1fr", 1 + handle.styles.pointer = "ns-resize" + + @classmethod + def handle_text(cls, size: Size) -> str: + return "─" * size.width + + @classmethod + def control_offset(cls, distance: int) -> Offset: + return Offset(0, -cls.sign * distance) + + +class LeftSidebarEdge(LeadingSidebarEdge, WidthSidebarEdge): + dock = "left" + symbol = "<" + + @classmethod + def gutter(cls, extent: int) -> Spacing: + return Spacing(0, 0, 0, extent) + + +class RightSidebarEdge(TrailingSidebarEdge, WidthSidebarEdge): + dock = "right" + symbol = ">" + + @classmethod + def gutter(cls, extent: int) -> Spacing: + return Spacing(0, extent, 0, 0) + + +class TopSidebarEdge(LeadingSidebarEdge, HeightSidebarEdge): + dock = "top" + symbol = "↑" + + @classmethod + def gutter(cls, extent: int) -> Spacing: + return Spacing(extent, 0, 0, 0) + + +class BottomSidebarEdge(TrailingSidebarEdge, HeightSidebarEdge): + dock = "bottom" + symbol = "↓" + + @classmethod + def gutter(cls, extent: int) -> Spacing: + return Spacing(0, 0, extent, 0) class SidebarShift(DeclaredFamily, affix="SidebarShift"): available = True @abstractmethod - def apply(self, layout: "SidebarLayout", identity: str, direction: Side) -> bool: ... + def apply(self, layout: SidebarLayout, identity: type[SidebarPlacement], direction: type[SidebarEdge]) -> bool: ... @abstractmethod - def tooltip(self, direction: Side) -> str: ... + def tooltip(self, direction: type[SidebarEdge]) -> str: ... class BlockedSidebarShift(SidebarShift): available = False - def apply(self, layout, identity, direction): + def apply(self, layout: SidebarLayout, identity: type[SidebarPlacement], direction: type[SidebarEdge]) -> bool: return False - def tooltip(self, direction): + def tooltip(self, direction: type[SidebarEdge]) -> str: return "Outside workspace edge" class SwapSidebarShift(SidebarShift): - def apply(self, layout, identity, direction): + def apply(self, layout: SidebarLayout, identity: type[SidebarPlacement], direction: type[SidebarEdge]) -> bool: return layout.swap(identity) - def tooltip(self, direction): - return f"Swap with the sidebar to the {direction}" + def tooltip(self, direction: type[SidebarEdge]) -> str: + return f"Swap with the sidebar toward {direction.declared_name}" class MoveSidebarShift(SidebarShift): - def apply(self, layout, identity, direction): + def apply(self, layout: SidebarLayout, identity: type[SidebarPlacement], direction: type[SidebarEdge]) -> bool: return layout.move(identity, direction) - def tooltip(self, direction): - return f"Move sidebar to the {direction}" + def tooltip(self, direction: type[SidebarEdge]) -> str: + return f"Move sidebar to {direction.declared_name}" class SidebarPlacementAction(DeclaredFamily, affix="SidebarPlacementAction"): - directional = False - label: str + classes = "" + @property @abstractmethod - def apply(self, layout: "SidebarLayout", identity: str) -> None: ... + def label(self) -> str: ... + @property + def name(self) -> str: + return self.declared_name -class DirectionalSidebarPlacementAction(SidebarPlacementAction): - directional = True + @classmethod + def offered(cls) -> Iterator[SidebarPlacementAction]: + yield cls() - @property @abstractmethod - def direction(self) -> Side: ... + def apply(self, layout: SidebarLayout, identity: type[SidebarPlacement]) -> None: ... - def apply(self, layout, identity): - layout.shift(identity, self.direction) + @abstractmethod + def present(self, button: Static, placement: SidebarPlacement, + directions: dict[type[SidebarEdge], SidebarShift]) -> None: ... -class LeftSidebarPlacementAction(DirectionalSidebarPlacementAction): - direction = "left" - label = "<──" +@dataclass(frozen=True) +class DirectionalSidebarPlacementAction(SidebarPlacementAction): + direction: type[SidebarEdge] + classes = "-direction" + @property + def name(self) -> str: + return self.direction.declared_name -class RightSidebarPlacementAction(DirectionalSidebarPlacementAction): - direction = "right" - label = "──>" + @property + def label(self) -> str: + return self.direction.symbol + + @classmethod + def offered(cls) -> Iterator[SidebarPlacementAction]: + for edge in SidebarEdge.members_with(SidebarEdge): + yield cls(edge) + + def apply(self, layout: SidebarLayout, identity: type[SidebarPlacement]) -> None: + layout.shift(identity, self.direction) + + def present(self, button: Static, placement: SidebarPlacement, + directions: dict[type[SidebarEdge], SidebarShift]) -> None: + action = directions[self.direction] + button.display = action.available + button.tooltip = action.tooltip(self.direction) class FloatSidebarPlacementAction(SidebarPlacementAction): - label = "Float" + @property + def label(self) -> str: + return "Float" - def apply(self, layout, identity): + def apply(self, layout: SidebarLayout, identity: type[SidebarPlacement]) -> None: layout.float_mode(identity) + def present(self, button: Static, placement: SidebarPlacement, + directions: dict[type[SidebarEdge], SidebarShift]) -> None: + button.update("Push" if placement.floating else "Float", layout=False) + button.tooltip = ("Push conversation text" if placement.floating + else "Float over conversation text") + @dataclass(frozen=True, slots=True) -class SidebarPlacement: - side: Side - order: int # Zero is the outside edge; a second bar goes on its inside. - width_percent: int +class SidebarPlacement(DeclaredFamily, affix="SidebarPlacement"): + """A nominal workspace role owns its initial policy and native spelling.""" + + default_side: ClassVar[type[SidebarEdge]] + default_extent_percent: ClassVar[int] + + side: type[SidebarEdge] + order: int + extent_percent: int floating: bool = False + @classmethod + def initial(cls) -> Self: + return cls(cls.default_side, 0, cls.default_extent_percent) + + @classmethod + def native_id(cls) -> str: + return f"{cls.declared_name}-sidebar" + + +class ChannelsSidebarPlacement(SidebarPlacement): + default_side = LeftSidebarEdge + default_extent_percent = 40 + + +class ThreadSidebarPlacement(SidebarPlacement): + default_side = RightSidebarEdge + default_extent_percent = 34 + @dataclass(frozen=True, slots=True) class SidebarGeometry: - x: int - width: int + offset: int + extent: int @dataclass(frozen=True, slots=True) class ResolvedSidebarLayout: - bars: dict[str, SidebarGeometry] - left_gutter: int - right_gutter: int + bars: dict[type[SidebarPlacement], Region] + gutter: Spacing class SidebarLayout: """Placement only; sidebar content and thread navigation retain their owners.""" + minimum_percent = 15 + maximum_percent = 50 + def __init__(self) -> None: - self.placements: dict[str, SidebarPlacement] = { - "channels-sidebar": SidebarPlacement("left", 0, 40), - "thread-sidebar": SidebarPlacement("right", 0, 34), + self.placements: dict[type[SidebarPlacement], SidebarPlacement] = { + role: role.initial() for role in SidebarPlacement.members_with(SidebarPlacement) } - def get(self, identity: str) -> SidebarPlacement: + def get(self, identity: type[SidebarPlacement]) -> SidebarPlacement: return self.placements[identity] - def move(self, identity: str, side: Side) -> bool: - if side not in {"left", "right"}: - raise ValueError("Unknown sidebar edge.") + def move(self, identity: type[SidebarPlacement], side: type[SidebarEdge]) -> bool: current = self.get(identity) - if current.side == side: + if current.side is side: return False peers = [key for key, placement in self.placements.items() - if key != identity and placement.side == side] + if key != identity and placement.side is side] self.placements[identity] = replace(current, side=side, order=len(peers)) self._renumber(current.side) return True - def swap(self, identity: str) -> bool: + def swap(self, identity: type[SidebarPlacement]) -> bool: current = self.get(identity) peers = [key for key, placement in self.placements.items() - if key != identity and placement.side == current.side] + if key != identity and placement.side is current.side] if not peers: return False other = peers[0] @@ -139,84 +392,98 @@ def swap(self, identity: str) -> bool: self.placements[other] = replace(self.placements[other], order=current.order) return True - def directions(self, identity: str) -> dict[Side, SidebarShift]: - """Resolve spatial arrows against the current neighbors and outside wall.""" + def directions(self, identity: type[SidebarPlacement]) -> dict[type[SidebarEdge], SidebarShift]: + """Each spatial arrow swaps a neighbor, crosses the center or stops at a wall.""" current = self.get(identity) - actions: dict[Side, SidebarShift] = {"left": BlockedSidebarShift(), "right": BlockedSidebarShift()} - for direction in actions: - inward = direction != current.side + actions: dict[type[SidebarEdge], SidebarShift] = {} + for direction in SidebarEdge.members_with(SidebarEdge): + if direction.axis() is not current.side.axis(): + actions[direction] = MoveSidebarShift() + continue + inward = direction is not current.side neighbor_order = current.order + (1 if inward else -1) - if any(key != identity and peer.side == current.side and peer.order == neighbor_order + if any(key != identity and peer.side is current.side and peer.order == neighbor_order for key, peer in self.placements.items()): actions[direction] = SwapSidebarShift() elif inward: actions[direction] = MoveSidebarShift() + else: + actions[direction] = BlockedSidebarShift() return actions - def shift(self, identity: str, direction: Side) -> bool: - """One arrow step: swap with a neighbor, cross the center, or stop at a wall.""" + def shift(self, identity: type[SidebarPlacement], direction: type[SidebarEdge]) -> bool: return self.directions(identity)[direction].apply(self, identity, direction) - def width(self, identity: str, percentage: int) -> bool: - if type(percentage) is not int: - raise ValueError("Sidebar width must be an integer percentage.") + def resize(self, identity: type[SidebarPlacement], percentage: int) -> bool: current = self.get(identity) - percentage = max(15, min(50, percentage)) - if current.width_percent == percentage: + percentage = max(self.minimum_percent, min(self.maximum_percent, percentage)) + if current.extent_percent == percentage: return False - self.placements[identity] = replace(current, width_percent=percentage) + self.placements[identity] = replace(current, extent_percent=percentage) return True - def float_mode(self, identity: str) -> bool: + def float_mode(self, identity: type[SidebarPlacement]) -> bool: current = self.get(identity) self.placements[identity] = replace(current, floating=not current.floating) return self.placements[identity].floating - def resolve(self, viewport: int, collapsed: dict[str, bool]) -> ResolvedSidebarLayout: - """Pack actual bar widths; Float changes only the conversation's gutter. + def resolve(self, viewport: Size, collapsed: dict[type[SidebarPlacement], bool]) -> ResolvedSidebarLayout: + """Pack each axis once; top/bottom surfaces own the corners. - An inner pushed bar reserves space through its inside edge, including - any outer floating peer. Otherwise conversation text would overlap the - pushed bar. Collapsed handles always reserve their small edge extent. + Float changes only conversation clearance. A pushed inner peer clears + its floating outer peer too; collapsed handles always reserve space. """ - viewport = max(0, viewport) - widths = {key: 3 if collapsed[key] else max(18, viewport * self.get(key).width_percent // 100) - for key in self.placements if key in collapsed} - handles = sum(width for key, width in widths.items() if collapsed[key]) - expanded = [key for key in widths if not collapsed[key]] - budget = max(viewport - 24, min(viewport, handles + 18 * len(expanded))) - wanted = sum(widths[key] for key in expanded) - available = max(0, budget - handles) - if wanted > available: - for key in expanded: - widths[key] = available * widths[key] // wanted - for key in expanded[:available - sum(widths[key] for key in expanded)]: - widths[key] += 1 - if handles > viewport: - for key in widths: - widths[key] = viewport * widths[key] // handles - bars: dict[str, SidebarGeometry] = {} - gutters = {"left": 0, "right": 0} - for side in ("left", "right"): - offset = 0 - for key in sorted((key for key in widths if self.get(key).side == side), - key=lambda key: self.get(key).order): - width = widths[key] - bars[key] = SidebarGeometry(offset if side == "left" else viewport - offset - width, - width) - offset += width - if collapsed[key] or not self.get(key).floating: - gutters[side] = offset - return ResolvedSidebarLayout(bars, gutters["left"], gutters["right"]) - - def _renumber(self, side: Side) -> None: + spans: dict[type[SidebarPlacement], SidebarGeometry] = {} + occupied = gutter = Spacing() + edges = SidebarEdge.members_with(SidebarEdge) + for axis in dict.fromkeys(edge.axis() for edge in edges): + total = max(0, axis.extent(viewport)) + extents = { + key: axis.collapsed_extent if collapsed[key] else max(axis.minimum, total * placement.extent_percent // 100) + for key, placement in self.placements.items() + if key in collapsed and placement.side.axis() is axis + } + handles = sum(extent for key, extent in extents.items() if collapsed[key]) + expanded = [key for key in extents if not collapsed[key]] + budget = max(total - axis.conversation_minimum, + min(total, handles + axis.minimum * len(expanded))) + wanted = sum(extents[key] for key in expanded) + available = max(0, budget - handles) + if wanted > available: + minimum = min(axis.minimum, available // len(expanded)) + surplus = available - minimum * len(expanded) + demand = wanted - minimum * len(expanded) + for key in expanded: + extents[key] = minimum + surplus * (extents[key] - minimum) // demand + for key in expanded[:available - sum(extents[key] for key in expanded)]: + extents[key] += 1 + if handles > total: + for key in extents: + extents[key] = total * extents[key] // handles + for side in SidebarEdge.members_with(axis): + offset = clearance = 0 + for key in sorted((key for key in extents if self.get(key).side is side), + key=lambda key: self.get(key).order): + extent = extents[key] + spans[key] = SidebarGeometry(offset, extent) + offset += extent + if collapsed[key] or not self.get(key).floating: + clearance = offset + occupied += side.gutter(offset) + gutter += side.gutter(clearance) + return ResolvedSidebarLayout( + {key: self.get(key).side.region(viewport, span, occupied) for key, span in spans.items()}, + gutter, + ) + + def _renumber(self, side: type[SidebarEdge]) -> None: names = sorted((key for key, placement in self.placements.items() - if placement.side == side), key=lambda key: self.placements[key].order) + if placement.side is side), key=lambda key: self.placements[key].order) for index, key in enumerate(names): self.placements[key] = replace(self.placements[key], order=index) - def ordered(self) -> tuple[str, ...]: + def ordered(self) -> tuple[type[SidebarPlacement], ...]: + edges = SidebarEdge.members_with(SidebarEdge) return tuple(sorted(self.placements, key=lambda key: - (self.placements[key].side != "left", - self.placements[key].order if self.placements[key].side == "left" - else -self.placements[key].order))) + (edges.index(self.get(key).side), + self.get(key).side.order_key(self.get(key).order)))) diff --git a/src/toad/widget_actions.py b/src/toad/widget_actions.py index e75c10105..ec156c283 100644 --- a/src/toad/widget_actions.py +++ b/src/toad/widget_actions.py @@ -296,10 +296,14 @@ async def apply(self, context: DismissibleControl) -> None: context.dismiss_control() -class HorizontalCursorAction(VisibleAction, declared_name=None): +class CursorAction(VisibleAction, declared_name=None): + """Select along the widget's cursor axis.""" + group = Binding.Group("Select") direction: ClassVar[Literal[-1, 1]] + +class HorizontalCursorAction(CursorAction, declared_name=None): async def apply(self, context: MovesHorizontalCursor) -> None: context.cursor_horizontal(self.direction) @@ -314,6 +318,21 @@ class CursorRightAction(HorizontalCursorAction): direction = +1 +class VerticalCursorAction(CursorAction, declared_name=None): + async def apply(self, context: MovesVerticalCursor) -> None: + context.cursor_vertical(self.direction) + + +class CursorUpAction(VerticalCursorAction): + keys = {EditingMode: ("up",)} + direction = -1 + + +class CursorDownAction(VerticalCursorAction): + keys = {EditingMode: ("down",)} + direction = +1 + + class SubmitAction(VisibleAction): keys = {EditingMode: ("enter",)} @@ -417,6 +436,11 @@ class MovesHorizontalCursor(DeclaredWidgetActions, actions=(HorizontalCursorActi def cursor_horizontal(self, direction: Literal[-1, 1]) -> None: ... +class MovesVerticalCursor(DeclaredWidgetActions, actions=(VerticalCursorAction,)): + @abstractmethod + def cursor_vertical(self, direction: Literal[-1, 1]) -> None: ... + + class Submits(DeclaredWidgetActions, actions=(SubmitAction,)): @abstractmethod diff --git a/src/toad/widgets/channels_sidebar.py b/src/toad/widgets/channels_sidebar.py index 170ad0eb8..2f4b3f480 100644 --- a/src/toad/widgets/channels_sidebar.py +++ b/src/toad/widgets/channels_sidebar.py @@ -1,5 +1,6 @@ """The shared Channels view, including during a destination's loading frame.""" +from toad.sidebar_layout import ChannelsSidebarPlacement from toad.widgets.comms_sidebar import CommsSidebar from toad.widgets.session_sort import ChannelListSort from toad.widgets.side_bar import SideBar @@ -16,7 +17,7 @@ def __init__(self, *, observe: bool = True, defer_mount: bool = False) -> None: flex=True, header_control=ChannelListSort(), ), - id="channels-sidebar", + placement_identity=ChannelsSidebarPlacement, defer_mount=defer_mount, ) diff --git a/src/toad/widgets/line_markdown.py b/src/toad/widgets/line_markdown.py index 84bb2a1e6..3f130867b 100644 --- a/src/toad/widgets/line_markdown.py +++ b/src/toad/widgets/line_markdown.py @@ -191,5 +191,5 @@ def render_line(self, y: int, paint: PaintState) -> Strip: # Prepared rows span exactly this width (Lines.strips_at). return self.on_background(strips[y], paint, y) - def get_selection(self, selection: Selection) -> tuple[str, str] | None: + def get_selection(self, selection: Selection) -> tuple[str, str]: return selection.extract(self.text), "\n" diff --git a/src/toad/widgets/linked_rows.py b/src/toad/widgets/linked_rows.py index e45356d54..f031a3ed5 100644 --- a/src/toad/widgets/linked_rows.py +++ b/src/toad/widgets/linked_rows.py @@ -151,7 +151,7 @@ def on_mount(self) -> None: def line_sets(self) -> tuple[Lines, ...]: raise NotImplementedError - def get_selection(self, selection: Selection) -> tuple[str, str] | None: + def get_selection(self, selection: Selection) -> tuple[str, str]: rows = "\n".join("".join(segment.text for segment in row).rstrip() for lines in self.line_sets for row in lines) return selection.extract(rows), "\n" diff --git a/src/toad/widgets/message_divider.py b/src/toad/widgets/message_divider.py index 86b60ff52..55dc12142 100644 --- a/src/toad/widgets/message_divider.py +++ b/src/toad/widgets/message_divider.py @@ -23,8 +23,10 @@ from toad.widgets.committed_presentation import CommitParticipant from toad.widgets.linked_rows import LinkedRows, MessageRowBlock from toad.widgets.transcript_lines import ( + BlockLines, LineTheme, - PreparedLines, + NoRows, + RowAcquisition, RichLineRenderer, link_place, ) @@ -33,13 +35,13 @@ class LineBlockRow(LinkedRows, Widget): """One semantic line block, drawn exactly as transcript history draws it.""" - DEFAULT_CSS = "LineBlockRow { width: 1fr; height: 1; }" + DEFAULT_CSS = "LineBlockRow { width: 1fr; height: auto; }" def __init__(self, block: LineBlock) -> None: super().__init__() self.block = block self._link_place = link_place() - self._lines: tuple[LineTheme, PreparedLines] | None = None + self._lines: RowAcquisition = NoRows() @property def line_theme(self) -> LineTheme: @@ -47,30 +49,34 @@ def line_theme(self) -> LineTheme: def update_block(self, block: LineBlock) -> None: if block != self.block: - self.block, self._lines = block, None - self.refresh() + self.block, self._lines = block, NoRows() + self.refresh(layout=True) - def _drawn(self, theme: LineTheme, width: int) -> PreparedLines: - return PreparedLines(width, RichLineRenderer(theme, width).rows((self.block,))) + def prepared_lines(self, width: int) -> BlockLines: + measured = self._lines.plain( + width, RichLineRenderer(self.line_theme, width), (self.block,) + ) + self._lines = measured + return measured.lines def render_line(self, y: int, paint: PaintState) -> Strip: width = self.size.width - if y or not width: + if not width: return self.blank_row(width, paint) - theme = self.line_theme - if self._lines is None or self._lines[0] != theme or self._lines[1].width != width: - self._lines = theme, self._drawn(theme, width) - return self.on_background(self._lines[1].strips_at(self._link_place)[0], paint, y) + lines = self.prepared_lines(width) + if y >= lines.height: + return self.blank_row(width, paint) + return self.on_background(lines.strips_at(self._link_place)[y], paint, y) @height_dependency(INDEPENDENT_HEIGHT) def get_content_height(self, container: Size, viewport: Size, width: int) -> int: - return 1 + return self.prepared_lines(width).height -class SingleLineBlock(BlockContent, LineBlockRow): +class ConversationLineBlock(BlockContent, LineBlockRow): """A line block in the conversation: navigable and selectable like any block.""" - def get_selection(self, selection: Selection) -> tuple[str, str] | None: + def get_selection(self, selection: Selection) -> tuple[str, str]: return selection.extract(self.block.selection_text), "\n" @@ -94,7 +100,7 @@ def pin(self, headers: tuple[Divider, ...]) -> None: self.display = bool(headers) -class MessageDivider(Animated[Mix], SingleLineBlock): +class MessageDivider(Animated[Mix], ConversationLineBlock): """The rule above a live message: the same `Divider` block saved history draws. A message that has just arrived fades in from the accent colour. @@ -105,7 +111,7 @@ class MessageDivider(Animated[Mix], SingleLineBlock): DEFAULT_CSS = """ MessageDivider { width: 1fr; - height: 1; + height: auto; margin: 1 0 0 0; } """ @@ -139,7 +145,7 @@ def __init__(self, row: MessageRow, *, clock: MessageClock = LiveMessageClock()) self.message_row = row -class AgentActivityDivider(ConversationBlock, CommitParticipant, CategorizedBlock, SingleLineBlock): +class AgentActivityDivider(ConversationBlock, CommitParticipant, CategorizedBlock, ConversationLineBlock): """The shared activity-group row, filtered with the activity that follows it.""" ALLOW_SELECT = False diff --git a/src/toad/widgets/message_notifications.py b/src/toad/widgets/message_notifications.py index bcb8dc28f..344ecd9af 100644 --- a/src/toad/widgets/message_notifications.py +++ b/src/toad/widgets/message_notifications.py @@ -32,7 +32,10 @@ class MessageNotifications(Collapsible): MessageNotifications { height: auto; padding: 0; margin: 0; border: none; background: transparent; color: $text-muted; - CollapsibleTitle { padding: 0; min-height: 1; } + CollapsibleTitle { + padding: 0; width: 1fr; height: auto; min-height: 1; + text-wrap: wrap; text-overflow: fold; + } Contents { padding: 0; } } MessageNotifications.-working > CollapsibleTitle { color: $accent; } diff --git a/src/toad/widgets/prompt.py b/src/toad/widgets/prompt.py index 81d059387..f7b32471c 100644 --- a/src/toad/widgets/prompt.py +++ b/src/toad/widgets/prompt.py @@ -2,7 +2,8 @@ import asyncio import shlex -from dataclasses import replace +from abc import abstractmethod +from dataclasses import dataclass, replace from pathlib import Path from types import FunctionType from agent_comms.repo import Checkout @@ -36,13 +37,12 @@ from toad.agent_binding import NoAgentConnection from toad.conversation_turn import ConversationTurn from toad.core import input_events -from toad.core.input_events import UserInputSubmitted from toad.core_event_carrier import CoreEventReceiver from toad.input_decision import InputAccepted, InputDecision from toad.key_modes import Standard from toad.tone_paint import TonePaint from toad.path_complete import PathComplete -from toad.prompt_actions import PromptAction, SubmitNowAction +from toad.prompt_actions import PromptAction, SubmitInputAction, SubmitNowAction from toad.prompt_cursor import ( CommandText, HistoryCursor, @@ -184,23 +184,57 @@ def on_mouse_down(self, event: events.MouseUp) -> None: self.query_ancestor(Prompt).focus() -class PromptSubmission: +class PromptSubmissionState: + """A coalesced editor request owns its intent until its callback takes it.""" + + def request(self, editor: PromptSubmission, action: SubmitInputAction) -> PromptSubmissionState: + # Queue behind the widget's earlier input events, never a timer. + if editor.call_later(editor.submit_pending): + return PendingPromptSubmission(action, editor.editing_mode) + return self + + async def submit(self, editor: PromptSubmission) -> None: + pass + + +class NoPromptSubmission(PromptSubmissionState): + pass + + +@dataclass(frozen=True) +class PendingPromptSubmission(PromptSubmissionState): + action: SubmitInputAction + mode: EditingMode + + def request(self, editor: PromptSubmission, action: SubmitInputAction) -> PromptSubmissionState: + if self.mode is editor.editing_mode: + action = self.action.coalesce(action) + return PendingPromptSubmission(action, editor.editing_mode) + + async def submit(self, editor: PromptSubmission) -> None: + await editor._submit(self.action, self.mode) + + +class PromptSubmission(HighlightedTextArea): """Admit model and local command submission through the current prompt.""" - def schedule_submission(self, *, immediate: bool = False) -> None: - self._submit_immediate |= immediate - # The callback is queued behind this widget's input events. Unlike a - # timer, it preserves paste/typing order without adding a fixed latency. - if not self._submit_pending: - self._submit_pending = True - self.call_later(self._submit) + def __init__(self) -> None: + self._submission: PromptSubmissionState = NoPromptSubmission() + super().__init__() + def schedule_submission(self, action: SubmitInputAction) -> None: + self._submission = self._submission.request(self, action) + async def submit_pending(self) -> None: + request, self._submission = self._submission, NoPromptSubmission() + await request.submit(self) + @abstractmethod + async def _submit(self, action: SubmitInputAction, mode: EditingMode) -> None: ... class PromptTextArea( - CoreEventReceiver, LocalMethodKeys, PromptSubmission, HighlightedTextArea, + CoreEventReceiver, LocalMethodKeys, PromptSubmission, actions=(PromptAction,), ): HELP = """\ @@ -236,8 +270,6 @@ def __init__(self, *, prompt: Prompt) -> None: self.prompt = prompt self.set_reactive(TextArea.editing_mode, prompt.initial_editing_mode) self.set_reactive(PromptTextArea.project_path, prompt.project_path) - self._submit_pending = False - self._submit_immediate = False @property def agent_busy(self) -> bool: @@ -396,10 +428,10 @@ async def action_tab_complete(self) -> None: priority=True), ) - async def _submit(self) -> None: - """Silently cancel a submission superseded by focus, mode or popup changes.""" - self._submit_pending = False - mode = self.editing_mode + async def _submit(self, action: SubmitInputAction, mode: EditingMode) -> None: + """A refused or superseded transfer consumes no text and retains no intent.""" + if mode is not self.editing_mode: + return try: if not await mode.flush(self): return @@ -407,7 +439,6 @@ async def _submit(self) -> None: return # The engine already reported its failure; never submit stale text. if mode is not self.editing_mode: return - immediate, self._submit_immediate = self._submit_immediate, False if not self.has_focus or not self.submission_allowed: return input_text = CommandText.decode_input(self.text, self.slash_commands) @@ -432,7 +463,7 @@ async def _submit(self) -> None: shell = self.shell_mode text = self.text self.clear() - self.publish_core(UserInputSubmitted(text, shell, immediate=immediate)) + self.publish_core(action.submission_event(text, shell)) if shell: self.prompt.shell_mode = False diff --git a/src/toad/widgets/session_thread_sidebar.py b/src/toad/widgets/session_thread_sidebar.py index f8e9ee9ba..4c8e54f57 100644 --- a/src/toad/widgets/session_thread_sidebar.py +++ b/src/toad/widgets/session_thread_sidebar.py @@ -67,11 +67,10 @@ async def evict(self, screen: "MainScreen") -> None: self.navigation.panel_scroll_y = self.panels_viewport.scroll_y self.capture_panels(screen) await self.panels_viewport.remove_children() - if controls := self.query_one_optional("#sidebar-controls"): - await controls.remove_children() + await self.layout_controls.remove_children() self.panels.clear() self._panels_loaded = self._panels_loading = False - self._presented_layout = None + self.invalidate_controls() self._panels_ready.set() async def panels_hydrated(self) -> None: diff --git a/src/toad/widgets/side_bar.py b/src/toad/widgets/side_bar.py index f76639d9e..b60eb39ef 100644 --- a/src/toad/widgets/side_bar.py +++ b/src/toad/widgets/side_bar.py @@ -1,12 +1,14 @@ from __future__ import annotations +from functools import cached_property + from textual._callback import invoke from metaclass_registry.mro_dispatch import handles from toad.core_event_carrier import CoreEventReceiver from toad.core import events as core_events from textual.content import Content -from toad.widget_actions import ApplicationAction, ActivationKeys, Activates, DismissibleControl, WidgetAction, DeclaredWidgetActions, MovesHorizontalCursor +from toad.widget_actions import ApplicationAction, ActivationKeys, Activates, DismissibleControl, WidgetAction, DeclaredWidgetActions, MovesHorizontalCursor, MovesVerticalCursor from textual.document._editing import EditingMode from toad.widgets.comms_menu import ContextMenu from toad.core.preference_events import PreferenceChanged @@ -31,11 +33,12 @@ from toad.widgets.selection import HoverSelection, ColourSelection from toad.session_tracker import SidebarState from toad.sidebar_layout import ( - Side, - SidebarGeometry, + SidebarEdge, + SidebarLayout, SidebarPlacement, SidebarPlacementAction, SidebarShift, + ThreadSidebarPlacement, ) from toad.widgets.sidebar_viewport import SidebarHeader, SidebarViewport from toad.app_node import ToadNode @@ -46,12 +49,11 @@ @dataclass(frozen=True) class BarPresentation: - side: Side + side: type[SidebarEdge] order: int floating: bool collapsed: bool directions: tuple[type[SidebarShift], ...] - compact: bool @classmethod def from_placement( @@ -59,8 +61,7 @@ def from_placement( placement: SidebarPlacement, *, collapsed: bool, - directions: dict[Side, SidebarShift], - width: int, + directions: dict[type[SidebarEdge], SidebarShift], ) -> BarPresentation: return cls( placement.side, @@ -68,15 +69,33 @@ def from_placement( placement.floating, collapsed, tuple(type(action) for action in directions.values()), - all(action.available for action in directions.values()) and width - 4 < 21, ) +class BarDisplay: + """Whether native controls already show a requested sidebar projection.""" + + def matches(self, current: PresentedBar) -> bool: + return False + + def controls_changed(self, current: PresentedBar) -> bool: + return True + + +class UnpresentedBar(BarDisplay): + """No native projection has been applied, or its view was retired.""" + + @dataclass(frozen=True) -class PresentedBar: +class PresentedBar(BarDisplay): presentation: BarPresentation - geometry: SidebarGeometry - width_percent: int + extent_percent: int + + def matches(self, current: PresentedBar) -> bool: + return self == current + + def controls_changed(self, current: PresentedBar) -> bool: + return self.presentation != current.presentation class SidebarPanelWidget(Widget): @@ -186,7 +205,7 @@ def apply(self, context: SideBarToggle) -> None: class SideBarToggle(HoverSelection, DeclaredWidgetActions, PrimaryActivation, widgets.Static, actions=(ToggleSidebarAction,)): - """Full-height collapse target beside the conversation-facing resize edge.""" + """Collapse target spanning the conversation-facing resize edge.""" @property def paint_highlighted(self): @@ -197,9 +216,6 @@ def paint_highlighted(self): DEFAULT_CSS = """ SideBarToggle { - dock: right; - width: 3; - height: 1fr; content-align: center middle; color: $text-muted; background: $primary 8%; @@ -214,17 +230,15 @@ def __init__(self, *, focus: bool = True) -> None: super().__init__() self.focus = focus - def __init__(self, collapsed: bool = False, *, right: bool = False) -> None: + def __init__(self, collapsed: bool, *, edge: type[SidebarEdge]) -> None: super().__init__() - self.right = right - self.set_collapsed(collapsed) - - def set_collapsed(self, collapsed: bool) -> None: - self.styles.dock = "left" if self.right else "right" - self.offset = (0 if collapsed else 1 if self.right else -1, 0) - text = ( - ("<" if collapsed else ">") if self.right else (">" if collapsed else "<") - ) + self.set_collapsed(collapsed, edge) + + def set_collapsed(self, collapsed: bool, edge: type[SidebarEdge]) -> None: + edge.size_toggle(self) + self.styles.dock = edge.opposite().dock + self.offset = edge.control_offset(0 if collapsed else 1) + text = edge.toggle_symbol(collapsed) if self.content != text: self.update(text, layout=False) tooltip = "Expand sidebar" if collapsed else "Collapse sidebar" @@ -389,7 +403,7 @@ def __init__(self, *args, **kwargs) -> None: def drag_start(self, event: events.MouseDown) -> None: raise NotImplementedError - def drag_to(self, screen_x: int) -> None: + def drag_to(self, position: Offset) -> None: raise NotImplementedError def on_mouse_down(self, event: events.MouseDown) -> None: @@ -402,7 +416,7 @@ def on_mouse_down(self, event: events.MouseDown) -> None: def on_mouse_move(self, event: events.MouseMove) -> None: if self._dragging: event.stop() - self.drag_to(event.screen_x) + self.drag_to(event.screen_offset) def can_replace_mouse_move( self, event: events.MouseMove, pending: events.MouseMove @@ -413,7 +427,7 @@ def can_replace_mouse_move( def on_mouse_up(self, event: events.MouseUp) -> None: if event.button == 1 and self._dragging: event.stop() - self.drag_to(event.screen_x) + self.drag_to(event.screen_offset) self._dragging = False self.release_mouse() @@ -441,7 +455,7 @@ def __init__(self, minimum: int, maximum: int, value: int) -> None: self.maximum = maximum self.value = value self.reversed = False - self.tooltip = "Drag or use ←/→ to resize (15–50% of screen)" + self.tooltip = f"Drag or use ←/→ to resize ({minimum}–{maximum}% of the placement axis)" def set_range(self, minimum: int, maximum: int, value: int) -> None: self.minimum, self.maximum = minimum, max(minimum, maximum) @@ -458,8 +472,8 @@ def render(self) -> str: line = "".join("●" if index == thumb else "─" for index in range(track)) return f"{line} {label}" if self.reversed else f"{label} {line}" - def drag_to(self, screen_x: int) -> None: - x = screen_x - self._drag_origin_x + def drag_to(self, position: Offset) -> None: + x = position.x - self._drag_origin_x width = self._drag_width reverse = self._drag_reversed label_width = 4 @@ -489,7 +503,7 @@ def drag_start(self, event: events.MouseDown) -> None: self._drag_origin_x = self.region.x self._drag_width = self.size.width self._drag_reversed = self.reversed - self.drag_to(event.screen_x) + self.drag_to(event.screen_offset) class SidebarAction(Activates, HoverSelection, widgets.Static, can_focus=True): @@ -497,7 +511,7 @@ class SidebarAction(Activates, HoverSelection, widgets.Static, can_focus=True): DEFAULT_CSS = """ SidebarAction { height: 1; width: auto; padding: 0 1; color: $text-muted; pointer: pointer; } - SidebarAction.-direction { width: 7; content-align: center middle; text-style: bold; } + SidebarAction.-direction { width: 1fr; content-align: center middle; text-style: bold; } """ @dataclass @@ -505,53 +519,60 @@ class Pressed(Message): action: SidebarPlacementAction def __init__(self, action: SidebarPlacementAction) -> None: - super().__init__(action.label, id=f"sidebar-{action.declared_name}") + super().__init__(action.label, id=f"sidebar-{action.name}", classes=action.classes) self.action = action - self.set_class(action.directional, "-direction") def activate(self) -> None: self.post_message(self.Pressed(self.action)) class SidebarResizeHandle( - MovesHorizontalCursor, CapturedDrag, ColourSelection, widgets.Static, ToadNode, can_focus=True + MovesHorizontalCursor, MovesVerticalCursor, CapturedDrag, ColourSelection, widgets.Static, ToadNode, can_focus=True ): - """Drag the conversation-facing edge; the placement model owns the width.""" + """Drag the conversation-facing edge; the placement model owns the extent.""" highlight_foreground = ForegroundColour DEFAULT_CSS = """ SidebarResizeHandle { - dock: right; width: 1; height: 1fr; padding: 0; - color: $success; pointer: ew-resize; + padding: 0; + color: $success; } SidebarResizeHandle:ansi { color: ansi_green; } """ - def __init__(self) -> None: + def __init__(self, edge: type[SidebarEdge]) -> None: super().__init__(id="sidebar-resize-handle") - self.tooltip = "Drag toward the center to widen the sidebar" + edge.size_handle(self) + self.styles.dock = edge.opposite().dock + self.tooltip = "Drag toward the center to expand the sidebar" def render(self) -> str: - return "\n".join("│" for _ in range(self.size.height)) + return self.query_ancestor(SideBar).placement.side.handle_text(self.size) + + def drag_to(self, position: Offset) -> None: + edge = self._drag_edge + delta = edge.sign * edge.coordinate(position - self._start_point) + self.query_ancestor(SideBar).resize_to( + self._start_percent + round(100 * delta / max(1, self._drag_viewport)) + ) - def drag_to(self, screen_x: int) -> None: + def resize_by(self, direction: Offset) -> None: bar = self.query_ancestor(SideBar) - if bar.id is None or bar.parent is None: - return - app = self.app - direction = -1 if app.sidebar_layout.get(bar.id).side == "right" else 1 - width = self._start_width + direction * (screen_x - self._start_x) - percent = round(100 * width / max(1, bar.screen.size.width)) - if app.sidebar_layout.width(bar.id, percent): - app.workspace_chrome.refresh_layout() + placement = bar.placement + bar.resize_to(placement.extent_percent + placement.side.sign * placement.side.coordinate(direction)) def cursor_horizontal(self, direction: int) -> None: - bar = self.query_ancestor(SideBar) - bar.width_slider.cursor_horizontal(direction) + self.resize_by(Offset(direction, 0)) + + def cursor_vertical(self, direction: int) -> None: + self.resize_by(Offset(0, direction)) def drag_start(self, event: events.MouseDown) -> None: - self._start_x = event.screen_x - self._start_width = self.query_ancestor(SideBar).size.width + bar = self.query_ancestor(SideBar) + self._drag_edge = bar.placement.side + self._start_point = event.screen_offset + self._start_percent = bar.placement.extent_percent + self._drag_viewport = self._drag_edge.extent(bar.query_ancestor(Widget).content_size) class ClearSidebarSearchAction(WidgetAction): @@ -578,6 +599,35 @@ async def apply(self, sidebar: SideBar) -> None: sidebar.search_input.focus() +class SidebarContentStack(containers.Vertical): + """One padded flow for content; padding never intercepts the outer handles.""" + + hit_test_transparent = True + + DEFAULT_CSS = """ + SidebarContentStack { + width: 1fr; height: 1fr; layer: sidebar-content; + overflow: hidden hidden; + } + SidebarContentStack > SidebarViewport { + width: 1fr; + height: 1fr; + overflow: auto auto; + scrollbar-size: 1 1; + scrollbar-gutter: stable; + } + SidebarContentStack > #sidebar-controls { + width: 1fr; + height: auto; + } + SidebarContentStack #sidebar-layout-actions { + height: auto; width: 1fr; overflow: hidden hidden; + grid-size: 2; grid-columns: 1fr 1fr; grid-rows: 1; + } + SidebarContentStack #sidebar-float { column-span: 2; width: 1fr; } + """ + + class SideBar(DismissibleControl, WorkspaceHosted, TonePaint, CoreEventReceiver, containers.Vertical, ToadNode, actions=(SearchSidebarAction,)): tone = SidebarToneColour @@ -593,33 +643,6 @@ class SideBar(DismissibleControl, WorkspaceHosted, TonePaint, CoreEventReceiver, width: auto; border: none; } - SideBar > SidebarViewport { - width: 1fr; - height: 1fr; - margin: 0 4 0 0; - layer: sidebar-content; - overflow: auto auto; - scrollbar-size: 1 1; - scrollbar-gutter: stable; - } - SideBar > #sidebar-controls { - width: 1fr; - height: auto; - margin: 0 4 0 0; - layer: sidebar-content; - } - SideBar #sidebar-layout-actions { height: 1; width: 1fr; overflow: hidden hidden; grid-size: 3; grid-columns: 7 7 auto; } - SideBar #sidebar-layout-actions.-compact { - grid-size: 2; grid-columns: 1fr 1fr; height: 2; - } - SideBar #sidebar-layout-actions.-compact > #sidebar-float { column-span: 2; width: 1fr; } - SideBar.-right { - border-right: none; - border-left: none; - } - SideBar.-right > SideBarToggle { dock: left; } - SideBar.-right > SidebarResizeHandle { dock: left; } - SideBar.-right > SidebarViewport, SideBar.-right > #sidebar-controls { margin: 0 0 0 4; } """ collapsed = reactive(False) @@ -638,31 +661,53 @@ class Panel: id: str | None = None header_control: Widget | None = None + @property + def placement_identity(self) -> type[SidebarPlacement]: + return self._placement_identity + def __init__( self, *panels: Panel, + placement_identity: type[SidebarPlacement], name: str | None = None, - id: str | None = None, classes: str | None = None, disabled: bool = False, hide: bool = False, - right: bool = False, defer_mount: bool = False, ) -> None: - super().__init__(name=name, id=id, classes=classes, disabled=disabled) + self._placement_identity = placement_identity + super().__init__(name=name, id=placement_identity.native_id(), + classes=classes, disabled=disabled) self.panels: list[SideBar.Panel] = [*panels] + self.content_stack = SidebarContentStack() self.search_input = SidebarSearch(placeholder="Search", compact=True) - self.search_input.styles.layer = "sidebar-content" self.panels_viewport: SidebarViewport = SidebarViewport() + self.layout_controls = containers.Vertical(id="sidebar-controls") self.hide = hide - self.right = right - self.width_slider = SidebarSlider(15, 50, 40 if not right else 34) + self.extent_slider = SidebarSlider( + SidebarLayout.minimum_percent, SidebarLayout.maximum_percent, + SidebarLayout.minimum_percent, + ) self._panels_loaded = not defer_mount self._panels_loading = False self._panels_ready = asyncio.Event() self._presented_collapsed: bool | None = None - self._presented_layout: PresentedBar | None = None - self.set_class(right, "-right") + self._presented_layout: BarDisplay = UnpresentedBar() + + def invalidate_controls(self) -> None: + self._presented_layout = UnpresentedBar() + + @cached_property + def toggle_control(self) -> SideBarToggle: + return SideBarToggle(self.collapsed, edge=self.placement.side) + + @cached_property + def resize_handle(self) -> SidebarResizeHandle: + return SidebarResizeHandle(self.placement.side) + + @property + def placement(self) -> SidebarPlacement: + return self.app.sidebar_layout.get(self.placement_identity) @property def panels_loaded(self) -> bool: @@ -692,13 +737,16 @@ def on_mount(self) -> None: self.schedule_hydration() def schedule_hydration(self) -> None: - if not self._panels_loaded and not self._panels_loading: + if not self._panels_loaded and not self._panels_loading and self.presentation_visible: self.screen.frame_presentation.defer(self, self._start_hydration) @property def presentation_visible(self) -> bool: + """Native attachment owns whether this retained resource may present.""" return ( - self.screen.is_current + self.is_attached + and not self.is_leaving + and self.screen.is_current and self.display and all( ancestor.display @@ -711,9 +759,7 @@ def _start_hydration(self) -> None: if ( self._panels_loaded or self._panels_loading - or not self.is_attached or not self.presentation_visible - or self.is_leaving ): return self._panels_loading = True @@ -722,14 +768,12 @@ def _start_hydration(self) -> None: async def _hydrate_panels(self) -> None: try: await self.panels_viewport.mount_all(self._compose_panels()) - if not self.is_attached or self.is_leaving or not self.presentation_visible: + if not self.presentation_visible: return - controls = self.query_one_optional("#sidebar-controls") - if controls is not None: - await controls.mount_all(self._compose_controls()) - if self.is_attached and not self.is_leaving and self.presentation_visible: + await self.layout_controls.mount_all(self._compose_controls()) + if self.presentation_visible: self._panels_loaded = True - self._presented_layout = None + self.invalidate_controls() await self.panels_hydrated() self.restore_navigation() finally: @@ -745,15 +789,16 @@ def on_unmount(self) -> None: self._panels_ready.set() def compose(self) -> ComposeResult: - yield SideBarToggle(self.collapsed, right=self.right) - yield SidebarResizeHandle() - yield self.search_input - with self.panels_viewport: - if self._panels_loaded: - yield from self._compose_panels() - with containers.Vertical(id="sidebar-controls"): - if self._panels_loaded: - yield from self._compose_controls() + yield self.toggle_control + yield self.resize_handle + with self.content_stack: + yield self.search_input + with self.panels_viewport: + if self._panels_loaded: + yield from self._compose_panels() + with self.layout_controls: + if self._panels_loaded: + yield from self._compose_controls() @on(widgets.Input.Changed) def search_changed(self, event: widgets.Input.Changed) -> None: @@ -774,117 +819,69 @@ def _compose_panels(self) -> ComposeResult: ) def _compose_controls(self) -> ComposeResult: - yield self.width_slider + yield self.extent_slider yield containers.Grid( *( - SidebarAction(kind()) + SidebarAction(action) for kind in SidebarPlacementAction.members_with(SidebarPlacementAction) + for action in kind.offered() ), id="sidebar-layout-actions", ) - def on_resize(self, event: events.Resize) -> None: - self._present_controls() - def on_show(self) -> None: - self._present_controls() + self.restore_navigation() - def _present_controls(self) -> bool: - app = self.app - if not self.is_mounted: + def present_controls(self) -> bool: + if not self.presentation_visible: + self.invalidate_controls() return False - placement = app.sidebar_layout.get(self.id) - if self.parent is None or not self.presentation_visible: - self._presented_layout = None - return False - geometry = SidebarGeometry(self.region.x, self.size.width) - width = geometry.width - directions = app.sidebar_layout.directions(self.id) + placement = self.placement + directions = self.app.sidebar_layout.directions(self.placement_identity) current = PresentedBar( presentation=BarPresentation.from_placement( - placement, collapsed=self.collapsed, directions=directions, width=width + placement, collapsed=self.collapsed, directions=directions ), - geometry=geometry, - width_percent=placement.width_percent, + extent_percent=placement.extent_percent, ) previous = self._presented_layout - if current == previous: + if previous.matches(current): return False self._presented_layout = current - controls = ( - self.query_one_optional("#sidebar-controls") - if self._panels_loaded - else None - ) - slider = self.width_slider - if previous is None or previous.presentation != current.presentation: - self.right = placement.side == "right" - self.set_class(self.right, "-right") - self.search_input.styles.margin = ( - (0, 0, 0, 4) if self.right else (0, 4, 0, 0) - ) - if toggle := self.query_one_optional(SideBarToggle): - toggle.right = self.right - toggle.set_collapsed(self.collapsed) - if handle := self.query_one_optional(SidebarResizeHandle): - handle.display = not self.collapsed - if controls is not None: - controls.display = not self.collapsed - actions = controls.query_one_optional("#sidebar-layout-actions") - buttons = { - direction: controls.query_one_optional( - f"#sidebar-{direction}", SidebarAction - ) - for direction in (*directions, "float") - } - if actions is None or any( - button is None for button in buttons.values() - ): - # Mounted parents can precede their composed action rows. - self._presented_layout = None - else: - slider.reversed = self.right - slider.set_range(15, 50, placement.width_percent) - for direction, action in directions.items(): - button = buttons[direction] - button.display = action.available - button.tooltip = action.tooltip(direction) - toggle_mode = buttons["float"] - toggle_mode.update( - "Push" if placement.floating else "Float", layout=False - ) - toggle_mode.tooltip = ( - "Push conversation text" - if placement.floating - else "Float over conversation text" - ) - actions.set_class(current.presentation.compact, "-compact") - if slider.value != placement.width_percent: - slider.value = placement.width_percent + slider = self.extent_slider + if previous.controls_changed(current): + edge = placement.side + self.content_stack.styles.padding = edge.content_padding() + self.toggle_control.set_collapsed(self.collapsed, edge) + handle = self.resize_handle + edge.size_handle(handle) + handle.styles.dock = edge.opposite().dock + handle.display = not self.collapsed + if self._panels_loaded: + slider.reversed = edge.sign < 0 + slider.set_range(SidebarLayout.minimum_percent, SidebarLayout.maximum_percent, + placement.extent_percent) + for button in self.layout_controls.query(SidebarAction): + button.action.present(button, placement, directions) + if slider.value != placement.extent_percent: + slider.value = placement.extent_percent slider.refresh() return True + def resize_to(self, percentage: int) -> None: + if self.app.sidebar_layout.resize(self.placement_identity, percentage): + self.app.workspace_chrome.refresh_layout() + @on(SidebarSlider.Changed) def on_sidebar_slider(self, event: SidebarSlider.Changed) -> None: event.stop() - if self.id is not None: - app = self.app - if app.sidebar_layout.width(self.id, event.value): - self._present_controls() - app.workspace_chrome.refresh_layout() + self.resize_to(event.value) @on(SidebarAction.Pressed) def on_sidebar_action(self, event: SidebarAction.Pressed) -> None: event.stop() - if self.id is None: - return app = self.app - event.action.apply(app.sidebar_layout, self.id) - for bar in ( - app.workspace_chrome.channels, - *app.workspace_sessions.source.sidebars(), - ): - bar._present_controls() + event.action.apply(app.sidebar_layout, self.placement_identity) app.workspace_chrome.refresh_layout() def restore_navigation(self) -> bool: @@ -892,7 +889,6 @@ def restore_navigation(self) -> bool: if changed: self.watch_collapsed(self.collapsed) else: - self._present_controls() self.app.workspace_chrome.refresh_layout() navigation = self.navigation for panel in self.query(SideBarCollapsible): @@ -901,33 +897,26 @@ def restore_navigation(self) -> bool: return changed def watch_collapsed(self, collapsed: bool) -> None: - if self.is_mounted and not self.presentation_visible: - # Keep shared intent current without resizing every hidden transcript. - # Activation applies the latest value inside its render transaction; - # an open/close round trip can therefore keep unchanged geometry. + if not self.presentation_visible: + # Keep shared intent current without native work before attachment, + # while hidden or during retirement. Activation applies the latest + # value inside its render transaction. return - if not collapsed and self.is_mounted: + if not collapsed: self.schedule_hydration() self._presented_collapsed = collapsed - # Panel visibility is independent of its native placement. - self.panels_viewport.display = not collapsed - self.search_input.display = not collapsed - if controls := self.query_one_optional("#sidebar-controls"): - controls.display = not collapsed + # One content flow hides without retiring its retained children. + self.content_stack.display = not collapsed self.tooltip = "Expand sidebar" if collapsed else None - toggle = self.query_one_optional(SideBarToggle) - if toggle is not None: - toggle.set_collapsed(collapsed) - toggle.refresh_paint() + self.toggle_control.set_collapsed(collapsed, self.placement.side) + self.toggle_control.refresh_paint() for panel in self.panels: panel.widget.sidebar_visibility_changed() - self._presented_layout = None - if self.is_mounted: - self._present_controls() - self.app.workspace_chrome.refresh_layout() + self.invalidate_controls() + self.app.workspace_chrome.refresh_layout() def render(self) -> str: - return ("<" if self.right else ">") if self.collapsed else "" + return self.placement.side.toggle_symbol(self.collapsed) if self.collapsed else "" @handles(PreferenceChanged) async def _settings_changed(self, event: PreferenceChanged) -> None: @@ -947,12 +936,13 @@ def on_click(self, event: Click) -> None: @on(events.Enter) @on(events.Leave) def gutter_hover_changed(self) -> None: - self.query_one(SideBarToggle).refresh_paint() + if self.presentation_visible: + self.toggle_control.refresh_paint() def toggle(self, *, focus: bool = True) -> None: collapsed = not self.collapsed return_focus = collapsed and (focus or self.has_focus_within) - if return_focus and self.is_mounted and self.screen.is_current: + if return_focus and self.presentation_visible: # Return input focus before native Hide reconstructs its focus chain. self.screen.focus_sidebar_return() self.store_collapsed(collapsed) @@ -980,7 +970,7 @@ def dismiss_control(self) -> None: class ThreadSidebar(SideBar): def __init__(self, *panels, state: SidebarState, **kwargs): self.state = state - super().__init__(*panels, id="thread-sidebar", right=True, hide=True, **kwargs) + super().__init__(*panels, placement_identity=ThreadSidebarPlacement, hide=True, **kwargs) self.collapsed = self.hide @property diff --git a/src/toad/widgets/sidebar_viewport.py b/src/toad/widgets/sidebar_viewport.py index a317e448a..ec5d666fb 100644 --- a/src/toad/widgets/sidebar_viewport.py +++ b/src/toad/widgets/sidebar_viewport.py @@ -2,17 +2,31 @@ from __future__ import annotations +from fractions import Fraction + +from textual.box_model import BoxModel from textual.containers import HorizontalGroup, VerticalScroll +from textual.geometry import Size class SidebarHeader(HorizontalGroup): DEFAULT_CSS = "SidebarHeader { width: 100%; }" + def _get_box_model( + self, container: Size, viewport: Size, + width_fraction: Fraction, height_fraction: Fraction, + constrain_width: bool = False, greedy: bool = True, + ) -> BoxModel: + # The immediate auto-width group belongs to the wide body. Header + # controls instead consume the enclosing scrollport's native width. + width = self.query_ancestor(SidebarViewport).scrollable_content_region.width + return super()._get_box_model( + container.with_width(width), viewport, width_fraction, height_fraction, + constrain_width, greedy, + ) + def on_mount(self) -> None: - viewport = next((node for node in self.walk_ancestors() - if isinstance(node, SidebarViewport)), None) - if viewport is not None: - viewport.align_header(self) + self.query_ancestor(SidebarViewport).align_header(self) class SidebarViewport(VerticalScroll): @@ -39,6 +53,12 @@ def on_mount(self) -> None: self.watch(self, "scroll_x", self._align_headers, init=False) self._align_headers() + def on_resize(self) -> None: + # Native resize commits the scrollport, including its scrollbar gutter. + # Invalidate only dependent headers; never store a second pixel width. + for header in self.query(SidebarHeader): + header.refresh(repaint=False, layout=True) + def align_header(self, header: SidebarHeader) -> None: # The content still owns its full intrinsic width. Counter only its # horizontal scroll for these one-line headers, without pinning y or diff --git a/src/toad/widgets/terminal.py b/src/toad/widgets/terminal.py index 537fa941e..0d54b7071 100644 --- a/src/toad/widgets/terminal.py +++ b/src/toad/widgets/terminal.py @@ -184,14 +184,14 @@ def allow_focus(self) -> bool: """Prohibit focus when the terminal is finalized and couldn't accept input.""" return not self.is_finalized - def get_selection(self, selection: Selection) -> tuple[str, str] | None: + def get_selection(self, selection: Selection) -> tuple[str, str]: """Get the text under the selection. Args: selection: Selection information. Returns: - Tuple of extracted text and ending (typically "\n" or " "), or `None` if no text could be extracted. + Tuple of extracted text and ending (typically "\n" or " "). """ text = "\n".join( line_record.content.plain for line_record in self.state.buffer.lines diff --git a/src/toad/widgets/tool_call.py b/src/toad/widgets/tool_call.py index e966e2392..963860d2d 100644 --- a/src/toad/widgets/tool_call.py +++ b/src/toad/widgets/tool_call.py @@ -21,14 +21,14 @@ from toad.widgets.agent_activity import AgentActivityPresentation from toad.widgets.line_markdown import LineMarkdown -from toad.widgets.message_divider import SingleLineBlock +from toad.widgets.message_divider import ConversationLineBlock from toad.widgets.tool_content import ToolContent from toad.app_node import ToadNode -class ToolCallHeader(SingleLineBlock): +class ToolCallHeader(ConversationLineBlock): ALLOW_SELECT = False DEFAULT_CSS = """ ToolCallHeader { diff --git a/src/toad/widgets/transcript_fragments.py b/src/toad/widgets/transcript_fragments.py index 16f39020d..01b54fb76 100644 --- a/src/toad/widgets/transcript_fragments.py +++ b/src/toad/widgets/transcript_fragments.py @@ -39,6 +39,9 @@ LineRole, MarkdownBody, MutedRole, + Span, + SpanText, + Spans, Summary, ToggleDetails, UserRole, @@ -217,10 +220,11 @@ class Shown: """What a reader chose about one drawn fragment.""" details_open: bool = False + summary_type: ClassVar[type[SpanText]] = Summary - def summary(self, title: str, role: type[LineRole], link: LineLink) -> Summary: + def summary(self, title: str, role: type[LineRole], link: LineLink) -> SpanText: """The disclosure retains the source occurrence supplied by its fragment.""" - return Summary.titled(("▼ " if self.details_open else "▶ ") + title, role, link) + return self.summary_type((Span(("▼ " if self.details_open else "▶ ") + title, role, link),)) def toggled(self) -> "Shown": return replace(self, details_open=not self.details_open) @@ -230,6 +234,7 @@ def toggled(self) -> "Shown": class MessageShown(Shown): """Style and Core's feedback for one bus message, in any conversation.""" + summary_type: ClassVar[type[SpanText]] = Spans style: WireMessageStyle = field(default_factory=MarkdownMessageStyle) status: NotificationStatus = field(default_factory=UncheckedNotifications) diff --git a/src/toad/widgets/transcript_lines.py b/src/toad/widgets/transcript_lines.py index 7f89c4fb7..65f1a2811 100644 --- a/src/toad/widgets/transcript_lines.py +++ b/src/toad/widgets/transcript_lines.py @@ -458,26 +458,31 @@ def rich_text(self, block: RichTextBody) -> None: @handles(Divider) def divider(self, block: Divider) -> None: - spans = block.spans + groups: tuple[tuple[Span, ...], ...] = (block.spans,) if block.timestamp is not None: - spans = (*spans, Span(f" · {MessageClock.recorded(block.timestamp).display()[0]}")) + timestamp = MessageClock.recorded(block.timestamp).display()[0] + timed = (*block.spans, Span(f" · {timestamp}")) + groups = ((block.spans, (Span(timestamp),)) + if sum(cell_len(span.text) for span in timed) > max(1, self.width - 8) + else (timed,)) rule, bold = self.theme.style(block.role), Style(bold=True) - title, room = [Segment(" ", rule)], max(0, self.width - 8) - for span in spans: - # A title longer than the rule ends in an ellipsis; what follows is not drawn. - text = _fit(span.text, room) - room -= cell_len(text) - title.append( - Segment( - text, (rule if span.link is None else self.theme.style(span.role)) + bold + link_style(span.link) - ) - ) - if room == 0: - break - title.append(Segment(" ", rule)) - length = sum(segment.cell_length for segment in title) - left = max(0, (self.width - length) // 2) - self.output.append((Segment("─" * left, rule), *title, Segment("─" * max(0, self.width - left - length), rule))) + # Fit actual content, not a guessed phone width. Each semantic span and + # the complete recorded time survive wrapping, including long names. + inset = min(1, (self.width - 1) // 2) + for spans in groups: + content = Text.assemble(*( + (span.text, (rule if span.link is None else self.theme.style(span.role)) + + bold + link_style(span.link)) + for span in spans + )) + for row in self.console.render_lines( + content, self.console.options.update(width=self.width - 2 * inset), pad=False + ): + title = (Segment(" " * inset, rule), *row, Segment(" " * inset, rule)) + length = sum(segment.cell_length for segment in title) + left = max(0, (self.width - length) // 2) + self.output.append((Segment("─" * left, rule), *title, + Segment("─" * max(0, self.width - left - length), rule))) @handles(Summary) def summary(self, block: Summary) -> None: @@ -492,10 +497,14 @@ def summary(self, block: Summary) -> None: @handles(Spans) def spans(self, block: Spans) -> None: + content = Text.assemble(*( + (span.text, self.theme.style(span.role) + link_style(span.link)) + for span in block.spans + )) self.output.extend( - (Segment(line[start : start + self.width]),) - for line in _plain_lines(block.text) - for start in _plain_spans(line, self.width) + tuple(row) for row in self.console.render_lines( + content, self.console.options, pad=False + ) ) @handles(PreparedLines) @@ -709,6 +718,13 @@ def rewrapped_plain(self, width: int, renderer: RichLineRenderer, def replaced_at(self, row: LaidOut, width: int) -> RowChange: return row.replaced_by(self.viewed(width)) + def plain(self, width: int, renderer: RichLineRenderer, blocks: tuple[LineBlock, ...]) -> MeasuredRows: + # Synchronous rows depend on style values, including a divider's fade. + # Async publication/retention still requires the captured theme identity. + if self.source.theme == renderer.theme: + return self.rewrapped_plain(width, renderer, blocks) + return super().plain(width, renderer, blocks) + @dataclass class AcquiredRows(MeasuredRows): diff --git a/src/toad/workspace_chrome.py b/src/toad/workspace_chrome.py index 87991f935..619b7480b 100644 --- a/src/toad/workspace_chrome.py +++ b/src/toad/workspace_chrome.py @@ -3,11 +3,12 @@ from typing import TYPE_CHECKING from textual.app import ComposeResult from textual.containers import Horizontal -from textual.geometry import Offset, Region, Size, Spacing +from textual.geometry import Offset, Size, Spacing from textual.layout import ArrangeResult, WidgetPlacement from textual.layouts.vertical import VerticalLayout from textual.widget import Widget from toad.widgets.channels_sidebar import ChannelsSidebar +from toad.widgets.side_bar import SideBar from toad.widgets.footer import ToadFooter from toad.widgets.switch_toast import SwitchToast from ui_theme.scheme import HeaderToneColour @@ -15,7 +16,7 @@ from toad.widget_view import AUTO, FILL, WidgetView from toad.session_presentation import NativeSessionSurface from toad.session_scope import SessionScope -from toad.sidebar_layout import Side +from toad.sidebar_layout import SidebarEdge if TYPE_CHECKING: from toad.app import ToadApp @@ -43,16 +44,14 @@ def __init__(self, app: "ToadApp") -> None: self.switch_toast = SwitchToast() self.native = NativeSessionSurface(app) - def presented_sidebars(self): + def presented_sidebars(self) -> tuple[SideBar, ...]: source = self.app.workspace_sessions.source - layout = self.app.sidebar_layout return tuple(bar for bar in (self.channels, *source.sidebars()) - if bar.presentation_visible and bar.id in layout.placements) + if bar.presentation_visible) - def toggle_sidebar(self, side: Side) -> None: - layout = self.app.sidebar_layout + def toggle_sidebar(self, side: type[SidebarEdge]) -> None: bars = tuple(bar for bar in self.presented_sidebars() - if layout.get(bar.id).side == side) + if bar.placement.side is side) collapsed = any(not bar.collapsed for bar in bars) for bar in bars: if bar.collapsed != collapsed: @@ -68,52 +67,49 @@ async def prepare_navigation(self, screen) -> bool: return changed def refresh_layout(self) -> None: - """Placement changes retire both native parent arrangements.""" - source = self.app.workspace_sessions.source - for parent in { - bar.parent for bar in (self.channels, *source.sidebars()) if bar.is_mounted - }: - parent.refresh(repaint=False, layout=True) + """Project placement intent before retiring both native arrangements.""" + bars = self.presented_sidebars() + for bar in bars: + bar.present_controls() + # Native invalidation retires this resource's parent arrangement. + bar.refresh(repaint=False, layout=True) def arrange( self, parent: Widget, children: list[Widget], size: Size, greedy: bool = True ) -> ArrangeResult: """Place chrome and conversation directly in the native layout pass.""" - source = self.app.workspace_sessions.source layout = self.app.sidebar_layout - own = tuple(source.sidebars()) bars = self.presented_sidebars() - resolved = layout.resolve(size.width, {bar.id: bar.collapsed for bar in bars}) + resolved = layout.resolve(size, {bar.placement_identity: bar.collapsed for bar in bars}) # The outer host stays full width. Its session's own native layout # clears both gutters, including the shared channel surface. - nested = parent is self.channels.parent and any( - bar.parent.layout is self for bar in own + nested = self.channels in parent.children and any( + bar not in children for bar in bars ) - left = 0 if nested else resolved.left_gutter - right = 0 if nested else resolved.right_gutter + gutter = Spacing() if nested else resolved.gutter placements = [ placement._replace( - region=placement.region + Offset(left, 0), - margin=placement.margin + Spacing(0, right, 0, left), + region=placement.region + Offset(gutter.left, gutter.top), + margin=placement.margin + gutter, ) for placement in super().arrange( parent, [child for child in children if child not in bars], - Size(max(0, size.width - left - right), size.height), + Size(max(0, size.width - gutter.width), max(0, size.height - gutter.height)), greedy, ) ] order = {identity: index for index, identity in enumerate(layout.ordered())} for bar in bars: if bar in children: - geometry = resolved.bars[bar.id] + geometry = resolved.bars[bar.placement_identity] placements.append( WidgetPlacement( - Region(0, 0, geometry.width, size.height), - Offset(geometry.x, 0), + geometry.reset_offset, + geometry.offset, Spacing(), bar, - order[bar.id], + order[bar.placement_identity], overlay=True, absolute=True, ) diff --git a/src/toad/workspace_sessions.py b/src/toad/workspace_sessions.py index 8c86117f7..4ad4ac977 100644 --- a/src/toad/workspace_sessions.py +++ b/src/toad/workspace_sessions.py @@ -16,10 +16,11 @@ SessionOrigin, SessionScope, ) +from toad.widget_actions import DeclaredWidgetActions +from toad.widgets.side_bar import SideBar if TYPE_CHECKING: from toad.app import ToadApp - from toad.widget_actions import DeclaredWidgetActions def welcome_view() -> "SessionScope": @@ -63,7 +64,7 @@ def focus_sidebar_return(self) -> None: ... def visible_target(self, sessions: WorkspaceSessions, root: Path | None) -> NavigationTarget: ... @abstractmethod - def sidebars(self): + def sidebars(self) -> tuple[SideBar, ...]: """Native sidebar membership belongs to this logical source.""" ... @@ -93,7 +94,7 @@ def focus_sidebar_return(self) -> None: def visible_target(self, sessions: WorkspaceSessions, root: Path | None) -> NavigationTarget: return NoTarget() - def sidebars(self): + def sidebars(self) -> tuple[SideBar, ...]: return () @@ -137,7 +138,7 @@ def visible_target(self, sessions: WorkspaceSessions, root: Path | None) -> Navi else NoTarget() ) - def sidebars(self): + def sidebars(self) -> tuple[SideBar, ...]: return self.view.sidebars() def keyboard_scopes(self) -> tuple[DeclaredWidgetActions, ...]: diff --git a/tests/app/test_channel_display_basis.py b/tests/app/test_channel_display_basis.py index 925a25dc8..ec4761506 100644 --- a/tests/app/test_channel_display_basis.py +++ b/tests/app/test_channel_display_basis.py @@ -1,5 +1,7 @@ """A channel marks read exactly the messages whose text it showed, never the history it left out.""" +from agent_comms.read_basis import ChannelConversation from agent_comms.repo import Checkout +from agent_comms.ui_model.unread import ExactUnread async def test_shown_messages_are_read_exactly_and_hidden_expansion_stays_unread(workspace): @@ -7,14 +9,15 @@ async def test_shown_messages_are_read_exactly_and_hidden_expansion_stays_unread workspace.thread(name, *tags, running=True) comms = workspace.comms viewer = workspace.viewer - project = str(workspace.project) + project = Checkout(workspace.project) hidden = comms.messaging.send("bob", "carol", "older hidden DM").message # Every line of a message names it, so a message whose text is partly on screen is recognisable. messages = [comms.messaging.send("alice", "#team", "\n".join(f"channel {i:02d} line" for _ in range(12))).message for i in range(12)] - def seen(): - return comms.bus.reads.seen_sequences(viewer, comms.registry.snapshot()) + ledger = comms.bus.reads + snapshot = comms.registry.snapshot() + channel = ChannelConversation("#team") async with workspace.app(size=(120, 40)) as ui: owner = ui.selected_tab @@ -24,14 +27,21 @@ def seen(): def shown(): return {message.seq for i, message in enumerate(messages) if ui.shows(f"channel {i:02d} line")} - await ui.until(lambda: seen() == shown(), why="the shown messages to be marked read") + await ui.until(lambda: all( + (message.seq in ledger.cursor(viewer, snapshot).for_conversation(channel)) + == (message.seq in shown()) for message in messages + ), why="the shown messages to be marked read") # Earlier messages were read from Core for scrolling, but never shown. assert 0 < len(shown()) < len(messages) and not ui.shows("channel 00 line") - assert comms.views.viewer_snapshot(Checkout(project)).channel_unread["#team"] == len(messages) - len(shown()) - assert hidden.seq not in seen() + assert comms.views.viewer_snapshot(project).channel_unread["#team"] == ExactUnread( + count=len(messages) - len(shown())) + assert hidden.seq not in ledger.cursor(viewer, snapshot).for_conversation( + ledger.conversation(hidden, snapshot)) await ui.select_tab(owner) comms.channels.set_channel_any_mode("#team", True) comms.channels.update_tags("bob", add=frozenset({"team"})) # Expanding a hidden tab's view never manufactures read facts. - assert comms.views.viewer_snapshot(Checkout(project)).channel_unread["#team"] >= len(messages) - len(shown()) + 1 - assert hidden.seq not in seen() + assert comms.views.viewer_snapshot(project).channel_unread["#team"] == ExactUnread( + count=len(messages) - len(shown()) + 1) + assert hidden.seq not in ledger.cursor(viewer, snapshot).for_conversation( + ledger.conversation(hidden, snapshot)) diff --git a/tests/model/test_sidebar_layout_policy.py b/tests/model/test_sidebar_layout_policy.py deleted file mode 100644 index c0da18821..000000000 --- a/tests/model/test_sidebar_layout_policy.py +++ /dev/null @@ -1,71 +0,0 @@ -"""The placement model cannot duplicate, discard or over-expand a sidebar.""" - -from itertools import product - -import pytest - -from toad.sidebar_layout import SidebarLayout - - -def test_moving_swapping_and_resizing_keep_both_sidebars_and_clamp_width(): - layout = SidebarLayout() - assert layout.ordered() == ("channels-sidebar", "thread-sidebar") - assert layout.move("channels-sidebar", "right") - assert layout.get("thread-sidebar").order == 0 - assert layout.get("channels-sidebar").order == 1 - assert layout.swap("channels-sidebar") - assert layout.get("channels-sidebar").order == 0 - assert layout.get("thread-sidebar").order == 1 - assert layout.ordered() == ("thread-sidebar", "channels-sidebar") - assert layout.move("thread-sidebar", "left") - assert layout.ordered() == ("thread-sidebar", "channels-sidebar") - assert layout.get("thread-sidebar").order == 0 - assert layout.width("channels-sidebar", 70) - assert layout.get("channels-sidebar").width_percent == 50 - assert layout.width("channels-sidebar", 1) - assert layout.get("channels-sidebar").width_percent == 15 - assert layout.float_mode("thread-sidebar") is True - assert layout.float_mode("thread-sidebar") is False - assert set(layout.ordered()) == {"thread-sidebar", "channels-sidebar"} - - -@pytest.mark.parametrize("side", ["left", "right"]) -def test_two_sidebars_on_one_side_swap_inward_then_cross(side): - pair = SidebarLayout() - pair.move("channels-sidebar", side) - pair.move("thread-sidebar", side) - outer, inner = sorted(pair.placements, key=lambda key: pair.get(key).order) - inward = "right" if side == "left" else "left" - before = dict(pair.placements) - assert not pair.shift(outer, side) and pair.placements == before - assert pair.shift(outer, inward) - assert pair.get(outer).side == pair.get(inner).side == side - assert pair.get(outer).order == 1 and pair.get(inner).order == 0 - assert pair.shift(outer, inward) and pair.get(outer).side == inward - assert not pair.shift(outer, inward) - - -@pytest.mark.parametrize( - "side, width, hide_left, hide_right", list(product(("left", "right"), (76, 120), (False, True), (False, True)))) -def test_float_keeps_order_and_swap_keeps_widths(side, width, hide_left, hide_right): - pair = SidebarLayout() - pair.move("channels-sidebar", side) - pair.move("thread-sidebar", side) - collapsed = {"channels-sidebar": hide_left, "thread-sidebar": hide_right} - before = pair.resolve(width, collapsed) - order = pair.ordered() - for name in order: - pair.float_mode(name) - assert pair.ordered() == order, "Float must not reorder sidebars" - assert pair.resolve(width, collapsed).bars == before.bars - physical = sorted(before.bars.values(), key=lambda bar: bar.x) - assert physical[0].x + physical[0].width == physical[1].x - assert physical[0].x >= 0 and physical[1].x + physical[1].width <= width - for name, hidden in collapsed.items(): - if hidden: - assert before.bars[name].width == 3 - pair.swap("channels-sidebar") - after = pair.resolve(width, dict(reversed(tuple(collapsed.items())))) - assert {key: bar.width for key, bar in after.bars.items()} == { - key: bar.width for key, bar in before.bars.items() - }, "Swap must not change widths through order-dependent rounding" diff --git a/tests/support/textual_steps.py b/tests/support/textual_steps.py index e0f2d2c56..b9b073f12 100644 --- a/tests/support/textual_steps.py +++ b/tests/support/textual_steps.py @@ -96,7 +96,7 @@ def text_rows_of(chat, message) -> range | None: the window is not on them until that layout ends. """ window = chat.window - if window.reader_position is not None or window.screen._stepped_layout is not None: + if not window.reader_position.laid_out(window) or window.screen._stepped_layout is not None: return None for page in chat.message_history.pages: for fragment, first, lines in page._layout: diff --git a/tests/textual/test_channel_partial_paint.py b/tests/textual/test_channel_partial_paint.py index 4886825f5..6b9e04d22 100644 --- a/tests/textual/test_channel_partial_paint.py +++ b/tests/textual/test_channel_partial_paint.py @@ -1,6 +1,6 @@ """Textual-facing: read marks follow the message text the viewport has painted; scrolling the window reveals the rest.""" -from support.textual_steps import text_rows_of +from agent_comms.read_basis import ChannelConversation async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_remaining_messages(workspace): @@ -11,41 +11,70 @@ async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_ comms = workspace.comms viewer = workspace.viewer rows = [comms.messaging.send("peer", "#team", f"row {i}\n" + "body\n" * 10).message for i in range(8)] - - def seen(): - return comms.bus.reads.seen_sequences(viewer, comms.registry.snapshot()) + ledger = comms.bus.reads + snapshot = comms.registry.snapshot() + channel = ChannelConversation("#team") async with workspace.app(size=(90, 24)) as ui: app = ui.textual[0] await ui.open_channel("#team", actor=viewer) chat = app.screen.query_one(CommsChatView) history, window = chat.message_history, chat.window - await ui.until(lambda: seen() and not history.marking, why="the first viewport to be marked") + await ui.until(lambda: any(row.seq in ledger.cursor( + viewer, snapshot).for_conversation(channel) for row in rows), + why="the first viewport to be marked") + await history._read_worker.wait() in_view = {fragment.message.seq for fragment in history.read_fragments()} - assert 0 < len(seen()) < len(rows) - assert seen() == in_view - # Messages beyond the first viewport stay unread until their text is scrolled to. + assert 0 < len(in_view) < len(rows) + cursor = ledger.cursor(viewer, snapshot).for_conversation(channel) + assert all((message.seq in cursor) == (message.seq in in_view) for message in rows) + # Address each original message through the history's own source + # fragments; revealing its head prepares virtualized rows on demand. for message in reversed(rows): - if message.seq in seen(): + if message.seq in ledger.cursor(viewer, snapshot).for_conversation(channel): continue - if text_rows_of(chat, message) is None: - # Earlier messages are laid out as the reader nears them. - window.scroll_to(y=0, animate=False, immediate=True) - await ui.until(lambda: text_rows_of(chat, message) is not None, why=f"row {message.seq} laid out") - window.scroll_to(y=text_rows_of(chat, message).start, animate=False, immediate=True) - await ui.until(lambda: message.seq in seen(), why=f"row {message.seq} to be marked once scrolled to") - assert seen() == {row.seq for row in rows} + fragment = next(part for part in history.fragments + if part.message.view_key == message.view_key) + for _ in rows: + if history.draws_fragment(fragment): + break + window.action_page_up() + await ui.step() + await ui.until(lambda: history.state.accepts_source_work + and window.reader_position.laid_out(window), + why="the scrolled viewport to finish preparation and layout") + assert history.draws_fragment(fragment), f"Message {message.seq} was not prepared by paging" + await history.reveal_message_head(fragment, lambda: True) + history.show_fragment(fragment) + await ui.until(lambda: message.seq in ledger.cursor( + viewer, snapshot).for_conversation(channel), + why=f"row {message.seq} to be marked once scrolled to") + cursor = ledger.cursor(viewer, snapshot).for_conversation(channel) + assert all(row.seq in cursor for row in rows) # A wholly read history has nothing left to mark: no further worker or route read. - await ui.until(lambda: not history.marking, why="marking to settle") - assert all(fragment.evidence is None for fragment in history.wire_fragments) + await history._read_worker.wait() + assert all(fragment.evidence is None for fragment in history.fragments) + completed = history._read_worker for _ in range(50): history.mark_visible() - assert not history.marking - recorded = tuple(message.view_key for message in history.messages) + assert history._read_worker is completed + window.action_scroll_end() + await ui.until(lambda: history.state.accepts_source_work and any( + part.message.view_key == rows[-1].view_key for part in history.drawn_fragments), + why="the latest message prepared by End") + # End shows the newest bottom viewport, not necessarily the first line + # of a tall message. Reveal that head before checking style preservation. + latest = next(part for part in history.drawn_fragments + if part.message.view_key == rows[-1].view_key) + await history.reveal_message_head(latest, lambda: True) + history.show_fragment(latest) + await ui.until(lambda: ui.shows("row 7"), why="the newest message head") + recorded = tuple(fragment.message.view_key for fragment in history.fragments) await ui.press("ctrl+t") - await ui.until(lambda: isinstance(history.style, MarkdownMessageStyle), why="the Markdown style") + await ui.until(lambda: isinstance(history.page_source.loader.style, MarkdownMessageStyle), why="the Markdown style") assert ui.shows("row 7") await ui.press("ctrl+t") - await ui.until(lambda: isinstance(history.style, IrcMessageStyle), why="the IRC style") - assert tuple(message.view_key for message in history.messages) == recorded - assert seen() == {row.seq for row in rows} + await ui.until(lambda: isinstance(history.page_source.loader.style, IrcMessageStyle), why="the IRC style") + assert tuple(fragment.message.view_key for fragment in history.fragments) == recorded + cursor = ledger.cursor(viewer, snapshot).for_conversation(channel) + assert all(row.seq in cursor for row in rows) diff --git a/tests/textual/test_channel_scope_rows.py b/tests/textual/test_channel_scope_rows.py index b8e10e58e..c899e3cb1 100644 --- a/tests/textual/test_channel_scope_rows.py +++ b/tests/textual/test_channel_scope_rows.py @@ -11,11 +11,11 @@ async def test_any_mode_channel_rows_include_older_messages_after_a_refresh(work for name, tags in (("alice", ("team",)), ("bob", ()), ("carol", ()), ("dave", ())): workspace.thread(name, *tags, running=True) comms = workspace.comms - project = str(workspace.project) + viewer = comms.views.display_viewer(Checkout(workspace.project)) comms.messaging.send("carol", "dave", "older newly visible DM") comms.messaging.send("alice", "#team", "current channel message") comms.channels.set_channel_any_mode("#team", True) - assert [m.body for m in comms.views.channel_display_page("#team", viewer=comms.views.display_viewer(Checkout(project))).messages] == [ + assert [m.body for m in comms.views.channel_display_page("#team", viewer=viewer).messages] == [ "current channel message"] async with workspace.app(size=(100, 30)) as ui: @@ -30,9 +30,9 @@ async def test_any_mode_channel_rows_include_older_messages_after_a_refresh(work await ui.app.coordination_access.stop_observing() history = chat.message_history source, snapshot = history.page_source, history.source_snapshot() - marked = comms.views.channel_display_page("#team", viewer=comms.views.display_viewer(Checkout(project))) + marked = comms.views.channel_display_page("#team", viewer=viewer) comms.channels.update_tags("carol", add=frozenset({"team"})) - expanded = comms.views.channel_display_page("#team", viewer=comms.views.display_viewer(Checkout(project))) + expanded = comms.views.channel_display_page("#team", viewer=viewer) assert [m.body for m in expanded.messages][:2] == ["older newly visible DM", "current channel message"] await history._mark_page(marked, (), snapshot) await ui.until(lambda: history.page_source is not source, diff --git a/tests/textual/test_conversation_value.py b/tests/textual/test_conversation_value.py index d13e1f935..1964d34d6 100644 --- a/tests/textual/test_conversation_value.py +++ b/tests/textual/test_conversation_value.py @@ -190,7 +190,7 @@ async def test_recreated_dm_peer_retires_the_old_view_and_opens_a_new_incarnatio def replace_peer(): registry = workspace.comms.registry registry.unregister(peer.name) - registry.remove_originals((registry.require(peer.name),)) + workspace.comms.threads.delete(peer.name) return registry.declare(Thread( peer.name, peer.tags, peer.worktree, created_at=peer.created_at + 1, )) diff --git a/tests/textual/test_divider_only_viewport_stays_unread.py b/tests/textual/test_divider_only_viewport_stays_unread.py index f12b88a91..98d2fa14d 100644 --- a/tests/textual/test_divider_only_viewport_stays_unread.py +++ b/tests/textual/test_divider_only_viewport_stays_unread.py @@ -1,54 +1,71 @@ """Textual-facing: whether a channel message counts as read depends on which rows Textual painted, and a divider painted without its text is not enough.""" +import asyncio + from agent_comms.repo import Checkout -from unittest.mock import patch +from agent_comms.ui_model.unread import ExactUnread from support.textual_steps import text_rows_of from toad.widgets.comms_chat import CommsChatView -from toad.widgets.wire_history import WireHistory async def test_divider_without_body_leaves_the_message_unread(workspace): workspace.thread("peer", "team") comms = workspace.comms - project = str(workspace.project) + project = Checkout(workspace.project) for index in range(7): comms.messaging.send("peer", "#team", f"earlier {index} " + "body " * 20) - comms.views.mark_user_view_read("#team", worktree=Checkout(project)) - last = comms.messaging.send("peer", "#team", "LAST " + "body " * 250).message - - # Hold read marks while arranging the divider-only viewport; the first - # viewport shows the text and may legitimately mark it. - mark_visible = WireHistory.mark_visible - with patch.object(WireHistory, "mark_visible"): - async with workspace.app(size=(80, 22)) as ui: - app, _ = ui.textual - await ui.open_channel("#team", actor="peer") - chat = app.screen.query_one(CommsChatView) - history, window = chat.message_history, chat.window - await ui.until(lambda: text_rows_of(chat, last) is not None, seconds=5, why="the last message laid out") - # Its caption is the row above its text: make that the last row in view. Going up there reads an - # earlier page and redraws rows; arrange the viewport again until the history is idle with it in place. - def caption_is_last_row(): - rows = text_rows_of(chat, last) - return (history.state.accepts_source_work and rows is not None - and rows.start - window.scroll_y == window.outer_size.height) - - for _ in range(10): - await ui.until(lambda: history.state.accepts_source_work and text_rows_of(chat, last) is not None, - why="the history idle") - await ui.step(.2) - if caption_is_last_row(): - break - window.scroll_to(y=text_rows_of(chat, last).start - window.outer_size.height, - animate=False, immediate=True) + comms.views.mark_user_view_read("#team", worktree=project) + + async with workspace.app(size=(80, 22)) as ui: + app, _ = ui.textual + await ui.open_channel("#team", actor="peer") + chat = app.screen.query_one(CommsChatView) + history, window = chat.message_history, chat.window + # The initial source must have committed scrollable rows before moving + # the reader; opening the tab alone does not establish that state. + await ui.until(lambda: history.state.accepts_source_work and window.max_scroll_y > 0 + and window.reader_position.laid_out(window), why="the initial scrollable history") + # Move away from the tail before the new message exists. Its text must + # never paint during setup; no patched read-marker can hide a mistake. + window.release_anchor() + window.scroll_home(animate=False, immediate=True) + await ui.step() + assert not window.follows_tail + last = comms.messaging.send("peer", "#team", "LAST " + "body " * 250).message + # Offscreen rows are deliberately lazy. Approach through the viewport's + # real input route until lookahead prepares the new message, stopping + # before its body can paint or supply a read mark. + async with asyncio.timeout(5): + while text_rows_of(chat, last) is None: + window.action_scroll_down() await ui.step() - assert caption_is_last_row() - assert not ui.shows("LAST") - assert any(fragment.message.seq == last.seq for fragment in history.visible_fragments()) - assert all(fragment.message.seq != last.seq for fragment in history.read_fragments()) + assert not ui.shows("LAST"), "Setup scrolled past the unread caption" + assert not ui.shows("LAST") - mark_visible(history) - await ui.until(lambda: not history.marking, why="marking to settle") + # Its caption is above its text: place that caption at the viewport's + # last row. Paging can change the layout, so follow the committed rows. + def caption_is_last_row(): + rows = text_rows_of(chat, last) + return (history.state.accepts_source_work and rows is not None + and rows.start - window.scroll_y == window.outer_size.height) + + for _ in range(10): + await ui.until(lambda: history.state.accepts_source_work and text_rows_of(chat, last) is not None, + why="the history idle") await ui.step(.2) - assert comms.views.viewer_snapshot(Checkout(project)).channel_unread["#team"] == 1 + if caption_is_last_row(): + break + window.scroll_to(y=text_rows_of(chat, last).start - window.outer_size.height, + animate=False, immediate=True) + await ui.step() + assert caption_is_last_row() + assert not ui.shows("LAST") + assert any(fragment.message.seq == last.seq for fragment in history.visible_fragments()) + assert all(fragment.message.seq != last.seq for fragment in history.read_fragments()) + + history.mark_visible() + if (worker := history._read_worker) is not None: + await worker.wait() + await ui.step(.2) + assert comms.views.viewer_snapshot(project).channel_unread["#team"] == ExactUnread(count=1) diff --git a/tests/textual/test_hidden_session_retirement.py b/tests/textual/test_hidden_session_retirement.py index 92ac5d6c4..6441608fd 100644 --- a/tests/textual/test_hidden_session_retirement.py +++ b/tests/textual/test_hidden_session_retirement.py @@ -120,7 +120,7 @@ async def test_a_same_name_successor_does_not_keep_the_deleted_threads_hidden_se _, gone = app.session_navigation.get(modes["agent-3"]).original_threads(app.session_navigation)[0] old = registry.require(gone.name) registry.unregister(old.name) - registry.remove_originals((registry.require(old.name),)) + workspace.comms.threads.delete(old.name) registry.declare(Thread(old.name, old.tags, old.worktree, created_at=old.created_at + 1)) await app.session_navigation.retire_missing() await ui.until(lambda: app.session_navigation.get(modes["agent-3"]) is None, seconds=15, diff --git a/tests/textual/test_initial_prompt_handoff.py b/tests/textual/test_initial_prompt_handoff.py index b93029ad9..991e617e7 100644 --- a/tests/textual/test_initial_prompt_handoff.py +++ b/tests/textual/test_initial_prompt_handoff.py @@ -10,17 +10,10 @@ from toad.core.input_events import UserInputSubmitted from toad.screens.main import MainScreen -from toad.session_presentation import OperationalSessionSources +from toad.session_presentation import SessionViewState from support.ui import saved_tab -class ObservedLaunchSources(OperationalSessionSources): - def make_conversation(self, screen): - conversation = super().make_conversation(screen) - screen.subscriptions.append(conversation.core_publications.subscribe(UserInputSubmitted, screen.observed)) - return conversation - - class ObservedLaunchScreen(MainScreen): """Records the conversation's published inputs; creation and handlers stay native.""" @@ -29,7 +22,13 @@ def __init__(self, *args, **kwargs): self.inputs = Counter() self.publication = asyncio.Event() self.subscriptions = [] - self.presentation.sources = ObservedLaunchSources() + + def make_conversation(self): + conversation = super().make_conversation() + self.subscriptions.append( + conversation.core_publications.subscribe(UserInputSubmitted, self.observed) + ) + return conversation def observed(self, event, subscription): assert not event.shell @@ -43,7 +42,11 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th async with workspace.app(size=(110, 35)) as ui: app, _pilot = ui.textual owner = ObservedLaunchScreen(Checkout(workspace.project), initial_prompt="INITIAL_LOCAL_UI_EVENT") + sidebar = owner.thread_sidebar + assert owner.sidebars() == (sidebar,) and not sidebar.is_mounted + assert not sidebar.presentation_visible await app.session_navigation.new(await saved_tab(app), lambda: owner) + assert owner.thread_sidebar is sidebar await owner.wait_content_ready() await asyncio.wait_for(owner.publication.wait(), 12) await ui.step() @@ -62,6 +65,7 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th await app.session_navigation.new(await saved_tab(app), lambda: MainScreen(app.project_dir)) await app.selected_session.wait_content_ready() await owner.presentation.evict(owner) + assert owner.thread_sidebar is sidebar assert view._initial_prompt is None assert owner.presentation.state.initial_prompt == "NEWEST_PENDING_LOCAL_UI_EVENT" assert owner.presentation.widget is None @@ -71,7 +75,8 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th await ui.step() restored = owner.conversation assert restored is not view and isinstance(restored.agent_binding, NoAgentConnection) - assert restored._initial_prompt is None and owner.presentation.state is None + assert restored._initial_prompt is None + assert type(owner.presentation.state) is SessionViewState assert owner.inputs == {"INITIAL_LOCAL_UI_EVENT": 1, "NEWEST_PENDING_LOCAL_UI_EVENT": 1} editor = restored.prompt.prompt_text_area assert editor.document is document and editor.history is undo @@ -92,6 +97,9 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th assert owner.inputs == {"INITIAL_LOCAL_UI_EVENT": 1, "NEWEST_PENDING_LOCAL_UI_EVENT": 1} assert isinstance(owner.conversation.agent_binding, NoAgentConnection) and not owner.conversation.submissions.active await app.session_navigation.close(owner.id) - assert owner.presentation.widget is None and owner.presentation.state is None + assert owner.thread_sidebar is sidebar + assert not sidebar.presentation_visible + assert owner.presentation.widget is None + assert type(owner.presentation.state) is SessionViewState assert owner.id not in app.workspace_sessions.views assert ui.failure() is None diff --git a/tests/textual/test_navigation_keys.py b/tests/textual/test_navigation_keys.py index fefaa1c5e..621987a6b 100644 --- a/tests/textual/test_navigation_keys.py +++ b/tests/textual/test_navigation_keys.py @@ -79,9 +79,13 @@ async def navigate(key, expected): assert editor.text == 'HJKL' assert ui.selected_tab == third assert app.tab_order.names == originals - await ui.press('ctrl+shift+k') - assert editor.text == '' + # Neovim owns Insert chords: Ctrl-K starts a digraph, not the + # native TextArea's delete-line command. The workspace must yield. + await ui.press('ctrl+shift+k', 'A', 'E') + assert editor.text == 'HJKLÆ' assert ui.selected_tab == third and app.tab_order.names == originals + await ui.press('ctrl+u') + assert editor.text == '' await ui.press('H', 'J', 'K', 'L') await ui.press('escape') @@ -137,11 +141,12 @@ async def test_sidebar_keys_follow_actual_edges_after_a_pointer_move(workspace): field.value = '' # Use the existing spatial control to move the right bar to the left. - button = next(button for button in thread.query(SidebarAction) - if button.display and button.action.directional and button.action.direction == 'left') + from toad.sidebar_layout import LeftSidebarEdge + + button = thread.query_one('#sidebar-left', SidebarAction) assert await pilot.click(button) await ui.step() - assert not thread.right and not channels.right + assert thread.placement.side is channels.placement.side is LeftSidebarEdge ui.conversation.prompt.focus() assert ui.conversation.prompt.prompt_text_area.editing_mode.allows_application_commands await ui.press('ctrl+shift+l') @@ -176,4 +181,9 @@ async def test_workspace_keys_do_not_interrupt_editor_sequences(workspace): assert ui.selected_tab == selected and editor.has_focus await ui.press('escape', 'd', 'H') assert ui.selected_tab == selected and editor.has_focus + # H is a real linewise motion under the pending delete operator. + # Its edit and undo must happen here, never turn into tab navigation. + assert editor.text == '' + await ui.press('u') assert editor.text == 'abcHdef' + assert ui.selected_tab == selected and editor.has_focus diff --git a/tests/textual/test_prompt_enter_insert.py b/tests/textual/test_prompt_enter_insert.py index d7c81476a..7f87acd0e 100644 --- a/tests/textual/test_prompt_enter_insert.py +++ b/tests/textual/test_prompt_enter_insert.py @@ -1,7 +1,11 @@ """Textual-facing: vim `i` asks the prompt for insert mode; the prompt owns whether its editor exists.""" +import pytest + from agent_comms.repo import Checkout -from support.agents import peer +from support.agents import RecordedEvents, peer +from toad.core.input_events import UserInputSubmitted +from toad.prompt_actions import SubmitAction, SubmitInputAction, SubmitNowAction from toad.key_modes import Standard, VimInsert, VimNormal from toad.preferences import SETTINGS from toad.screens.settings import SettingsScreen @@ -83,6 +87,74 @@ async def test_real_neovim_submission_shrinks_document_and_cursor_together(works why="the local clear command, not the yielding app quit hint") +@pytest.mark.parametrize("rejected_action", (SubmitAction, SubmitNowAction)) +async def test_refused_native_transfer_cannot_change_the_next_ordinary_submission( + workspace, rejected_action: type[SubmitInputAction], +): + async with workspace.app(binding=AgentAttached(peer("echo"))) as ui: + await ui.until_agent_ready() + area = ui.conversation.prompt.prompt_text_area + ui.app.saved_settings.set(SETTINGS.ui.key_mode, VimNormal) + await ui.until(lambda: area.key_mode is VimNormal and area.editing_mode.completed, + why="the real Neovim child in Normal") + area.focus() + await ui.press("i") + await ui.until(lambda: area.editing_mode.accepts_text, why="real Insert") + await ui.type("kept draft") + await ui.press("escape") + await ui.until(lambda: area.text == "kept draft" and area.editing_mode.allows_application_commands, + why="the completed draft in real Normal") + submitted = RecordedEvents() + subscription = area.core_publications.subscribe(UserInputSubmitted, submitted.receive) + try: + await ui.press("d") + await ui.until(lambda: not area.editing_mode.completed + and not area.editing_mode.snapshot.state.transferable, + why="the actual pending Neovim operator refuses transfer") + # Exercise the queued request's refusal, including a request whose + # editing state became pending after its key was admitted. + area.schedule_submission(rejected_action()) + await ui.step() + assert area.text == "kept draft" + assert submitted == [] and workspace.prompts_received() == [] + await ui.press("escape") + await ui.until(lambda: area.editing_mode.allows_application_commands, + why="the real operator cancelled without replay") + await ui.press("i") + await ui.until(lambda: area.editing_mode.accepts_text, why="real Insert resumed") + await ui.press("enter") + await ui.until(lambda: workspace.prompts_received() == ["kept draft"], + why="one fresh ordinary submission reaches the real private peer") + assert submitted == [UserInputSubmitted("kept draft")] + finally: + subscription.close() + + +@pytest.mark.parametrize("actions,immediate", ( + ((SubmitAction, SubmitAction), False), + ((SubmitAction, SubmitNowAction, SubmitAction), True), + ((SubmitNowAction, SubmitAction), True), +)) +async def test_coalesced_prompt_actions_publish_one_owned_intent(workspace, actions, immediate): + async with workspace.app(binding=AgentAttached(peer("echo"))) as ui: + await ui.until_agent_ready() + area = ui.conversation.prompt.prompt_text_area + ui.app.saved_settings.set(SETTINGS.ui.key_mode, Standard) + await ui.until(lambda: area.key_mode is Standard, why="the native Standard editor") + area.focus() + await ui.type("one request") + submitted = RecordedEvents() + subscription = area.core_publications.subscribe(UserInputSubmitted, submitted.receive) + try: + for action in actions: + area.schedule_submission(action()) + await ui.until(lambda: workspace.prompts_received() == ["one request"], + why="the coalesced request reaches the real private peer once") + assert submitted == [UserInputSubmitted("one request", immediate=immediate)] + finally: + subscription.close() + + async def test_standard_chooser_retires_neovim_and_saved_selection_reopens(workspace): async with workspace.app() as ui: app = ui.app @@ -101,9 +173,9 @@ async def test_standard_chooser_retires_neovim_and_saved_selection_reopens(works await ui.press("ctrl+comma") await ui.until(lambda: isinstance(app.screen, SettingsScreen), why="the application settings binding before Neovim") - await ui.until(lambda: str(SETTINGS.ui.key_mode) in app.screen.form.values, + await ui.until(lambda: str(SETTINGS.ui.key_mode) in app.screen.values, why="the existing declared mode chooser") - chooser = app.screen.form.values[str(SETTINGS.ui.key_mode)].editor + chooser = app.screen.values[str(SETTINGS.ui.key_mode)].editor assert chooser.lists(Standard) chooser.value = Standard await ui.until(lambda: app.settings.ui.key_mode is Standard and area.key_mode is Standard, diff --git a/tests/textual/test_saved_history_coverage.py b/tests/textual/test_saved_history_coverage.py index ff9b7479c..7c5ee75c8 100644 --- a/tests/textual/test_saved_history_coverage.py +++ b/tests/textual/test_saved_history_coverage.py @@ -30,7 +30,7 @@ async def saved_history(workspace, ui): root = str(workspace.comms.root) await ui.app.session_navigation.new( MailboxTab(owner.incarnation, root), - partial(MainScreen.from_thread, workspace.project, root, owner.incarnation), + partial(MainScreen.from_thread, owner.worktree, root, owner.incarnation), original=(root, owner.incarnation), ) conversation = ui.conversation diff --git a/tests/textual/test_scroll_down_bounce.py b/tests/textual/test_scroll_down_bounce.py index baede7257..e88dac19b 100644 --- a/tests/textual/test_scroll_down_bounce.py +++ b/tests/textual/test_scroll_down_bounce.py @@ -94,7 +94,7 @@ async def test_scroll_down_through_evicted_pages_never_moves_up(workspace, recor ui.app.saved_settings.set(SETTINGS.ui.history_buffer_viewports, 1) await ui.app.session_navigation.new( MailboxTab(owner.incarnation, root), - partial(MainScreen.from_thread, workspace.project, root, owner.incarnation), + partial(MainScreen.from_thread, owner.worktree, root, owner.incarnation), original=(root, owner.incarnation)) await ui.until(lambda: ui.conversation.transcript.reports_coverage, why="the retained transcript to load") diff --git a/tests/textual/test_sent_message_drawn_once.py b/tests/textual/test_sent_message_drawn_once.py index 768358ef1..37812f915 100644 --- a/tests/textual/test_sent_message_drawn_once.py +++ b/tests/textual/test_sent_message_drawn_once.py @@ -32,7 +32,7 @@ async def test_a_start_notice_after_the_saved_history_draws_the_message_once(wor root = str(workspace.comms.root) await ui.app.session_navigation.new( MailboxTab(owner.incarnation, root), - partial(MainScreen.from_thread, workspace.project, root, owner.incarnation), + partial(MainScreen.from_thread, owner.worktree, root, owner.incarnation), original=(root, owner.incarnation), ) view = ui.conversation diff --git a/tests/textual/test_sidebar_direction.py b/tests/textual/test_sidebar_direction.py index c73c3dae5..26734272e 100644 --- a/tests/textual/test_sidebar_direction.py +++ b/tests/textual/test_sidebar_direction.py @@ -1,67 +1,70 @@ -"""Textual-facing: sidebar arrow buttons follow spatial neighbours (walls, inward swaps, outward swaps, cross-centre moves) as laid out by the compositor.""" - -from toad.widgets.side_bar import SideBar, SidebarAction +"""Native sidebar controls reflect the placement owner at every declared edge.""" +import pytest -def arrows(bar: SideBar) -> list[str]: - return [button.render().plain for button in bar.query(SidebarAction) - if button.display and button.action.directional] +from toad.sidebar_layout import SidebarEdge +from toad.widgets.side_bar import SideBar, SidebarAction -async def test_arrow_clicks_follow_spatial_neighbours(workspace): - async with workspace.app(size=(120, 40)) as ui: +@pytest.mark.parametrize("side", SidebarEdge.members_with(SidebarEdge)) +async def test_arrow_clicks_follow_spatial_neighbours(workspace, side): + async with workspace.app(size=(120, 60)) as ui: app, pilot = ui.textual - channels = app.screen.query_one("#channels-sidebar", SideBar) - thread = app.selected_session.query_one("#thread-sidebar", SideBar) + channels = app.workspace_chrome.channels + thread = app.selected_session.thread_sidebar + channels.reveal() thread.reveal() + # Vacate the destination before establishing the requested outer/inner + # order. Moving an existing occupant to its own edge is a no-op. + app.sidebar_layout.move(channels.placement_identity, side.opposite()) + app.sidebar_layout.move(thread.placement_identity, side.opposite()) + app.sidebar_layout.move(channels.placement_identity, side) + app.sidebar_layout.move(thread.placement_identity, side) + app.workspace_chrome.refresh_layout() await ui.step() - async def click(bar: SideBar, label: str) -> None: - button = next(button for button in bar.query(SidebarAction) - if button.display and button.render().plain == label) - assert await pilot.click(button), ( - bar.id, label, button.region, bar.region, - tuple((item.id, item.region, item.collapsed) for item in app.screen.query(SideBar)), - button in app.screen._compositor.visible_widgets) + async def click(bar: SideBar, edge) -> None: + button = bar.query_one(f"#sidebar-{edge.declared_name}", SidebarAction) + assert button.display and await pilot.click(button), (bar.id, edge, button.region, bar.region) await ui.step() - assert arrows(channels) == ["──>"] and arrows(thread) == ["<──"], ( - arrows(channels), arrows(thread), ui.visible_text()) - await click(thread, "<──") - assert not channels.right and not thread.right - assert channels.region.right == thread.region.x - assert arrows(channels) == ["──>"], "Wall-adjacent bar must have only the inward arrow" - assert arrows(thread) == ["<──", "──>"] + def check_controls(bar: SideBar) -> None: + for edge, shift in app.sidebar_layout.directions(bar.placement_identity).items(): + button = bar.query_one(f"#sidebar-{edge.declared_name}", SidebarAction) + assert button.display == shift.available - await click(channels, "──>") - assert not channels.right and not thread.right, "Inward click must swap, not cross the conversation" - assert thread.region.right == channels.region.x - assert arrows(thread) == ["──>"] and arrows(channels) == ["<──", "──>"] - await click(channels, "<──") - assert channels.region.right == thread.region.x - await click(thread, "──>") - assert thread.right and not channels.right + def check_adjacent() -> None: + regions = sorted((channels.region, thread.region), key=lambda region: side.coordinate(region.offset)) + assert side.coordinate(regions[0].offset) + side.extent(regions[0].size) == side.coordinate(regions[1].offset) - await click(channels, "──>") - assert channels.right and thread.right - assert channels.region.right == thread.region.x - assert arrows(thread) == ["<──"] and arrows(channels) == ["<──", "──>"] - await click(thread, "<──") - assert channels.right and thread.right - assert thread.region.right == channels.region.x - assert arrows(channels) == ["<──"] and arrows(thread) == ["<──", "──>"] - await click(thread, "──>") - assert channels.region.right == thread.region.x + # The wall is blocked, the inward arrow swaps, then crosses the centre. + assert channels.placement.order == 0 and thread.placement.order == 1 + check_controls(channels) + check_controls(thread) + check_adjacent() + inward = side.opposite() + await click(channels, inward) + assert channels.placement.side is thread.placement.side is side + assert thread.placement.order == 0 and channels.placement.order == 1 + check_adjacent() + await click(channels, side) + assert channels.placement.order == 0 and thread.placement.order == 1 + await click(thread, inward) + assert thread.placement.side is inward and channels.placement.side is side + await click(channels, inward) + assert channels.placement.side is thread.placement.side is inward + check_controls(channels) + check_controls(thread) + check_adjacent() - # Float and a collapsed neighbour do not change the arrow policy. - await click(thread, "Float") - channels.toggle() + # Float and collapse keep the same canonical peer/action policy. + assert await pilot.click(thread.query_one("#sidebar-float", SidebarAction)) + channels.toggle(focus=False) await ui.step() - assert arrows(thread) == ["<──"] - await click(thread, "<──") - assert thread.right and channels.right - assert thread.region.right == channels.region.x - assert arrows(thread) == ["<──", "──>"] + check_controls(thread) + await click(thread, side) + assert thread.placement.side is channels.placement.side is inward channels.reveal() await ui.step() - assert arrows(channels) == ["<──"] + check_controls(channels) + check_controls(thread) diff --git a/tests/textual/test_sidebar_drag_resize.py b/tests/textual/test_sidebar_drag_resize.py index ad64d7b58..6930371e1 100644 --- a/tests/textual/test_sidebar_drag_resize.py +++ b/tests/textual/test_sidebar_drag_resize.py @@ -1,32 +1,33 @@ -"""Textual-facing: pointer capture, drag, hover styling and keyboard resize of a sidebar from either edge, with the width slider mirrored.""" +"""Textual-facing: captured resize, hover and keys; zero-motion release preserves intent on every edge.""" import pytest from toad.key_modes import Standard from toad.keymap import KeymapRow from toad.preferences import SETTINGS -from toad.widget_actions import CursorRightAction +from textual.keys import RightKey +from toad.sidebar_layout import SidebarEdge, WidthSidebarEdge from toad.widgets.side_bar import ( - SideBar, SideBarToggle, SidebarAction, SidebarResizeHandle, SidebarSlider, TabHistoryControls, + SideBarToggle, SidebarAction, SidebarResizeHandle, SidebarSlider, TabHistoryControls, ) -@pytest.mark.parametrize("side", ["left", "right"]) +@pytest.mark.parametrize("side", SidebarEdge.members_with(WidthSidebarEdge)) async def test_resize_by_edge_drag_slider_and_keys_follows_the_side(workspace, side): async with workspace.app(size=(120, 40)) as ui: app, pilot = ui.textual - bar = app.screen.query_one("#channels-sidebar", SideBar) - app.sidebar_layout.move("channels-sidebar", side) - app.sidebar_layout.width("channels-sidebar", 30) + bar = app.workspace_chrome.channels + app.sidebar_layout.move(bar.placement_identity, side) + app.sidebar_layout.resize(bar.placement_identity, 30) app.workspace_chrome.refresh_layout() await ui.step() - slider = bar.width_slider + slider = bar.extent_slider handle = bar.query_one(SidebarResizeHandle) toggle = bar.query_one(SideBarToggle) - assert (toggle.region.right == handle.region.x if side == "left" + assert (toggle.region.right == handle.region.x if side.sign > 0 else toggle.region.x == handle.region.right), (side, toggle.region, handle.region) - direction = 1 if side == "left" else -1 - assert slider.reversed == (side == "right") + direction = side.sign + assert slider.reversed == (side.sign < 0) for theme in ("ansi-dark", "textual-dark"): app.theme = theme ui.conversation.prompt.focus() @@ -36,7 +37,7 @@ async def test_resize_by_edge_drag_slider_and_keys_follows_the_side(workspace, s assert await pilot.hover(handle, offset=(0, 3)) await ui.step() assert handle._paint_state.foreground != normal and "hover" in handle.pseudo_classes - for selector in (f"#sidebar-{'right' if side == 'left' else 'left'}", "#sidebar-float"): + for selector in (f"#sidebar-{side.opposite().declared_name}", "#sidebar-float"): button = bar.query_one(selector, SidebarAction) normal = button._paint_state.background assert await pilot.hover(button) @@ -53,39 +54,40 @@ async def test_resize_by_edge_drag_slider_and_keys_follows_the_side(workspace, s await pilot.mouse_up(offset=(start + direction * 12, y)) await ui.step() assert bar.size.width == old_width + 12, (side, bar.size, old_width) - assert slider.value == app.sidebar_layout.get(bar.id).width_percent == 40 + assert slider.value == bar.placement.extent_percent == 40 assert not handle._dragging and app.mouse_captured is None # The physical arrow keys and slider endpoints mirror as well. slider.focus() - await ui.press("right" if side == "left" else "left") + await ui.press("right" if side.sign > 0 else "left") assert slider.value == 41 - await ui.press("left" if side == "left" else "right") + await ui.press("left" if side.sign > 0 else "right") assert slider.value == 40 - remap = app.settings.ui.keymap.changed(KeymapRow.of(CursorRightAction(), Standard), ("f6",)) + remapped_key = "ctrl+f6" # Plain f6 already belongs to TextArea's Select line. + remap = app.settings.ui.keymap.changed(KeymapRow.of(RightKey(), Standard), (remapped_key,)) app.saved_settings.set(SETTINGS.ui.keymap.bindings, remap.bindings) for control in (slider, handle): control.focus() await ui.press("right") assert slider.value == 40 - await ui.press("f6") + await ui.press(remapped_key) assert slider.value == 40 + direction await ui.press("left") assert slider.value == 40 - inward = slider.size.width - 1 if side == "left" else 0 + inward = slider.size.width - 1 if side.sign > 0 else 0 assert await pilot.mouse_down(slider, offset=(inward, 0)) assert not app.screen._selecting, "Width slider started a text selection" assert await pilot.mouse_up(slider, offset=(inward, 0)) await ui.step() assert slider.value == 50 and bar.size.width == 60, (side, slider.value, bar.region) - outward = 4 if side == "left" else slider.size.width - 1 + outward = 4 if side.sign > 0 else slider.size.width - 1 # Width changes on press, then the capture owner gets the release. # A final Click may land outside the track it has just resized. assert await pilot.mouse_down(slider, offset=(outward, 0)) await pilot.mouse_up(offset=(slider._drag_origin_x + outward, slider.region.y)) await ui.step() assert slider.value == 15 and bar.size.width == 18 - assert handle.region.x == (bar.region.right - 1 if side == "left" else bar.region.x) + assert handle.region.x == (bar.region.right - 1 if side.sign > 0 else bar.region.x) bar.toggle() await ui.step() assert not handle.display @@ -93,3 +95,22 @@ async def test_resize_by_edge_drag_slider_and_keys_follows_the_side(workspace, s bar.reveal() await ui.step() assert handle.display + + +@pytest.mark.parametrize("side", SidebarEdge.members_with(SidebarEdge)) +async def test_zero_motion_resize_keeps_requested_percentage_on_every_edge(workspace, side): + async with workspace.app(size=(51, 27)) as ui: + app, pilot = ui.textual + bar = app.workspace_chrome.channels + app.sidebar_layout.move(bar.placement_identity, side) + app.sidebar_layout.resize(bar.placement_identity, 40) + bar.reveal() + app.workspace_chrome.refresh_layout() + await ui.step() + handle = bar.query_one(SidebarResizeHandle) + before = bar.region + assert await pilot.click(handle) + await ui.step() + assert bar.placement.extent_percent == 40 + assert bar.region == before + assert not handle._dragging and app.mouse_captured is None diff --git a/tests/textual/test_sidebar_layout_policy.py b/tests/textual/test_sidebar_layout_policy.py new file mode 100644 index 000000000..6ea5c881e --- /dev/null +++ b/tests/textual/test_sidebar_layout_policy.py @@ -0,0 +1,106 @@ +"""Textual-facing: native sidebar geometry preserves peer order and conversation clearance.""" + +from itertools import product + +import pytest +from textual.geometry import Size + +from toad.sidebar_layout import ( + ChannelsSidebarPlacement, LeftSidebarEdge, RightSidebarEdge, + SidebarEdge, SidebarLayout, ThreadSidebarPlacement, +) + + +EDGES = SidebarEdge.members_with(SidebarEdge) + + +def test_moving_swapping_and_resizing_keep_both_sidebars_and_clamp_extent(): + layout = SidebarLayout() + assert layout.ordered() == (ChannelsSidebarPlacement, ThreadSidebarPlacement) + assert layout.move(ChannelsSidebarPlacement, RightSidebarEdge) + assert layout.get(ThreadSidebarPlacement).order == 0 + assert layout.get(ChannelsSidebarPlacement).order == 1 + assert layout.swap(ChannelsSidebarPlacement) + assert layout.get(ChannelsSidebarPlacement).order == 0 + assert layout.get(ThreadSidebarPlacement).order == 1 + assert layout.ordered() == (ThreadSidebarPlacement, ChannelsSidebarPlacement) + assert layout.move(ThreadSidebarPlacement, LeftSidebarEdge) + assert layout.ordered() == (ThreadSidebarPlacement, ChannelsSidebarPlacement) + assert layout.get(ThreadSidebarPlacement).order == 0 + assert layout.resize(ChannelsSidebarPlacement, 70) + assert layout.get(ChannelsSidebarPlacement).extent_percent == layout.maximum_percent + assert layout.resize(ChannelsSidebarPlacement, 1) + assert layout.get(ChannelsSidebarPlacement).extent_percent == layout.minimum_percent + assert layout.float_mode(ThreadSidebarPlacement) is True + assert layout.float_mode(ThreadSidebarPlacement) is False + assert set(layout.ordered()) == {ThreadSidebarPlacement, ChannelsSidebarPlacement} + + +@pytest.mark.parametrize("side", EDGES) +def test_two_sidebars_on_one_edge_swap_inward_then_cross(side): + pair = SidebarLayout() + pair.move(ChannelsSidebarPlacement, side) + pair.move(ThreadSidebarPlacement, side) + outer, inner = sorted(pair.placements, key=lambda key: pair.get(key).order) + inward = side.opposite() + before = dict(pair.placements) + assert not pair.shift(outer, side) and pair.placements == before + assert pair.shift(outer, inward) + assert pair.get(outer).side is pair.get(inner).side is side + assert pair.get(outer).order == 1 and pair.get(inner).order == 0 + assert pair.shift(outer, inward) and pair.get(outer).side is inward + assert not pair.shift(outer, inward) + + +@pytest.mark.parametrize( + "side, viewport, hide_channels, hide_thread", + list(product(EDGES, (Size(76, 40), Size(120, 70)), (False, True), (False, True))), +) +def test_float_keeps_order_and_swap_keeps_extents(side, viewport, hide_channels, hide_thread): + pair = SidebarLayout() + pair.move(ChannelsSidebarPlacement, side) + pair.move(ThreadSidebarPlacement, side) + collapsed = {ChannelsSidebarPlacement: hide_channels, ThreadSidebarPlacement: hide_thread} + before = pair.resolve(viewport, collapsed) + order = pair.ordered() + for name in order: + pair.float_mode(name) + assert pair.ordered() == order, "Float must not reorder sidebars" + assert pair.resolve(viewport, collapsed).bars == before.bars + physical = sorted(before.bars.values(), key=lambda bar: side.coordinate(bar.offset)) + assert side.coordinate(physical[0].offset) + side.extent(physical[0].size) == side.coordinate(physical[1].offset) + assert side.coordinate(physical[0].offset) >= 0 + assert side.coordinate(physical[1].offset) + side.extent(physical[1].size) <= side.extent(viewport) + for name, hidden in collapsed.items(): + if hidden: + assert side.extent(before.bars[name].size) == side.collapsed_extent + pair.swap(ChannelsSidebarPlacement) + after = pair.resolve(viewport, dict(reversed(tuple(collapsed.items())))) + assert {key: side.extent(bar.size) for key, bar in after.bars.items()} == { + key: side.extent(bar.size) for key, bar in before.bars.items() + }, "Swap must not change extents through order-dependent rounding" + + +@pytest.mark.parametrize("side, viewport", list(product(EDGES, (Size(51, 27), Size(36, 18))))) +def test_shrinking_preserves_each_axis_minimum_when_both_controls_fit(side, viewport): + layout = SidebarLayout() + layout.move(ChannelsSidebarPlacement, side) + layout.move(ThreadSidebarPlacement, side.opposite()) + layout.resize(ChannelsSidebarPlacement, layout.maximum_percent) + layout.resize(ThreadSidebarPlacement, layout.minimum_percent) + resolved = layout.resolve(viewport, {key: False for key in layout.placements}) + assert all(side.extent(bar.size) >= side.minimum for bar in resolved.bars.values()) + + +@pytest.mark.parametrize("channels_edge, thread_edge", list(product(EDGES, repeat=2))) +def test_every_edge_combination_stays_inside_native_viewport(channels_edge, thread_edge): + layout = SidebarLayout() + layout.move(ChannelsSidebarPlacement, channels_edge) + layout.move(ThreadSidebarPlacement, thread_edge) + viewport = Size(52, 70) + resolved = layout.resolve(viewport, {key: False for key in layout.placements}) + bars = tuple(resolved.bars.values()) + assert all(viewport.region.contains_region(bar) for bar in bars) + assert not bars[0].overlaps(bars[1]) + assert resolved.gutter.width < viewport.width + assert resolved.gutter.height < viewport.height diff --git a/tests/textual/test_sidebar_pointer_focus.py b/tests/textual/test_sidebar_pointer_focus.py index b8977e661..3c0db1418 100644 --- a/tests/textual/test_sidebar_pointer_focus.py +++ b/tests/textual/test_sidebar_pointer_focus.py @@ -1,9 +1,8 @@ -"""Textual-facing: pointer clicks on a sidebar toggle keep focus in the editor (no handle focus, no footer rebuild); keyboard toggles still enter the pane.""" - -from unittest.mock import PropertyMock, patch +"""Textual-facing: pointer toggles preserve editor/footer resources; keyboard toggles enter the pane.""" from textual.widgets import Footer -from toad.widgets.side_bar import SideBar, SideBarToggle +from toad.sidebar_layout import SidebarPlacement +from toad.widgets.side_bar import SideBarToggle async def test_pointer_toggles_keep_editing_focus_and_keyboard_toggle_enters_the_pane(workspace): @@ -11,35 +10,37 @@ async def test_pointer_toggles_keep_editing_focus_and_keyboard_toggle_enters_the app, pilot = ui.textual prompt = ui.conversation.prompt.prompt_text_area await ui.write_draft("retained draft") - for identity in ("channels-sidebar", "thread-sidebar"): - bar = app.selected_session.query_one(f"#{identity}", SideBar) if identity == "thread-sidebar" \ - else app.screen.query_one(f"#{identity}", SideBar) - toggle = bar.query_one(SideBarToggle) + bars = app.workspace_chrome.presented_sidebars() + assert {bar.placement_identity for bar in bars} == set(SidebarPlacement.members_with(SidebarPlacement)) + for bar in bars: + toggle = bar.toggle_control prompt.focus() await ui.step() footer = app.screen.query_one(Footer) - with patch.object(footer, "recompose", wraps=footer.recompose) as recompose: - for _ in range(4): - before = bar.collapsed - assert await pilot.click(toggle) - await ui.step() - assert bar.collapsed != before - assert app.focused is prompt, (identity, type(app.focused).__name__) - assert ui.draft() == "retained draft" - assert recompose.call_count == 0 + footer_children = tuple(footer.walk_children()) + for _ in range(4): + before = bar.collapsed + assert await pilot.click(toggle) + await ui.step() + assert bar.collapsed != before + assert app.focused is prompt, (bar.placement_identity, type(app.focused).__name__) + assert ui.draft() == "retained draft" + assert app.screen.query_one(Footer) is footer + assert tuple(footer.walk_children()) == footer_children # Focus remains available for users reaching the handle by Tab. if not bar.collapsed: bar.toggle() await ui.step() toggle.focus(scroll_visible=False) - await ui.step() + await ui.until(lambda: app.focused is toggle, why="native focus on the keyboard handle") await ui.press("enter") assert not bar.collapsed title = bar.query_one("SideBarCollapsible CollapsibleTitle") + await ui.until(lambda: app.focused is title, why="the queued native focus enters the title") assert app.focused is title - # Closing while focus is inside the pane returns it to input. - with patch.object(type(app.screen), "focus_chain", new_callable=PropertyMock, - side_effect=AssertionError("Hidden pane rebuilt the full focus chain")): - assert await pilot.click(toggle) - await ui.step() + # Exercise native focus selection, not an overridden property that + # deliberately sends Textual down its foreign-chain path. + assert await pilot.click(toggle) + await ui.step() assert bar.collapsed and app.focused is prompt + assert ui.draft() == "retained draft" diff --git a/tests/textual/test_sidebar_projection.py b/tests/textual/test_sidebar_projection.py index 158c730cf..88efa66ac 100644 --- a/tests/textual/test_sidebar_projection.py +++ b/tests/textual/test_sidebar_projection.py @@ -24,7 +24,7 @@ def _display(self, screen, renderable): sidebar = screen.query_one_optional("#channels-sidebar", SideBar) if sidebar is not None: self.frames.append((self.selected_mode, sidebar.collapsed, sidebar.size.width, - sidebar.panels_viewport.display)) + sidebar.content_stack.display)) return result diff --git a/tests/textual/test_sidebar_retirement.py b/tests/textual/test_sidebar_retirement.py index bf63a99da..f4b8e11f0 100644 --- a/tests/textual/test_sidebar_retirement.py +++ b/tests/textual/test_sidebar_retirement.py @@ -4,6 +4,7 @@ import gc from weakref import ref +from agent_comms.repo import Checkout from agent_comms.relationships import ( AddRelationshipEdit, CollaboratingRelationshipGroup, @@ -132,7 +133,7 @@ async def exercise(ui, screen, root): assert state.selected == chosen saved = [ref(panel.widget) for panel in sidebar.panels] tree_ref = ref(tree) - await sidebar.retire_presentation() + await sidebar.retire_presentation(screen) assert all( widget() is panel.widget for widget, panel in zip(saved, sidebar.panels, strict=True) @@ -235,7 +236,7 @@ async def test_panels_show_the_current_project_after_the_source_project_changes_ replacement = workspace.project.parent / "replacement-project" replacement.mkdir() (replacement / "new-source.txt").write_text("Current filesystem") - screen.project_path = replacement + screen.project_path = Checkout(replacement) await sidebar.prepare_presentation() await sidebar.wait_content_ready() tree = await prepare_project(ui, sidebar) diff --git a/tests/textual/test_wire_message_lines.py b/tests/textual/test_wire_message_lines.py index 7fb066138..d0950865f 100644 --- a/tests/textual/test_wire_message_lines.py +++ b/tests/textual/test_wire_message_lines.py @@ -1,15 +1,19 @@ """Textual-facing: a wire message's lines wrap at the full width as Rich draws them, and its names stay clickable at every width.""" +from dataclasses import replace + +from rich.style import Style + from agent_comms.message_page import MessagePageRequest from agent_comms.display_references import ProjectReferences from agent_comms.messages import InfoKind, Message from textual.app import App -from toad.line_blocks import OpenTarget +from toad.line_blocks import Divider, OpenTarget, Span, UserRole from toad.markdown_links import LinkContext from toad.notification_status import CheckedNotifications from toad.textual_colours import ThemedApp -from toad.widgets.transcript_lines import LinePreparation, PlainLines, RichLineRenderer, TranscriptLinesRenderTask +from toad.widgets.transcript_lines import LinePreparation, NoRows, PlainLines, RichLineRenderer, TranscriptLinesRenderTask from toad.message_rows import IrcMessageStyle, MarkdownMessageStyle from toad.widgets.transcript_fragments import MessageShown from toad.wire_history import WireFragment @@ -60,6 +64,30 @@ def test_rows_fit_the_width_wrap_on_words_and_keep_drawn_names_clickable_at_ever assert actual == expected, (width, text, actual, expected) +def test_divider_acquisition_reuses_equal_fades_rewraps_and_repaints_changed_styles() -> None: + current_theme = theme() + link = OpenTarget("receiver-with-long-name") + block = Divider((Span("Sender 界 → receiver-with-long-name", UserRole, link),), UserRole) + acquired = NoRows().plain(36, RichLineRenderer(current_theme, 36), (block,)) + first = acquired.lines + assert tuple(first) == RichLineRenderer(current_theme, 36).rows((block,)) + assert link.click in clicks(first) + + equal_fade = replace(current_theme, styles=dict(current_theme.styles)) + assert acquired.plain(36, RichLineRenderer(equal_fade, 36), (block,)) is acquired + assert acquired.lines is first + assert acquired.plain(80, RichLineRenderer(equal_fade, 80), (block,)) is acquired + assert acquired.lines is not first + assert tuple(acquired.lines) == RichLineRenderer(equal_fade, 80).rows((block,)) + + changed_fade = replace(current_theme, styles={**current_theme.styles, UserRole: Style(color="red")}) + changed = acquired.plain(80, RichLineRenderer(changed_fade, 80), (block,)) + assert changed is not acquired + assert tuple(changed.lines) == RichLineRenderer(changed_fade, 80).rows((block,)) + assert tuple(changed.lines) != tuple(acquired.lines) + assert link.click in clicks(changed.lines) + + def test_opening_notification_details_draws_more_rows_than_the_collapsed_message(): status = CheckedNotifications.from_notifications(()) for style in (IrcMessageStyle(), MarkdownMessageStyle()):