diff --git a/CHANGELOG.md b/CHANGELOG.md index a080ae1928..760e79e010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased +### Changed + +- The tabs within a `TabbedContent` now prefix their IDs to stop any clash with their associated `TabPane` https://github.com/Textualize/textual/pull/3815 +- Breaking change: `tab` is no longer a `@on` decorator selector for `TabbedContent.TabActivated` -- use `pane` instead https://github.com/Textualize/textual/pull/3815 + +### Added + +- Added a `pane` attribute to `TabbedContent.TabActivated` https://github.com/Textualize/textual/pull/3815 + ### Fixed - Fixed `DataTable.update_cell` not raising an error with an invalid column key https://github.com/Textualize/textual/issues/3335 diff --git a/docs/examples/widgets/tabbed_content_label_color.py b/docs/examples/widgets/tabbed_content_label_color.py new file mode 100644 index 0000000000..573944b948 --- /dev/null +++ b/docs/examples/widgets/tabbed_content_label_color.py @@ -0,0 +1,25 @@ +from textual.app import App, ComposeResult +from textual.widgets import Label, TabbedContent, TabPane + + +class ColorTabsApp(App): + CSS = """ + TabbedContent #--content-tab-green { + color: green; + } + + TabbedContent #--content-tab-red { + color: red; + } + """ + + def compose(self) -> ComposeResult: + with TabbedContent(): + with TabPane("Red", id="red"): + yield Label("Red!") + with TabPane("Green", id="green"): + yield Label("Green!") + + +if __name__ == "__main__": + ColorTabsApp().run() diff --git a/docs/widgets/tabbed_content.md b/docs/widgets/tabbed_content.md index f121e314e8..15164e7907 100644 --- a/docs/widgets/tabbed_content.md +++ b/docs/widgets/tabbed_content.md @@ -94,6 +94,30 @@ The following example contains a `TabbedContent` with three tabs. --8<-- "docs/examples/widgets/tabbed_content.py" ``` +## Styling + +The `TabbedContent` widget is composed of two main sub-widgets: a +[`Tabs`](tabs.md) and a [`ContentSwitcher`]((content_switcher.md)); you can +style them accordingly. + +The tabs within the `Tabs` widget will have prefixed IDs; each ID being the +ID of the `TabPane` the `Tab` is for, prefixed with `--content-tab-`. If you +wish to style individual tabs within the `TabbedContent` widget you will +need to use that prefix for the `Tab` IDs. + +For example, to create a `TabbedContent` that has red and green labels: + +=== "Output" + + ```{.textual path="docs/examples/widgets/tabbed_content_label_color.py"} + ``` + +=== "tabbed_content.py" + + ```python + --8<-- "docs/examples/widgets/tabbed_content_label_color.py" + ``` + ## Reactive Attributes | Name | Type | Default | Description | diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 605b2f9521..47fa6865c0 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -6,6 +6,7 @@ from rich.repr import Result from rich.text import Text, TextType +from typing_extensions import Final from ..app import ComposeResult from ..await_complete import AwaitComplete @@ -26,7 +27,38 @@ class ContentTab(Tab): """A Tab with an associated content id.""" - def __init__(self, label: Text, content_id: str, disabled: bool = False): + _PREFIX: Final[str] = "--content-tab-" + """The prefix given to the tab IDs.""" + + @classmethod + def add_prefix(cls, content_id: str) -> str: + """Add the prefix to the given ID. + + Args: + content_id: The ID to add the prefix to. + + Returns: + The ID with the prefix added. + """ + return f"{cls._PREFIX}{content_id}" if content_id else content_id + + @classmethod + def sans_prefix(cls, content_id: str) -> str: + """Remove the prefix from the given ID. + + Args: + content_id: The ID to remove the prefix from. + + Returns: + The ID with the prefix removed. + """ + return ( + content_id[len(cls._PREFIX) :] + if content_id.startswith(cls._PREFIX) + else content_id + ) + + def __init__(self, label: Text, content_id: str, disabled: bool = False) -> None: """Initialize a ContentTab. Args: @@ -34,7 +66,7 @@ def __init__(self, label: Text, content_id: str, disabled: bool = False): content_id: The id of the content associated with the tab. disabled: Is the tab disabled? """ - super().__init__(label, id=content_id, disabled=disabled) + super().__init__(label, id=self.add_prefix(content_id), disabled=disabled) class ContentTabs(Tabs): @@ -53,9 +85,78 @@ def __init__( active: ID of the tab which should be active on start. tabbed_content: The associated TabbedContent instance. """ - super().__init__(*tabs, active=active) + super().__init__( + *tabs, active=active if active is None else ContentTab.add_prefix(active) + ) self.tabbed_content = tabbed_content + def get_content_tab(self, tab_id: str) -> ContentTab: + """Get the `ContentTab` associated with the given `TabPane` ID. + + Args: + tab_id: The ID of the tab to get. + + Returns: + The tab associated with that ID. + """ + return self.query_one(f"#{ContentTab.add_prefix(tab_id)}", ContentTab) + + def disable(self, tab_id: str) -> Tab: + """Disable the indicated tab. + + Args: + tab_id: The ID of the [`Tab`][textual.widgets.Tab] to disable. + + Returns: + The [`Tab`][textual.widgets.Tab] that was targeted. + + Raises: + TabError: If there are any issues with the request. + """ + return super().disable(ContentTab.add_prefix(tab_id)) + + def enable(self, tab_id: str) -> Tab: + """Enable the indicated tab. + + Args: + tab_id: The ID of the [`Tab`][textual.widgets.Tab] to enable. + + Returns: + The [`Tab`][textual.widgets.Tab] that was targeted. + + Raises: + TabError: If there are any issues with the request. + """ + return super().enable(ContentTab.add_prefix(tab_id)) + + def hide(self, tab_id: str) -> Tab: + """Hide the indicated tab. + + Args: + tab_id: The ID of the [`Tab`][textual.widgets.Tab] to hide. + + Returns: + The [`Tab`][textual.widgets.Tab] that was targeted. + + Raises: + TabError: If there are any issues with the request. + """ + return super().hide(ContentTab.add_prefix(tab_id)) + + def show(self, tab_id: str) -> Tab: + """Show the indicated tab. + + Args: + tab_id: The ID of the [`Tab`][textual.widgets.Tab] to show. + + Returns: + The [`Tab`][textual.widgets.Tab] that was targeted. + + Raises: + TabError: If there are any issues with the request. + """ + return super().show(ContentTab.add_prefix(tab_id)) + class TabPane(Widget): """A container for switchable content, with additional title. @@ -142,10 +243,10 @@ class TabbedContent(Widget): class TabActivated(Message): """Posted when the active tab changes.""" - ALLOW_SELECTOR_MATCH = {"tab"} + ALLOW_SELECTOR_MATCH = {"pane"} """Additional message attributes that can be used with the [`on` decorator][textual.on].""" - def __init__(self, tabbed_content: TabbedContent, tab: Tab) -> None: + def __init__(self, tabbed_content: TabbedContent, tab: ContentTab) -> None: """Initialize message. Args: @@ -156,6 +257,8 @@ def __init__(self, tabbed_content: TabbedContent, tab: Tab) -> None: """The `TabbedContent` widget that contains the tab activated.""" self.tab = tab """The `Tab` widget that was selected (contains the tab label).""" + self.pane = tabbed_content.get_pane(tab) + """The `TabPane` widget that was activated by selecting the tab.""" super().__init__() @property @@ -170,6 +273,7 @@ def control(self) -> TabbedContent: def __rich_repr__(self) -> Result: yield self.tabbed_content yield self.tab + yield self.pane class Cleared(Message): """Posted when there are no more tab panes.""" @@ -317,7 +421,11 @@ def add_pane( assert pane.id is not None pane.display = False return AwaitComplete( - tabs.add_tab(ContentTab(pane._title, pane.id), before=before, after=after), + tabs.add_tab( + ContentTab(pane._title, pane.id), + before=before if before is None else ContentTab.add_prefix(before), + after=after if after is None else ContentTab.add_prefix(after), + ), self.get_child_by_type(ContentSwitcher).mount(pane), ) @@ -331,7 +439,11 @@ def remove_pane(self, pane_id: str) -> AwaitComplete: An optionally awaitable object that waits for the pane to be removed and the Cleared message to be posted. """ - removal_awaitables = [self.get_child_by_type(ContentTabs).remove_tab(pane_id)] + removal_awaitables = [ + self.get_child_by_type(ContentTabs).remove_tab( + ContentTab.add_prefix(pane_id) + ) + ] try: removal_awaitables.append( self.get_child_by_type(ContentSwitcher) @@ -390,8 +502,8 @@ def _on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: # The message is relevant, so consume it and update state accordingly. event.stop() switcher = self.get_child_by_type(ContentSwitcher) - switcher.current = event.tab.id - self.active = event.tab.id + switcher.current = ContentTab.sans_prefix(event.tab.id) + self.active = ContentTab.sans_prefix(event.tab.id) self.post_message( TabbedContent.TabActivated( tabbed_content=self, @@ -425,7 +537,7 @@ def _is_associated_tabs(self, tabs: Tabs) -> bool: def _watch_active(self, active: str) -> None: """Switch tabs when the active attributes changes.""" with self.prevent(Tabs.TabActivated): - self.get_child_by_type(ContentTabs).active = active + self.get_child_by_type(ContentTabs).active = ContentTab.add_prefix(active) self.get_child_by_type(ContentSwitcher).current = active @property @@ -433,6 +545,51 @@ def tab_count(self) -> int: """Total number of tabs.""" return self.get_child_by_type(ContentTabs).tab_count + def get_tab(self, pane_id: str | TabPane) -> Tab: + """Get the `Tab` associated with the given ID or `TabPane`. + + Args: + pane_id: The ID of the pane, or the pane itself. + + Returns: + The Tab associated with the ID. + + Raises: + ValueError: Raised if no ID was available. + """ + if target_id := (pane_id if isinstance(pane_id, str) else pane_id.id): + return self.get_child_by_type(ContentTabs).get_content_tab(target_id) + raise ValueError( + "'pane_id' must be a non-empty string or a TabPane with an id." + ) + + def get_pane(self, pane_id: str | ContentTab) -> TabPane: + """Get the `TabPane` associated with the given ID or tab. + + Args: + pane_id: The ID of the pane to get, or the Tab it is associated with. + + Returns: + The `TabPane` associated with the ID or the given tab. + + Raises: + ValueError: Raised if no ID was available. + """ + target_id: str | None = None + if isinstance(pane_id, ContentTab): + target_id = ( + pane_id.id if pane_id.id is None else ContentTab.sans_prefix(pane_id.id) + ) + else: + target_id = pane_id + if target_id: + pane = self.get_child_by_type(ContentSwitcher).get_child_by_id(target_id) + assert isinstance(pane, TabPane) + return pane + raise ValueError( + "'pane_id' must be a non-empty string or a ContentTab with an id." + ) + def _on_tabs_tab_disabled(self, event: Tabs.TabDisabled) -> None: """Disable the corresponding tab pane.""" event.stop() @@ -440,7 +597,7 @@ def _on_tabs_tab_disabled(self, event: Tabs.TabDisabled) -> None: try: with self.prevent(TabPane.Disabled): self.get_child_by_type(ContentSwitcher).get_child_by_id( - tab_id, expect_type=TabPane + ContentTab.sans_prefix(tab_id), expect_type=TabPane ).disabled = True except NoMatches: return @@ -448,12 +605,9 @@ def _on_tabs_tab_disabled(self, event: Tabs.TabDisabled) -> None: def _on_tab_pane_disabled(self, event: TabPane.Disabled) -> None: """Disable the corresponding tab.""" event.stop() - tab_pane_id = event.tab_pane.id or "" try: with self.prevent(Tab.Disabled): - self.get_child_by_type(ContentTabs).query_one( - f"Tab#{tab_pane_id}" - ).disabled = True + self.get_tab(event.tab_pane).disabled = True except NoMatches: return @@ -464,7 +618,7 @@ def _on_tabs_tab_enabled(self, event: Tabs.TabEnabled) -> None: try: with self.prevent(TabPane.Enabled): self.get_child_by_type(ContentSwitcher).get_child_by_id( - tab_id, expect_type=TabPane + ContentTab.sans_prefix(tab_id), expect_type=TabPane ).disabled = False except NoMatches: return @@ -472,12 +626,9 @@ def _on_tabs_tab_enabled(self, event: Tabs.TabEnabled) -> None: def _on_tab_pane_enabled(self, event: TabPane.Enabled) -> None: """Enable the corresponding tab.""" event.stop() - tab_pane_id = event.tab_pane.id or "" try: - with self.prevent(Tab.Enabled): - self.get_child_by_type(ContentTabs).query_one( - f"Tab#{tab_pane_id}" - ).disabled = False + with self.prevent(Tab.Disabled): + self.get_tab(event.tab_pane).disabled = False except NoMatches: return diff --git a/tests/snapshot_tests/snapshot_apps/modified_tabs.py b/tests/snapshot_tests/snapshot_apps/modified_tabs.py index 48bb66d567..c94771c3d3 100644 --- a/tests/snapshot_tests/snapshot_apps/modified_tabs.py +++ b/tests/snapshot_tests/snapshot_apps/modified_tabs.py @@ -18,9 +18,9 @@ def compose(self) -> ComposeResult: yield Button() def on_mount(self) -> None: - self.query_one(TabbedContent).disable_tab(f"tab-1") - self.query_one(TabbedContent).disable_tab(f"tab-2") - self.query_one(TabbedContent).hide_tab(f"tab-3") + self.query_one(TabbedContent).disable_tab("tab-1") + self.query_one(TabbedContent).disable_tab("tab-2") + self.query_one(TabbedContent).hide_tab("tab-3") if __name__ == "__main__": diff --git a/tests/test_on.py b/tests/test_on.py index 2bfaf476a6..6ca0be9fd0 100644 --- a/tests/test_on.py +++ b/tests/test_on.py @@ -134,11 +134,11 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: self.query_one(TabbedContent).add_class("tabs") - @on(TabbedContent.TabActivated, tab="#one") + @on(TabbedContent.TabActivated, pane="#one") def one(self) -> None: log.append("one") - @on(TabbedContent.TabActivated, ".tabs", tab="#two") + @on(TabbedContent.TabActivated, pane="#two") def two(self) -> None: log.append("two") @@ -189,7 +189,6 @@ def on_mount(self) -> None: self.query_one(MessageSender).post_parent() self.query_one(MessageSender).post_child() - async with InheritTestApp().run_test(): pass diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py index 9bd49de273..5b67cc508a 100644 --- a/tests/test_tabbed_content.py +++ b/tests/test_tabbed_content.py @@ -3,6 +3,7 @@ from textual.app import App, ComposeResult from textual.reactive import var from textual.widgets import Label, Tab, TabbedContent, TabPane, Tabs +from textual.widgets._tabbed_content import ContentTab async def test_tabbed_content_switch_via_ui(): @@ -29,7 +30,7 @@ def compose(self) -> ComposeResult: assert not app.query_one("#baz-label").region # Click second tab - await pilot.click("Tab#bar") + await pilot.click(f"Tab#{ContentTab.add_prefix('bar')}") assert tabbed_content.active == "bar" await pilot.pause() assert not app.query_one("#foo-label").region @@ -37,7 +38,7 @@ def compose(self) -> ComposeResult: assert not app.query_one("#baz-label").region # Click third tab - await pilot.click("Tab#baz") + await pilot.click(f"Tab#{ContentTab.add_prefix('baz')}") assert tabbed_content.active == "baz" await pilot.pause() assert not app.query_one("#foo-label").region @@ -209,7 +210,9 @@ def compose(self) -> ComposeResult: await tabbed_content.add_pane(TabPane("Added", id="new-1"), before="initial-1") assert tabbed_content.tab_count == 2 assert tabbed_content.active == "initial-1" - assert [tab.id for tab in tabbed_content.query(Tab).results(Tab)] == [ + assert [ + ContentTab.sans_prefix(tab.id) for tab in tabbed_content.query(Tab) + ] == [ "new-1", "initial-1", ] @@ -231,7 +234,10 @@ def compose(self) -> ComposeResult: ) assert tabbed_content.tab_count == 2 assert tabbed_content.active == "initial-1" - assert [tab.id for tab in tabbed_content.query(Tab).results(Tab)] == [ + assert [ + ContentTab.sans_prefix(tab.id) + for tab in tabbed_content.query(Tab).results(Tab) + ] == [ "new-1", "initial-1", ] @@ -266,7 +272,10 @@ def compose(self) -> ComposeResult: await tabbed_content.add_pane(TabPane("Added", id="new-1"), after="initial-1") assert tabbed_content.tab_count == 2 assert tabbed_content.active == "initial-1" - assert [tab.id for tab in tabbed_content.query(Tab).results(Tab)] == [ + assert [ + ContentTab.sans_prefix(tab.id) + for tab in tabbed_content.query(Tab).results(Tab) + ] == [ "initial-1", "new-1", ] @@ -289,7 +298,10 @@ def compose(self) -> ComposeResult: await pilot.pause() assert tabbed_content.tab_count == 2 assert tabbed_content.active == "initial-1" - assert [tab.id for tab in tabbed_content.query(Tab).results(Tab)] == [ + assert [ + ContentTab.sans_prefix(tab.id) + for tab in tabbed_content.query(Tab).results(Tab) + ] == [ "initial-1", "new-1", ] @@ -429,11 +441,11 @@ def compose(self) -> ComposeResult: yield Label("tab-1") def on_mount(self) -> None: - self.query_one("Tab#tab-1").disabled = True + self.query_one(TabbedContent).get_tab("tab-1").disabled = True app = TabbedApp() async with app.run_test(): - assert app.query_one(Tabs).active == "tab-1" + assert app.query_one(TabbedContent).active == "tab-1" async def test_disabled_tab_cannot_be_clicked(): @@ -444,12 +456,12 @@ def compose(self) -> ComposeResult: yield Label("tab-2") def on_mount(self) -> None: - self.query_one("Tab#tab-2").disabled = True + self.query_one(TabbedContent).get_tab("tab-2").disabled = True app = TabbedApp() async with app.run_test() as pilot: - await pilot.click("Tab#tab-2") - assert app.query_one(Tabs).active == "tab-1" + await pilot.click(f"Tab#{ContentTab.add_prefix('tab-2')}") + assert app.query_one(TabbedContent).active == "tab-1" async def test_disabling_via_tabbed_content(): @@ -464,8 +476,8 @@ def on_mount(self) -> None: app = TabbedApp() async with app.run_test() as pilot: - await pilot.click("Tab#tab-2") - assert app.query_one(Tabs).active == "tab-1" + await pilot.click(f"Tab#{ContentTab.add_prefix('tab-2')}") + assert app.query_one(TabbedContent).active == "tab-1" async def test_disabling_via_tab_pane(): @@ -481,8 +493,8 @@ def on_mount(self) -> None: app = TabbedApp() async with app.run_test() as pilot: await pilot.pause() - await pilot.click("Tab#tab-2") - assert app.query_one(Tabs).active == "tab-1" + await pilot.click(f"Tab#{ContentTab.add_prefix('tab-2')}") + assert app.query_one(TabbedContent).active == "tab-1" async def test_creating_disabled_tab(): @@ -496,8 +508,8 @@ def compose(self) -> ComposeResult: app = TabbedApp() async with app.run_test() as pilot: - await pilot.click("Tab#tab-2") - assert app.query_one(Tabs).active == "tab-1" + await pilot.click(f"Tab#{ContentTab.add_prefix('tab-2')}") + assert app.query_one(TabbedContent).active == "tab-1" async def test_navigation_around_disabled_tabs(): @@ -510,21 +522,26 @@ def compose(self) -> ComposeResult: yield Label("tab-4") def on_mount(self) -> None: - self.query_one("Tab#tab-1").disabled = True - self.query_one("Tab#tab-3").disabled = True + self.query_one(TabbedContent).get_tab("tab-1").disabled = True + self.query_one(TabbedContent).get_tab("tab-3").disabled = True app = TabbedApp() - async with app.run_test(): + async with app.run_test() as pilot: + tabbed_conent = app.query_one(TabbedContent) tabs = app.query_one(Tabs) - assert tabs.active == "tab-1" + assert tabbed_conent.active == "tab-1" tabs.action_next_tab() - assert tabs.active == "tab-2" + await pilot.pause() + assert tabbed_conent.active == "tab-2" tabs.action_next_tab() - assert tabs.active == "tab-4" + await pilot.pause() + assert tabbed_conent.active == "tab-4" tabs.action_next_tab() - assert tabs.active == "tab-2" + await pilot.pause() + assert tabbed_conent.active == "tab-2" tabs.action_previous_tab() - assert tabs.active == "tab-4" + await pilot.pause() + assert tabbed_conent.active == "tab-4" async def test_reenabling_tab(): @@ -535,18 +552,18 @@ def compose(self) -> ComposeResult: yield Label("tab-2") def on_mount(self) -> None: - self.query_one("Tab#tab-2").disabled = True + self.query_one(TabbedContent).get_tab("tab-2").disabled = True def reenable(self) -> None: - app.query_one("Tab#tab-2").disabled = False + self.query_one(TabbedContent).get_tab("tab-2").disabled = False app = TabbedApp() async with app.run_test() as pilot: - await pilot.click("Tab#tab-2") - assert app.query_one(Tabs).active == "tab-1" + await pilot.click(f"Tab#{ContentTab.add_prefix('tab-2')}") + assert app.query_one(TabbedContent).active == "tab-1" app.reenable() - await pilot.click("Tab#tab-2") - assert app.query_one(Tabs).active == "tab-2" + await pilot.click(f"Tab#{ContentTab.add_prefix('tab-2')}") + assert app.query_one(TabbedContent).active == "tab-2" async def test_reenabling_via_tabbed_content(): @@ -564,11 +581,11 @@ def reenable(self) -> None: app = TabbedApp() async with app.run_test() as pilot: - await pilot.click("Tab#tab-2") - assert app.query_one(Tabs).active == "tab-1" + await pilot.click(f"Tab#{ContentTab.add_prefix('tab-2')}") + assert app.query_one(TabbedContent).active == "tab-1" app.reenable() - await pilot.click("Tab#tab-2") - assert app.query_one(Tabs).active == "tab-2" + await pilot.click(f"Tab#{ContentTab.add_prefix('tab-2')}") + assert app.query_one(TabbedContent).active == "tab-2" async def test_reenabling_via_tab_pane(): @@ -587,11 +604,11 @@ def reenable(self) -> None: app = TabbedApp() async with app.run_test() as pilot: await pilot.pause() - await pilot.click("Tab#tab-2") - assert app.query_one(Tabs).active == "tab-1" + await pilot.click(f"Tab#{ContentTab.add_prefix('tab-2')}") + assert app.query_one(TabbedContent).active == "tab-1" app.reenable() - await pilot.click("Tab#tab-2") - assert app.query_one(Tabs).active == "tab-2" + await pilot.click(f"Tab#{ContentTab.add_prefix('tab-2')}") + assert app.query_one(TabbedContent).active == "tab-2" async def test_disabling_unknown_tab():