diff --git a/CHANGELOG.md b/CHANGELOG.md index 64b3e225c8..0856eca6c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased -- Fixed `page_up` and `page_down` bug in `DataTable` when `show_header = False` https://github.com/Textualize/textual/pull/3093 +### Added + +- Methods `TabbedContent.disable_tab` and `TabbedContent.enable_tab` https://github.com/Textualize/textual/pull/3112 +- Methods `Tabs.disable` and `Tabs.enable` https://github.com/Textualize/textual/pull/3112 +- Messages `Tab.Disabled`, `Tab.Enabled`, `Tabs.TabDisabled` and `Tabs.Enabled` https://github.com/Textualize/textual/pull/3112 +- Methods `TabbedContent.hide_tab` and `TabbedContent.show_tab` https://github.com/Textualize/textual/pull/3112 +- Methods `Tabs.hide` and `Tabs.show` https://github.com/Textualize/textual/pull/3112 +- Messages `Tabs.TabHidden` and `Tabs.TabShown` https://github.com/Textualize/textual/pull/3112 ### Changed @@ -16,6 +23,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed - Fixed auto height container with default grid-rows https://github.com/Textualize/textual/issues/1597 +- Fixed `page_up` and `page_down` bug in `DataTable` when `show_header = False` https://github.com/Textualize/textual/pull/3093 ## [0.33.0] - 2023-08-15 diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 53a1b6afa8..e3d8d20c28 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -375,3 +375,69 @@ def _watch_active(self, active: str) -> None: def tab_count(self) -> int: """Total number of tabs.""" return self.get_child_by_type(Tabs).tab_count + + def _on_tabs_tab_disabled(self, event: Tabs.TabDisabled) -> None: + """Disable the corresponding tab pane.""" + event.stop() + tab_id = event.tab.id + try: + self.query_one(f"TabPane#{tab_id}").disabled = True + except NoMatches: + return + + def _on_tabs_tab_enabled(self, event: Tabs.TabEnabled) -> None: + """Enable the corresponding tab pane.""" + event.stop() + tab_id = event.tab.id + try: + self.query_one(f"TabPane#{tab_id}").disabled = False + except NoMatches: + return + + def disable_tab(self, tab_id: str) -> None: + """Disables the tab with the given ID. + + Args: + tab_id: The ID of the [`TabPane`][textual.widgets.TabPane] to disable. + + Raises: + Tabs.TabError: If there are any issues with the request. + """ + + self.query_one(Tabs).disable(tab_id) + + def enable_tab(self, tab_id: str) -> None: + """Enables the tab with the given ID. + + Args: + tab_id: The ID of the [`TabPane`][textual.widgets.TabPane] to enable. + + Raises: + Tabs.TabError: If there are any issues with the request. + """ + + self.query_one(Tabs).enable(tab_id) + + def hide_tab(self, tab_id: str) -> None: + """Hides the tab with the given ID. + + Args: + tab_id: The ID of the [`TabPane`][textual.widgets.TabPane] to hide. + + Raises: + Tabs.TabError: If there are any issues with the request. + """ + + self.query_one(Tabs).hide(tab_id) + + def show_tab(self, tab_id: str) -> None: + """Shows the tab with the given ID. + + Args: + tab_id: The ID of the [`TabPane`][textual.widgets.TabPane] to show. + + Raises: + Tabs.TabError: If there are any issues with the request. + """ + + self.query_one(Tabs).show(tab_id) diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index 46fecf72bc..d98759ce70 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass from typing import ClassVar import rich.repr @@ -104,17 +105,42 @@ class Tab(Static): Tab.-active:hover { color: $text; } + Tab:disabled { + color: $text-disabled; + text-opacity: 50%; + } + Tab.-hidden { + display: none; + } """ - class Clicked(Message): - """A tab was clicked.""" + @dataclass + class TabMessage(Message): + """Tab-related messages. + + These are mostly intended for internal use when interacting with `Tabs`. + """ tab: Tab - """The tab that was clicked.""" + """The tab that is the object of this message.""" - def __init__(self, tab: Tab) -> None: - self.tab = tab - super().__init__() + @property + def control(self) -> Tab: + """The tab that is the object of this message. + + This is an alias for the attribute `tab` and is used by the + [`on`][textual.on] decorator. + """ + return self.tab + + class Clicked(TabMessage): + """A tab was clicked.""" + + class Disabled(TabMessage): + """A tab was disabled.""" + + class Enabled(TabMessage): + """A tab was enabled.""" def __init__( self, @@ -143,6 +169,10 @@ def _on_click(self): """Inform the message that the tab was clicked.""" self.post_message(self.Clicked(self)) + def _watch_disabled(self, disabled: bool) -> None: + """Notify the parent `Tabs` that a tab was enabled/disabled.""" + self.post_message(self.Disabled(self) if disabled else self.Enabled(self)) + class Tabs(Widget, can_focus=True): """A row of tabs.""" @@ -184,8 +214,8 @@ class Tabs(Widget, can_focus=True): class TabError(Exception): """Exception raised when there is an error relating to tabs.""" - class TabActivated(Message): - """Sent when a new tab is activated.""" + class TabMessage(Message): + """Parent class for all messages that have to do with a specific tab.""" ALLOW_SELECTOR_MATCH = {"tab"} """Additional message attributes that can be used with the [`on` decorator][textual.on].""" @@ -195,20 +225,20 @@ def __init__(self, tabs: Tabs, tab: Tab) -> None: Args: tabs: The Tabs widget. - tab: The tab that was activated. + tab: The tab that is the object of this message. """ self.tabs: Tabs = tabs """The tabs widget containing the tab.""" self.tab: Tab = tab - """The tab that was activated.""" + """The tab that is the object of this message.""" super().__init__() @property def control(self) -> Tabs: - """The tabs widget containing the tab that was activated. + """The tabs widget containing the tab that is the object of this message. - This is an alias for [`TabActivated.tabs`][textual.widgets.Tabs.TabActivated.tabs] - which is used by the [`on`][textual.on] decorator. + This is an alias for the attribute `tabs` and is used by the + [`on`][textual.on] decorator. """ return self.tabs @@ -216,6 +246,21 @@ def __rich_repr__(self) -> rich.repr.Result: yield self.tabs yield self.tab + class TabActivated(TabMessage): + """Sent when a new tab is activated.""" + + class TabDisabled(TabMessage): + """Sent when a tab is disabled.""" + + class TabEnabled(TabMessage): + """Sent when a tab is enabled.""" + + class TabHidden(TabMessage): + """Sent when a tab is hidden.""" + + class TabShown(TabMessage): + """Sent when a tab is shown.""" + class Cleared(Message): """Sent when there are no active tabs.""" @@ -299,10 +344,24 @@ def tab_count(self) -> int: """Total number of tabs.""" return len(self.query("#tabs-list > Tab")) + @property + def _potentially_active_tabs(self) -> list[Tab]: + """List of all tabs that could be active. + + This list is comprised of all tabs that are shown and enabled, + plus the active tab in case it is disabled. + """ + return [ + tab + for tab in self.query("#tabs-list > Tab").results(Tab) + if ((not tab.disabled or tab is self.active_tab) and tab.display) + ] + @property def _next_active(self) -> Tab | None: """Next tab to make active if the active tab is removed.""" - tabs = list(self.query("#tabs-list > Tab").results(Tab)) + active_tab = self.active_tab + tabs = self._potentially_active_tabs if self.active_tab is None: return None try: @@ -412,7 +471,7 @@ def remove_tab(self, tab_or_id: Tab | str | None) -> AwaitRemove: """Remove a tab. Args: - tab_or_id: The Tab's id. + tab_or_id: The Tab to remove or its id. Returns: An awaitable object that waits for the tab to be removed. @@ -590,10 +649,117 @@ def _move_tab(self, direction: int) -> None: active_tab = self.active_tab if active_tab is None: return - tabs = list(self.query(Tab)) + tabs = self._potentially_active_tabs if not tabs: return tab_count = len(tabs) new_tab_index = (tabs.index(active_tab) + direction) % tab_count self.active = tabs[new_tab_index].id or "" self._scroll_active_tab() + + def _on_tab_disabled(self, event: Tab.Disabled) -> None: + """Re-post the disabled message.""" + event.stop() + self.post_message(self.TabDisabled(self, event.tab)) + + def _on_tab_enabled(self, event: Tab.Enabled) -> None: + """Re-post the enabled message.""" + event.stop() + self.post_message(self.TabEnabled(self, event.tab)) + + 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. + """ + + try: + tab_to_disable = self.query_one(f"#tabs-list > Tab#{tab_id}", Tab) + except NoMatches: + raise self.TabError( + f"There is no tab with ID {tab_id!r} to disable." + ) from None + + tab_to_disable.disabled = True + return tab_to_disable + + 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. + """ + + try: + tab_to_enable = self.query_one(f"#tabs-list > Tab#{tab_id}", Tab) + except NoMatches: + raise self.TabError( + f"There is no tab with ID {tab_id!r} to enable." + ) from None + + tab_to_enable.disabled = False + return tab_to_enable + + 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. + """ + + try: + tab_to_hide = self.query_one(f"#tabs-list > Tab#{tab_id}", Tab) + except NoMatches: + raise self.TabError(f"There is no tab with ID {tab_id!r} to hide.") + + if tab_to_hide.has_class("-active"): + next_tab = self._next_active + self.active = next_tab.id or "" if next_tab else "" + tab_to_hide.add_class("-hidden") + self.post_message(self.TabHidden(self, tab_to_hide)) + self.call_after_refresh(self._highlight_active) + return tab_to_hide + + 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. + """ + + try: + tab_to_show = self.query_one(f"#tabs-list > Tab#{tab_id}", Tab) + except NoMatches: + raise self.TabError(f"There is no tab with ID {tab_id!r} to show.") + + tab_to_show.remove_class("-hidden") + self.post_message(self.TabShown(self, tab_to_show)) + if not self.active: + self._activate_tab(tab_to_show) + self.call_after_refresh(self._highlight_active) + return tab_to_show diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 66ed21facc..4fe8a4f049 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -27476,6 +27476,169 @@ ''' # --- +# name: test_tabbed_content_with_modified_tabs + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + FiddleWithTabsApp + + + + + + + + + + + Tab 1Tab 2Tab 4Tab 5 + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +  Button  + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + + + + + + ''' +# --- # name: test_table_markup ''' diff --git a/tests/snapshot_tests/snapshot_apps/modified_tabs.py b/tests/snapshot_tests/snapshot_apps/modified_tabs.py new file mode 100644 index 0000000000..48bb66d567 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/modified_tabs.py @@ -0,0 +1,27 @@ +from textual.app import App, ComposeResult +from textual.widgets import Button, TabbedContent + + +class FiddleWithTabsApp(App[None]): + CSS = """ + TabPane:disabled { + background: red; + } + """ + + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Button() + yield Button() + yield Button() + yield Button() + 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") + + +if __name__ == "__main__": + FiddleWithTabsApp().run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 77fc2ba10b..36b003bb7e 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -222,6 +222,11 @@ def test_tabbed_content(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "tabbed_content.py") +def test_tabbed_content_with_modified_tabs(snap_compare): + # Tabs enabled and hidden. + assert snap_compare(SNAPSHOT_APPS_DIR / "modified_tabs.py") + + def test_option_list_strings(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "option_list_strings.py") diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py index 882dd11523..5eec2abcfb 100644 --- a/tests/test_tabbed_content.py +++ b/tests/test_tabbed_content.py @@ -427,3 +427,272 @@ def on_tabbed_content_cleared(self) -> None: assert tabbed_content.tab_count == 0 assert tabbed_content.active == "" assert pilot.app.cleared == 1 + + +async def test_disabling_does_not_deactivate_tab(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + + def on_mount(self) -> None: + self.query_one("Tab#tab-1").disabled = True + + app = TabbedApp() + async with app.run_test(): + assert app.query_one(Tabs).active == "tab-1" + + +async def test_disabled_tab_cannot_be_clicked(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + yield Label("tab-2") + + def on_mount(self) -> None: + self.query_one("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" + + +async def test_disabling_via_tabbed_content(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + yield Label("tab-2") + + def on_mount(self) -> None: + self.query_one(TabbedContent).disable_tab("tab-2") + + app = TabbedApp() + async with app.run_test() as pilot: + await pilot.click("Tab#tab-2") + assert app.query_one(Tabs).active == "tab-1" + + +async def test_navigation_around_disabled_tabs(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + yield Label("tab-2") + yield Label("tab-3") + 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 + + app = TabbedApp() + async with app.run_test(): + tabs = app.query_one(Tabs) + assert tabs.active == "tab-1" + tabs.action_next_tab() + assert tabs.active == "tab-2" + tabs.action_next_tab() + assert tabs.active == "tab-4" + tabs.action_next_tab() + assert tabs.active == "tab-2" + tabs.action_previous_tab() + assert tabs.active == "tab-4" + + +async def test_reenabling_tab(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + yield Label("tab-2") + + def on_mount(self) -> None: + self.query_one("Tab#tab-2").disabled = True + + def reenable(self) -> None: + app.query_one("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" + app.reenable() + await pilot.click("Tab#tab-2") + assert app.query_one(Tabs).active == "tab-2" + + +async def test_reenabling_via_tabbed_content(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + yield Label("tab-2") + + def on_mount(self) -> None: + self.query_one(TabbedContent).disable_tab("tab-2") + + def reenable(self) -> None: + self.query_one(TabbedContent).enable_tab("tab-2") + + app = TabbedApp() + async with app.run_test() as pilot: + await pilot.click("Tab#tab-2") + assert app.query_one(Tabs).active == "tab-1" + app.reenable() + await pilot.click("Tab#tab-2") + assert app.query_one(Tabs).active == "tab-2" + + +async def test_disabling_unknown_tab(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + + app = TabbedApp() + async with app.run_test(): + with pytest.raises(Tabs.TabError): + app.query_one(TabbedContent).disable_tab("foo") + + +async def test_enabling_unknown_tab(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + + app = TabbedApp() + async with app.run_test(): + with pytest.raises(Tabs.TabError): + app.query_one(TabbedContent).enable_tab("foo") + + +async def test_hide_unknown_tab(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + + app = TabbedApp() + async with app.run_test(): + with pytest.raises(Tabs.TabError): + app.query_one(TabbedContent).hide_tab("foo") + + +async def test_show_unknown_tab(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + + app = TabbedApp() + async with app.run_test(): + with pytest.raises(Tabs.TabError): + app.query_one(TabbedContent).show_tab("foo") + + +async def test_hide_show_messages(): + hide_msg = False + show_msg = False + + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + + def on_tabs_tab_hidden(self) -> None: + nonlocal hide_msg + hide_msg = True + + def on_tabs_tab_shown(self) -> None: + nonlocal show_msg + show_msg = True + + app = TabbedApp() + async with app.run_test() as pilot: + app.query_one(TabbedContent).hide_tab("tab-1") + await pilot.pause() + assert hide_msg + app.query_one(TabbedContent).show_tab("tab-1") + await pilot.pause() + assert show_msg + + +async def test_hide_last_tab_means_no_tab_active(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + + app = TabbedApp() + async with app.run_test() as pilot: + tabbed_content = app.query_one(TabbedContent) + tabbed_content.hide_tab("tab-1") + await pilot.pause() + assert not tabbed_content.active + + +async def test_hiding_tabs_moves_active_to_next_tab(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + yield Label("tab-2") + yield Label("tab-3") + + app = TabbedApp() + async with app.run_test() as pilot: + tabbed_content = app.query_one(TabbedContent) + tabbed_content.hide_tab("tab-1") + await pilot.pause() + assert tabbed_content.active == "tab-2" + tabbed_content.hide_tab("tab-2") + await pilot.pause() + assert tabbed_content.active == "tab-3" + + +async def test_showing_tabs_does_not_change_active_tab(): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + yield Label("tab-2") + yield Label("tab-3") + + app = TabbedApp() + async with app.run_test() as pilot: + tabbed_content = app.query_one(TabbedContent) + tabbed_content.hide_tab("tab-1") + tabbed_content.hide_tab("tab-2") + await pilot.pause() + # sanity check + assert tabbed_content.active == "tab-3" + + tabbed_content.show_tab("tab-1") + tabbed_content.show_tab("tab-2") + assert tabbed_content.active == "tab-3" + + +@pytest.mark.parametrize("tab_id", ["tab-1", "tab-2"]) +async def test_showing_first_tab_activates_tab(tab_id: str): + class TabbedApp(App[None]): + def compose(self) -> ComposeResult: + with TabbedContent(): + yield Label("tab-1") + yield Label("tab-2") + + app = TabbedApp() + async with app.run_test() as pilot: + tabbed_content = app.query_one(TabbedContent) + tabbed_content.hide_tab("tab-1") + tabbed_content.hide_tab("tab-2") + await pilot.pause() + # sanity check + assert not tabbed_content.active + + tabbed_content.show_tab(tab_id) + await pilot.pause() + assert tabbed_content.active == tab_id