From cfad6a2fcf6bd2b95da868e2a1ad1de9afbf1eb1 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 2 Nov 2026 09:57:15 -0500 Subject: [PATCH 1/5] Declare session resume layout on its native widget classes --- src/toad/native_layout.py | 15 ++++++ src/toad/screens/session_resume_modal.py | 62 +++++++++++++++++++--- src/toad/screens/session_resume_modal.tcss | 30 +---------- 3 files changed, 71 insertions(+), 36 deletions(-) create mode 100644 src/toad/native_layout.py diff --git a/src/toad/native_layout.py b/src/toad/native_layout.py new file mode 100644 index 000000000..635034b91 --- /dev/null +++ b/src/toad/native_layout.py @@ -0,0 +1,15 @@ +"""Native widgets declare their layout before their first stylesheet application.""" + +from textual.app import App +from textual.widget import Widget + + +class NativeLayout(Widget): + """The shared registration point for a widget's own layout declarations.""" + + def _post_register(self, app: App) -> None: + super()._post_register(app) + self.apply_native_layout() + + def apply_native_layout(self) -> None: + """A widget without additional layout keeps its inherited native defaults.""" diff --git a/src/toad/screens/session_resume_modal.py b/src/toad/screens/session_resume_modal.py index 0cffcda7c..620e445df 100644 --- a/src/toad/screens/session_resume_modal.py +++ b/src/toad/screens/session_resume_modal.py @@ -3,6 +3,8 @@ from textual import containers, getters, on, widgets from textual.app import ComposeResult +from textual.widgets._markdown import MarkdownH1 +from toad.native_layout import NativeLayout from toad.screens.modal import DismissibleScreen from toad.db import DB, Session @@ -14,23 +16,67 @@ """ -class SessionResumeModal(DismissibleScreen[Session]): +class ResumeHeading(NativeLayout, MarkdownH1): + def apply_native_layout(self) -> None: + self.styles.margin = (1, 0) + + +class ResumeHelp(widgets.Markdown): + BLOCKS = widgets.Markdown.BLOCKS | {f"h{ResumeHeading.LEVEL}": ResumeHeading} + + +class ResumeWarning(NativeLayout, widgets.Static): + def apply_native_layout(self) -> None: + self.styles.padding = (0, 1) + self.styles.margin = (0, 0, 1, 1) + + +class ResumeFrame(NativeLayout, containers.VerticalGroup): + def apply_native_layout(self) -> None: + self.styles.margin = (2, 4, 1, 4) + self.styles.padding = (0, 1, 0, 0) + self.styles.max_width = 100 + self.styles.height = "auto" + + +class ResumeTableContainer(NativeLayout, containers.Center): + def apply_native_layout(self) -> None: + self.styles.height = "auto" + self.styles.padding = (0, 0, 0, 1) + + +class ResumeTable(NativeLayout, widgets.DataTable): + def apply_native_layout(self) -> None: + self.styles.width = "1fr" + self.styles.height = 12 + + +class ResumeButtons(NativeLayout, containers.HorizontalGroup): + def apply_native_layout(self) -> None: + self.styles.margin = (1, 0, 0, 0) + self.styles.align = ("right", "top") + + +class SessionResumeModal(NativeLayout, DismissibleScreen[Session]): """Dialog to select a session to resume.""" CSS_PATH = "session_resume_modal.tcss" - session_table = getters.query_one("#sessions", widgets.DataTable) + session_table = getters.query_one("#sessions", ResumeTable) + + def apply_native_layout(self) -> None: + self.styles.align = ("center", "middle") def compose(self) -> ComposeResult: - with containers.VerticalGroup(id="container"): - yield widgets.Markdown(HELP) - yield widgets.Static( + with ResumeFrame(id="container"): + yield ResumeHelp(HELP) + yield ResumeWarning( "⚠ Not all ACP agents currently support resume", classes="warning", ) - with containers.Center(id="table-container"): - yield widgets.DataTable(id="sessions", cursor_type="row") - with containers.HorizontalGroup(id="buttons"): + with ResumeTableContainer(id="table-container"): + yield ResumeTable(id="sessions", cursor_type="row") + with ResumeButtons(id="buttons"): yield widgets.Button( "Resume", id="resume", variant="primary", disabled=True ) diff --git a/src/toad/screens/session_resume_modal.tcss b/src/toad/screens/session_resume_modal.tcss index 30e1a25c0..ea535e569 100644 --- a/src/toad/screens/session_resume_modal.tcss +++ b/src/toad/screens/session_resume_modal.tcss @@ -1,35 +1,9 @@ SessionResumeModal { - align: center middle; .warning { border: round $warning; - padding: 0 1; color: $text-warning; - margin: 0 0 1 1; } - #container { - margin: 2 4 1 4; - padding: 0 1 0 0; - max-width: 100; - height: auto; + #container { border: thick $primary 20%; - - Markdown { - MarkdownH1 { - margin: 1 0; - } - } - - #table-container { - height: auto; - padding: 0 0 0 1; - DataTable { - width: 1fr; - height: 12; - } - } - #buttons { - margin-top: 1; - align: right top; - } } -} \ No newline at end of file +} From ea9c19b91b01f871d2e68bba8049fe185faf5cc1 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 2 Nov 2026 10:31:25 -0500 Subject: [PATCH 2/5] Delete the resume stylesheet through native theme declarations --- src/toad/native_layout.py | 24 ++++++++++++++++++++++ src/toad/screens/session_resume_modal.py | 23 +++++++++++++++------ src/toad/screens/session_resume_modal.tcss | 9 -------- src/toad/textual_colours.py | 7 +++++++ 4 files changed, 48 insertions(+), 15 deletions(-) delete mode 100644 src/toad/screens/session_resume_modal.tcss diff --git a/src/toad/native_layout.py b/src/toad/native_layout.py index 635034b91..6df026c31 100644 --- a/src/toad/native_layout.py +++ b/src/toad/native_layout.py @@ -1,6 +1,10 @@ """Native widgets declare their layout before their first stylesheet application.""" +from typing import ClassVar + from textual.app import App +from textual.css.native import NativeDeclaration +from textual.theme import Theme from textual.widget import Widget @@ -13,3 +17,23 @@ def _post_register(self, app: App) -> None: def apply_native_layout(self) -> None: """A widget without additional layout keeps its inherited native defaults.""" + + +class NativeTheme(Widget): + """Native property declarations follow the app's current theme value owner.""" + + native_theme: ClassVar[tuple[NativeDeclaration, ...]] = () + + def _post_register(self, app: App) -> None: + super()._post_register(app) + self.apply_native_theme() + + def on_mount(self) -> None: + self.apply_native_theme() + self.app.theme_changed_signal.subscribe(self, self._native_theme_changed) + + def _native_theme_changed(self, theme: Theme) -> None: + self.apply_native_theme() + + def apply_native_theme(self) -> None: + self.styles.merge(self.app.stylesheet.native_styles(*self.native_theme)) diff --git a/src/toad/screens/session_resume_modal.py b/src/toad/screens/session_resume_modal.py index 620e445df..876d540c5 100644 --- a/src/toad/screens/session_resume_modal.py +++ b/src/toad/screens/session_resume_modal.py @@ -3,8 +3,13 @@ from textual import containers, getters, on, widgets from textual.app import ComposeResult +from textual.css.native import Keyword, NativeDeclaration, Percent, Variable +from textual.css.styles import Styles from textual.widgets._markdown import MarkdownH1 -from toad.native_layout import NativeLayout +from ui_theme.scheme import PrimaryColour, WarningColour + +from toad.native_layout import NativeLayout, NativeTheme +from toad.textual_colours import WarningTextColour from toad.screens.modal import DismissibleScreen from toad.db import DB, Session @@ -25,13 +30,22 @@ class ResumeHelp(widgets.Markdown): BLOCKS = widgets.Markdown.BLOCKS | {f"h{ResumeHeading.LEVEL}": ResumeHeading} -class ResumeWarning(NativeLayout, widgets.Static): +class ResumeWarning(NativeTheme, NativeLayout, widgets.Static): + native_theme = ( + NativeDeclaration.of(Styles.border, Keyword("round"), Variable(WarningColour.name)), + NativeDeclaration.of(Styles.color, Variable(WarningTextColour.name)), + ) + def apply_native_layout(self) -> None: self.styles.padding = (0, 1) self.styles.margin = (0, 0, 1, 1) -class ResumeFrame(NativeLayout, containers.VerticalGroup): +class ResumeFrame(NativeTheme, NativeLayout, containers.VerticalGroup): + native_theme = ( + NativeDeclaration.of(Styles.border, Keyword("thick"), Variable(PrimaryColour.name), Percent(20)), + ) + def apply_native_layout(self) -> None: self.styles.margin = (2, 4, 1, 4) self.styles.padding = (0, 1, 0, 0) @@ -60,8 +74,6 @@ def apply_native_layout(self) -> None: class SessionResumeModal(NativeLayout, DismissibleScreen[Session]): """Dialog to select a session to resume.""" - CSS_PATH = "session_resume_modal.tcss" - session_table = getters.query_one("#sessions", ResumeTable) def apply_native_layout(self) -> None: @@ -72,7 +84,6 @@ def compose(self) -> ComposeResult: yield ResumeHelp(HELP) yield ResumeWarning( "⚠ Not all ACP agents currently support resume", - classes="warning", ) with ResumeTableContainer(id="table-container"): yield ResumeTable(id="sessions", cursor_type="row") diff --git a/src/toad/screens/session_resume_modal.tcss b/src/toad/screens/session_resume_modal.tcss deleted file mode 100644 index ea535e569..000000000 --- a/src/toad/screens/session_resume_modal.tcss +++ /dev/null @@ -1,9 +0,0 @@ -SessionResumeModal { - .warning { - border: round $warning; - color: $text-warning; - } - #container { - border: thick $primary 20%; - } -} diff --git a/src/toad/textual_colours.py b/src/toad/textual_colours.py index b66655d49..fd9423ac9 100644 --- a/src/toad/textual_colours.py +++ b/src/toad/textual_colours.py @@ -60,6 +60,7 @@ TextStyle, ThemeColour, ToneColour, + WarningColour, ) from ui_theme.splash import TextEffect from ui_theme.wal import WalFile @@ -117,6 +118,12 @@ class ChannelAlternateRowToneColour(HueToneColour): amount = 0.2 +class WarningTextColour(ThemeColour): + """The native design system's warning text expression, including auto and alpha.""" + + name = f"text-{WarningColour.name}" + + class ClockCadence: """The animation clock's cadence; this base is the stopped one, with no tick due.""" From 64244c1892dd51736499102534b8ca2380474c7c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 2 Nov 2026 10:36:54 -0500 Subject: [PATCH 3/5] Keep warning text expressions distinct from RGB theme colours --- src/toad/screens/session_resume_modal.py | 5 ++--- src/toad/textual_colours.py | 7 ------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/toad/screens/session_resume_modal.py b/src/toad/screens/session_resume_modal.py index 876d540c5..648fa86b4 100644 --- a/src/toad/screens/session_resume_modal.py +++ b/src/toad/screens/session_resume_modal.py @@ -3,13 +3,12 @@ from textual import containers, getters, on, widgets from textual.app import ComposeResult -from textual.css.native import Keyword, NativeDeclaration, Percent, Variable +from textual.css.native import Keyword, NativeDeclaration, Percent, TextVariable, Variable from textual.css.styles import Styles from textual.widgets._markdown import MarkdownH1 from ui_theme.scheme import PrimaryColour, WarningColour from toad.native_layout import NativeLayout, NativeTheme -from toad.textual_colours import WarningTextColour from toad.screens.modal import DismissibleScreen from toad.db import DB, Session @@ -33,7 +32,7 @@ class ResumeHelp(widgets.Markdown): class ResumeWarning(NativeTheme, NativeLayout, widgets.Static): native_theme = ( NativeDeclaration.of(Styles.border, Keyword("round"), Variable(WarningColour.name)), - NativeDeclaration.of(Styles.color, Variable(WarningTextColour.name)), + NativeDeclaration.of(Styles.color, TextVariable(WarningColour.name)), ) def apply_native_layout(self) -> None: diff --git a/src/toad/textual_colours.py b/src/toad/textual_colours.py index fd9423ac9..b66655d49 100644 --- a/src/toad/textual_colours.py +++ b/src/toad/textual_colours.py @@ -60,7 +60,6 @@ TextStyle, ThemeColour, ToneColour, - WarningColour, ) from ui_theme.splash import TextEffect from ui_theme.wal import WalFile @@ -118,12 +117,6 @@ class ChannelAlternateRowToneColour(HueToneColour): amount = 0.2 -class WarningTextColour(ThemeColour): - """The native design system's warning text expression, including auto and alpha.""" - - name = f"text-{WarningColour.name}" - - class ClockCadence: """The animation clock's cadence; this base is the stopped one, with no tick due.""" From 488b92a34647fa1cafe1e2204a3f6549c915a110 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 2 Nov 2026 10:41:45 -0500 Subject: [PATCH 4/5] Apply native theme fields in the publication callback --- src/toad/native_layout.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/toad/native_layout.py b/src/toad/native_layout.py index 6df026c31..037d19b0c 100644 --- a/src/toad/native_layout.py +++ b/src/toad/native_layout.py @@ -30,7 +30,7 @@ def _post_register(self, app: App) -> None: def on_mount(self) -> None: self.apply_native_theme() - self.app.theme_changed_signal.subscribe(self, self._native_theme_changed) + self.app.theme_changed_signal.subscribe(self, self._native_theme_changed, immediate=True) def _native_theme_changed(self, theme: Theme) -> None: self.apply_native_theme() From 34095462775122359861e3e6c7e309f829e621a8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 2 Nov 2026 11:30:43 -0500 Subject: [PATCH 5/5] Use native stylesheet publication instead of parallel theme subscriptions --- src/toad/native_layout.py | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/src/toad/native_layout.py b/src/toad/native_layout.py index 037d19b0c..cd72c9061 100644 --- a/src/toad/native_layout.py +++ b/src/toad/native_layout.py @@ -3,8 +3,7 @@ from typing import ClassVar from textual.app import App -from textual.css.native import NativeDeclaration -from textual.theme import Theme +from textual.css.native import NativeDeclaration, NativeStyleResolver from textual.widget import Widget @@ -24,16 +23,10 @@ class NativeTheme(Widget): native_theme: ClassVar[tuple[NativeDeclaration, ...]] = () - def _post_register(self, app: App) -> None: - super()._post_register(app) - self.apply_native_theme() - - def on_mount(self) -> None: - self.apply_native_theme() - self.app.theme_changed_signal.subscribe(self, self._native_theme_changed, immediate=True) - - def _native_theme_changed(self, theme: Theme) -> None: - self.apply_native_theme() + def native_declarations(self) -> tuple[NativeDeclaration, ...]: + """Providers may extend this cooperatively when their declarations overlap.""" + return self.native_theme - def apply_native_theme(self) -> None: - self.styles.merge(self.app.stylesheet.native_styles(*self.native_theme)) + def apply_native_styles(self, resolver: NativeStyleResolver) -> None: + super().apply_native_styles(resolver) + self.styles.merge(resolver.native_styles(*self.native_declarations()))