From 6e0adc05606d261e9cd2dea5060bb04f310bec29 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 14 Mar 2023 17:57:57 +0000 Subject: [PATCH 01/25] tabbed content widget --- src/textual/_compose.py | 2 +- src/textual/_compositor.py | 5 +- src/textual/app.py | 14 +++ src/textual/css/_style_properties.py | 6 +- src/textual/dom.py | 12 +++ src/textual/widget.py | 37 +++++++- src/textual/widgets/_content_switcher.py | 6 +- src/textual/widgets/_tabbed_content.py | 104 +++++++++++++++++++++++ src/textual/widgets/_tabs.py | 2 +- 9 files changed, 178 insertions(+), 10 deletions(-) create mode 100644 src/textual/widgets/_tabbed_content.py diff --git a/src/textual/_compose.py b/src/textual/_compose.py index 272e8690ae..022664d2b0 100644 --- a/src/textual/_compose.py +++ b/src/textual/_compose.py @@ -28,7 +28,7 @@ def compose(node: App | Widget) -> list[Widget]: nodes.extend(composed) composed.clear() if compose_stack: - compose_stack[-1]._nodes._append(child) + compose_stack[-1].compose_add_child(child) else: nodes.append(child) if composed: diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 79080f59f5..ebb356906b 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -893,10 +893,11 @@ def update_widgets(self, widgets: set[Widget]) -> None: widget: Widget to update. """ - if not self._full_map_invalidated and not widgets.issuperset( - self.visible_widgets + if not self._full_map_invalidated and not widgets.issubset( + self.visible_widgets.keys() ): self._full_map_invalidated = True + regions: list[Region] = [] add_region = regions.append get_widget = self.visible_widgets.__getitem__ diff --git a/src/textual/app.py b/src/textual/app.py index 2c9f8f4764..6a0a84d9c3 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1131,6 +1131,20 @@ def get_widget_by_id( else self.screen.get_widget_by_id(id, expect_type) ) + def get_child_by_type(self, expect_type: type[ExpectType]) -> ExpectType: + """Get a child of a give type. + + Args: + expect_type: The type of the expected child. + + Raises: + NoMatches: If no valid child is found. + + Returns: + A widget. + """ + return self.screen.get_child_by_type(expect_type) + def update_styles(self, node: DOMNode | None = None) -> None: """Request update of styles. diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 5fc5b9932f..720dc5e7d7 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -761,7 +761,11 @@ def __set__(self, obj: StylesBase, value: str | None = None): if value is None: if obj.clear_rule(self.name): self._before_refresh(obj, value) - obj.refresh(layout=self._layout, children=self._refresh_children) + obj.refresh( + layout=self._layout, + children=self._refresh_children, + parent=self._refresh_parent, + ) else: if value not in self._valid_values: raise StyleValueError( diff --git a/src/textual/dom.py b/src/textual/dom.py index 4571310302..dba0c1b914 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -151,6 +151,18 @@ def __init__( super().__init__() + def compose_add_child(self, widget: Widget) -> None: + """Add a node to children. + + This is used by the compose process when it adds children. + There is no need to use it directly, but you may want to override it in a subclass + if you want children to be attached to a different node. + + Args: + node: A DOM node to add. + """ + self._nodes._append(widget) + @property def children(self) -> Sequence["Widget"]: """A view on to the children.""" diff --git a/src/textual/widget.py b/src/textual/widget.py index 6008bc62f2..0145d11841 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -475,6 +475,23 @@ def get_widget_by_id( ) from exc raise NoMatches(f"No descendant found with id={id!r}") + def get_child_by_type(self, expect_type: type[ExpectType]) -> ExpectType: + """Get a child of a give type. + + Args: + expect_type: The type of the expected child. + + Raises: + NoMatches: If no valid child is found. + + Returns: + A widget. + """ + for child in self._nodes: + if isinstance(child, expect_type): + return child + raise NoMatches(f"No immediate child of type {expect_type}; {self._nodes}") + def get_component_rich_style(self, name: str, *, partial: bool = False) -> Style: """Get a *Rich* style for a component. @@ -496,6 +513,24 @@ def get_component_rich_style(self, name: str, *, partial: bool = False) -> Style return partial_style if partial else style + def render_str(self, text_content: str | Text) -> Text: + """Convert str in to a Text object. + + If you pass in an existing Text object it will be returned unaltered. + + Args: + text_content: Text or str. + + Returns: + A text object. + """ + text = ( + Text.from_markup(text_content) + if isinstance(text_content, str) + else text_content + ) + return text + def _arrange(self, size: Size) -> DockArrangeResult: """Arrange children. @@ -2589,7 +2624,7 @@ def post_message(self, message: Message) -> bool: True if the message was posted, False if this widget was closed / closing. """ - if not self.is_running: + if not self.is_running and not message.no_dispatch: try: self.log.warning(self, f"IS NOT RUNNING, {message!r} not sent") except NoActiveAppError: diff --git a/src/textual/widgets/_content_switcher.py b/src/textual/widgets/_content_switcher.py index 72ab0f96a4..b884cd0dbb 100644 --- a/src/textual/widgets/_content_switcher.py +++ b/src/textual/widgets/_content_switcher.py @@ -61,12 +61,10 @@ def __init__( def on_mount(self) -> None: """Perform the initial setup of the widget once the DOM is ready.""" - # On startup, ensure everything is hidden. + initial = self._initial with self.app.batch_update(): for child in self.children: - child.display = False - # Then set the initial display. - self.current = self._initial + child.display = child.id == initial @property def visible_content(self) -> Widget | None: diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py new file mode 100644 index 0000000000..f774b697b0 --- /dev/null +++ b/src/textual/widgets/_tabbed_content.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from itertools import zip_longest + +from rich.text import Text, TextType + +from ..app import ComposeResult +from ..widget import Widget +from ._content_switcher import ContentSwitcher +from ._tabs import Tab, Tabs + + +class _Tab(Tab): + def __init__(self, label: Text, content_id: str): + super().__init__(label) + self.content_id = content_id + + +class TabPane(Widget): + DEFAULT_CSS = """ + TabPane { + height: auto; + background: $boost; + padding: 1 2; + } + """ + + def __init__( + self, + title: Text, + *children: Widget, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + disabled: bool = False, + ): + self.title = title + super().__init__( + *children, name=name, id=id, classes=classes, disabled=disabled + ) + + +class TabbedContent(Widget): + DEFAULT_CSS = """ + TabbedContent { + height: auto; + background: $boost; + } + TabbedContent > ContentSwitcher { + height: auto; + } + """ + + def __init__(self, *titles: TextType) -> None: + self.titles = [self.render_str(title) for title in titles] + self._tab_content: list[Widget] = [] + super().__init__() + + def compose(self) -> ComposeResult: + def set_id(content: TabPane, new_id: str) -> TabPane: + if content.id is None: + content.id = new_id + return content + + pane_content = [ + ( + set_id(content, f"tab-{index}") + if isinstance(content, TabPane) + else TabPane( + title or self.render_str(f"Tab {index}"), content, id=f"tab-{index}" + ) + ) + for index, (title, content) in enumerate( + zip_longest(self.titles, self._tab_content), 1 + ) + ] + tabs = [_Tab(content.title, content.id or "") for content in pane_content] + yield Tabs(*tabs) + with ContentSwitcher(): + yield from pane_content + + def compose_add_child(self, widget: Widget) -> None: + """When using the context manager compose syntax, we want to attach nodes to the switcher.""" + self._tab_content.append(widget) + + def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: + switcher = self.query_one(ContentSwitcher) + assert isinstance(event.tab, _Tab) + switcher.current = event.tab.content_id + + +if __name__ == "__main__": + from textual.app import App + from textual.widgets import Label + + class TabbedApp(App): + def compose(self) -> ComposeResult: + with TabbedContent("Foo", "Bar", "baz"): + yield Label("This is foo\nfoo") + yield Label("This is Bar") + yield Label("This is Baz") + + app = TabbedApp() + app.run() diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index 7c190bf94a..d95a0f1a4b 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -175,7 +175,7 @@ class Tabs(Widget, can_focus=True): class TabActivated(Message): """Sent when a new tab is activated.""" - tab: Tab + tab: Tab | None """The tab that was activated.""" def __init__(self, tab: Tab | None) -> None: From 8830a548485b23edfd2a80575cffacf4f0571626 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 16 Mar 2023 17:01:59 +0000 Subject: [PATCH 02/25] TabbedContent widget and docs --- CHANGELOG.md | 10 ++-- docs/roadmap.md | 2 +- docs/widget_gallery.md | 8 +++ docs/widgets/_template.md | 2 +- docs/widgets/checkbox.md | 2 +- docs/widgets/data_table.md | 4 +- docs/widgets/list_view.md | 2 +- docs/widgets/radiobutton.md | 4 +- docs/widgets/switch.md | 4 +- docs/widgets/tabs.md | 1 + docs/widgets/tree.md | 2 +- mkdocs-nav.yml | 4 +- src/textual/widget.py | 3 +- src/textual/widgets/__init__.py | 3 + src/textual/widgets/__init__.pyi | 2 + src/textual/widgets/_tabbed_content.py | 81 +++++++++++++++++++++----- src/textual/widgets/_tabs.py | 35 ++++++++--- 17 files changed, 129 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66c4330cdc..27fcc37f17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed - Dropped "loading-indicator--dot" component style from LoadingIndicator https://github.com/Textualize/textual/pull/2050 +- Tabs widget now sends Tabs.TabsCleared when there is not active tab. +- Breaking change: changed default behaviour of `Vertical` (see `VerticalScroll`) https://github.com/Textualize/textual/issues/1957 +- The default `overflow` style for `Horizontal` was changed to `hidden hidden` https://github.com/Textualize/textual/issues/1957 ### Removed @@ -20,18 +23,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed borders not rendering correctly. https://github.com/Textualize/textual/pull/2074 - Fix for error when removing nodes. https://github.com/Textualize/textual/issues/2079 - -### Changed - -- Breaking change: changed default behaviour of `Vertical` (see `VerticalScroll`) https://github.com/Textualize/textual/issues/1957 -- The default `overflow` style for `Horizontal` was changed to `hidden hidden` https://github.com/Textualize/textual/issues/1957 - ### Added - Added `HorizontalScroll` https://github.com/Textualize/textual/issues/1957 - Added `Center` https://github.com/Textualize/textual/issues/1957 - Added `Middle` https://github.com/Textualize/textual/issues/1957 - Added `VerticalScroll` (mimicking the old behaviour of `Vertical`) https://github.com/Textualize/textual/issues/1957 +- Added `TabbedContent` widget ## [0.15.1] - 2023-03-14 diff --git a/docs/roadmap.md b/docs/roadmap.md index 478ac534e2..4637235d7b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -73,7 +73,7 @@ Widgets are key to making user-friendly interfaces. The builtin widgets should c - [X] Radio boxes - [ ] Spark-lines - [X] Switch -- [ ] Tabs +- [X] Tabs - [ ] TextArea (multi-line input) * [ ] Basic controls * [ ] Indentation guides diff --git a/docs/widget_gallery.md b/docs/widget_gallery.md index ec5d885c91..abcd8a7492 100644 --- a/docs/widget_gallery.md +++ b/docs/widget_gallery.md @@ -194,6 +194,14 @@ A row of tabs you can select with the mouse or navigate with keys. ```{.textual path="docs/examples/widgets/tabs.py" press="a,a,a,a,right,right"} ``` +## TabbedContent + +A Combination of Tabs and ContentSwitcher to navigate static content. + +[TabbedContent reference](./widgets/tabbed_content.md){ .md-button .md-button--primary } + +```{.textual path="docs/examples/widgets/tabbed_content.py" press="j"} +``` ## TextLog diff --git a/docs/widgets/_template.md b/docs/widgets/_template.md index 1e8c6f9720..c9e60413b9 100644 --- a/docs/widgets/_template.md +++ b/docs/widgets/_template.md @@ -33,7 +33,7 @@ Example app showing the widget: ## Bindings -The WIDGET widget defines directly the following bindings: +The WIDGET widget defines the following bindings: ::: textual.widgets.WIDGET.BINDINGS options: diff --git a/docs/widgets/checkbox.md b/docs/widgets/checkbox.md index 1f7b51a8a9..809de2e66b 100644 --- a/docs/widgets/checkbox.md +++ b/docs/widgets/checkbox.md @@ -34,7 +34,7 @@ The example below shows check boxes in various states. ## Bindings -The checkbox widget defines directly the following bindings: +The checkbox widget defines the following bindings: ::: textual.widgets._toggle_button.ToggleButton.BINDINGS options: diff --git a/docs/widgets/data_table.md b/docs/widgets/data_table.md index 7cc0a00d6c..8c09a032cb 100644 --- a/docs/widgets/data_table.md +++ b/docs/widgets/data_table.md @@ -23,7 +23,7 @@ The example below populates a table with CSV data. ## Reactive Attributes | Name | Type | Default | Description | -|---------------------|---------------------------------------------|--------------------|-------------------------------------------------------| +| ------------------- | ------------------------------------------- | ------------------ | ----------------------------------------------------- | | `show_header` | `bool` | `True` | Show the table header | | `fixed_rows` | `int` | `0` | Number of fixed rows (rows which do not scroll) | | `fixed_columns` | `int` | `0` | Number of fixed columns (columns which do not scroll) | @@ -52,7 +52,7 @@ The example below populates a table with CSV data. ## Bindings -The data table widget defines directly the following bindings: +The data table widget defines the following bindings: ::: textual.widgets.DataTable.BINDINGS options: diff --git a/docs/widgets/list_view.md b/docs/widgets/list_view.md index 6e294323e1..2aa03cb101 100644 --- a/docs/widgets/list_view.md +++ b/docs/widgets/list_view.md @@ -41,7 +41,7 @@ The example below shows an app with a simple `ListView`. ## Bindings -The list view widget defines directly the following bindings: +The list view widget defines the following bindings: ::: textual.widgets.ListView.BINDINGS options: diff --git a/docs/widgets/radiobutton.md b/docs/widgets/radiobutton.md index 95f510365d..6eb6983127 100644 --- a/docs/widgets/radiobutton.md +++ b/docs/widgets/radiobutton.md @@ -31,12 +31,12 @@ The example below shows radio buttons, used within a [`RadioSet`](./radioset.md) ## Reactive Attributes | Name | Type | Default | Description | -|---------|--------|---------|--------------------------------| +| ------- | ------ | ------- | ------------------------------ | | `value` | `bool` | `False` | The value of the radio button. | ## Bindings -The radio button widget defines directly the following bindings: +The radio button widget defines the following bindings: ::: textual.widgets._toggle_button.ToggleButton.BINDINGS options: diff --git a/docs/widgets/switch.md b/docs/widgets/switch.md index 0b497da506..e5f3bcd7c3 100644 --- a/docs/widgets/switch.md +++ b/docs/widgets/switch.md @@ -29,12 +29,12 @@ The example below shows switches in various states. ## Reactive Attributes | Name | Type | Default | Description | -|---------|--------|---------|--------------------------| +| ------- | ------ | ------- | ------------------------ | | `value` | `bool` | `False` | The value of the switch. | ## Bindings -The switch widget defines directly the following bindings: +The switch widget defines the following bindings: ::: textual.widgets.Switch.BINDINGS options: diff --git a/docs/widgets/tabs.md b/docs/widgets/tabs.md index 77b94fc7aa..5546e5dbd9 100644 --- a/docs/widgets/tabs.md +++ b/docs/widgets/tabs.md @@ -60,6 +60,7 @@ The following example adds a `Tabs` widget above a text label. Press ++a++ to ad ## Messages ### ::: textual.widgets.Tabs.TabActivated +### ::: textual.widgets.Tabs.TabsCleared ## Bindings diff --git a/docs/widgets/tree.md b/docs/widgets/tree.md index 9014f3d979..ff7df3ed0a 100644 --- a/docs/widgets/tree.md +++ b/docs/widgets/tree.md @@ -44,7 +44,7 @@ Tree widgets have a "root" attribute which is an instance of a [TreeNode][textua ## Bindings -The tree widget defines directly the following bindings: +The tree widget defines the following bindings: ::: textual.widgets.Tree.BINDINGS options: diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml index 3805239fb6..704bd75e2a 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -140,6 +140,7 @@ nav: - "widgets/radioset.md" - "widgets/static.md" - "widgets/switch.md" + - "widgets/tabbed_content.md" - "widgets/tabs.md" - "widgets/text_log.md" - "widgets/tree.md" @@ -181,10 +182,11 @@ nav: - "api/static.md" - "api/strip.md" - "api/switch.md" + - "api/tabbed_content.md" - "api/tabs.md" - "api/text_log.md" - - "api/toggle_button.md" - "api/timer.md" + - "api/toggle_button.md" - "api/tree.md" - "api/walk.md" - "api/welcome.md" diff --git a/src/textual/widget.py b/src/textual/widget.py index 0145d11841..c02812f9b1 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -390,7 +390,8 @@ def __exit__( compose_stack = self.app._compose_stacks[-1] composed = compose_stack.pop() if compose_stack: - compose_stack[-1]._nodes._append(composed) + # compose_stack[-1]._nodes._append(composed) + compose_stack[-1].compose_add_child(composed) else: self.app._composed[-1].append(composed) diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index 51985b13ad..db57172848 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -29,6 +29,7 @@ from ._radio_set import RadioSet from ._static import Static from ._switch import Switch + from ._tabbed_content import TabbedContent, TabPane from ._tabs import Tab, Tabs from ._text_log import TextLog from ._tree import Tree @@ -57,6 +58,8 @@ "Static", "Switch", "Tab", + "TabbedContent", + "TabPane", "Tabs", "TextLog", "Tree", diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index d19537ac75..66e78fe941 100644 --- a/src/textual/widgets/__init__.pyi +++ b/src/textual/widgets/__init__.pyi @@ -19,6 +19,8 @@ from ._radio_button import RadioButton as RadioButton from ._radio_set import RadioSet as RadioSet from ._static import Static as Static from ._switch import Switch as Switch +from ._tabbed_content import TabbedContent as TabbedContent +from ._tabbed_content import TabPane as TabPane from ._tabs import Tab as Tab from ._tabs import Tabs as Tabs from ._text_log import TextLog as TextLog diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index f774b697b0..76f58dfb49 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -5,63 +5,95 @@ from rich.text import Text, TextType from ..app import ComposeResult +from ..reactive import reactive from ..widget import Widget from ._content_switcher import ContentSwitcher from ._tabs import Tab, Tabs +__all__ = [ + "ContentTab", + "TabbedContent", + "TabPane", +] + + +class ContentTab(Tab): + """A Tab with an associated content id.""" -class _Tab(Tab): def __init__(self, label: Text, content_id: str): - super().__init__(label) - self.content_id = content_id + """Initialize a ContentTab. + + Args: + label: The label to be displayed within the tab. + content_id: The id of the content associated with the tab. + """ + super().__init__(label, id=content_id) class TabPane(Widget): + """A container for switchable content, with additional title.""" + DEFAULT_CSS = """ TabPane { height: auto; - background: $boost; padding: 1 2; } """ def __init__( self, - title: Text, + title: TextType, *children: Widget, name: str | None = None, id: str | None = None, classes: str | None = None, disabled: bool = False, ): - self.title = title + """Initialize a TabPane. + + Args: + title: Title of the TabPane (will be displayed in a tab label). + name: Optional name for the TabPane. + id: Optional ID for the TabPane. + classes: Optional initial classes for the widget. + disabled: Whether the TabPane is disabled or not. + """ + self._title = self.render_str(title) super().__init__( *children, name=name, id=id, classes=classes, disabled=disabled ) class TabbedContent(Widget): + """A container with associated tabs to toggle content visibility.""" + DEFAULT_CSS = """ TabbedContent { height: auto; - background: $boost; } TabbedContent > ContentSwitcher { height: auto; } """ + active: reactive[str] = reactive("", init=False) + """The ID of the active tab, or empty string if none are active.""" + def __init__(self, *titles: TextType) -> None: self.titles = [self.render_str(title) for title in titles] self._tab_content: list[Widget] = [] super().__init__() def compose(self) -> ComposeResult: + """Compose the tabbed content.""" + def set_id(content: TabPane, new_id: str) -> TabPane: + """Set an id on the content, if not already present.""" if content.id is None: content.id = new_id return content + # Wrap content in a `TabPane` if required. pane_content = [ ( set_id(content, f"tab-{index}") @@ -74,8 +106,13 @@ def set_id(content: TabPane, new_id: str) -> TabPane: zip_longest(self.titles, self._tab_content), 1 ) ] - tabs = [_Tab(content.title, content.id or "") for content in pane_content] + # Get a tab for each pane + tabs = [ + ContentTab(content._title, content.id or "") for content in pane_content + ] + # Yield the tabs yield Tabs(*tabs) + # Yield the content switcher and panes with ContentSwitcher(): yield from pane_content @@ -84,9 +121,19 @@ def compose_add_child(self, widget: Widget) -> None: self._tab_content.append(widget) def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: + """User clicked a tab.""" + event.stop() switcher = self.query_one(ContentSwitcher) - assert isinstance(event.tab, _Tab) - switcher.current = event.tab.content_id + assert isinstance(event.tab, ContentTab) + switcher.current = event.tab.id + + def on_tabs_tabs_cleared(self, event: Tabs.TabsCleared) -> None: + """All tabs were removed.""" + event.stop() + + def watch_active(self, active: str) -> None: + """Switch tabs when the active attributes changes.""" + self.query_one(Tabs).active = active if __name__ == "__main__": @@ -95,10 +142,16 @@ def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: class TabbedApp(App): def compose(self) -> ComposeResult: - with TabbedContent("Foo", "Bar", "baz"): - yield Label("This is foo\nfoo") - yield Label("This is Bar") - yield Label("This is Baz") + with TabbedContent(): + with TabPane("foo"): + yield Label("This is foo\nfoo") + with TabPane("bar"): + yield Label("This is Bar") + with TabPane("baz"): + yield Label("This is Baz") + + def on_ready(self) -> None: + self.log(self.tree) app = TabbedApp() app.run() diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index d95a0f1a4b..946ba3eae8 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -175,16 +175,33 @@ class Tabs(Widget, can_focus=True): class TabActivated(Message): """Sent when a new tab is activated.""" - tab: Tab | None + tabs: Tabs + """The tabs widget containing the tab.""" + tab: Tab """The tab that was activated.""" - def __init__(self, tab: Tab | None) -> None: + def __init__(self, tabs: Tabs, tab: Tab) -> None: + self.tabs = tabs self.tab = tab super().__init__() def __rich_repr__(self) -> rich.repr.Result: + yield self.tabs yield self.tab + class TabsCleared(Message): + """Sent when there are no active tabs.""" + + tabs: Tabs + """The tabs widget which was cleared""" + + def __init__(self, tabs: Tabs) -> None: + self.tabs = tabs + super().__init__() + + def __rich_repr__(self) -> rich.repr.Result: + yield self.tabs + active: reactive[str] = reactive("", init=False) """The ID of the active tab, or empty string if none are active.""" @@ -278,7 +295,7 @@ def add_tab(self, tab: Tab | str | Text) -> None: mount_await = self.query_one("#tabs-list").mount(tab_widget) if from_empty: tab_widget.add_class("-active") - self.post_message(self.TabActivated(tab_widget)) + self.post_message(self.TabActivated(self, tab_widget)) async def refresh_active() -> None: """Wait for things to be mounted before highlighting.""" @@ -294,7 +311,7 @@ def clear(self) -> None: underline.highlight_start = 0 underline.highlight_end = 0 self.query("#tabs-list > Tab").remove() - self.post_message(self.TabActivated(None)) + self.post_message(self.TabsCleared(self)) def remove_tab(self, tab_or_id: Tab | str | None) -> None: """Remove a tab. @@ -314,9 +331,13 @@ def remove_tab(self, tab_or_id: Tab | str | None) -> None: removing_active_tab = remove_tab.has_class("-active") next_tab = self._next_active - self.post_message(self.TabActivated(next_tab)) + if next_tab is None: + self.post_message(self.TabsCleared(self)) + else: + self.post_message(self.TabActivated(self, next_tab)) async def do_remove() -> None: + """Perform the remove after refresh so the underline bar gets new positions.""" await remove_tab.remove() if removing_active_tab: if next_tab is not None: @@ -365,12 +386,12 @@ def watch_active(self, previously_active: str, active: str) -> None: self.query("#tabs-list > Tab.-active").remove_class("-active") active_tab.add_class("-active") self._highlight_active(animate=previously_active != "") - self.post_message(self.TabActivated(active_tab)) + self.post_message(self.TabActivated(self, active_tab)) else: underline = self.query_one(Underline) underline.highlight_start = 0 underline.highlight_end = 0 - self.post_message(self.TabActivated(None)) + self.post_message(self.TabsCleared(self)) def _highlight_active(self, animate: bool = True) -> None: """Move the underline bar to under the active tab. From f3e3c6c78a7f5acc7e3bcef4587f4779b169853a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 16 Mar 2023 17:02:35 +0000 Subject: [PATCH 03/25] missing docs --- docs/api/tabbed_content.md | 2 + docs/examples/widgets/tabbed_content.py | 54 ++++++++++++++ docs/widgets/tabbed_content.md | 98 +++++++++++++++++++++++++ src/textual/widgets/_tab_pane.py | 3 + 4 files changed, 157 insertions(+) create mode 100644 docs/api/tabbed_content.md create mode 100644 docs/examples/widgets/tabbed_content.py create mode 100644 docs/widgets/tabbed_content.md create mode 100644 src/textual/widgets/_tab_pane.py diff --git a/docs/api/tabbed_content.md b/docs/api/tabbed_content.md new file mode 100644 index 0000000000..6d4338d213 --- /dev/null +++ b/docs/api/tabbed_content.md @@ -0,0 +1,2 @@ +::: textual.widgets.TabbedContent +::: textual.widgets.TabPane diff --git a/docs/examples/widgets/tabbed_content.py b/docs/examples/widgets/tabbed_content.py new file mode 100644 index 0000000000..aba482f6f3 --- /dev/null +++ b/docs/examples/widgets/tabbed_content.py @@ -0,0 +1,54 @@ +from textual.app import App, ComposeResult +from textual.widgets import Footer, Markdown, TabbedContent, TabPane + +LETO = """ +# Duke Leto I Atreides + +Head of House Atreides +""" + +JESSICA = """ +# Lady Jessica + +Bene Gesserit and concubine of Leto, and mother of Paul and Alia +""" + +PAUL = """ +# Paul Atreides + +Son of Leto and Jessica + +""" + + +class TabbedApp(App): + """An example of tabbed content.""" + + BINDINGS = [ + ("l", "show_tab('leto')", "Leto"), + ("j", "show_tab('jessica')", "Jessica"), + ("p", "show_tab('paul')", "Paul"), + ] + + def compose(self) -> ComposeResult: + """Compose app with tabbed content.""" + # Footer to show keys + yield Footer() + + # Add the TabbedContent widget + with TabbedContent(): + with TabPane("Leto", id="leto"): # First tab + yield Markdown(LETO) # Tab content + with TabPane("Jessica", id="jessica"): + yield Markdown(JESSICA) + with TabPane("Paul", id="paul"): + yield Markdown(PAUL) + + def action_show_tab(self, tab: str) -> None: + """Switch to a new tab.""" + self.query_one(TabbedContent).active = tab + + +if __name__ == "__main__": + app = TabbedApp() + app.run() diff --git a/docs/widgets/tabbed_content.md b/docs/widgets/tabbed_content.md new file mode 100644 index 0000000000..a8cdc2a51a --- /dev/null +++ b/docs/widgets/tabbed_content.md @@ -0,0 +1,98 @@ +# TabbedContent + +Switch between mutually exclusive content panes via a row of tabs. + +This widget combines the [Tabs](../widgets/tabs.md) and [ContentSwitcher](../widgets/content_switcher.md) widgets to create a convenient way of navigating content. + +- [x] Focusable +- [x] Container + +Only a single child of TabbedContent is visible at a time. +Each child has an associated tab which to make it visible, and hide the others. + +## Composing + +There are two way to provide the titles for the tab. +You can pass them as positional arguments to the [TabbedContent][textual.widgets.TabbedContent] constructor: + +```python +def compose(self) -> ComposeResult: + with TabbedContent("Leto", "Jessica", "Paul"): + yield Markdown(LETO) + yield Markdown(JESSICA) + yield Markdown(PAUL) +``` + +Alternatively you can wrap the content in a [TabPane][textual.widgets.TabPane] widget, which takes the tab title as the first parameter: + +```python +def compose(self) -> ComposeResult: + with TabbedContent(): + with TabPane("Leto"): + yield Markdown(LETO) + with TabPane("Jessica"): + yield Markdown(JESSICA) + with TabPane("Paul"): + yield Markdown(PAUL) +``` + +## Switching tabs + +If you need to programmatically switch tabs, you should provide an `id` attribute to the `TabPane`s. + +```python +def compose(self) -> ComposeResult: + with TabbedContent(): + with TabPane("Leto", id="leto"): + yield Markdown(LETO) + with TabPane("Jessica", id="jessica"): + yield Markdown(JESSICA) + with TabPane("Paul", id="paul"): + yield Markdown(PAUL) +``` + +!!! note + + If you don't provide `id` attributes to the tab panes, they will be assigned sequentially starting at `tab-1` (then `tab-2` etc). + +You can then switch tabs by setting the `active` reactive attribute: + +```python +# Switch to Jessica tab +self.query_one(TabbedContent).active = "jessica" +``` + +## Example + +Example app showing the widget: + +=== "Output" + + ```{.textual path="docs/examples/widgets/tabbed_content.py"} + ``` + +=== "tabbed_content.py" + + ```python + --8<-- "docs/examples/widgets/tabbed_content.py" + ``` + +## Reactive attributes + +| Name | Type | Default | Description | +| -------- | ----- | ------- | -------------------------------------------------------------- | +| `active` | `str` | `""` | The `id` attribute of the active tab. Set this to switch tabs. | + + + +## Additional notes + +- Did you know this? +- Another pro tip. + + +## See also + +- [TabbedContent](../api/tabbed_content.md) code reference. +- [Tabs](../api/tabs.md) code reference. +- [ContentSwitcher](../ap/../api/content_switcher.md) diff --git a/src/textual/widgets/_tab_pane.py b/src/textual/widgets/_tab_pane.py new file mode 100644 index 0000000000..db70d22a3f --- /dev/null +++ b/src/textual/widgets/_tab_pane.py @@ -0,0 +1,3 @@ +from ._tabbed_content import TabPane + +__all__ = ["TabPane"] From 8de31b6d1c73125c1102cf8b6bf48172a39921d5 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 16 Mar 2023 17:24:15 +0000 Subject: [PATCH 04/25] fix active --- docs/examples/widgets/tabbed_content.py | 2 +- docs/widgets/tabbed_content.md | 7 ----- src/textual/widgets/_content_switcher.py | 9 +++--- src/textual/widgets/_tabbed_content.py | 36 ++++++++---------------- 4 files changed, 18 insertions(+), 36 deletions(-) diff --git a/docs/examples/widgets/tabbed_content.py b/docs/examples/widgets/tabbed_content.py index aba482f6f3..9b0065f3fc 100644 --- a/docs/examples/widgets/tabbed_content.py +++ b/docs/examples/widgets/tabbed_content.py @@ -36,7 +36,7 @@ def compose(self) -> ComposeResult: yield Footer() # Add the TabbedContent widget - with TabbedContent(): + with TabbedContent(initial="jessica"): with TabPane("Leto", id="leto"): # First tab yield Markdown(LETO) # Tab content with TabPane("Jessica", id="jessica"): diff --git a/docs/widgets/tabbed_content.md b/docs/widgets/tabbed_content.md index a8cdc2a51a..0b4c755a28 100644 --- a/docs/widgets/tabbed_content.md +++ b/docs/widgets/tabbed_content.md @@ -84,13 +84,6 @@ Example app showing the widget: | `active` | `str` | `""` | The `id` attribute of the active tab. Set this to switch tabs. | - -## Additional notes - -- Did you know this? -- Another pro tip. - - ## See also - [TabbedContent](../api/tabbed_content.md) code reference. diff --git a/src/textual/widgets/_content_switcher.py b/src/textual/widgets/_content_switcher.py index b884cd0dbb..fac54287ce 100644 --- a/src/textual/widgets/_content_switcher.py +++ b/src/textual/widgets/_content_switcher.py @@ -17,7 +17,7 @@ class ContentSwitcher(Container): Children that have no ID will be hidden and ignored. """ - current: reactive[str | None] = reactive[Optional[str]](None) + current: reactive[str | None] = reactive[Optional[str]](None, init=False) """The ID of the currently-displayed widget. If set to `None` then no widget is visible. @@ -44,7 +44,7 @@ def __init__( id: The ID of the content switcher in the DOM. classes: The CSS classes of the content switcher. disabled: Whether the content switcher is disabled or not. - initial: The ID of the initial widget to show. + initial: The ID of the initial widget to show, or ``None`` for the first tab. Note: If `initial` is not supplied no children will be shown to start @@ -57,14 +57,15 @@ def __init__( classes=classes, disabled=disabled, ) - self._initial = initial + self._initial = initial or "" def on_mount(self) -> None: """Perform the initial setup of the widget once the DOM is ready.""" initial = self._initial with self.app.batch_update(): for child in self.children: - child.display = child.id == initial + child.display = bool(initial) and child.id == initial + self._reactive_current = initial @property def visible_content(self) -> Widget | None: diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 76f58dfb49..103081efe7 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -79,9 +79,16 @@ class TabbedContent(Widget): active: reactive[str] = reactive("", init=False) """The ID of the active tab, or empty string if none are active.""" - def __init__(self, *titles: TextType) -> None: + def __init__(self, *titles: TextType, initial: str = "") -> None: + """Initialize a TabbedContent widgets. + + Args: + *titles: Positional argument will be used as title. + initial: The id of the initial tab, or empty string to select the first tab. + """ self.titles = [self.render_str(title) for title in titles] self._tab_content: list[Widget] = [] + self._initial = initial super().__init__() def compose(self) -> ComposeResult: @@ -111,9 +118,10 @@ def set_id(content: TabPane, new_id: str) -> TabPane: ContentTab(content._title, content.id or "") for content in pane_content ] # Yield the tabs - yield Tabs(*tabs) + yield Tabs(*tabs, active=self._initial) # Yield the content switcher and panes - with ContentSwitcher(): + print("INITIAL", self._initial) + with ContentSwitcher(initial=self._initial): yield from pane_content def compose_add_child(self, widget: Widget) -> None: @@ -122,6 +130,7 @@ def compose_add_child(self, widget: Widget) -> None: def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: """User clicked a tab.""" + self.log("on_tabs_tab_activated", event) event.stop() switcher = self.query_one(ContentSwitcher) assert isinstance(event.tab, ContentTab) @@ -134,24 +143,3 @@ def on_tabs_tabs_cleared(self, event: Tabs.TabsCleared) -> None: def watch_active(self, active: str) -> None: """Switch tabs when the active attributes changes.""" self.query_one(Tabs).active = active - - -if __name__ == "__main__": - from textual.app import App - from textual.widgets import Label - - class TabbedApp(App): - def compose(self) -> ComposeResult: - with TabbedContent(): - with TabPane("foo"): - yield Label("This is foo\nfoo") - with TabPane("bar"): - yield Label("This is Bar") - with TabPane("baz"): - yield Label("This is Baz") - - def on_ready(self) -> None: - self.log(self.tree) - - app = TabbedApp() - app.run() From 4ce71be1571f2a5dbefc76e1bb60d841f462ecb5 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 16 Mar 2023 17:26:07 +0000 Subject: [PATCH 05/25] doc fix --- docs/widgets/tabbed_content.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/widgets/tabbed_content.md b/docs/widgets/tabbed_content.md index 0b4c755a28..eeba9ea334 100644 --- a/docs/widgets/tabbed_content.md +++ b/docs/widgets/tabbed_content.md @@ -88,4 +88,4 @@ Example app showing the widget: - [TabbedContent](../api/tabbed_content.md) code reference. - [Tabs](../api/tabs.md) code reference. -- [ContentSwitcher](../ap/../api/content_switcher.md) +- [ContentSwitcher](../api/content_switcher.md) From 4da50cf55128c9b0657243146829ca295535b4bd Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Mar 2023 08:24:10 +0000 Subject: [PATCH 06/25] test fix --- src/textual/widgets/_content_switcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_content_switcher.py b/src/textual/widgets/_content_switcher.py index fac54287ce..0044ae2665 100644 --- a/src/textual/widgets/_content_switcher.py +++ b/src/textual/widgets/_content_switcher.py @@ -57,7 +57,7 @@ def __init__( classes=classes, disabled=disabled, ) - self._initial = initial or "" + self._initial = initial def on_mount(self) -> None: """Perform the initial setup of the widget once the DOM is ready.""" From 1e2d8b4eb41bf633eeb14a6bbaa3c1cdb8832ae0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Mar 2023 08:40:35 +0000 Subject: [PATCH 07/25] additional test --- CHANGELOG.md | 1 + src/textual/widget.py | 5 +++-- tests/test_widget.py | 19 +++++++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27fcc37f17..e156de00c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `Middle` https://github.com/Textualize/textual/issues/1957 - Added `VerticalScroll` (mimicking the old behaviour of `Vertical`) https://github.com/Textualize/textual/issues/1957 - Added `TabbedContent` widget +- Added `get_child_by_type` method to widgets / app ## [0.15.1] - 2023-03-14 diff --git a/src/textual/widget.py b/src/textual/widget.py index c02812f9b1..2bbff49098 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -390,7 +390,6 @@ def __exit__( compose_stack = self.app._compose_stacks[-1] composed = compose_stack.pop() if compose_stack: - # compose_stack[-1]._nodes._append(composed) compose_stack[-1].compose_add_child(composed) else: self.app._composed[-1].append(composed) @@ -489,7 +488,9 @@ def get_child_by_type(self, expect_type: type[ExpectType]) -> ExpectType: A widget. """ for child in self._nodes: - if isinstance(child, expect_type): + # We want the child with the exact type (not subclasses) + if type(child) is expect_type: + assert isinstance(child, expect_type) return child raise NoMatches(f"No immediate child of type {expect_type}; {self._nodes}") diff --git a/tests/test_widget.py b/tests/test_widget.py index bef67857dd..6e42181820 100644 --- a/tests/test_widget.py +++ b/tests/test_widget.py @@ -2,6 +2,7 @@ from textual._node_list import DuplicateIds from textual.app import App, ComposeResult +from textual.containers import Container from textual.css.errors import StyleValueError from textual.css.query import NoMatches from textual.geometry import Size @@ -111,6 +112,24 @@ def test_get_child_by_id_only_immediate_descendents(parent): parent.get_child_by_id(id="grandchild1") +async def test_get_child_by_type(): + class GetChildApp(App): + def compose(self) -> ComposeResult: + yield Widget(id="widget1") + yield Container( + Label(id="label1"), + Widget(id="widget2"), + id="container1", + ) + + app = GetChildApp() + async with app.run_test(): + assert app.get_child_by_type(Widget).id == "widget1" + assert app.get_child_by_type(Container).id == "container1" + with pytest.raises(NoMatches): + app.get_child_by_type(Label) + + def test_get_widget_by_id_no_matching_child(parent): with pytest.raises(NoMatches): parent.get_widget_by_id(id="i-dont-exist") From aeb9b6e91931dd4b4acbaab10baa04f8219a2c54 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Mar 2023 08:48:31 +0000 Subject: [PATCH 08/25] test for render_str --- CHANGELOG.md | 1 + src/textual/widgets/_tabbed_content.py | 1 - tests/test_widget.py | 10 ++++++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e156de00c1..517b011d7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `VerticalScroll` (mimicking the old behaviour of `Vertical`) https://github.com/Textualize/textual/issues/1957 - Added `TabbedContent` widget - Added `get_child_by_type` method to widgets / app +- Added `Widget.render_str` method ## [0.15.1] - 2023-03-14 diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 103081efe7..bb9a5e3290 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -120,7 +120,6 @@ def set_id(content: TabPane, new_id: str) -> TabPane: # Yield the tabs yield Tabs(*tabs, active=self._initial) # Yield the content switcher and panes - print("INITIAL", self._initial) with ContentSwitcher(initial=self._initial): yield from pane_content diff --git a/tests/test_widget.py b/tests/test_widget.py index 6e42181820..a51040a6cc 100644 --- a/tests/test_widget.py +++ b/tests/test_widget.py @@ -1,4 +1,5 @@ import pytest +from rich.text import Text from textual._node_list import DuplicateIds from textual.app import App, ComposeResult @@ -217,3 +218,12 @@ def on_mount(self): async with app.run_test() as pilot: await pilot.pause() assert mounted + + +def test_render_str() -> None: + widget = Label() + assert widget.render_str("foo") == Text("foo") + assert widget.render_str("[b]foo") == Text.from_markup("[b]foo") + # Text objects are passed unchanged + text = Text("bar") + assert widget.render_str(text) is text From ea1d403204c68816113a17541f0d301eb82bba16 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Mar 2023 08:51:55 +0000 Subject: [PATCH 09/25] docstring --- src/textual/widgets/_tabs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index 946ba3eae8..b2ec6f0fb8 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -193,7 +193,7 @@ class TabsCleared(Message): """Sent when there are no active tabs.""" tabs: Tabs - """The tabs widget which was cleared""" + """The tabs widget which was cleared.""" def __init__(self, tabs: Tabs) -> None: self.tabs = tabs From 92375ac5e62153741cf383d79b7b2614c3bb1f11 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Mar 2023 08:52:40 +0000 Subject: [PATCH 10/25] changelog --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 517b011d7b..1f5d0bd8e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,9 +29,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `Center` https://github.com/Textualize/textual/issues/1957 - Added `Middle` https://github.com/Textualize/textual/issues/1957 - Added `VerticalScroll` (mimicking the old behaviour of `Vertical`) https://github.com/Textualize/textual/issues/1957 -- Added `TabbedContent` widget -- Added `get_child_by_type` method to widgets / app -- Added `Widget.render_str` method +- Added `TabbedContent` widget https://github.com/Textualize/textual/pull/2059 +- Added `get_child_by_type` method to widgets / app https://github.com/Textualize/textual/pull/2059 +- Added `Widget.render_str` method https://github.com/Textualize/textual/pull/2059 ## [0.15.1] - 2023-03-14 From d7b18eab7ba996561918783cf799711e94b0356a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Mar 2023 09:13:34 +0000 Subject: [PATCH 11/25] doc update --- docs/examples/widgets/tabbed_content.py | 7 +++--- docs/widgets/tabbed_content.md | 33 ++++++++++++++++++------- src/textual/widgets/_tabbed_content.py | 10 +++++--- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/docs/examples/widgets/tabbed_content.py b/docs/examples/widgets/tabbed_content.py index 9b0065f3fc..c53c6d2e0d 100644 --- a/docs/examples/widgets/tabbed_content.py +++ b/docs/examples/widgets/tabbed_content.py @@ -4,20 +4,19 @@ LETO = """ # Duke Leto I Atreides -Head of House Atreides +Head of House Atreides. """ JESSICA = """ # Lady Jessica -Bene Gesserit and concubine of Leto, and mother of Paul and Alia +Bene Gesserit and concubine of Leto, and mother of Paul and Alia. """ PAUL = """ # Paul Atreides -Son of Leto and Jessica - +Son of Leto and Jessica. """ diff --git a/docs/widgets/tabbed_content.md b/docs/widgets/tabbed_content.md index eeba9ea334..10db05fca2 100644 --- a/docs/widgets/tabbed_content.md +++ b/docs/widgets/tabbed_content.md @@ -2,13 +2,13 @@ Switch between mutually exclusive content panes via a row of tabs. -This widget combines the [Tabs](../widgets/tabs.md) and [ContentSwitcher](../widgets/content_switcher.md) widgets to create a convenient way of navigating content. - - [x] Focusable - [x] Container -Only a single child of TabbedContent is visible at a time. -Each child has an associated tab which to make it visible, and hide the others. +This widget combines the [Tabs](../widgets/tabs.md) and [ContentSwitcher](../widgets/content_switcher.md) widgets to create a convenient way of navigating content. + +Only a single child of TabbedContent is visible at once. +Each child has an associated tab which will make it visible and hide the others. ## Composing @@ -51,20 +51,35 @@ def compose(self) -> ComposeResult: yield Markdown(PAUL) ``` +You can then switch tabs by setting the `active` reactive attribute: + +```python +# Switch to Jessica tab +self.query_one(TabbedContent).active = "jessica" +``` + !!! note If you don't provide `id` attributes to the tab panes, they will be assigned sequentially starting at `tab-1` (then `tab-2` etc). -You can then switch tabs by setting the `active` reactive attribute: +## Initial tab + +The first child of `TabbedContent` will be the initial active tab by default. You can pick a different initial tab by setting the `initial` argument to the `id` of the tab: ```python -# Switch to Jessica tab -self.query_one(TabbedContent).active = "jessica" +def compose(self) -> ComposeResult: + with TabbedContent(initial="jessica"): + with TabPane("Leto", id="leto"): + yield Markdown(LETO) + with TabPane("Jessica", id="jessica"): + yield Markdown(JESSICA) + with TabPane("Paul", id="paul"): + yield Markdown(PAUL) ``` ## Example -Example app showing the widget: +The following example contains a `TabbedContent` with three tabs. === "Output" @@ -88,4 +103,4 @@ Example app showing the widget: - [TabbedContent](../api/tabbed_content.md) code reference. - [Tabs](../api/tabs.md) code reference. -- [ContentSwitcher](../api/content_switcher.md) +- [ContentSwitcher](../api/content_switcher.md) code reference. diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index bb9a5e3290..22c427106e 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -31,7 +31,11 @@ def __init__(self, label: Text, content_id: str): class TabPane(Widget): - """A container for switchable content, with additional title.""" + """A container for switchable content, with additional title. + + This widget is intended to be used with [TabbedContent][textual.widgets.TabbedContent]. + + """ DEFAULT_CSS = """ TabPane { @@ -127,7 +131,7 @@ def compose_add_child(self, widget: Widget) -> None: """When using the context manager compose syntax, we want to attach nodes to the switcher.""" self._tab_content.append(widget) - def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: + def _on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: """User clicked a tab.""" self.log("on_tabs_tab_activated", event) event.stop() @@ -135,7 +139,7 @@ def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: assert isinstance(event.tab, ContentTab) switcher.current = event.tab.id - def on_tabs_tabs_cleared(self, event: Tabs.TabsCleared) -> None: + def _on_tabs_tabs_cleared(self, event: Tabs.TabsCleared) -> None: """All tabs were removed.""" event.stop() From 165e123b4c495a22ef05d383be79e8020dd46f00 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Mar 2023 09:15:32 +0000 Subject: [PATCH 12/25] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f5d0bd8e5..8825ed6c96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed - Dropped "loading-indicator--dot" component style from LoadingIndicator https://github.com/Textualize/textual/pull/2050 -- Tabs widget now sends Tabs.TabsCleared when there is not active tab. +- Tabs widget now sends Tabs.TabsCleared when there is no active tab. - Breaking change: changed default behaviour of `Vertical` (see `VerticalScroll`) https://github.com/Textualize/textual/issues/1957 - The default `overflow` style for `Horizontal` was changed to `hidden hidden` https://github.com/Textualize/textual/issues/1957 From f8ff07ad33573b0457868caef16c3e96106550b2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Mar 2023 09:37:54 +0000 Subject: [PATCH 13/25] fix bad optimization --- src/textual/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index d594050afe..34a9538ea7 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2216,7 +2216,7 @@ async def prune_widgets_task( await self._prune_nodes(widgets) finally: finished_event.set() - if parent is not None and parent.styles.auto_dimensions: + if parent is not None: parent.refresh(layout=True) removed_widgets = self._detach_from_dom(widgets) From 2497f3b40d98263c78e8403b439e41a3f78f7a29 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Mar 2023 08:52:57 +0000 Subject: [PATCH 14/25] Update docs/widgets/tabbed_content.md Co-authored-by: Dave Pearson --- docs/widgets/tabbed_content.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/widgets/tabbed_content.md b/docs/widgets/tabbed_content.md index 10db05fca2..8c0c2dbde6 100644 --- a/docs/widgets/tabbed_content.md +++ b/docs/widgets/tabbed_content.md @@ -12,7 +12,7 @@ Each child has an associated tab which will make it visible and hide the others. ## Composing -There are two way to provide the titles for the tab. +There are two ways to provide the titles for the tab. You can pass them as positional arguments to the [TabbedContent][textual.widgets.TabbedContent] constructor: ```python From b2e2417c1944270892596d952c368012fe302bec Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Mar 2023 08:58:08 +0000 Subject: [PATCH 15/25] fix for empty initial --- src/textual/widgets/_content_switcher.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_content_switcher.py b/src/textual/widgets/_content_switcher.py index 0044ae2665..cb91719541 100644 --- a/src/textual/widgets/_content_switcher.py +++ b/src/textual/widgets/_content_switcher.py @@ -83,7 +83,7 @@ def watch_current(self, old: str | None, new: str | None) -> None: new: The new widget ID (or `None` if nothing should be shown). """ with self.app.batch_update(): - if old is not None: + if old: self.get_child_by_id(old).display = False - if new is not None: + if new: self.get_child_by_id(new).display = True From db9cb815341141eb3891eef7bc06b56877a8071a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Mar 2023 09:02:21 +0000 Subject: [PATCH 16/25] docstrings --- src/textual/dom.py | 2 +- src/textual/widgets/_tabbed_content.py | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index a1380ed862..cc6d64604d 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -159,7 +159,7 @@ def compose_add_child(self, widget: Widget) -> None: if you want children to be attached to a different node. Args: - node: A DOM node to add. + node: A Widget to add. """ self._nodes._append(widget) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 22c427106e..7a3336f40f 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -99,7 +99,15 @@ def compose(self) -> ComposeResult: """Compose the tabbed content.""" def set_id(content: TabPane, new_id: str) -> TabPane: - """Set an id on the content, if not already present.""" + """Set an id on the content, if not already present. + + Args: + content: a TabPane. + new_id: New `is` attribute, if it is not already set. + + Returns: + The same TabPane. + """ if content.id is None: content.id = new_id return content @@ -128,7 +136,11 @@ def set_id(content: TabPane, new_id: str) -> TabPane: yield from pane_content def compose_add_child(self, widget: Widget) -> None: - """When using the context manager compose syntax, we want to attach nodes to the switcher.""" + """When using the context manager compose syntax, we want to attach nodes to the switcher. + + Args: + widget: A Widget to add. + """ self._tab_content.append(widget) def _on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: From d0c9deb8fdb3eb04cd14bdfc1d58114b30c20d41 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Mar 2023 09:04:18 +0000 Subject: [PATCH 17/25] Update src/textual/widgets/_content_switcher.py Co-authored-by: Dave Pearson --- src/textual/widgets/_content_switcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_content_switcher.py b/src/textual/widgets/_content_switcher.py index cb91719541..bc4f2cab52 100644 --- a/src/textual/widgets/_content_switcher.py +++ b/src/textual/widgets/_content_switcher.py @@ -44,7 +44,7 @@ def __init__( id: The ID of the content switcher in the DOM. classes: The CSS classes of the content switcher. disabled: Whether the content switcher is disabled or not. - initial: The ID of the initial widget to show, or ``None`` for the first tab. + initial: The ID of the initial widget to show, or ``None`` for the first widget. Note: If `initial` is not supplied no children will be shown to start From 28412a260d60c8ee6faa8867dede4d5b6a0f160b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Mar 2023 09:08:41 +0000 Subject: [PATCH 18/25] docstring --- src/textual/dom.py | 2 +- src/textual/widgets/_content_switcher.py | 5 ++--- src/textual/widgets/_tabbed_content.py | 1 + 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index cc6d64604d..23e878ba46 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -159,7 +159,7 @@ def compose_add_child(self, widget: Widget) -> None: if you want children to be attached to a different node. Args: - node: A Widget to add. + widget: A Widget to add. """ self._nodes._append(widget) diff --git a/src/textual/widgets/_content_switcher.py b/src/textual/widgets/_content_switcher.py index bc4f2cab52..aca1f68624 100644 --- a/src/textual/widgets/_content_switcher.py +++ b/src/textual/widgets/_content_switcher.py @@ -44,11 +44,10 @@ def __init__( id: The ID of the content switcher in the DOM. classes: The CSS classes of the content switcher. disabled: Whether the content switcher is disabled or not. - initial: The ID of the initial widget to show, or ``None`` for the first widget. + initial: The ID of the initial widget to show, ``None`` or empty string for the first tab. Note: - If `initial` is not supplied no children will be shown to start - with. + If `initial` is not supplied no children will be shown to start with. """ super().__init__( *children, diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 7a3336f40f..0fd78c0186 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -57,6 +57,7 @@ def __init__( Args: title: Title of the TabPane (will be displayed in a tab label). + *children: Widget to go inside the TabPane. name: Optional name for the TabPane. id: Optional ID for the TabPane. classes: Optional initial classes for the widget. From 96bd8a5e463f6c52761822ae23e34a311ed4bdc7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Mar 2023 09:15:17 +0000 Subject: [PATCH 19/25] remove log --- src/textual/widgets/_tabbed_content.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 0fd78c0186..f330e36dea 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -146,7 +146,6 @@ def compose_add_child(self, widget: Widget) -> None: def _on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: """User clicked a tab.""" - self.log("on_tabs_tab_activated", event) event.stop() switcher = self.query_one(ContentSwitcher) assert isinstance(event.tab, ContentTab) From 53d092c080d7ca93a4d81dcec8b42480990e99d2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Mar 2023 09:20:41 +0000 Subject: [PATCH 20/25] permit nested tabs --- src/textual/widgets/_tabbed_content.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index f330e36dea..6ff15ed197 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -147,7 +147,7 @@ def compose_add_child(self, widget: Widget) -> None: def _on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: """User clicked a tab.""" event.stop() - switcher = self.query_one(ContentSwitcher) + switcher = self.get_child_by_type(ContentSwitcher) assert isinstance(event.tab, ContentTab) switcher.current = event.tab.id From b1ea5643ab610faaf35925028590c20e5a5a0206 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Mar 2023 09:25:35 +0000 Subject: [PATCH 21/25] renamed TabsCleared to Cleared --- CHANGELOG.md | 2 +- src/textual/widgets/_tabbed_content.py | 2 +- src/textual/widgets/_tabs.py | 19 +++++++++++++++---- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8825ed6c96..0e068003e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed - Dropped "loading-indicator--dot" component style from LoadingIndicator https://github.com/Textualize/textual/pull/2050 -- Tabs widget now sends Tabs.TabsCleared when there is no active tab. +- Tabs widget now sends Tabs.Cleared when there is no active tab. - Breaking change: changed default behaviour of `Vertical` (see `VerticalScroll`) https://github.com/Textualize/textual/issues/1957 - The default `overflow` style for `Horizontal` was changed to `hidden hidden` https://github.com/Textualize/textual/issues/1957 diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 6ff15ed197..ef27b6f09c 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -151,7 +151,7 @@ def _on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: assert isinstance(event.tab, ContentTab) switcher.current = event.tab.id - def _on_tabs_tabs_cleared(self, event: Tabs.TabsCleared) -> None: + def _on_tabs_cleared(self, event: Tabs.Cleared) -> None: """All tabs were removed.""" event.stop() diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index b2ec6f0fb8..1474975edc 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -181,6 +181,12 @@ class TabActivated(Message): """The tab that was activated.""" def __init__(self, tabs: Tabs, tab: Tab) -> None: + """Initialize event. + + Args: + tabs: The Tabs widget. + tab: The tab that was activated. + """ self.tabs = tabs self.tab = tab super().__init__() @@ -189,13 +195,18 @@ def __rich_repr__(self) -> rich.repr.Result: yield self.tabs yield self.tab - class TabsCleared(Message): + class Cleared(Message): """Sent when there are no active tabs.""" tabs: Tabs """The tabs widget which was cleared.""" def __init__(self, tabs: Tabs) -> None: + """Initialize the event. + + Args: + tabs: The tabs widget. + """ self.tabs = tabs super().__init__() @@ -311,7 +322,7 @@ def clear(self) -> None: underline.highlight_start = 0 underline.highlight_end = 0 self.query("#tabs-list > Tab").remove() - self.post_message(self.TabsCleared(self)) + self.post_message(self.Cleared(self)) def remove_tab(self, tab_or_id: Tab | str | None) -> None: """Remove a tab. @@ -332,7 +343,7 @@ def remove_tab(self, tab_or_id: Tab | str | None) -> None: next_tab = self._next_active if next_tab is None: - self.post_message(self.TabsCleared(self)) + self.post_message(self.Cleared(self)) else: self.post_message(self.TabActivated(self, next_tab)) @@ -391,7 +402,7 @@ def watch_active(self, previously_active: str, active: str) -> None: underline = self.query_one(Underline) underline.highlight_start = 0 underline.highlight_end = 0 - self.post_message(self.TabsCleared(self)) + self.post_message(self.Cleared(self)) def _highlight_active(self, animate: bool = True) -> None: """Move the underline bar to under the active tab. From 2ede38f653fb92299ab77d5a8e8a788df1692d1c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Mar 2023 09:49:55 +0000 Subject: [PATCH 22/25] added tests, fix types on click --- src/textual/pilot.py | 7 ++++--- src/textual/widgets/_tabbed_content.py | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 9f52190f4b..d5582f0682 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -7,7 +7,6 @@ from ._wait import wait_for_idle from .app import App, ReturnType -from .css.query import QueryType from .events import Click, MouseDown, MouseMove, MouseUp from .geometry import Offset from .widget import Widget @@ -65,7 +64,7 @@ async def press(self, *keys: str) -> None: async def click( self, - selector: QueryType | None = None, + selector: type[Widget] | str | None = None, offset: Offset = Offset(), shift: bool = False, meta: bool = False, @@ -100,7 +99,9 @@ async def click( await self.pause() async def hover( - self, selector: QueryType | None = None, offset: Offset = Offset() + self, + selector: type[Widget] | str | None | None = None, + offset: Offset = Offset(), ) -> None: """Simulate hovering with the mouse cursor. diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index ef27b6f09c..61a6a57bc7 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -81,7 +81,7 @@ class TabbedContent(Widget): } """ - active: reactive[str] = reactive("", init=False) + active: reactive[str | None] = reactive(None, init=False) """The ID of the active tab, or empty string if none are active.""" def __init__(self, *titles: TextType, initial: str = "") -> None: @@ -150,6 +150,7 @@ def _on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: switcher = self.get_child_by_type(ContentSwitcher) assert isinstance(event.tab, ContentTab) switcher.current = event.tab.id + self.active = event.tab.id def _on_tabs_cleared(self, event: Tabs.Cleared) -> None: """All tabs were removed.""" From 43d13c73ecfc668708a75113f34b5c8e427bac6a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Mar 2023 09:55:46 +0000 Subject: [PATCH 23/25] tests --- tests/test_tabbed_content.py | 77 ++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 tests/test_tabbed_content.py diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py new file mode 100644 index 0000000000..4ae64e345f --- /dev/null +++ b/tests/test_tabbed_content.py @@ -0,0 +1,77 @@ +from textual.app import App, ComposeResult +from textual.widgets import Label, TabbedContent, TabPane + + +async def test_tabbed_content_switch(): + """Check tab navigation.""" + + class TabbedApp(App): + def compose(self) -> ComposeResult: + with TabbedContent(): + with TabPane("foo", id="foo"): + yield Label("Foo", id="foo-label") + with TabPane("bar", id="bar"): + yield Label("Bar", id="bar-label") + with TabPane("baz`", id="baz"): + yield Label("Baz", id="baz-label") + + app = TabbedApp() + async with app.run_test() as pilot: + tabbed_content = app.query_one(TabbedContent) + # Check first tab + assert tabbed_content.active == "foo" + assert app.query_one("#foo-label").region + assert not app.query_one("#bar-label").region + assert not app.query_one("#baz-label").region + + # Click second tab + await pilot.click("Tab#bar") + assert tabbed_content.active == "bar" + assert not app.query_one("#foo-label").region + assert app.query_one("#bar-label").region + assert not app.query_one("#baz-label").region + + # Click third tab + await pilot.click("Tab#baz") + assert tabbed_content.active == "baz" + assert not app.query_one("#foo-label").region + assert not app.query_one("#bar-label").region + assert app.query_one("#baz-label").region + + # Press left + await pilot.press("left") + assert tabbed_content.active == "bar" + assert not app.query_one("#foo-label").region + assert app.query_one("#bar-label").region + assert not app.query_one("#baz-label").region + + # Press right + await pilot.press("right") + assert tabbed_content.active == "baz" + assert not app.query_one("#foo-label").region + assert not app.query_one("#bar-label").region + assert app.query_one("#baz-label").region + + +async def test_tabbed_content_initial(): + """Checked tabbed content with non-default tab.""" + + class TabbedApp(App): + def compose(self) -> ComposeResult: + with TabbedContent(initial="bar"): + with TabPane("foo", id="foo"): + yield Label("Foo", id="foo-label") + with TabPane("bar", id="bar"): + yield Label("Bar", id="bar-label") + with TabPane("baz`", id="baz"): + yield Label("Baz", id="baz-label") + + app = TabbedApp() + async with app.run_test() as pilot: + tabbed_content = app.query_one(TabbedContent) + assert tabbed_content.active == "bar" + + # Check only bar is visible + assert not app.query_one("#foo-label").region + assert app.query_one("#bar-label").region + assert not app.query_one("#baz-label").region From 521fbdb2ea3ff1e77e76d4a97444525eac9a0592 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Mar 2023 10:17:56 +0000 Subject: [PATCH 24/25] fix broken test --- src/textual/widgets/_tabbed_content.py | 22 ++- .../__snapshots__/test_snapshots.ambr | 163 ++++++++++++++++++ .../programmatic_scrollbar_gutter_change.py | 6 +- tests/snapshot_tests/test_snapshots.py | 4 + tests/test_tabbed_content.py | 10 ++ 5 files changed, 198 insertions(+), 7 deletions(-) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 61a6a57bc7..6cac3b7c8c 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -81,7 +81,7 @@ class TabbedContent(Widget): } """ - active: reactive[str | None] = reactive(None, init=False) + active: reactive[str] = reactive("", init=False) """The ID of the active tab, or empty string if none are active.""" def __init__(self, *titles: TextType, initial: str = "") -> None: @@ -96,6 +96,22 @@ def __init__(self, *titles: TextType, initial: str = "") -> None: self._initial = initial super().__init__() + def validate_active(self, active: str) -> str: + """It doesn't make sense for `active` to be an empty string. + + Args: + active: Attribute to be validated. + + Returns: + Value of `active`. + + Raises: + ValueError: If the active attribute is set to empty string. + """ + if not active: + raise ValueError("'active' tab must not be empty string.") + return active + def compose(self) -> ComposeResult: """Compose the tabbed content.""" @@ -131,9 +147,9 @@ def set_id(content: TabPane, new_id: str) -> TabPane: ContentTab(content._title, content.id or "") for content in pane_content ] # Yield the tabs - yield Tabs(*tabs, active=self._initial) + yield Tabs(*tabs, active=self._initial or None) # Yield the content switcher and panes - with ContentSwitcher(initial=self._initial): + with ContentSwitcher(initial=self._initial or None): yield from pane_content def compose_add_child(self, widget: Widget) -> None: diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 2033ee8ced..3618786aea 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -17909,6 +17909,169 @@ ''' # --- +# name: test_tabbed_content + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TabbedApp + + + + + + + + + + + LetoJessicaPaul + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + Lady Jessica + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Bene Gesserit and concubine of Leto, and mother of Paul and Alia. + + + + + + + + + + + + + +  L  Leto  J  Jessica  P  Paul  + + + + + ''' +# --- # name: test_textlog_max_lines ''' diff --git a/tests/snapshot_tests/snapshot_apps/programmatic_scrollbar_gutter_change.py b/tests/snapshot_tests/snapshot_apps/programmatic_scrollbar_gutter_change.py index d1a7fba0d3..5d93439a9b 100644 --- a/tests/snapshot_tests/snapshot_apps/programmatic_scrollbar_gutter_change.py +++ b/tests/snapshot_tests/snapshot_apps/programmatic_scrollbar_gutter_change.py @@ -22,8 +22,6 @@ def on_key(self, event): self.query_one(Grid).styles.scrollbar_gutter = "stable" -app = ProgrammaticScrollbarGutterChange() - - if __name__ == "__main__": - app().run() + app = ProgrammaticScrollbarGutterChange() + app.run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 7769c4a62d..27676eb230 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -183,6 +183,10 @@ def test_content_switcher_example_switch(snap_compare): ) +def test_tabbed_content(snap_compare): + assert snap_compare(WIDGET_EXAMPLES_DIR / "tabbed_content.py") + + # --- CSS properties --- # We have a canonical example for each CSS property that is shown in their docs. # If any of these change, something has likely broken, so snapshot each of them. diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py index 4ae64e345f..06a676a6ff 100644 --- a/tests/test_tabbed_content.py +++ b/tests/test_tabbed_content.py @@ -1,3 +1,5 @@ +import pytest + from textual.app import App, ComposeResult from textual.widgets import Label, TabbedContent, TabPane @@ -52,6 +54,14 @@ def compose(self) -> ComposeResult: assert not app.query_one("#bar-label").region assert app.query_one("#baz-label").region + # Check fail with non existent tab + with pytest.raises(ValueError): + tabbed_content.active = "X" + + # Check fail with empty tab + with pytest.raises(ValueError): + tabbed_content.active = "" + async def test_tabbed_content_initial(): """Checked tabbed content with non-default tab.""" From c54005667da558b28d78f21ae69b3f1d5b6032b0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Mar 2023 10:29:25 +0000 Subject: [PATCH 25/25] fix for nested tabs --- docs/examples/widgets/tabbed_content.py | 8 +- src/textual/widgets/_tabbed_content.py | 2 +- .../__snapshots__/test_snapshots.ambr | 128 +++++++++--------- 3 files changed, 71 insertions(+), 67 deletions(-) diff --git a/docs/examples/widgets/tabbed_content.py b/docs/examples/widgets/tabbed_content.py index c53c6d2e0d..0a56d3cf07 100644 --- a/docs/examples/widgets/tabbed_content.py +++ b/docs/examples/widgets/tabbed_content.py @@ -1,5 +1,5 @@ from textual.app import App, ComposeResult -from textual.widgets import Footer, Markdown, TabbedContent, TabPane +from textual.widgets import Footer, Label, Markdown, TabbedContent, TabPane LETO = """ # Duke Leto I Atreides @@ -40,12 +40,16 @@ def compose(self) -> ComposeResult: yield Markdown(LETO) # Tab content with TabPane("Jessica", id="jessica"): yield Markdown(JESSICA) + with TabbedContent("Paul", "Alia"): + yield TabPane("Paul", Label("First child")) + yield TabPane("Alia", Label("Second child")) + with TabPane("Paul", id="paul"): yield Markdown(PAUL) def action_show_tab(self, tab: str) -> None: """Switch to a new tab.""" - self.query_one(TabbedContent).active = tab + self.get_child_by_type(TabbedContent).active = tab if __name__ == "__main__": diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 6cac3b7c8c..f2b9b06cd3 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -174,4 +174,4 @@ def _on_tabs_cleared(self, event: Tabs.Cleared) -> None: def watch_active(self, active: str) -> None: """Switch tabs when the active attributes changes.""" - self.query_one(Tabs).active = active + self.get_child_by_type(Tabs).active = active diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 3618786aea..3ad921cdcb 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -17932,140 +17932,140 @@ font-weight: 700; } - .terminal-2062826888-matrix { + .terminal-4003130388-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2062826888-title { + .terminal-4003130388-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2062826888-r1 { fill: #c5c8c6 } - .terminal-2062826888-r2 { fill: #737373 } - .terminal-2062826888-r3 { fill: #e1e1e1;font-weight: bold } - .terminal-2062826888-r4 { fill: #323232 } - .terminal-2062826888-r5 { fill: #0178d4 } - .terminal-2062826888-r6 { fill: #121212 } - .terminal-2062826888-r7 { fill: #0053aa } - .terminal-2062826888-r8 { fill: #dde8f3;font-weight: bold } - .terminal-2062826888-r9 { fill: #e1e1e1 } - .terminal-2062826888-r10 { fill: #ddedf9 } + .terminal-4003130388-r1 { fill: #c5c8c6 } + .terminal-4003130388-r2 { fill: #737373 } + .terminal-4003130388-r3 { fill: #e1e1e1;font-weight: bold } + .terminal-4003130388-r4 { fill: #323232 } + .terminal-4003130388-r5 { fill: #0178d4 } + .terminal-4003130388-r6 { fill: #121212 } + .terminal-4003130388-r7 { fill: #0053aa } + .terminal-4003130388-r8 { fill: #dde8f3;font-weight: bold } + .terminal-4003130388-r9 { fill: #e1e1e1 } + .terminal-4003130388-r10 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TabbedApp + TabbedApp - - - - - LetoJessicaPaul - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - Lady Jessica - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Bene Gesserit and concubine of Leto, and mother of Paul and Alia. - - - - - - - - - - - - - -  L  Leto  J  Jessica  P  Paul  + + + + + LetoJessicaPaul + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + Lady Jessica + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Bene Gesserit and concubine of Leto, and mother of Paul and Alia. + + + + PaulAlia + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + First child + + + + + + +  L  Leto  J  Jessica  P  Paul