diff --git a/CHANGELOG.md b/CHANGELOG.md index a0733b856..15e8cf97e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Breaking change: Added `toggle_button` attribute to RadioButton and Checkbox events, replaces `input` https://github.com/Textualize/textual/pull/1940 - A percentage alpha can now be applied to a border https://github.com/Textualize/textual/issues/1863 - Added `Color.multiply_alpha`. +- Added `ContentSwitcher` https://github.com/Textualize/textual/issues/1945 ### Fixed diff --git a/docs/api/content_switcher.md b/docs/api/content_switcher.md new file mode 100644 index 000000000..ef2cfe930 --- /dev/null +++ b/docs/api/content_switcher.md @@ -0,0 +1 @@ +::: textual.widgets.ContentSwitcher diff --git a/docs/examples/widgets/content_switcher.css b/docs/examples/widgets/content_switcher.css new file mode 100644 index 000000000..b8546b117 --- /dev/null +++ b/docs/examples/widgets/content_switcher.css @@ -0,0 +1,27 @@ +Screen { + align: center middle; +} + +#buttons { + margin-top: 1; + height: 3; + width: auto; +} + +ContentSwitcher { + background: $panel; + border: round $primary; + width: 90%; + height: 80%; +} + +DataTable { + background: $panel; +} + +MarkdownH2 { + background: $primary; + color: yellow; + border: none; + padding: 0; +} diff --git a/docs/examples/widgets/content_switcher.py b/docs/examples/widgets/content_switcher.py new file mode 100644 index 000000000..1a774a805 --- /dev/null +++ b/docs/examples/widgets/content_switcher.py @@ -0,0 +1,64 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal +from textual.widgets import Button, ContentSwitcher, DataTable, Markdown + +MARKDOWN_EXAMPLE = """# Three Flavours Cornetto + +The Three Flavours Cornetto trilogy is an anthology series of British +comedic genre films directed by Edgar Wright. + +## Shaun of the Dead + +| Flavour | UK Release Date | Director | +| -- | -- | -- | +| Strawberry | 2004-04-09 | Edgar Wright | + +## Hot Fuzz + +| Flavour | UK Release Date | Director | +| -- | -- | -- | +| Classico | 2007-02-17 | Edgar Wright | + +## The World's End + +| Flavour | UK Release Date | Director | +| -- | -- | -- | +| Mint | 2013-07-19 | Edgar Wright | +""" + + +class ContentSwitcherApp(App[None]): + CSS_PATH = "content_switcher.css" + + def compose(self) -> ComposeResult: + with Horizontal(id="buttons"): # (1)! + yield Button("DataTable", id="data-table") # (2)! + yield Button("Markdown", id="markdown") # (3)! + + with ContentSwitcher(initial="data-table"): # (4)! + yield DataTable(id="data-table") + yield Markdown(MARKDOWN_EXAMPLE, id="markdown") + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.query_one(ContentSwitcher).current = event.button.id # (5)! + + def on_mount(self) -> None: + table = self.query_one(DataTable) + table.add_columns("Book", "Year") + table.add_rows( + [ + (title.ljust(35), year) + for title, year in ( + ("Dune", 1965), + ("Dune Messiah", 1969), + ("Children of Dune", 1976), + ("God Emperor of Dune", 1981), + ("Heretics of Dune", 1984), + ("Chapterhouse: Dune", 1985), + ) + ] + ) + + +if __name__ == "__main__": + ContentSwitcherApp().run() diff --git a/docs/widgets/content_switcher.md b/docs/widgets/content_switcher.md new file mode 100644 index 000000000..b9369452b --- /dev/null +++ b/docs/widgets/content_switcher.md @@ -0,0 +1,54 @@ +# ContentSwitcher + +A widget for containing and switching display between multiple child +widgets. + +- [ ] Focusable +- [X] Container + +## Example + +The example below uses a `ContentSwitcher` in combination with two `Button`s +to create a simple tabbed view. Note how each `Button` has an ID set, and +how each child of the `ContentSwitcher` has a corresponding ID; then a +`Button.Clicked` handler is used to set `ContentSwitcher.current` to switch +between the different views. + +=== "Output" + + ```{.textual path="docs/examples/widgets/content_switcher.py"} + ``` + +=== "content_switcher.py" + + ~~~python + --8<-- "docs/examples/widgets/content_switcher.py" + ~~~ + + 1. A `Horizontal` to hold the buttons, each with a unique ID. + 2. This button will select the `DataTable` in the `ContentSwitcher`. + 3. This button will select the `Markdown` in the `ContentSwitcher`. + 4. Note that the intial visible content is set by its ID, see below. + 5. When a button is pressed, its ID is used to switch to a different widget in the `ContentSwitcher`. Remember that IDs are unique within parent, so the buttons and the widgets in the `ContentSwitcher` can share IDs. + +=== "content_switcher.css" + + ~~~sass + --8<-- "docs/examples/widgets/content_switcher.css" + ~~~ + +When the user presses the "Markdown" button the view is switched: + +```{.textual path="docs/examples/widgets/content_switcher.py" lines="40" press="tab,tab,enter"} +``` + +## Reactive Attributes + +| Name | Type | Default | Description | +|-----------|-----------------|---------|----------------------------------------------------------------------| +| `current` | `str` \| `None` | `None` | The ID of the currently-visible child. `None` means nothing is visible. | + + +## See Also + +* [ContentSwitcher][textual.widgets.ContentSwitcher] code reference diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml index 3750fc9d7..820d601a7 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -122,6 +122,7 @@ nav: - Widgets: - "widgets/button.md" - "widgets/checkbox.md" + - "widgets/content_switcher.md" - "widgets/data_table.md" - "widgets/directory_tree.md" - "widgets/footer.md" @@ -148,6 +149,7 @@ nav: - "api/checkbox.md" - "api/color.md" - "api/containers.md" + - "api/content_switcher.md" - "api/coordinate.md" - "api/data_table.md" - "api/directory_tree.md" diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index 0b85c9126..5dc515dea 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -12,6 +12,7 @@ if typing.TYPE_CHECKING: from ..widget import Widget from ._button import Button from ._checkbox import Checkbox + from ._content_switcher import ContentSwitcher from ._data_table import DataTable from ._directory_tree import DirectoryTree from ._footer import Footer @@ -35,6 +36,7 @@ if typing.TYPE_CHECKING: __all__ = [ "Button", "Checkbox", + "ContentSwitcher", "DataTable", "DirectoryTree", "Footer", diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index 3d2edfc06..5fe292f2d 100644 --- a/src/textual/widgets/__init__.pyi +++ b/src/textual/widgets/__init__.pyi @@ -1,6 +1,7 @@ # This stub file must re-export every classes exposed in the __init__.py's `__all__` list: from ._button import Button as Button from ._checkbox import Checkbox as Checkbox +from ._content_switcher import ContentSwitcher as ContentSwitcher from ._data_table import DataTable as DataTable from ._directory_tree import DirectoryTree as DirectoryTree from ._footer import Footer as Footer diff --git a/src/textual/widgets/_content_switcher.py b/src/textual/widgets/_content_switcher.py new file mode 100644 index 000000000..72ab0f96a --- /dev/null +++ b/src/textual/widgets/_content_switcher.py @@ -0,0 +1,90 @@ +"""Provides a widget for switching between the display of its immediate children.""" + +from __future__ import annotations + +from typing import Optional + +from ..containers import Container +from ..reactive import reactive +from ..widget import Widget + + +class ContentSwitcher(Container): + """A widget for switching between different children. + + Note: + All child widgets that are to be switched between need a unique ID. + Children that have no ID will be hidden and ignored. + """ + + current: reactive[str | None] = reactive[Optional[str]](None) + """The ID of the currently-displayed widget. + + If set to `None` then no widget is visible. + + Note: + If set to an unknown ID, this will result in + [NoMatches][textual.css.query.NoMatches] being raised. + """ + + def __init__( + self, + *children: Widget, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + disabled: bool = False, + initial: str | None = None, + ) -> None: + """Initialise the content switching widget. + + Args: + *children: The widgets to switch between. + name: The name of the content switcher. + 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. + + Note: + If `initial` is not supplied no children will be shown to start + with. + """ + super().__init__( + *children, + name=name, + id=id, + classes=classes, + disabled=disabled, + ) + self._initial = initial + + def on_mount(self) -> None: + """Perform the initial setup of the widget once the DOM is ready.""" + # On startup, ensure everything is hidden. + with self.app.batch_update(): + for child in self.children: + child.display = False + # Then set the initial display. + self.current = self._initial + + @property + def visible_content(self) -> Widget | None: + """A reference to the currently-visible widget. + + `None` if nothing is visible. + """ + return self.get_child_by_id(self.current) if self.current is not None else None + + def watch_current(self, old: str | None, new: str | None) -> None: + """React to the current visible child choice being changed. + + Args: + old: The old widget ID (or `None` if there was no widget). + new: The new widget ID (or `None` if nothing should be shown). + """ + with self.app.batch_update(): + if old is not None: + self.get_child_by_id(old).display = False + if new is not None: + self.get_child_by_id(new).display = True diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 312c72e6d..b69597054 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -1167,6 +1167,440 @@ ''' # --- +# name: test_content_switcher_example_initial + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ContentSwitcherApp + + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + DataTableMarkdown + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ────────────────────────────────────────────────────────────────────── +  Book                                 Year  +  Dune                                 1965  +  Dune Messiah                         1969  +  Children of Dune                     1976  +  God Emperor of Dune                  1981  +  Heretics of Dune                     1984  +  Chapterhouse: Dune                   1985  + + + + + + + + + + + ────────────────────────────────────────────────────────────────────── + + + + + + ''' +# --- +# name: test_content_switcher_example_switch + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ContentSwitcherApp + + + + + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + DataTableMarkdown + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ─────────────────────────────────────────── + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + Three Flavours Cornetto + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + The Three Flavours Cornetto trilogy + is an anthology series of British + comedic genre films directed by  + Edgar Wright. + + Shaun of the Dead + +  Flavour     UK Release Date  Dir + + + Hot Fuzz + +  Flavour   UK Release Date  Direc + + + The World's End + +  Flavour  UK Release Date  Direct + + + + + + + + + + + + + + + + ─────────────────────────────────────────── + + + + + + + + ''' +# --- # name: test_css_property[align.py] ''' diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 26e044060..7d338f1d5 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -1,3 +1,4 @@ +from os import terminal_size from pathlib import Path import pytest @@ -164,6 +165,16 @@ def test_radio_set_example(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "radio_set.py") +def test_content_switcher_example_initial(snap_compare): + assert snap_compare(WIDGET_EXAMPLES_DIR / "content_switcher.py") + + +def test_content_switcher_example_switch(snap_compare): + assert snap_compare(WIDGET_EXAMPLES_DIR / "content_switcher.py", press=[ + "tab", "tab", "enter", "wait:500" + ], terminal_size=(50, 50)) + + # --- 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_content_switcher.py b/tests/test_content_switcher.py new file mode 100644 index 000000000..8c194b5a9 --- /dev/null +++ b/tests/test_content_switcher.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import pytest + +from textual.app import App, ComposeResult +from textual.css.query import NoMatches +from textual.widget import Widget +from textual.widgets import ContentSwitcher + + +class SwitcherApp(App[None]): + def __init__(self, initial: str | None = None) -> None: + super().__init__() + self._initial = initial + + def compose(self) -> ComposeResult: + with ContentSwitcher(initial=self._initial): + for n in range(5): + yield Widget(id=f"w{n}") + + +async def test_no_initial_display() -> None: + """Test starting a content switcher with nothing shown.""" + async with SwitcherApp().run_test() as pilot: + assert pilot.app.query_one(ContentSwitcher).current is None + assert all( + not child.display for child in pilot.app.query_one(ContentSwitcher).children + ) + + +async def test_initial_display() -> None: + """Test starting a content switcher with a widget initially shown.""" + async with SwitcherApp("w3").run_test() as pilot: + assert pilot.app.query_one(ContentSwitcher).current == "w3" + for child in pilot.app.query_one(ContentSwitcher).children: + assert child.display is (child.id == "w3") + + +async def test_no_initial_display_then_set() -> None: + """Test starting a content switcher with nothing shown then setting the display.""" + async with SwitcherApp().run_test() as pilot: + assert pilot.app.query_one(ContentSwitcher).current is None + assert all( + not child.display for child in pilot.app.query_one(ContentSwitcher).children + ) + pilot.app.query_one(ContentSwitcher).current = "w3" + assert pilot.app.query_one(ContentSwitcher).current == "w3" + for child in pilot.app.query_one(ContentSwitcher).children: + assert child.display is (child.id == "w3") + + +async def test_initial_display_then_change() -> None: + """Test starting a content switcher with a widget initially shown then changing it.""" + async with SwitcherApp("w3").run_test() as pilot: + assert pilot.app.query_one(ContentSwitcher).current == "w3" + for child in pilot.app.query_one(ContentSwitcher).children: + assert child.display is (child.id == "w3") + pilot.app.query_one(ContentSwitcher).current = "w2" + assert pilot.app.query_one(ContentSwitcher).current == "w2" + for child in pilot.app.query_one(ContentSwitcher).children: + assert child.display is (child.id == "w2") + + +async def test_initial_display_then_hide() -> None: + """Test starting a content switcher with a widget initially shown then hide all.""" + async with SwitcherApp("w3").run_test() as pilot: + assert pilot.app.query_one(ContentSwitcher).current == "w3" + for child in pilot.app.query_one(ContentSwitcher).children: + assert child.display is (child.id == "w3") + pilot.app.query_one(ContentSwitcher).current = None + assert pilot.app.query_one(ContentSwitcher).current is None + assert all( + not child.display for child in pilot.app.query_one(ContentSwitcher).children + ) + + +@pytest.mark.xfail( + reason="The expected exception doesn't appear to make it to pytest -- perhaps related to https://github.com/Textualize/textual/issues/1972" +) +async def test_initial_display_unknown_id() -> None: + """Test setting an initial display to an unknown widget ID.""" + with pytest.raises(NoMatches): + async with SwitcherApp("does-not-exist").run_test(): + pass + + +async def test_set_current_to_unknown_id() -> None: + """Test attempting to switch to an unknown widget ID.""" + async with SwitcherApp().run_test() as pilot: + assert pilot.app.query_one(ContentSwitcher).current is None + assert all( + not child.display for child in pilot.app.query_one(ContentSwitcher).children + ) + with pytest.raises(NoMatches): + pilot.app.query_one(ContentSwitcher).current = "does-not-exist"