diff --git a/src/toad/frame_presentation.py b/src/toad/frame_presentation.py index 378286c33..a8f17c4db 100644 --- a/src/toad/frame_presentation.py +++ b/src/toad/frame_presentation.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: from toad.screens.workspace import WorkspaceFrame - from toad.widgets.transcript_history import TranscriptPageView + from toad.widgets.transcript_history import LineHistory, TranscriptPageView class FrameState(DeclaredFamily, affix="Frame"): @@ -201,30 +201,43 @@ async def wait(self) -> bool: class EffectPhase: + """A running effect; its subclasses are the effect not yet started and finished.""" + + # Whether the effect is moving now. + in_motion: ClassVar[bool] = True + def paint(self, owner: AnimationEffects[Mix], effect: Effect[Mix], strip: Strip): return owner.paint_effects(strip, owner.effect_frame(effect)) def keeps_arrivals[Item](self, arrivals: Iterable[Item]) -> Iterable[Item]: return arrivals - def on_new_arrival(self, owner: AnimationEffects[Mix], effect: Effect[Mix], pages: deque[TranscriptPageView]): - pass + def on_new_arrival(self, owner: LineHistory, effect: Effect[Mix], pages: deque[TranscriptPageView]): + """Arrivals during a running burst join its fade and reveal.""" class EffectUnstarted(EffectPhase): + in_motion = False + def paint(self, owner: AnimationEffects[Mix], effect: Effect[Mix], strip: Strip): owner.run_effect(effect) return super().paint(owner, effect, strip) + def on_new_arrival(self, owner: LineHistory, effect: Effect[Mix], pages: deque[TranscriptPageView]): + owner.arrivals_began() + class EffectFinished(EffectPhase): + in_motion = False + def keeps_arrivals[Item](self, arrivals: Iterable[Item]) -> Iterable[Item]: return () - def on_new_arrival(self, owner: AnimationEffects[Mix], effect: Effect[Mix], pages: deque[TranscriptPageView]): + def on_new_arrival(self, owner: LineHistory, effect: Effect[Mix], pages: deque[TranscriptPageView]): for page in pages: page.arrivals.clear() owner.stop_effect(effect) + owner.arrivals_began() EFFECT_UNSTARTED, EFFECT_RUNNING, EFFECT_FINISHED = EffectUnstarted(), EffectPhase(), EffectFinished() @@ -264,7 +277,7 @@ def stop_effect(self, *effects: Effect) -> None: if animation := self.effects.pop(effect, None): animation.stop() - def effect_frame(self, effect: Effect[FrameT]) -> FrameT: + def effect_frame[Shown: Frame](self, effect: Effect[Shown]) -> Shown: animation = self.effects.get(effect) return effect.final if animation is None else animation.frame() diff --git a/src/toad/keymap.py b/src/toad/keymap.py index 642acb12f..d6deb5b75 100644 --- a/src/toad/keymap.py +++ b/src/toad/keymap.py @@ -75,13 +75,6 @@ def of(cls, invocation: KeyInvocation, mode: type[KeyMode]) -> KeymapRow: def binding_id(self) -> str: return KeyInvocation.in_mode(self.declaration, self.mode().binding_mode) - @property - def defaults(self) -> tuple[str, ...]: - return KeymapCatalog.capture((self.mode,)).defaults(self) - - def effective(self, keymap: Keymap) -> tuple[str, ...]: - return keymap.keys_for(self, self.defaults) - @dataclass(frozen=True) class KeyRemap(KeymapRow): diff --git a/src/toad/large_text.py b/src/toad/large_text.py new file mode 100644 index 000000000..e59729a51 --- /dev/null +++ b/src/toad/large_text.py @@ -0,0 +1,76 @@ +"""Text drawn three rows tall in box-drawing glyphs, for a name read at a glance. + +Textual's own 3x3 font (`textual.renderables.digits`) owns the digits, `A`-`F` +and a few signs; this module adds the letters and signs it lacks, in the same +light style. Lower case is drawn as upper case. A character neither table +has is drawn at normal size on the middle row. +""" + +from __future__ import annotations + +from typing import ClassVar + +from rich.cells import cell_len +from textual.renderables.digits import DIGITS, DIGITS3X3 + +Glyph = tuple[str, str, str] + + +def _textual_glyphs() -> dict[str, Glyph]: + """Textual's glyphs, each padded to its three-cell width.""" + return { + character: tuple(row.ljust(3) for row in DIGITS3X3[index * 3 : index * 3 + 3]) # type: ignore[misc] + for index, character in enumerate(DIGITS) + } + + +class LargeText: + """A line of text three rows tall, with a column between glyphs.""" + + HEIGHT: ClassVar[int] = 3 + GLYPHS: ClassVar[dict[str, Glyph]] = { + **_textual_glyphs(), + "G": ("╭─╴", "│╶╮", "╰─╯"), + "H": ("╷ ╷", "├─┤", "╵ ╵"), + "I": ("╶┬╴", " │ ", "╶┴╴"), + "J": (" ╷", " │", "╰─╯"), + "K": ("╷╭╯", "├┤ ", "╵╰╮"), + "L": ("╷ ", "│ ", "╰─╴"), + "M": ("╭┬╮", "│││", "╵╵╵"), + "N": ("╭─╮", "│ │", "╵ ╵"), + "O": ("╭─╮", "│ │", "╰─╯"), + "P": ("┌─╮", "├─╯", "╵ "), + "Q": ("╭─╮", "│ │", "╰─╳"), + "R": ("┌─╮", "├┬╯", "╵╰╴"), + "S": ("╭─╴", "╰─╮", "╶─╯"), + "T": ("╶┬╴", " │ ", " ╵ "), + "U": ("╷ ╷", "│ │", "╰─╯"), + "V": ("╷ ╷", "╲ ╱", " ╹ "), + "W": ("╷ ╷", "│││", "╰┴╯"), + "X": ("╲ ╱", " ╳ ", "╱ ╲"), + "Y": ("╷ ╷", "╰┬╯", " ╵ "), + "Z": ("╶─╮", " ╱ ", "╰─╴"), + ".": (" ", " ", "•"), + "·": (" ", "•", " "), + "_": (" ", " ", "───"), + "/": (" ╱", " ╱ ", "╱ "), + "#": ("╪╪", "╪╪", " "), + } + + @classmethod + def glyph(cls, character: str) -> Glyph: + return cls.GLYPHS.get( + character.upper(), + (" " * cell_len(character), character, " " * cell_len(character)), + ) + + @classmethod + def rows(cls, text: str) -> tuple[str, ...]: + glyphs = [cls.glyph(character) for character in text] + return tuple( + " ".join(glyph[row] for glyph in glyphs) for row in range(cls.HEIGHT) + ) + + @classmethod + def width(cls, text: str) -> int: + return cell_len(cls.rows(text)[0]) diff --git a/src/toad/navigation_target.py b/src/toad/navigation_target.py index 2599918f0..1a4eb6769 100644 --- a/src/toad/navigation_target.py +++ b/src/toad/navigation_target.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: from agent_comms.display_references import CurrentDisplayReferences - from toad.widgets.comms_sidebar import CommsRow + from toad.widgets.comms_sidebar import CommsRow, ListedRow from toad.widgets.sidebar_tree import SidebarGroup @dataclass(frozen=True) @@ -55,7 +55,7 @@ def matches(self, target: NavigationTarget) -> bool: """Different opening behaviors may display the same sidebar address.""" return self.name == target.name - def rows_in(self, group: SidebarGroup[CommsRow]) -> tuple[CommsRow, ...]: + def rows_in(self, group: SidebarGroup[ListedRow]) -> tuple[CommsRow, ...]: return group.rows_for_target(self.name) @abstractmethod @@ -73,7 +73,7 @@ def selected(self, owner: NavigationOwner) -> None: def matches(self, target: NavigationTarget) -> bool: return False - def rows_in(self, group: SidebarGroup[CommsRow]) -> tuple[CommsRow, ...]: + def rows_in(self, group: SidebarGroup[ListedRow]) -> tuple[CommsRow, ...]: return () async def open(self, context: NavigationContext) -> str: diff --git a/src/toad/screens/action_modal.py b/src/toad/screens/action_modal.py index a2e3591ee..ee6fa09b2 100644 --- a/src/toad/screens/action_modal.py +++ b/src/toad/screens/action_modal.py @@ -10,7 +10,6 @@ from textual import widgets from textual.widget import Widget -from toad.app import ToadApp from toad.widgets.command_pane import CommandPane from toad.terminal_execution import Command, TerminalExecution from toad.agent_schema import AgentDefinition @@ -18,11 +17,12 @@ from toad.core.source_events import CommandComplete from toad.core_event_carrier import CoreEventReceiver from metaclass_registry.mro_dispatch import handles +from toad.app_node import ToadNode UV_INSTALL = "curl -LsSf https://astral.sh/uv/install.sh | sh" -class ActionModal(CoreEventReceiver, DismissibleScreen[int]): +class ActionModal(CoreEventReceiver, DismissibleScreen[int], ToadNode): """Executes an action command.""" CSS_PATH = "store.tcss" @@ -30,7 +30,6 @@ class ActionModal(CoreEventReceiver, DismissibleScreen[int]): command_pane = getters.query_one(CommandPane) ok_button = getters.query_one("#ok", widgets.Button) - app = getters.app(ToadApp) def __init__( self, diff --git a/src/toad/screens/agent_modal.py b/src/toad/screens/agent_modal.py index 643fc6ce7..16ed32ef5 100644 --- a/src/toad/screens/agent_modal.py +++ b/src/toad/screens/agent_modal.py @@ -16,9 +16,9 @@ from toad.agent_schema import AgentDefinition from toad.catalog_actions import CatalogCommandAction, LaunchAction from toad.core.session_requests import LaunchAgent -from toad.app import ToadApp from toad.preferences import SETTINGS from toad.platform import host +from toad.app_node import ToadNode class DescriptionContainer(containers.VerticalScroll): @@ -29,13 +29,13 @@ def allow_focus(self) -> bool: class AgentModal( PriorityActions, HiddenActions, Launches, DismissibleScreen[LaunchAgent | None], + ToadNode, scopes={PriorityActions: (widget_actions.LaunchAction,), HiddenActions: (widget_actions.DismissAction,)}, ): AUTO_FOCUS = "Select#action-select" - app = getters.app(ToadApp) action_select = getters.query_one("#action-select", widgets.Select) launcher_checkbox = getters.query_one("#launcher-checkbox", widgets.Checkbox) diff --git a/src/toad/screens/comms.py b/src/toad/screens/comms.py index 28d269ebd..4a5ba97c4 100644 --- a/src/toad/screens/comms.py +++ b/src/toad/screens/comms.py @@ -10,7 +10,7 @@ from typing import Literal from agent_comms.repo import Checkout -from textual import containers, getters, on +from textual import containers, on from textual.app import ComposeResult from textual.document._editing import EditingMode from toad.session_actions import ReturnToViewAction, CloseViewAction @@ -21,7 +21,6 @@ from toad import messages from toad.conversation_kind import ConversationKind -from toad.app import ToadApp from toad.widgets.comms_chat import CommsChatView from toad.core.input_events import SelectHistoricalIdentity from toad.core.input_events import SelectTarget @@ -32,6 +31,7 @@ from toad.widgets.session_tabs import SessionsTabs from toad.widgets.side_bar import SideBar, ThreadSidebar, TabHistoryControls from toad.widgets.thread_comms import RelationshipSort, ThreadCommsSidebar +from toad.app_node import ToadNode class ViewAction(WidgetAction, affix="Action"): @@ -79,7 +79,7 @@ def apply(self, context: CommsScreen) -> None: chat.prepare_prompt() -class CommsScreen(TogglesIrc, CyclesSessions, CheckoutRoot, SessionView, can_focus=False, +class CommsScreen(TogglesIrc, CyclesSessions, CheckoutRoot, SessionView, ToadNode, can_focus=False, actions=(ViewAction, ReturnToViewAction, CloseViewAction)): """A channel or DM represented as a native concurrent Toad session.""" @@ -108,7 +108,6 @@ def __init__( self._content_error: BaseException | None = None self._content_loaded = False - app = getters.app(ToadApp) @property def coordination_root(self) -> str | None: diff --git a/src/toad/screens/keymap.py b/src/toad/screens/keymap.py index 4489ee037..1bc27b94a 100644 --- a/src/toad/screens/keymap.py +++ b/src/toad/screens/keymap.py @@ -40,8 +40,9 @@ def __init__(self, focus_mode: type[EditingMode], focused: frozenset[str]) -> No self.focus_mode, self.focused_ids = focus_mode, focused self.mode_choices = FamilyChoices(KeyMode) self.modes = self.mode_choices.choices() + self.catalog = KeymapCatalog.capture(self.modes) self.declarations = tuple(reference for reference, invocation - in KeymapCatalog.capture(self.modes).invocations.items() + in self.catalog.invocations.items() if not invocation.key_system()) self.visible_declarations = self.declarations @@ -68,18 +69,17 @@ def populate(self, term: str) -> None: table = self.query_one(DataTable) table.clear() focused_only = self.query_one("#keymap-focused", Checkbox).value - catalog = KeymapCatalog.capture(self.modes) self.visible_declarations = tuple(declaration for declaration in self.declarations if (not focused_only or KeyInvocation.in_mode(declaration, self.focus_mode) in self.focused_ids) - and term.casefold() in (catalog.require(declaration).key_title() + " " - + catalog.require(declaration).key_summary()).casefold()) + and term.casefold() in (self.catalog.require(declaration).key_title() + " " + + self.catalog.require(declaration).key_summary()).casefold()) keymap = self.app.settings.ui.keymap conflicts = {row.binding_id for mode in self.modes for conflict in keymap.conflicts(mode) for row in (conflict.first, conflict.second)} for declaration in self.visible_declarations: - title = Text(catalog.require(declaration).key_title(), + title = Text(self.catalog.require(declaration).key_title(), style="" if KeyInvocation.in_mode(declaration, self.focus_mode) in self.focused_ids else "dim") - table.add_row(title, *(Text(", ".join(catalog.effective(row, keymap)) or "—", + table.add_row(title, *(Text(", ".join(self.catalog.effective(row, keymap)) or "—", style="bold red" if row.binding_id in conflicts else "") for mode in self.modes for row in (KeymapRow(declaration, mode),))) @@ -96,16 +96,16 @@ def selected_rows(self) -> tuple[KeymapRow, ...]: row, column = self.query_one(DataTable).cursor_coordinate if column and row < len(self.visible_declarations): selected = KeymapRow(self.visible_declarations[row], self.modes[column - 1]) - return (selected,) if selected.defaults else () + return (selected,) if self.catalog.defaults(selected) else () return () @on(DataTable.CellSelected) def picked(self, message: DataTable.CellSelected) -> None: for row in self.selected_rows(): self.query_one("#keymap-keys", Input).value = ", ".join( - row.effective(self.app.settings.ui.keymap)) + self.catalog.effective(row, self.app.settings.ui.keymap)) self.query_one("#keymap-summary", Static).update( - KeymapCatalog.capture((row.mode,)).require(row.declaration).key_summary()) + self.catalog.require(row.declaration).key_summary()) conflicts = tuple(conflict for conflict in self.app.settings.ui.keymap.conflicts(row.mode) if row in (conflict.first, conflict.second)) self.query_one("#keymap-status", Static).update("; ".join(map(str, conflicts))) diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index 46cec3a20..c7bbfb287 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -32,7 +32,6 @@ ) from toad.acp.agent_session import SessionDisposition -from toad.app import ToadApp from toad.core import events as core_events from toad.core import input_events, session_requests from toad.core.events import SessionChangedEvent @@ -60,6 +59,7 @@ from toad.widgets.session_thread_sidebar import SessionThreadSidebar from toad.widgets.side_bar import SideBar from toad.widgets.splash_text import ThreadLoading +from toad.app_node import ToadNode class SessionCommands(Provider): @@ -210,7 +210,7 @@ async def attached(self, screen: "MainScreen") -> None: await screen.prepare_presentation() -class MainScreen(NativeMethodKeys, HiddenActions, TogglesIrc, CyclesSessions, ViewSessionOrigin, SessionView, +class MainScreen(NativeMethodKeys, HiddenActions, TogglesIrc, CyclesSessions, ViewSessionOrigin, SessionView, ToadNode, scopes={HiddenActions: (SessionNavigationAction,)}, can_focus=False): footer_compact = True @@ -234,7 +234,6 @@ def side_bar(self): project_path = var[Checkout](Required()) - app = getters.app(ToadApp) def __init__( self, diff --git a/src/toad/screens/settings.py b/src/toad/screens/settings.py index 9c8aa59c7..215a5f1e1 100644 --- a/src/toad/screens/settings.py +++ b/src/toad/screens/settings.py @@ -4,7 +4,7 @@ from collections.abc import Iterable -from textual import containers, events, getters +from textual import containers, events from textual.app import ComposeResult from textual.binding import ModeBinding from textual.document._editing import EditingMode @@ -13,7 +13,7 @@ from textual.geometry import Spacing from textual.color import Color -from toad.app import ScreenWidth, ToadApp +from toad.app import ScreenWidth from toad.forms.editors import SelectEditor from toad.forms.form_view import FormView from toad.forms.presentation import FormInput, FormModal @@ -21,13 +21,13 @@ from toad.widget_actions import PriorityActions from toad.widget_view import WidgetView from toad.setting_choices import SchemeName +from toad.app_node import ToadNode -class SchemeEditor(SelectEditor): +class SchemeEditor(SelectEditor, ToadNode): """A colour scheme's name, picked from the schemes the running app has.""" shows = (SchemeName,) - app = getters.app(ToadApp) def options(self) -> Iterable[tuple[str, object]]: return ((name, name) for name in self.app.colours.schemes) @@ -52,10 +52,9 @@ class SettingsSearch(WidgetView, containers.VerticalGroup): padding = Spacing(1, 0, 1, 0) -class SettingsScreen(PriorityActions, FormModal[None], DismissibleScreen[None]): +class SettingsScreen(PriorityActions, FormModal[None], DismissibleScreen[None], ToadNode): align_horizontal = "right" align_vertical = "top" - app = getters.app(ToadApp) def __init__(self) -> None: super().__init__() diff --git a/src/toad/screens/store.py b/src/toad/screens/store.py index 24cb1a036..eddbebba1 100644 --- a/src/toad/screens/store.py +++ b/src/toad/screens/store.py @@ -38,6 +38,7 @@ from toad.widgets.grid_select import GridSelect from toad.agent_schema import AgentDefinition from toad.agents import AgentReadError, read_agents +from toad.app_node import ToadNode QR = """\ @@ -145,7 +146,7 @@ def compose(self) -> ComposeResult: yield widgets.Static(agent.description, id="description") -class LauncherGridSelect(Launches, CoreEventReceiver, GridSelect): +class LauncherGridSelect(Launches, CoreEventReceiver, GridSelect, ToadNode): HELP = """\ ## Launcher @@ -160,7 +161,6 @@ class LauncherGridSelect(Launches, CoreEventReceiver, GridSelect): """ BINDING_GROUP_TITLE = "Launcher" - app = getters.app(ToadApp) def action_details(self) -> None: if self.highlighted is None: @@ -188,8 +188,7 @@ async def launch(self) -> None: self.app.session_navigation.events.publish(session_requests.LaunchAgent(child.agent.identity)) -class Launcher(containers.VerticalGroup): - app = getters.app(ToadApp) +class Launcher(containers.VerticalGroup, ToadNode): grid_select = getters.query_one("#launcher-grid-select", LauncherGridSelect) DIGITS = "123456789ABCDEF" @@ -332,7 +331,7 @@ async def apply(self, store: StoreScreen) -> None: await launcher.launch_highlighted() -class StoreScreen(ProfileKeys, CyclesFocus, CoreEventReceiver, Screen, actions=(LaunchSlotAction,)): +class StoreScreen(ProfileKeys, CyclesFocus, CoreEventReceiver, Screen, ToadNode, actions=(LaunchSlotAction,)): BINDING_GROUP_TITLE = "Screen" CSS_PATH = "store.tcss" @@ -340,7 +339,6 @@ class StoreScreen(ProfileKeys, CyclesFocus, CoreEventReceiver, Screen, actions=( launcher = getters.query_one("#launcher", Launcher) container = getters.query_one("#container", Container) - app = getters.app(ToadApp) def __init__( self, name: str | None = None, id: str | None = None, classes: str | None = None diff --git a/src/toad/screens/welcome.py b/src/toad/screens/welcome.py index 511fe4436..621beb2af 100644 --- a/src/toad/screens/welcome.py +++ b/src/toad/screens/welcome.py @@ -7,7 +7,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, cast from metaclass_registry.mro_dispatch import handles from agent_comms.channel_targets import NoChannel @@ -30,7 +29,7 @@ from toad.session_scope import ProjectSessionOrigin from toad.tab_identity.history_origin import WelcomeHistoryOrigin from toad.thread_identity import ThreadIdentity -from toad.widgets.comms_sidebar import CommsRow, NewSessionButton, ThreadStatusRow +from toad.widgets.comms_sidebar import ChannelRow, CommsRow, ListedRow, NewSessionButton, ThreadStatusRow from toad.application_actions import NewThreadAction from toad.widgets.sidebar_tree import ( RowReconciliation, @@ -40,10 +39,7 @@ TargetTree, ) from toad.widgets.splash_text import SplashText - -if TYPE_CHECKING: - from toad.app import ToadApp - +from toad.app_node import ToadNode class WelcomeTitle(SplashText): """The app's name, swept into the theme's colours each time the view shows.""" @@ -78,7 +74,7 @@ def focus_changed(self, focused: bool) -> None: self.refresh() -class WelcomeSection(SidebarGroup[CommsRow]): +class WelcomeSection(SidebarGroup[ListedRow]): """Some of the sidebar model's rows under a title.""" TITLE = "" @@ -118,7 +114,7 @@ def keys(self, model): return tuple(model.channels.rows) def new_row(self, key): - return CommsRow(channel_target(key), key) + return ChannelRow(channel_target(key), key) def show_member(self, key, row): channel = self.query_ancestor(WelcomeEntries).model_state.shown.channels.rows[ @@ -194,7 +190,7 @@ async def present_selected(self, event: SessionSelected) -> None: self.present() -class WelcomeScreen(TogglesIrc, ProjectSessionOrigin, SessionView, can_focus=False): +class WelcomeScreen(TogglesIrc, ProjectSessionOrigin, SessionView, ToadNode, can_focus=False): AUTO_FOCUS = "WelcomeEntries NewSessionButton" BINDINGS = [ @@ -218,7 +214,7 @@ def history_origin(self) -> WelcomeHistoryOrigin: def __init__(self) -> None: super().__init__() - app = cast("ToadApp", self.app) + app = self.app self.project_path = app.project_dir def compose(self) -> ComposeResult: @@ -247,7 +243,7 @@ def read_history_identity(self, project: Checkout) -> ThreadIdentity: ) async def prepare_history_origin(self) -> None: - app = cast("ToadApp", self.app) + app = self.app project = self.project_path with self.changing_identity(): self._identity = await app.preparation.run_thread(self.read_history_identity, project) diff --git a/src/toad/screens/workspace.py b/src/toad/screens/workspace.py index 6ebd51269..7e9eec6e1 100644 --- a/src/toad/screens/workspace.py +++ b/src/toad/screens/workspace.py @@ -124,6 +124,7 @@ def compose(self): yield chrome.channels yield Container(id="workspace-session-host") yield chrome.footer + yield chrome.switch_toast def binding_scopes(self): return tuple( diff --git a/src/toad/textual_colours.py b/src/toad/textual_colours.py index 76e601bf4..be9083e62 100644 --- a/src/toad/textual_colours.py +++ b/src/toad/textual_colours.py @@ -55,8 +55,11 @@ ColourScheme, ForegroundColour, LayerColour, + PrimaryColour, + SidebarToneColour, TextStyle, ThemeColour, + ToneColour, ) from ui_theme.splash import TextEffect from ui_theme.wal import WalFile @@ -84,6 +87,35 @@ def on_any_event(self, event: FileSystemEvent) -> None: self.colours.app.call_from_thread(self.colours.read_wal) +class HueToneColour(ToneColour): + """A sidebar surface stepped toward a theme hue instead of toward the foreground. + + Rows on it read as a different family from rows on the neutral sidebar + tones. Without RGB (a native ANSI palette) it is the background, like + every derived tone. + """ + + hue: ClassVar[type[ThemeColour]] = PrimaryColour + + @classmethod + def resolve_rgb(cls, read: Callable[[type[ThemeColour]], Rgb]) -> Rgb: + return SidebarToneColour.resolve_rgb(read).blend(read(cls.hue), cls.amount) + + +class ChannelRowToneColour(HueToneColour): + """A channel row of the channel list.""" + + name = "tone-channel-row" + amount = 0.13 + + +class ChannelAlternateRowToneColour(HueToneColour): + """Every other channel row, one more step toward the hue.""" + + name = "tone-channel-row-alternate" + amount = 0.2 + + class ClockCadence: """The animation clock's cadence; this base is the stopped one, with no tick due.""" diff --git a/src/toad/tone_paint.py b/src/toad/tone_paint.py index 884e87154..dcc6f4196 100644 --- a/src/toad/tone_paint.py +++ b/src/toad/tone_paint.py @@ -6,7 +6,8 @@ from textual._paint_state import PaintLayer from textual.color import TRANSPARENT, Color -from ui_theme.scheme import BackgroundColour, ThemeColour +from textual.widget import Widget +from ui_theme.scheme import AlternateRowToneColour, BackgroundColour, SidebarToneColour, ThemeColour if TYPE_CHECKING: from textual.css.styles import StylesBase @@ -68,3 +69,26 @@ class UntintedTonePaint(TonePaint): """Keep the parent's area tone without an additional focus tint.""" background_tint = TRANSPARENT + + +class AlternatingTone(TonePaint, Widget): + """A list row on one of two tones by its position: rows separate without separator lines. + + A row family declares its own pair; the list that orders rows places them. + """ + + row_tones: ClassVar[tuple[type[ThemeColour], type[ThemeColour]]] = ( + SidebarToneColour, AlternateRowToneColour) + _alternate = False + + # TonePaint declares a class tone; a listed row computes its own from its place. + @property + def tone(self) -> type[ThemeColour]: # type: ignore[override] + return self.row_tones[self._alternate] + + def place(self, index: int) -> None: + """The row is now at `index` of its list.""" + alternate = bool(index % 2) + if alternate != self._alternate: + self._alternate = alternate + self.refresh_paint() diff --git a/src/toad/widgets/comms_sidebar.py b/src/toad/widgets/comms_sidebar.py index bbb885ca8..bdcda4ff5 100644 --- a/src/toad/widgets/comms_sidebar.py +++ b/src/toad/widgets/comms_sidebar.py @@ -17,7 +17,7 @@ from agent_comms.native_arguments import ConfiguredBinary, InheritedArguments from functools import partial from pathlib import Path -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING from agent_comms.ui_model.sidebar import ChannelRowModel from toad.widget_actions import ActivationKeys @@ -27,8 +27,7 @@ from textual.reactive import reactive from ui_theme.effects import NEW_MESSAGE from textual.widgets import Static -from textual._activation import PrimaryActivation -from ui_theme.scheme import AlternateRowToneColour +from textual._activation import ContextActivation, PrimaryActivation from metaclass_registry.mro_dispatch import handles @@ -44,12 +43,15 @@ from toad.widget_actions import CursorAction, HiddenActions, OpenSelectedAction, OpensSelected from toad.widgets.session_sort import ChannelListSort, SessionSort from toad.widgets.sidebar_tree import SidebarChange, SidebarGroup, SidebarModelView, SearchFollower, TargetTree, TargetTreeMember, UnpaintedChannel +from toad.tone_paint import AlternatingTone +from toad.textual_colours import ChannelAlternateRowToneColour, ChannelRowToneColour +from toad.widgets.sidebar_viewport import SidebarHeader from toad.widgets.side_bar import SideBar, SideBarCollapsible, SidebarPanelWidget, SidebarVisibilityObserver from toad.navigation_target import NavigationTarget +from toad.app_node import ToadNode if TYPE_CHECKING: from agent_comms.comms import Comms - from toad.app import ToadApp @@ -68,9 +70,21 @@ def primary_activation(self): return self.query_ancestor(ChannelGroup).row.open_selected +class ChannelHeader(AlternatingTone, SidebarHeader): + """A channel's header row, across its disclosure, name and controls. + + Channels alternate on their own hue-stepped pair, so the channel list and + the thread rows under it read as two families. + """ + + row_tones = (ChannelRowToneColour, ChannelAlternateRowToneColour) + + class ChannelGroup(SidebarGroup["ThreadRow"]): """A view of one channel row of the sidebar model and the thread rows it lists.""" navigation_header = True + header_type = ChannelHeader + header: ChannelHeader def __init__(self, row: CommsRow, *, expanded: bool): self.channel: ChannelRowModel = UnpaintedChannel(row.target_name) self.sort_control = SessionSort(channel=row.target_name) @@ -150,20 +164,12 @@ def repaint_members(self, names: frozenset[str]) -> None: class CommsRow( - TargetTreeMember, OpensSelected, PrimaryActivation, CoreEventReceiver, HoverSelection, ViewLabel, + TargetTreeMember, OpensSelected, PrimaryActivation, ContextActivation, CoreEventReceiver, HoverSelection, ViewLabel, scopes={HiddenActions: (CursorAction, OpenSelectedAction)}, ): """One interactive row: a channel or a thread.""" text_fades = (NEW_MESSAGE,) - alternate: reactive[bool] = reactive(False, repaint=False) - - def watch_alternate(self) -> None: - self.refresh_paint() - - @property - def tone(self): - return AlternateRowToneColour if self.alternate else None @property def target_name(self) -> str: @@ -172,6 +178,8 @@ def target_name(self) -> str: def show_menu(self, sidebar: TargetTree, offset: Offset) -> None: sidebar.show_row_menu(self, offset) + def context_activation(self, offset: Offset) -> None: + self.show_menu(self.navigation_tree, offset) can_focus = True @@ -220,7 +228,7 @@ def open_selected(self) -> None: def on_click(self, event) -> None: if event.button == 3: event.stop() - self.show_menu(self.navigation_tree, event.screen_offset) + self.context_activation(event.screen_offset) return if event.ctrl or event.shift: event.stop() @@ -229,7 +237,17 @@ def on_click(self, event) -> None: self.open_selected() -class ThreadStatusRow(CommsRow): +class ListedRow(CommsRow, AlternatingTone): + """A row its list orders: it alternates on its family's tone pair.""" + + +class ChannelRow(ListedRow): + """A channel listed as a row (not a group header): channels alternate on their hue pair.""" + + row_tones = (ChannelRowToneColour, ChannelAlternateRowToneColour) + + +class ThreadStatusRow(ListedRow, ToadNode): """Thread rows show activity through their glyph and summary, not held ink.""" DEFAULT_CSS = "ThreadStatusRow { color: $text-muted; }" @@ -251,7 +269,7 @@ def busy(self) -> bool: def show(self, row: ThreadRowModel, *, pinned: bool = False) -> None: """Render the model with this app's pin and pending action.""" - app = cast("ToadApp", self.app) + app = self.app row = app.session_navigation.thread_row(row) action_status = app.thread_actions.pending.get(row.name) if (row, pinned, action_status) == (self.thread_row, self.pinned, self.action_status): @@ -282,16 +300,16 @@ def retire_thread_row(self) -> None: class ThreadRow(ThreadStatusRow): """A retained wire thread row, with an optional currently open native view.""" - @property - def mode_name(self) -> str | None: - return self.navigation_tree.open_views.get(self.target_name) - def open_selected(self) -> None: - if self.mode_name is None or cast("ToadApp", self.app).session_navigation.session_details(self.mode_name) is None: - super().open_selected() - else: + """Select the tab that already shows this thread; open the thread when none does.""" + app = self.app + entries = {entry.mode: entry for entry in app.session_navigation.members} + mode = self.navigation_tree.open_views.get(self.target_name, "") + if mode in entries and entries[mode].identity in app.session_tracker.sessions: self.navigation_tree.remember_row(self) - self.app.select_session(self.mode_name) + app.select_session(mode) + else: + super().open_selected() class NewSessionButton(HoverSelection, ActivationKeys, TargetTreeMember, Static): @@ -481,7 +499,10 @@ def member_keys(self): def order_groups(self) -> None: """Order the groups admitted by sync; removed channels retire before member work.""" - ordered = (self.new_session, *(self.groups[key] for key in self.model_state.shown.channels.rows if key in self.groups)) + groups = tuple(self.groups[key] for key in self.model_state.shown.channels.rows if key in self.groups) + for index, group in enumerate(groups): + group.header.place(index) + ordered = (self.new_session, *groups) live = tuple(child for child in self.children if not child.is_leaving) if live != ordered: positions = {widget: index for index, widget in enumerate(ordered)} diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 113e1023f..d23f3961a 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -45,7 +45,6 @@ from toad.agent_schema import AgentDefinition from toad.answer import Answer from toad.conversation_actions import ConversationAction, WindowAction -from toad.app import ToadApp from toad.block_navigation import ( BlockCursor, ContentNavigation, @@ -88,6 +87,7 @@ from toad.shell import Shell from toad.status_thread import StatusThread from toad.transcript_publication import BoundView, TranscriptPresentation, ViewBinding +from toad.app_node import ToadNode from toad.widget_view import WorkspaceHosted from toad.widgets.cli_draft import CliDraft from toad.widgets.comms_menu import ContextMenu @@ -796,7 +796,7 @@ def watch_agent_binding(self, binding: AgentConnection) -> None: class Conversation( ConversationCommsHandlers, NativeMethodKeys, - ConversationSessionBinding, + ConversationSessionBinding, ToadNode, actions=(ConversationAction,), ): """Holds the agent conversation (input, output, and various controls / information).""" @@ -812,7 +812,6 @@ def keyboard_scopes(self) -> tuple[DeclaredWidgetActions, ...]: contents = getters.query_one("#contents", Contents) window = getters.query_one(Window) cursor = getters.query_one(CursorContainer) - app = getters.app(ToadApp) @property def visible_read_cursor(self): diff --git a/src/toad/widgets/flash.py b/src/toad/widgets/flash.py index 9f4c1dd67..98e94b20c 100644 --- a/src/toad/widgets/flash.py +++ b/src/toad/widgets/flash.py @@ -4,13 +4,12 @@ from textual.reactive import var from textual.widgets import Static from textual.timer import Timer -from textual import getters -from toad.app import ToadApp +from toad.app_node import ToadNode -class Flash(Static): +class Flash(Static, ToadNode): DEFAULT_CSS = """ Flash { height: 1; @@ -47,7 +46,6 @@ class Flash(Static): } } """ - app = getters.app(ToadApp) flash_timer: var[Timer | None] = var(None) def __init__(self, *args, **kwargs) -> None: diff --git a/src/toad/widgets/history_anchor.py b/src/toad/widgets/history_anchor.py index e754707d1..9a0a34c46 100644 --- a/src/toad/widgets/history_anchor.py +++ b/src/toad/widgets/history_anchor.py @@ -152,6 +152,10 @@ def joined_tail(self) -> "ReaderPosition": """The position after the reader chose to follow the latest message.""" return TailPosition() + def arrived(self, page: TranscriptPageView, key: FragmentKey) -> ReaderPosition: + """The position after a message arrived at the followed end: only following hands it the reader.""" + return self + def placed(self, window: "HistoryWindow", reached: bool) -> "ReaderPosition": """What the window keeps after a layout placed this: itself while the history is still changing. @@ -214,6 +218,9 @@ def shifted(self, rows: float) -> ReaderPosition: def joined_tail(self) -> ReaderPosition: return self + def arrived(self, page: TranscriptPageView, key: FragmentKey) -> ReaderPosition: + return ArrivalPosition(page, key) + class OffsetPosition(ReaderPosition): """A position inside the history, placed by its destination in the current layout.""" @@ -325,6 +332,53 @@ def destination(self, window: "HistoryWindow") -> float: return window.row_of(self.block) + self.y - self.row +@dataclass(frozen=True) +class ArrivalPosition(OffsetPosition): + """A message that just arrived at the followed end, on the page that draws it. + + One that fits in the window follows the tail as before. One taller than + the window is placed with its first row at the window's top instead of + its last, so the reader sees it arrive from its head; following stops + there, as when the reader scrolls up to read. The page is a geometry + target, so the layout that adds the message also places it. + """ + + page: TranscriptPageView + key: FragmentKey + + def location(self) -> FragmentLocation: + return self.page.fragment_row(self.key, 0) + + def bodies(self, window: HistoryWindow) -> tuple[Widget, ...]: + return (self.page,) + + def for_layout(self, window: HistoryWindow, position: ReaderPosition) -> ReaderPosition: + if self.page.is_attached and window.displays(self.page): + return position + return position.without_holder(window) + + def laid_out(self, window: HistoryWindow) -> bool: + return self.location().laid_out(window) + + def destination(self, window: HistoryWindow) -> float: + return self.location().destination(window, 0.0) + + def tall(self, window: HistoryWindow) -> bool: + """Whether the message is taller than the window, in the layout just made.""" + return window.message_head_hidden(self.page.drawn_height(self.location().location().fragment), None) + + def place(self, window: HistoryWindow) -> bool: + if self.tall(window): + return super().place(window) + return TailPosition().place(window) + + def shifted(self, rows: float) -> ReaderPosition: + # Kept through its change, so every layout of the change places it + # from its own page; afterwards the window reads where the reader is. + # The reader leaving the tail replaces it through left_tail. + return self + + @dataclass(frozen=True) class UnreachedPosition(ReaderPosition): """A position no layout has reached yet: the history is still too short, or is not drawn. @@ -469,6 +523,10 @@ def release(self, window: "HistoryWindow") -> None: window.reader_position = self.released() window.place_reader() + def arrived(self, page: TranscriptPageView, key: FragmentKey) -> ReaderPosition: + # End was pressed: the newest row stays the destination until its source settles. + return self + def released(self) -> ReaderPosition: return TailPosition() diff --git a/src/toad/widgets/session_tabs.py b/src/toad/widgets/session_tabs.py index 880b5d584..7d062c58a 100644 --- a/src/toad/widgets/session_tabs.py +++ b/src/toad/widgets/session_tabs.py @@ -9,14 +9,14 @@ from toad.widget_actions import ActivationKeys from textual.color import TRANSPARENT from textual.content import Content -from textual.css.scalar import Scalar, ScalarOffset +from textual.css.scalar import Scalar, ScalarOffset, Unit from textual.geometry import Offset, Region, Spacing from textual.layouts.vertical import VerticalLayout from textual.message import Message from textual.renderables.bar import Bar from textual.style import Style from textual.widget import Widget -from ui_theme.effects import NEW_TEXT, Mix +from ui_theme.effects import NEW_TEXT, Amount, Transition from ui_theme.scheme import ( AccentColour, ErrorColour, @@ -25,13 +25,13 @@ MutedColour, ) -from toad.app import ToadApp from toad.core import events as core_events from toad.core import session_requests from toad.core.events import SessionChangedEvent from toad.core_event_carrier import CoreEventReceiver from toad.frame_presentation import Animated from toad.session_tracker import OpenTab +from toad.app_node import ToadNode from toad.widget_view import AUTO, FILL, WidgetView from toad.widgets.selection import ChangingText, ViewLabel @@ -125,13 +125,32 @@ def primary_activation(self): def close_tab(self) -> None: self.app.session_navigation.events.publish(session_requests.SessionArchive(self.mode_name)) -class Underline(WidgetView, Animated[Mix]): - """The chosen tab's underline uses the shared content-change fade.""" +# The selected tab's underline travels from the cells it marked to the new ones. +TAB_SLIDE = Transition(Amount, 0.0, 1.0, duration=0.18, frames_per_second=60) + + +class Underline(WidgetView, Animated[Amount]): + """The track under the tab cells, marking the selected tab's cells. + + It is laid out under the cells themselves (TabCells), so it spans every + cell however far the strip scrolls. When the marked cells move (another tab + chosen, tabs reordered, closed, opened or renamed) it slides from what it + drew to them, on the app clock; it repaints one row per frame. + """ - text_fades = (NEW_TEXT,) width = FILL height = Scalar.from_number(1) - placement_offset = ScalarOffset.from_offset((0, 1)) + # The cells it drew last: where the next slide starts. + _drawn: tuple[float, float] = (0.0, 0.0) + _origin: tuple[float, float] = (0.0, 0.0) + + def mark_selected(self) -> None: + """The selected cells may have moved; slide to them from what is drawn.""" + start, end = self._drawn + if start < end and self._drawn != self.selected_cells: + self._origin = self._drawn + self.run_effect(TAB_SLIDE) + self.refresh() class Clicked(Message): """Inform ancestors the underline was clicked.""" @@ -144,16 +163,21 @@ def __init__(self, offset: Offset) -> None: super().__init__() @property - def _highlight_range(self) -> tuple[int, int]: + def selected_cells(self) -> tuple[float, float]: """The selected tab's cells, read from its label when the bar is drawn.""" - return self.query_ancestor(SessionsTabs).selected_span + start, end = self.query_ancestor(SessionsTabs).selected_span + return float(start), float(end) def render(self) -> RenderResult: """Render the bar.""" colours = self.app.colours background = colours.colour(HeaderToneColour) + colours.colour(ForegroundColour).multiply_alpha(0.1) + amount = self.effect_frame(TAB_SLIDE).value + (from_start, from_end), (to_start, to_end) = self._origin, self.selected_cells + self._drawn = (from_start + (to_start - from_start) * amount, + from_end + (to_end - from_end) * amount) return Bar( - highlight_range=self._highlight_range, + highlight_range=self._drawn, highlight_style=RichStyle.from_color(colours.colour(AccentColour).rich_color), background_style=RichStyle.from_color(background.rich_color), ) @@ -165,12 +189,28 @@ def _on_click(self, event: events.Click): class SessionTitles(WidgetView, containers.HorizontalGroup): + """The tab cells: each tab's label and close button.""" + width = AUTO height = Scalar.from_number(1) + + +class TabCells(WidgetView, containers.VerticalGroup): + """The tab cells over their underline, as wide as the cells or the strip, whichever is wider. + + The underline's columns are the cells' columns by layout, so the selected + tab's span needs no translation and is never past the underline's end. + """ + + # WidgetView declares its geometry inputs as None, so mypy reads every + # Scalar override as a new type (the other views here hit the same error). + width = AUTO # type: ignore[assignment] + min_width = Scalar(100, Unit.PERCENT, Unit.WIDTH) # type: ignore[assignment] + height = Scalar.from_number(2) placement_offset = ScalarOffset.from_offset((0, 1)) -class SessionsTabs(WidgetView, CoreEventReceiver, Widget): +class SessionsTabs(WidgetView, CoreEventReceiver, Widget, ToadNode): DEFAULT_LAYOUT = VerticalLayout width = FILL height = Scalar.from_number(3) @@ -179,7 +219,6 @@ class SessionsTabs(WidgetView, CoreEventReceiver, Widget): vertical_scrollbar_size = 0 ALLOW_SELECT = False - app: getters.app[ToadApp] = getters.app(ToadApp) title_container = getters.query_one("#title-container", Widget) @@ -227,14 +266,15 @@ async def handle_mode_change(self, event: core_events.SessionSelected) -> None: label.paint_current() self.call_later(self._sync_tabs) self.call_after_refresh(self.show_selected_tab) - self.query_one(Underline).run_effect(NEW_TEXT) @property def selected_span(self) -> tuple[int, int]: - """The cells under the selected tab's label; empty until that label is laid out.""" + """The selected tab's cells, label and close button; empty until they are laid out.""" selected = self.app.selected_mode - label = self.query_one_optional(f"#{selected}", SessionLabel) if selected else None - return (0, 0) if label is None else label.virtual_region.shrink((0, 1, 0, 1)).column_span + if not selected: + return 0, 0 + cells = [cell.virtual_region for cell in self.title_container.query_children(f"#{selected}, #close-{selected}")] + return Region.from_union(cells).column_span if cells else (0, 0) def show_selected_tab(self) -> None: """Redraw the underline and bring the selected tab into the strip. @@ -244,7 +284,7 @@ def show_selected_tab(self) -> None: """ if not self.is_mounted or not self.is_attached: return - self.query_one(Underline).refresh() + self.query_one(Underline).mark_selected() selected = self.app.selected_mode if selected and (label := self.query_one_optional(f"#{selected}", SessionLabel)): if label.virtual_region: @@ -253,11 +293,12 @@ def show_selected_tab(self) -> None: def compose(self) -> ComposeResult: tabs = self.app.open_tabs self._last_tabs = {tab.mode_name: tab for tab in tabs} - with SessionTitles(id="title-container"): - for session in tabs: - yield SessionLabel(session, id=session.mode_name) - yield SessionTabClose(session.mode_name) - yield Underline() + with TabCells(): + with SessionTitles(id="title-container"): + for session in tabs: + yield SessionLabel(session, id=session.mode_name) + yield SessionTabClose(session.mode_name) + yield Underline() @handles(SessionChangedEvent) async def handle_session_update(self, event: SessionChangedEvent) -> None: @@ -344,6 +385,12 @@ async def _reconcile_tabs(self) -> None: if [widget.id for widget in self.title_container.children] != list(order): self.title_container.sort_children(key=lambda widget: order[widget.id]) geometry_changed = True + # A tab that opened or moved among the others rises into its new cells. + before = list(previous_tabs) + for index, tab in enumerate(tabs): + if before[index:index + 1] != [tab.mode_name] and ( + label := self.query_one_optional(f"#{tab.mode_name}", SessionLabel)): + label.run_effect(NEW_TEXT) self._last_tabs = {tab.mode_name: tab for tab in tabs} if geometry_changed: self.call_after_refresh(self.show_selected_tab) diff --git a/src/toad/widgets/side_bar.py b/src/toad/widgets/side_bar.py index f6a3d6dc2..f76639d9e 100644 --- a/src/toad/widgets/side_bar.py +++ b/src/toad/widgets/side_bar.py @@ -13,7 +13,7 @@ from toad.preferences import SETTINGS from dataclasses import dataclass import asyncio -from typing import TYPE_CHECKING, ClassVar, cast +from typing import TYPE_CHECKING, ClassVar from textual import containers, events, on, widgets from textual.app import ComposeResult @@ -38,9 +38,9 @@ SidebarShift, ) from toad.widgets.sidebar_viewport import SidebarHeader, SidebarViewport +from toad.app_node import ToadNode if TYPE_CHECKING: - from toad.app import ToadApp from toad.tab_order import TabOrder @@ -268,7 +268,7 @@ class HeaderButton(ActivationKeys, HoverSelection, widgets.Static, can_focus=Tru } """ -class TabHistoryButton(HeaderButton): +class TabHistoryButton(HeaderButton, ToadNode): """Back or forward through visited tabs.""" direction: ClassVar[int] @@ -293,7 +293,7 @@ def primary_activation(self): return self.navigate def navigate(self) -> None: - cast("ToadApp", self.app).tab_order.navigate(self.direction) + self.app.tab_order.navigate(self.direction) class BackTabButton(TabHistoryButton): @@ -344,7 +344,7 @@ async def chosen(self, action: ApplicationAction | None) -> None: await invoke(action.apply, self.app) -class TabHistoryControls(CoreEventReceiver, containers.HorizontalGroup): +class TabHistoryControls(CoreEventReceiver, containers.HorizontalGroup, ToadNode): """Left-sidebar controls shared by native thread and channel screens.""" DEFAULT_CSS = """ @@ -361,7 +361,7 @@ def compose(self) -> ComposeResult: yield ForwardTabButton() def on_mount(self) -> None: - app = cast("ToadApp", self.app) + app = self.app self.observe_core(app.tab_order.events) self.observe_core(app.events) self._sync() @@ -371,7 +371,7 @@ async def history_changed(self, event: core_events.TabHistoryChanged | core_even self._sync() def _sync(self) -> None: - app = cast("ToadApp", self.app) + app = self.app for button in self.query(TabHistoryButton): button.sync(app.tab_order) @@ -513,7 +513,7 @@ def activate(self) -> None: self.post_message(self.Pressed(self.action)) class SidebarResizeHandle( - MovesHorizontalCursor, CapturedDrag, ColourSelection, widgets.Static, can_focus=True + MovesHorizontalCursor, CapturedDrag, ColourSelection, widgets.Static, ToadNode, can_focus=True ): """Drag the conversation-facing edge; the placement model owns the width.""" @@ -538,7 +538,7 @@ def drag_to(self, screen_x: int) -> None: bar = self.query_ancestor(SideBar) if bar.id is None or bar.parent is None: return - app = cast("ToadApp", self.app) + 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)) @@ -579,7 +579,7 @@ async def apply(self, sidebar: SideBar) -> None: class SideBar(DismissibleControl, WorkspaceHosted, TonePaint, CoreEventReceiver, - containers.Vertical, actions=(SearchSidebarAction,)): + containers.Vertical, ToadNode, actions=(SearchSidebarAction,)): tone = SidebarToneColour BINDING_GROUP_TITLE = "Sidebar" DEFAULT_CSS = """ @@ -670,15 +670,15 @@ def panels_loaded(self) -> bool: @property def navigation(self) -> SidebarState: - return cast("ToadApp", self.app).sidebar_state + return self.app.sidebar_state def mount_collapse(self) -> None: - app = cast("ToadApp", self.app) + app = self.app self.observe_core(app.saved_settings.events) self.collapsed = SETTINGS.sidebar.hide.bind(app.saved_settings.state).value def store_collapsed(self, value: bool) -> None: - app = cast("ToadApp", self.app) + app = self.app app.saved_settings.set(SETTINGS.sidebar.hide, value) self.collapsed = SETTINGS.sidebar.hide.bind(app.saved_settings.state).value @@ -790,7 +790,7 @@ def on_show(self) -> None: self._present_controls() def _present_controls(self) -> bool: - app = cast("ToadApp", self.app) + app = self.app if not self.is_mounted: return False placement = app.sidebar_layout.get(self.id) @@ -868,7 +868,7 @@ def _present_controls(self) -> bool: def on_sidebar_slider(self, event: SidebarSlider.Changed) -> None: event.stop() if self.id is not None: - app = cast("ToadApp", self.app) + app = self.app if app.sidebar_layout.width(self.id, event.value): self._present_controls() app.workspace_chrome.refresh_layout() @@ -878,7 +878,7 @@ def on_sidebar_action(self, event: SidebarAction.Pressed) -> None: event.stop() if self.id is None: return - app = cast("ToadApp", self.app) + app = self.app event.action.apply(app.sidebar_layout, self.id) for bar in ( app.workspace_chrome.channels, @@ -893,7 +893,7 @@ def restore_navigation(self) -> bool: self.watch_collapsed(self.collapsed) else: self._present_controls() - cast("ToadApp", self.app).workspace_chrome.refresh_layout() + self.app.workspace_chrome.refresh_layout() navigation = self.navigation for panel in self.query(SideBarCollapsible): if str(panel.title) in navigation.panels_collapsed: @@ -924,7 +924,7 @@ def watch_collapsed(self, collapsed: bool) -> None: self._presented_layout = None if self.is_mounted: self._present_controls() - cast("ToadApp", self.app).workspace_chrome.refresh_layout() + self.app.workspace_chrome.refresh_layout() def render(self) -> str: return ("<" if self.right else ">") if self.collapsed else "" diff --git a/src/toad/widgets/sidebar_tree.py b/src/toad/widgets/sidebar_tree.py index 396406d22..1c84ede3e 100644 --- a/src/toad/widgets/sidebar_tree.py +++ b/src/toad/widgets/sidebar_tree.py @@ -21,7 +21,7 @@ from toad.comms_root import FailedObservation, Observing from toad.navigation_target import NavigationTarget from toad.widgets.channel_activity import ChannelActivity - from toad.widgets.comms_sidebar import CommsRow, CommsSidebar + from toad.widgets.comms_sidebar import CommsRow, CommsSidebar, ListedRow from toad.widgets.thread_comms import RelationshipRows from agent_comms.display_order import ThreadSort @@ -220,10 +220,11 @@ def reconcile_keys(self, keys: Iterable[KeyT], *, changed: frozenset[KeyT]): return ordered -class SidebarGroup[RowT: "CommsRow"](KeyedRows[str], VerticalGroup): +class SidebarGroup[RowT: "ListedRow"](KeyedRows[str], VerticalGroup): """A common header/disclosure and optional bounded member viewport.""" navigation_header = False + header_type: type[SidebarHeader] = SidebarHeader DEFAULT_CSS = """ SidebarGroup { height: auto; } @@ -255,12 +256,13 @@ def __init__( self.row.add_class("group-title") self.disclosure = disclosure_type() self.controls = controls + self.header = self.header_type(classes="group-header") self.member_container = VerticalGroup(classes="group-members channel-members") self.member_container.styles.layout = SidebarReaderLayout() self.expanded = expanded def compose(self) -> ComposeResult: - with SidebarHeader(classes="group-header"): + with self.header: yield self.disclosure yield self.row yield from self.controls @@ -349,9 +351,7 @@ def update_row(self, key: str, row: RowT) -> None: self.show_member(key, row) def place_row(self, row: RowT, index: int) -> None: - alternate = bool(index % 2) - if row.alternate != alternate: - row.alternate = alternate + row.place(index) @property def member_keys(self): diff --git a/src/toad/widgets/switch_toast.py b/src/toad/widgets/switch_toast.py new file mode 100644 index 000000000..871e4cfc9 --- /dev/null +++ b/src/toad/widgets/switch_toast.py @@ -0,0 +1,156 @@ +"""The name of the tab just chosen, large and centred over the workspace, fading in and out.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from metaclass_registry.mro_dispatch import handles +from rich.cells import cell_len, set_cell_size +from rich.segment import Segment +from rich.style import Style +from textual.geometry import Offset +from textual.strip import Strip +from ui_theme.effects import Amount, Effect +from ui_theme.scheme import ( + AccentColour, + BackgroundColour, + ForegroundColour, + HeaderToneColour, +) + +from toad.core import events as core_events +from toad.core_event_carrier import CoreEventReceiver +from toad.frame_presentation import Animated +from toad.large_text import LargeText +from toad.app_node import ToadNode + +if TYPE_CHECKING: + from textual._paint_state import PaintState + + +@dataclass(frozen=True) +class Swell(Effect[Amount]): + """Rises to full, holds, then falls back to nothing; it ends at nothing.""" + + rise: float + hold: float + fall: float + frames_per_second: float + + @property + def duration(self) -> float: # type: ignore[override] + return self.rise + self.hold + self.fall + + def frame(self, elapsed: float) -> Amount: + rising = self.ease_out(self.progress(elapsed, self.rise)) + falling = self.ease_out(self.progress(self.duration - elapsed, self.fall)) + return Amount(min(rising, falling)) + + @property + def final(self) -> Amount: + return Amount(0.0) + + +TAB_TOAST = Swell(rise=0.12, hold=0.5, fall=0.35, frames_per_second=30) + + +class SwitchToast(CoreEventReceiver, Animated[Amount], ToadNode): + """The chosen tab's name, so the reader sees where they landed without finding the tab. + + It never takes focus or pointer input: clicks pass to what is under it. + A new choice while it shows replaces its name and restarts its swell. + It is laid out only when it appears and when it ends; every frame between + repaints its own few rows. + """ + + DEFAULT_CSS = "SwitchToast { position: absolute; overlay: screen; }" + can_focus = False + hit_test_transparent = True + # Cells around the name, inside the frame. + MARGIN = (1, 6) + + def __init__(self) -> None: + super().__init__() + # The name as drawn: three rows tall when that fits the screen, else one. + self.lines: tuple[str, ...] = () + self.display = False + + def on_mount(self) -> None: + self.observe_core(self.app.events) + + @handles(core_events.SessionSelected) + def session_selected(self, event: core_events.SessionSelected) -> None: + titles = {tab.mode_name: tab.title for tab in self.app.open_tabs} + if event.mode_name in titles: + self.show(titles[event.mode_name]) + + @property + def box(self) -> tuple[int, int]: + rows, columns = self.MARGIN + width = min(max(map(cell_len, self.lines)) + 2 * columns + 2, self.room) + return width, len(self.lines) + 2 * rows + 2 + + @property + def room(self) -> int: + """The widest the toast may be: the screen less a margin each side.""" + return max(0, self.screen.size.width - 4) + + def show(self, title: str) -> None: + large = LargeText.rows(title) + fits = LargeText.width(title) + 2 * self.MARGIN[1] + 2 <= self.room + self.lines = large if fits else (title,) + width, height = self.box + self.styles.width, self.styles.height = width, height + screen = self.screen.size + self.absolute_offset = Offset( + (screen.width - width) // 2, (screen.height - height) // 2 + ) + self.display = True + self.run_effect(TAB_TOAST) + self.refresh(layout=True) + + def on_show(self) -> None: + super().on_show() + self.repaint() + + def repaint(self) -> None: + """The clock's paint request: the toast leaves the layout once its swell ends.""" + self.display = self.effect_phase(TAB_TOAST).in_motion + self.refresh() + + def render_line(self, y: int, paint: PaintState) -> Strip: + colours = self.app.colours + amount = self.effect_frame(TAB_TOAST).value + background = colours.mixed( + BackgroundColour, HeaderToneColour, amount + ).rich_color + frame = Style( + color=colours.mixed(BackgroundColour, AccentColour, amount).rich_color, + bgcolor=background, + ) + name = Style( + color=colours.mixed(BackgroundColour, ForegroundColour, amount).rich_color, + bgcolor=background, + bold=True, + ) + width, height = self.box + inner = max(0, width - 2) + if y == 0 or y == height - 1: + left, right = ("╭", "╮") if y == 0 else ("╰", "╯") + return Strip([Segment(left + "─" * inner + right, frame)], width) + line = y - 1 - self.MARGIN[0] + text = self.lines[line] if 0 <= line < len(self.lines) else "" + if cell_len(text) > inner: + text = set_cell_size(text, max(0, inner - 1)) + "…" + padding = inner - cell_len(text) + return Strip( + [ + Segment("│", frame), + Segment( + " " * (padding // 2) + text + " " * (padding - padding // 2), name + ), + Segment("│", frame), + ], + width, + ) diff --git a/src/toad/widgets/tool_call.py b/src/toad/widgets/tool_call.py index d48c9cd0b..2b27801fd 100644 --- a/src/toad/widgets/tool_call.py +++ b/src/toad/widgets/tool_call.py @@ -5,14 +5,12 @@ from textual.app import ComposeResult from textual.await_complete import AwaitComplete -from textual import getters from textual.reactive import var from textual.css.query import NoMatches from textual import containers from textual.widget import Widget -from toad.app import ToadApp from toad.tool_output import ToolOutput, ToolOutputPart from metaclass_registry.mro_dispatch import handles from toad.acp.status import ToolCallStatus @@ -25,6 +23,7 @@ from toad.widgets.line_markdown import LineMarkdown from toad.widgets.message_divider import SingleLineBlock from toad.widgets.tool_content import ToolContent +from toad.app_node import ToadNode @@ -49,7 +48,7 @@ def toggle_details(self, link: ToggleDetails) -> None: self.app.bell() -class ToolCall(ConversationBlock, SnapshotPresentation, CategorizedBlock, containers.VerticalGroup): +class ToolCall(ConversationBlock, SnapshotPresentation, CategorizedBlock, containers.VerticalGroup, ToadNode): DEFAULT_CSS = """ ToolCall { margin: 0 !important; @@ -80,7 +79,6 @@ class ToolCall(ConversationBlock, SnapshotPresentation, CategorizedBlock, contai def message_category(self) -> type[MessageCategory]: return ToolCategory - app = getters.app(ToadApp) has_content: var[bool] = var(False, toggle_class="-has-content") expanded: var[bool] = var(False, toggle_class="-expanded") tool_call: var[ToolCallStatus | None] = var(None) diff --git a/src/toad/widgets/transcript_history.py b/src/toad/widgets/transcript_history.py index 2a0da24db..ba7afc168 100644 --- a/src/toad/widgets/transcript_history.py +++ b/src/toad/widgets/transcript_history.py @@ -9,6 +9,7 @@ from contextlib import AsyncExitStack, asynccontextmanager from dataclasses import dataclass, field, replace from functools import cached_property, partial +from math import inf from itertools import accumulate, chain, islice from typing import TYPE_CHECKING, Final from weakref import ref @@ -34,7 +35,7 @@ from textual.strip import Strip from textual.widget import Widget from textual.widgets import Static -from ui_theme.effects import NEW_TEXT +from ui_theme.effects import NEW_TEXT, Amount, Transition from toad.block_navigation import ConversationEntry, FragmentBlockCursor from toad.core.source_events import TranscriptCoverage @@ -113,6 +114,11 @@ from toad.widgets.conversation import Contents, Conversation +# An arriving message is uncovered from its first row down, alongside NEW_TEXT's fade. +ARRIVAL_REVEAL = Transition(Amount, 0.0, 1.0, duration=NEW_TEXT.duration, + frames_per_second=NEW_TEXT.frames_per_second) + + class HistoryEdge(Activates, Static, can_focus=True): DEFAULT_CSS = "HistoryEdge { height: 1; color: $text-muted; pointer: pointer; }" @@ -321,6 +327,8 @@ def render_line(self, y: int, paint: PaintState) -> Strip: strip = lines.strips_at(fragment.link_place)[row] if id(fragment) in self.arrivals: history = self.history + if row >= history.revealed_rows: + return selection_painted(self, self.blank_row(width, paint), y) strip = history.effect_phase(NEW_TEXT).paint(history, NEW_TEXT, strip) return selection_painted(self, self.on_background(strip, paint, y), y) @@ -357,6 +365,11 @@ def drawn_fragments(self) -> tuple[TranscriptFragment, ...]: """The fragments this page draws rows for, oldest first.""" return tuple(fragment for fragment, _first, _lines in self._layout) + def drawn_height(self, fragment: TranscriptFragment) -> int: + """The rows this page draws for a fragment it draws.""" + _fragment, _first, lines = self._layout[self._fragment_slots[id(fragment)]] + return lines.height + def fragment_rows(self, fragment: TranscriptFragment) -> range | None: """The rows of this page that draw a fragment.""" if (slot := self._fragment_slots.get(id(fragment))) is None: @@ -964,6 +977,36 @@ def on_earlier_history(self, event: HistoryEdge.Requested) -> None: def drawn_tail_followed(self) -> bool: return not self.drawn_end_more and self.follows_source_tail + # The window's height when the current burst of arrivals began. + reveal_rows = 0 + + @property + def revealed_rows(self) -> float: + """How many rows of each arriving fragment are uncovered: all of them once the sweep ends.""" + amount = self.effect_frame(ARRIVAL_REVEAL).value + return amount * self.reveal_rows if amount < 1.0 else inf + + def arrivals_began(self) -> None: + """A burst of arrivals uncovers each message from its first row down, one window height in one sweep.""" + if not self.is_on_screen: + return # A burst nobody sees has nothing to uncover. + self.reveal_rows = self.window.scrollable_content_region.height + # The fade starts with the reveal: covered rows never paint it, and + # the burst is decided by the fade's phase. + self.run_effect(NEW_TEXT) + self.run_effect(ARRIVAL_REVEAL) + + def hold_arrival(self, arrivals: tuple[TranscriptFragment, ...]) -> None: + """The first shown arriving message places a following reader: from its head when it is taller than the window.""" + window = self.window + if not window.shown: + return + for head in arrivals: + page = self.page_holding(head.key) + if head.shown_in(page.visible_categories): + window.reader_position = window.reader_position.arrived(page, head.key) + return + def repaint(self, fragments: tuple[TranscriptFragment, ...] = (), *, continuing=()) -> None: phase = self.effect_phase(NEW_TEXT) if fragments: @@ -972,8 +1015,9 @@ def repaint(self, fragments: tuple[TranscriptFragment, ...] = (), *, continuing= for page in self.pages: previous = bool(page.arrivals) page.arrivals.intersection_update(phase.keeps_arrivals(page.arrivals)) - page.arrivals.update(id(fragment) for fragment in page.prepared.fragments_in_range - if fragment.arrival_position in positions) + if positions: + page.arrivals.update(id(fragment) for fragment in page.prepared.fragments_in_range + if fragment.arrival_position in positions) if previous or page.arrivals: page.refresh() @@ -1039,6 +1083,7 @@ def current() -> bool: arrivals = self._source_state.arriving(staged, followed_end) await self._extend_and_trim(edge, end, local, protected, staged, current=current) self._require_can_draw() + self.hold_arrival(arrivals) self.repaint(arrivals) self.rows_published() if not local: diff --git a/src/toad/workspace_chrome.py b/src/toad/workspace_chrome.py index 4b2ba0dd2..87991f935 100644 --- a/src/toad/workspace_chrome.py +++ b/src/toad/workspace_chrome.py @@ -9,6 +9,7 @@ from textual.widget import Widget from toad.widgets.channels_sidebar import ChannelsSidebar from toad.widgets.footer import ToadFooter +from toad.widgets.switch_toast import SwitchToast from ui_theme.scheme import HeaderToneColour from toad.tone_paint import TonePaint from toad.widget_view import AUTO, FILL, WidgetView @@ -39,6 +40,7 @@ def __init__(self, app: "ToadApp") -> None: self.navigation = WorkspaceHeader(id="tab-navigation-header") self.channels = ChannelsSidebar() self.footer = ToadFooter(id="workspace-footer") + self.switch_toast = SwitchToast() self.native = NativeSessionSurface(app) def presented_sidebars(self): diff --git a/tests/textual/test_channel_scrolling.py b/tests/textual/test_channel_scrolling.py index 802e41ea5..177d31ada 100644 --- a/tests/textual/test_channel_scrolling.py +++ b/tests/textual/test_channel_scrolling.py @@ -116,8 +116,11 @@ async def test_a_growing_channel_draws_new_messages_for_a_tail_reader_and_keeps_ await ui.until(lambda: history.state.accepts_source_work and not window.follows_tail, why="the reader above the tail") await ui.step() def message_rows(): - # The message area's part of each screen line; the sidebar's unread count changes beside it. - return [line.rsplit("│", 1)[-1] for line in ui.visible_lines() if "Message" in line] + # Read the message area, excluding sidebar and scrollbar ink. + region = window.scrollable_content_region + rows = (strip.crop(region.x, region.right).text + for strip in ui.app.screen._compositor.render_strips()[region.y:region.bottom]) + return [line for line in rows if "Message" in line] position, top = window.scroll_y, message_rows() for index in range(60): diff --git a/tests/textual/test_keymap_model.py b/tests/textual/test_keymap_model.py index 1b3e52f4b..6615600b6 100644 --- a/tests/textual/test_keymap_model.py +++ b/tests/textual/test_keymap_model.py @@ -3,12 +3,13 @@ from toad.application_actions import CommanderAction, HelpQuitAction, KeymapAction from toad.key_modes import KeyMode, Standard, VimNormal -from toad.keymap import Keymap, KeymapRow +from toad.keymap import Keymap, KeymapCatalog, KeymapRow from toad.preferences import SETTINGS, SavedSettings from toad.screens.store import LaunchSlotAction def test_saved_remaps_keep_modes_independent_and_reset_to_the_declaration(): + catalog = KeymapCatalog.capture(KeyMode.members_with(KeyMode)) ordinary = KeymapRow.of(KeymapAction(), Standard) normal = KeymapRow.of(KeymapAction(), VimNormal) keymap = Keymap().changed(normal, ("f7",)) @@ -17,9 +18,9 @@ def test_saved_remaps_keep_modes_independent_and_reset_to_the_declaration(): settings.set(SETTINGS.ui.keymap.bindings, keymap.bindings) loaded = SavedSettings(settings.document()).current.ui.keymap assert [setting.path for setting in applied] == [SETTINGS.ui.keymap.bindings.bind(settings.state).path] - assert normal.effective(loaded) == ("f7",) - assert ordinary.effective(loaded) == ordinary.defaults - assert normal.effective(loaded.reset(normal)) == normal.defaults + assert catalog.effective(normal, loaded) == ("f7",) + assert catalog.effective(ordinary, loaded) == catalog.defaults(ordinary) + assert catalog.effective(normal, loaded.reset(normal)) == catalog.defaults(normal) with pytest.raises(ValueError, match="Commander.*Keymap|Keymap.*Commander"): loaded.changed(KeymapRow.of(CommanderAction(), VimNormal), ("f7",)) @@ -30,9 +31,10 @@ def test_defaults_allow_the_declared_alternatives_in_every_mode(): def test_reset_cannot_reintroduce_a_collision_and_duplicate_rows_are_refused(): + catalog = KeymapCatalog.capture(KeyMode.members_with(KeyMode)) menu = KeymapRow.of(KeymapAction(), Standard) keymap = Keymap().changed(menu, ("f7",)) - keymap = keymap.changed(KeymapRow.of(CommanderAction(), Standard), menu.defaults) + keymap = keymap.changed(KeymapRow.of(CommanderAction(), Standard), catalog.defaults(menu)) with pytest.raises(ValueError, match="Key conflict"): keymap.reset(menu) with pytest.raises(ValueError, match="only one override"): @@ -40,17 +42,18 @@ def test_reset_cannot_reintroduce_a_collision_and_duplicate_rows_are_refused(): def test_launch_slots_have_distinct_saved_invocations_and_mode_defaults(): + catalog = KeymapCatalog.capture(KeyMode.members_with(KeyMode)) rows = tuple(KeymapRow.of(LaunchSlotAction(slot), Standard) for slot in range(15)) assert len({row.declaration for row in rows}) == len(rows) - assert tuple(row.defaults for row in rows) == tuple((key,) for key in LaunchSlotAction.keys[Standard().binding_mode]) + assert tuple(catalog.defaults(row) for row in rows) == tuple((key,) for key in LaunchSlotAction.keys[Standard().binding_mode]) keymap = Keymap().changed(rows[0], ("f6",)) settings = SavedSettings() settings.set(SETTINGS.ui.keymap.bindings, keymap.bindings) loaded = SavedSettings(settings.document()).current.ui.keymap assert loaded == keymap - assert rows[0].effective(loaded) == ("f6",) - assert rows[1].effective(loaded) == rows[1].defaults - assert KeymapRow.of(LaunchSlotAction(0), VimNormal).effective(loaded) == ("1",) + assert catalog.effective(rows[0], loaded) == ("f6",) + assert catalog.effective(rows[1], loaded) == catalog.defaults(rows[1]) + assert catalog.effective(KeymapRow.of(LaunchSlotAction(0), VimNormal), loaded) == ("1",) def test_hidden_system_bindings_keep_their_valid_saved_references(): diff --git a/tests/textual/test_keymap_screen.py b/tests/textual/test_keymap_screen.py index addfc4823..f96983409 100644 --- a/tests/textual/test_keymap_screen.py +++ b/tests/textual/test_keymap_screen.py @@ -115,17 +115,18 @@ async def test_the_menu_discovers_and_remaps_a_native_callable_with_both_aliases counter.focus() invocation = counter.MODE_BINDINGS[0].invocation row = KeymapRow.of(invocation, Standard) - assert row.defaults == ("f8", "f9") await ui.press("f1") await ui.until(lambda: isinstance(ui.app.screen, KeymapScreen), why="the native callable in the menu") + catalog = ui.app.screen.catalog + assert catalog.defaults(row) == ("f8", "f9") assert len(ui.app.screen.modes) == 4 await choose(ui, invocation) ui.app.screen.query_one("#keymap-keys", Input).value = "f6" await ui.pilot.click("#keymap-apply") - await ui.until(lambda: row.effective(ui.app.settings.ui.keymap) == ("f6",), + await ui.until(lambda: catalog.effective(row, ui.app.settings.ui.keymap) == ("f6",), why="the saved callable remap") loaded = SavedSettings(ui.app.saved_settings.document()).current.ui.keymap - assert row.effective(loaded) == ("f6",) + assert catalog.effective(row, loaded) == ("f6",) await ui.press("escape") counter.focus() await ui.press("f8", "f9") diff --git a/tests/textual/test_stepped_layout_restoration.py b/tests/textual/test_stepped_layout_restoration.py index 5b3777396..2ec2ff06f 100644 --- a/tests/textual/test_stepped_layout_restoration.py +++ b/tests/textual/test_stepped_layout_restoration.py @@ -48,7 +48,8 @@ async def test_stepped_layout_keeps_previous_scene_and_restoration_waits_for_the async def settled(): await ui.step(0.1) seen.append(scene(screen)) - return len(seen) > 2 and seen[-1] == seen[-2] == seen[-3] + return (not ui.app.workspace_chrome.switch_toast.display + and len(seen) > 2 and seen[-1] == seen[-2] == seen[-3]) await ui.until(settled, why="the workspace's widgets to finish mounting")