diff --git a/CHANGELOG.md b/CHANGELOG.md index 66c4330cd..0e068003e 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.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 ### Removed @@ -20,18 +23,15 @@ 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 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 diff --git a/docs/api/tabbed_content.md b/docs/api/tabbed_content.md new file mode 100644 index 000000000..6d4338d21 --- /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 000000000..0a56d3cf0 --- /dev/null +++ b/docs/examples/widgets/tabbed_content.py @@ -0,0 +1,57 @@ +from textual.app import App, ComposeResult +from textual.widgets import Footer, Label, 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(initial="jessica"): + with TabPane("Leto", id="leto"): # First tab + 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.get_child_by_type(TabbedContent).active = tab + + +if __name__ == "__main__": + app = TabbedApp() + app.run() diff --git a/docs/roadmap.md b/docs/roadmap.md index 478ac534e..4637235d7 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 ec5d885c9..abcd8a749 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 1e8c6f972..c9e60413b 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 1f7b51a8a..809de2e66 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 7cc0a00d6..8c09a032c 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 6e294323e..2aa03cb10 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 95f510365..6eb698312 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 0b497da50..e5f3bcd7c 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/tabbed_content.md b/docs/widgets/tabbed_content.md new file mode 100644 index 000000000..8c0c2dbde --- /dev/null +++ b/docs/widgets/tabbed_content.md @@ -0,0 +1,106 @@ +# TabbedContent + +Switch between mutually exclusive content panes via a row of tabs. + +- [x] Focusable +- [x] Container + +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 + +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 +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) +``` + +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). + +## 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 +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 + +The following example contains a `TabbedContent` with three tabs. + +=== "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. | + + +## See also + +- [TabbedContent](../api/tabbed_content.md) code reference. +- [Tabs](../api/tabs.md) code reference. +- [ContentSwitcher](../api/content_switcher.md) code reference. diff --git a/docs/widgets/tabs.md b/docs/widgets/tabs.md index 77b94fc7a..5546e5dbd 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 9014f3d97..ff7df3ed0 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 3805239fb..704bd75e2 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/_compose.py b/src/textual/_compose.py index 272e8690a..022664d2b 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 79080f59f..ebb356906 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -893,10 +893,11 @@ class Compositor: 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 9091acf69..34a9538ea 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1133,6 +1133,20 @@ class App(Generic[ReturnType], DOMNode): 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. @@ -2202,7 +2216,7 @@ class App(Generic[ReturnType], DOMNode): 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) diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 5fc5b9932..720dc5e7d 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -761,7 +761,11 @@ class StringEnumProperty: 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 9e9ec8163..23e878ba4 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -151,6 +151,18 @@ class DOMNode(MessagePump): 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: + widget: A Widget to add. + """ + self._nodes._append(widget) + @property def children(self) -> Sequence["Widget"]: """A view on to the children.""" diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 9f52190f4..d5582f068 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -7,7 +7,6 @@ import rich.repr 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 @@ class Pilot(Generic[ReturnType]): 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 @@ class Pilot(Generic[ReturnType]): 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/widget.py b/src/textual/widget.py index 6008bc62f..2bbff4909 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -390,7 +390,7 @@ class Widget(DOMNode): 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) @@ -475,6 +475,25 @@ class Widget(DOMNode): ) 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: + # 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}") + def get_component_rich_style(self, name: str, *, partial: bool = False) -> Style: """Get a *Rich* style for a component. @@ -496,6 +515,24 @@ class Widget(DOMNode): 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 +2626,7 @@ class Widget(DOMNode): 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/__init__.py b/src/textual/widgets/__init__.py index 51985b13a..db5717284 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -29,6 +29,7 @@ if typing.TYPE_CHECKING: 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 @@ __all__ = [ "Static", "Switch", "Tab", + "TabbedContent", + "TabPane", "Tabs", "TextLog", "Tree", diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index d19537ac7..66e78fe94 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/_content_switcher.py b/src/textual/widgets/_content_switcher.py index 72ab0f96a..aca1f6862 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,11 +44,10 @@ class ContentSwitcher(Container): 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, ``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, @@ -61,12 +60,11 @@ class ContentSwitcher(Container): 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 = bool(initial) and child.id == initial + self._reactive_current = initial @property def visible_content(self) -> Widget | None: @@ -84,7 +82,7 @@ class ContentSwitcher(Container): 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 diff --git a/src/textual/widgets/_tab_pane.py b/src/textual/widgets/_tab_pane.py new file mode 100644 index 000000000..db70d22a3 --- /dev/null +++ b/src/textual/widgets/_tab_pane.py @@ -0,0 +1,3 @@ +from ._tabbed_content import TabPane + +__all__ = ["TabPane"] diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py new file mode 100644 index 000000000..f2b9b06cd --- /dev/null +++ b/src/textual/widgets/_tabbed_content.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from itertools import zip_longest + +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.""" + + def __init__(self, label: Text, content_id: str): + """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. + + This widget is intended to be used with [TabbedContent][textual.widgets.TabbedContent]. + + """ + + DEFAULT_CSS = """ + TabPane { + height: auto; + padding: 1 2; + } + """ + + def __init__( + self, + title: TextType, + *children: Widget, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + disabled: bool = False, + ): + """Initialize a TabPane. + + 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. + 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; + } + 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, 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 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.""" + + def set_id(content: TabPane, new_id: str) -> TabPane: + """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 + + # Wrap content in a `TabPane` if required. + 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 + ) + ] + # Get a tab for each pane + tabs = [ + ContentTab(content._title, content.id or "") for content in pane_content + ] + # Yield the tabs + yield Tabs(*tabs, active=self._initial or None) + # Yield the content switcher and panes + with ContentSwitcher(initial=self._initial or None): + 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. + + Args: + widget: A Widget to add. + """ + self._tab_content.append(widget) + + def _on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: + """User clicked a tab.""" + event.stop() + 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.""" + event.stop() + + def watch_active(self, active: str) -> None: + """Switch tabs when the active attributes changes.""" + self.get_child_by_type(Tabs).active = active diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index 7c190bf94..1474975ed 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -175,16 +175,44 @@ class Tabs(Widget, can_focus=True): class TabActivated(Message): """Sent when a new tab is activated.""" + 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: + """Initialize event. + + Args: + tabs: The Tabs widget. + tab: The tab that was activated. + """ + self.tabs = tabs self.tab = tab super().__init__() def __rich_repr__(self) -> rich.repr.Result: + yield self.tabs yield self.tab + 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__() + + 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 +306,7 @@ class Tabs(Widget, can_focus=True): 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 +322,7 @@ class Tabs(Widget, can_focus=True): underline.highlight_start = 0 underline.highlight_end = 0 self.query("#tabs-list > Tab").remove() - self.post_message(self.TabActivated(None)) + self.post_message(self.Cleared(self)) def remove_tab(self, tab_or_id: Tab | str | None) -> None: """Remove a tab. @@ -314,9 +342,13 @@ class Tabs(Widget, can_focus=True): 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.Cleared(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 +397,12 @@ class Tabs(Widget, can_focus=True): 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.Cleared(self)) def _highlight_active(self, animate: bool = True) -> None: """Move the underline bar to under the active tab. diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 36930d78d..326be7e82 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. + + + + PaulAlia + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + First child + + + + + + +  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 d1a7fba0d..5d93439a9 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 @@ class ProgrammaticScrollbarGutterChange(App[None]): 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 7769c4a62..27676eb23 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 new file mode 100644 index 000000000..06a676a6f --- /dev/null +++ b/tests/test_tabbed_content.py @@ -0,0 +1,87 @@ +import pytest + +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 + + # 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.""" + + 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 diff --git a/tests/test_widget.py b/tests/test_widget.py index bef67857d..a51040a6c 100644 --- a/tests/test_widget.py +++ b/tests/test_widget.py @@ -1,7 +1,9 @@ import pytest +from rich.text import Text 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 +113,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") @@ -198,3 +218,12 @@ async def test_remove_unmounted(): 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