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
+ '''
+
+
+ '''
+# ---
+# name: test_content_switcher_example_switch
+ '''
+
+
+ '''
+# ---
# name: test_css_property[align.py]
'''