diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1a7487fe28..ef410b0749 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
+
+## Unreleased
+
+### Changed
+
+- Grid will now size children to the maximum height of a row https://github.com/Textualize/textual/pull/5113
+- Markdown links will be opened with `App.open_url` automatically https://github.com/Textualize/textual/pull/5113
+- The universal selector (`*`) will now not match widgets with the class `-textual-system` (scrollbars, notifications etc) https://github.com/Textualize/textual/pull/5113
+
+### Added
+
+- Added Link widget https://github.com/Textualize/textual/pull/5113
+- Added `open_links` to `Markdown` and `MarkdownViewer` widgets https://github.com/Textualize/textual/pull/5113
+- Added `App.DEFAULT_MODE` https://github.com/Textualize/textual/pull/5113
+- Added `Containers.HorizontalGroup` and `Containers.VerticalGroup` https://github.com/Textualize/textual/pull/5113
+- Added `$`, `ยฃ`, `โฌ`, `(`, `)` symbols to Digits https://github.com/Textualize/textual/pull/5113
+- Added `Button.action` parameter to invoke action when clicked https://github.com/Textualize/textual/pull/5113
+
## [0.84.0] - 2024-10-22
### Fixed
diff --git a/docs/api/layout.md b/docs/api/layout.md
new file mode 100644
index 0000000000..06fba9113c
--- /dev/null
+++ b/docs/api/layout.md
@@ -0,0 +1,6 @@
+---
+title: "textual.layout"
+---
+
+
+::: textual.layout
diff --git a/docs/examples/widgets/link.py b/docs/examples/widgets/link.py
new file mode 100644
index 0000000000..d4d9885622
--- /dev/null
+++ b/docs/examples/widgets/link.py
@@ -0,0 +1,23 @@
+from textual.app import App, ComposeResult
+from textual.widgets import Link
+
+
+class LabelApp(App):
+ AUTO_FOCUS = None
+ CSS = """
+ Screen {
+ align: center middle;
+ }
+ """
+
+ def compose(self) -> ComposeResult:
+ yield Link(
+ "Go to textualize.io",
+ url="https://textualize.io",
+ tooltip="Click me",
+ )
+
+
+if __name__ == "__main__":
+ app = LabelApp()
+ app.run()
diff --git a/docs/widget_gallery.md b/docs/widget_gallery.md
index 62d6df383f..f1d26d69fa 100644
--- a/docs/widget_gallery.md
+++ b/docs/widget_gallery.md
@@ -121,6 +121,13 @@ A simple text label.
[Label reference](./widgets/label.md){ .md-button .md-button--primary }
+## Link
+
+A clickable link that opens a URL.
+
+[Link reference](./widgets/link.md){ .md-button .md-button--primary }
+
+
## ListView
Display a list of items (items may be other widgets).
diff --git a/docs/widgets/link.md b/docs/widgets/link.md
new file mode 100644
index 0000000000..a719962eec
--- /dev/null
+++ b/docs/widgets/link.md
@@ -0,0 +1,61 @@
+# Link
+
+!!! tip "Added in version 0.84.0"
+
+A widget to display a piece of text that opens a URL when clicked, like a web browser link.
+
+- [x] Focusable
+- [ ] Container
+
+
+## Example
+
+A trivial app with a link.
+Clicking the link open's a web-browser—as you might expect!
+
+=== "Output"
+
+ ```{.textual path="docs/examples/widgets/link.py"}
+ ```
+
+=== "link.py"
+
+ ```python
+ --8<-- "docs/examples/widgets/link.py"
+ ```
+
+
+## Reactive Attributes
+
+| Name | Type | Default | Description |
+| ------ | ----- | ------- | ----------------------------------------- |
+| `text` | `str` | `""` | The text of the link. |
+| `url` | `str` | `""` | The URL to open when the link is clicked. |
+
+
+## Messages
+
+This widget sends no messages.
+
+## Bindings
+
+The Link widget defines the following bindings:
+
+::: textual.widgets.Link.BINDINGS
+ options:
+ show_root_heading: false
+ show_root_toc_entry: false
+
+
+## Component classes
+
+This widget contains no component classes.
+
+
+
+---
+
+
+::: textual.widgets.Link
+ options:
+ heading_level: 2
diff --git a/docs/widgets/masked_input.md b/docs/widgets/masked_input.md
index d40350b2c8..426af52937 100644
--- a/docs/widgets/masked_input.md
+++ b/docs/widgets/masked_input.md
@@ -16,7 +16,7 @@ The example below shows a masked input to ease entering a credit card number.
```{.textual path="docs/examples/widgets/masked_input.py"}
```
-=== "checkbox.py"
+=== "masked_input.py"
```python
--8<-- "docs/examples/widgets/masked_input.py"
diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml
index 232e7b23ac..23d258eb18 100644
--- a/mkdocs-nav.yml
+++ b/mkdocs-nav.yml
@@ -152,6 +152,7 @@ nav:
- "widgets/index.md"
- "widgets/input.md"
- "widgets/label.md"
+ - "widgets/link.md"
- "widgets/list_item.md"
- "widgets/list_view.md"
- "widgets/loading_indicator.md"
@@ -195,6 +196,7 @@ nav:
- "api/filter.md"
- "api/fuzzy_matcher.md"
- "api/geometry.md"
+ - "api/layout.md"
- "api/lazy.md"
- "api/logger.md"
- "api/logging.md"
diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py
index 2f2f7fe60d..347e8ce09e 100644
--- a/src/textual/_arrange.py
+++ b/src/textual/_arrange.py
@@ -5,9 +5,9 @@
from operator import attrgetter
from typing import TYPE_CHECKING, Iterable, Mapping, Sequence
-from textual._layout import DockArrangeResult, WidgetPlacement
from textual._partition import partition
from textual.geometry import Region, Size, Spacing
+from textual.layout import DockArrangeResult, WidgetPlacement
if TYPE_CHECKING:
from textual.widget import Widget
@@ -90,7 +90,7 @@ def arrange(
if layout_widgets:
# Arrange layout widgets (i.e. not docked)
- layout_placements = widget._layout.arrange(
+ layout_placements = widget.layout.arrange(
widget,
layout_widgets,
dock_region.size,
diff --git a/src/textual/_resolve.py b/src/textual/_resolve.py
index dce4663281..11e7519fa2 100644
--- a/src/textual/_resolve.py
+++ b/src/textual/_resolve.py
@@ -21,6 +21,7 @@ def resolve(
gutter: int,
size: Size,
viewport: Size,
+ min_size: int | None = None,
) -> list[tuple[int, int]]:
"""Resolve a list of dimensions.
@@ -62,6 +63,11 @@ def resolve(
"list[Fraction]", [fraction for _, fraction in resolved]
)
+ if min_size is not None:
+ resolved_fractions = [
+ max(Fraction(min_size), fraction) for fraction in resolved_fractions
+ ]
+
fraction_gutter = Fraction(gutter)
offsets = [0] + [
int(fraction)
diff --git a/src/textual/app.py b/src/textual/app.py
index 850a60e14b..36fbe50f15 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -371,7 +371,7 @@ class App(Generic[ReturnType], DOMNode):
overflow-y: auto !important;
align: center middle;
.-maximized {
- dock: initial !important;
+ dock: initial !important;
}
}
/* Fade the header title when app is blurred */
@@ -413,6 +413,9 @@ class MyApp(App[None]):
...
```
"""
+ DEFAULT_MODE: ClassVar[str] = "_default"
+ """Name of the default mode."""
+
SCREENS: ClassVar[dict[str, Callable[[], Screen[Any]]]] = {}
"""Screens associated with the app for the lifetime of the app."""
@@ -490,6 +493,9 @@ class MyApp(App[None]):
SUSPENDED_SCREEN_CLASS: ClassVar[str] = ""
"""Class to apply to suspended screens, or empty string for no class."""
+ HOVER_EFFECTS_SCROLL_PAUSE: ClassVar[float] = 0.2
+ """Seconds to pause hover effects for when scrolling."""
+
_PSEUDO_CLASSES: ClassVar[dict[str, Callable[[App], bool]]] = {
"focus": lambda app: app.app_focus,
"blur": lambda app: not app.app_focus,
@@ -587,9 +593,9 @@ def __init__(
self._workers = WorkerManager(self)
self.error_console = Console(markup=False, highlight=False, stderr=True)
self.driver_class = driver_class or self.get_driver_class()
- self._screen_stacks: dict[str, list[Screen[Any]]] = {"_default": []}
+ self._screen_stacks: dict[str, list[Screen[Any]]] = {self.DEFAULT_MODE: []}
"""A stack of screens per mode."""
- self._current_mode: str = "_default"
+ self._current_mode: str = self.DEFAULT_MODE
"""The current mode the app is in."""
self._sync_available = False
@@ -775,6 +781,11 @@ def __init__(
self._previous_inline_height: int | None = None
"""Size of previous inline update."""
+ self._paused_hover_effects: bool = False
+ """Have the hover effects been paused?"""
+
+ self._hover_effects_timer: Timer | None = None
+
if self.ENABLE_COMMAND_PALETTE:
for _key, binding in self._bindings:
if binding.action in {"command_palette", "app.command_palette"}:
@@ -2171,8 +2182,12 @@ def _init_mode(self, mode: str) -> AwaitMount:
stack = self._screen_stacks.get(mode, [])
if stack:
- await_mount = AwaitMount(stack[0], [])
- else:
+ # Mode already exists
+ # Return an dummy await
+ return AwaitMount(stack[0], [])
+
+ if mode in self._modes:
+ # Mode is defined in MODES
_screen = self._modes[mode]
if isinstance(_screen, Screen):
raise TypeError(
@@ -2183,6 +2198,17 @@ def _init_mode(self, mode: str) -> AwaitMount:
screen, await_mount = self._get_screen(new_screen)
stack.append(screen)
self._load_screen_css(screen)
+ self.refresh_css()
+ screen.post_message(events.ScreenResume())
+ else:
+ # Mode is not defined
+ screen = self.get_default_screen()
+ stack.append(screen)
+ self._register(self, screen)
+ screen.post_message(events.ScreenResume())
+ await_mount = AwaitMount(stack[0], [])
+
+ screen._screen_resized(self.size)
self._screen_stacks[mode] = stack
return await_mount
@@ -2199,7 +2225,12 @@ def switch_mode(self, mode: str) -> AwaitMount:
Raises:
UnknownModeError: If trying to switch to an unknown mode.
+
"""
+
+ if mode == self._current_mode:
+ return AwaitMount(self.screen, [])
+
if mode not in self._modes:
raise UnknownModeError(f"No known mode {mode!r}")
@@ -2673,12 +2704,43 @@ def set_focus(self, widget: Widget | None, scroll_visible: bool = True) -> None:
"""
self.screen.set_focus(widget, scroll_visible)
+ def _pause_hover_effects(self):
+ """Pause any hover effects based on Enter and Leave events for 200ms."""
+ if not self.HOVER_EFFECTS_SCROLL_PAUSE or self.is_headless:
+ return
+ self._paused_hover_effects = True
+ if self._hover_effects_timer is None:
+ self._hover_effects_timer = self.set_interval(
+ self.HOVER_EFFECTS_SCROLL_PAUSE, self._resume_hover_effects
+ )
+ else:
+ self._hover_effects_timer.reset()
+ self._hover_effects_timer.resume()
+
+ def _resume_hover_effects(self):
+ """Resume sending Enter and Leave for hover effects."""
+ if not self.HOVER_EFFECTS_SCROLL_PAUSE or self.is_headless:
+ return
+ if self._paused_hover_effects:
+ self._paused_hover_effects = False
+ if self._hover_effects_timer is not None:
+ self._hover_effects_timer.pause()
+ try:
+ widget, _ = self.screen.get_widget_at(*self.mouse_position)
+ except NoWidget:
+ pass
+ else:
+ if widget is not self.mouse_over:
+ self._set_mouse_over(widget)
+
def _set_mouse_over(self, widget: Widget | None) -> None:
"""Called when the mouse is over another widget.
Args:
widget: Widget under mouse, or None for no widgets.
"""
+ if self._paused_hover_effects:
+ return
if widget is None:
if self.mouse_over is not None:
try:
@@ -3505,10 +3567,7 @@ async def on_event(self, event: events.Event) -> None:
# Handle input events that haven't been forwarded
# If the event has been forwarded it may have bubbled up back to the App
if isinstance(event, events.Compose):
- screen: Screen[Any] = self.get_default_screen()
- self._register(self, screen)
- self._screen_stack.append(screen)
- screen.post_message(events.ScreenResume())
+ await self._init_mode(self._current_mode)
await super().on_event(event)
elif isinstance(event, events.InputEvent) and not event.is_forwarded:
diff --git a/src/textual/color.py b/src/textual/color.py
index 6568ba8f3e..2de8aa1bf5 100644
--- a/src/textual/color.py
+++ b/src/textual/color.py
@@ -332,7 +332,7 @@ def __rich_repr__(self) -> rich.repr.Result:
yield g
yield b
yield "a", a, 1.0
- yield "ansi", ansi
+ yield "ansi", ansi, None
def with_alpha(self, alpha: float) -> Color:
"""Create a new color with the given alpha.
diff --git a/src/textual/containers.py b/src/textual/containers.py
index 3c2fc1e467..70986b9e47 100644
--- a/src/textual/containers.py
+++ b/src/textual/containers.py
@@ -10,6 +10,9 @@
from typing import ClassVar
from textual.binding import Binding, BindingType
+from textual.layout import Layout
+from textual.layouts.grid import GridLayout
+from textual.reactive import reactive
from textual.widget import Widget
@@ -72,7 +75,7 @@ class ScrollableContainer(Widget, can_focus=True, inherit_bindings=False):
class Vertical(Widget, inherit_bindings=False):
- """A container with vertical layout and no scrollbars."""
+ """An expanding container with vertical layout and no scrollbars."""
DEFAULT_CSS = """
Vertical {
@@ -84,6 +87,19 @@ class Vertical(Widget, inherit_bindings=False):
"""
+class VerticalGroup(Widget, inherit_bindings=False):
+ """A non-expanding container with vertical layout and no scrollbars."""
+
+ DEFAULT_CSS = """
+ VerticalGroup {
+ width: 1fr;
+ height: auto;
+ layout: vertical;
+ overflow: hidden hidden;
+ }
+ """
+
+
class VerticalScroll(ScrollableContainer):
"""A container with vertical layout and an automatic scrollbar on the Y axis."""
@@ -97,7 +113,7 @@ class VerticalScroll(ScrollableContainer):
class Horizontal(Widget, inherit_bindings=False):
- """A container with horizontal layout and no scrollbars."""
+ """An expanding container with horizontal layout and no scrollbars."""
DEFAULT_CSS = """
Horizontal {
@@ -109,6 +125,19 @@ class Horizontal(Widget, inherit_bindings=False):
"""
+class HorizontalGroup(Widget, inherit_bindings=False):
+ """A non-expanding container with horizontal layout and no scrollbars."""
+
+ DEFAULT_CSS = """
+ HorizontalGroup {
+ width: 1fr;
+ height: auto;
+ layout: horizontal;
+ overflow: hidden hidden;
+ }
+ """
+
+
class HorizontalScroll(ScrollableContainer):
"""A container with horizontal layout and an automatic scrollbar on the X axis."""
@@ -133,6 +162,18 @@ class Center(Widget, inherit_bindings=False):
"""
+class Right(Widget, inherit_bindings=False):
+ """A container which aligns children on the X axis."""
+
+ DEFAULT_CSS = """
+ Right {
+ align-horizontal: right;
+ width: 1fr;
+ height: auto;
+ }
+ """
+
+
class Middle(Widget, inherit_bindings=False):
"""A container which aligns children on the Y axis."""
@@ -155,3 +196,55 @@ class Grid(Widget, inherit_bindings=False):
layout: grid;
}
"""
+
+
+class ItemGrid(Widget, inherit_bindings=False):
+ """A container with grid layout."""
+
+ DEFAULT_CSS = """
+ ItemGrid {
+ width: 1fr;
+ height: auto;
+ layout: grid;
+ }
+ """
+
+ stretch_height: reactive[bool] = reactive(True)
+ min_column_width: reactive[int | None] = reactive(None, layout=True)
+ regular: reactive[bool] = reactive(False)
+
+ def __init__(
+ self,
+ *children: Widget,
+ name: str | None = None,
+ id: str | None = None,
+ classes: str | None = None,
+ disabled: bool = False,
+ min_column_width: int | None = None,
+ stretch_height: bool = True,
+ regular: bool = False,
+ ) -> None:
+ """Initialize a Widget.
+
+ Args:
+ *children: Child widgets.
+ name: The name of the widget.
+ id: The ID of the widget in the DOM.
+ classes: The CSS classes for the widget.
+ disabled: Whether the widget is disabled or not.
+ stretch_height: Expand the height of widgets to the row height.
+ min_column_width: The smallest permitted column width.
+ regular: All rows should have the same number of items.
+ """
+ super().__init__(
+ *children, name=name, id=id, classes=classes, disabled=disabled
+ )
+ self.set_reactive(ItemGrid.stretch_height, stretch_height)
+ self.set_reactive(ItemGrid.min_column_width, min_column_width)
+ self.set_reactive(ItemGrid.regular, regular)
+
+ def pre_layout(self, layout: Layout) -> None:
+ if isinstance(layout, GridLayout):
+ layout.stretch_height = self.stretch_height
+ layout.min_column_width = self.min_column_width
+ layout.regular = self.regular
diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py
index e8989847eb..3fb548de8a 100644
--- a/src/textual/css/_style_properties.py
+++ b/src/textual/css/_style_properties.py
@@ -57,7 +57,7 @@
if TYPE_CHECKING:
from textual.canvas import CanvasLineType
- from textual._layout import Layout
+ from textual.layout import Layout
from textual.css.styles import StylesBase
from textual.css.types import AlignHorizontal, AlignVertical, DockEdge, EdgeType
diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py
index 8bbb26767a..11ff7e370c 100644
--- a/src/textual/css/_styles_builder.py
+++ b/src/textual/css/_styles_builder.py
@@ -540,10 +540,11 @@ def process_outline_left(self, name: str, tokens: list[Token]) -> None:
def process_keyline(self, name: str, tokens: list[Token]) -> None:
if not tokens:
return
- if len(tokens) > 2:
+ if len(tokens) > 3:
self.error(name, tokens[0], keyline_help_text())
keyline_style = "none"
keyline_color = Color.parse("green")
+ keyline_alpha = 1.0
for token in tokens:
if token.name == "color":
try:
@@ -562,7 +563,16 @@ def process_keyline(self, name: str, tokens: list[Token]) -> None:
if keyline_style not in VALID_KEYLINE:
self.error(name, token, keyline_help_text())
- self.styles._rules["keyline"] = (keyline_style, keyline_color)
+ elif token.name == "scalar":
+ alpha_scalar = Scalar.parse(token.value)
+ if alpha_scalar.unit != Unit.PERCENT:
+ self.error(name, token, "alpha must be given as a percentage.")
+ keyline_alpha = alpha_scalar.value / 100.0
+
+ self.styles._rules["keyline"] = (
+ keyline_style,
+ keyline_color.multiply_alpha(keyline_alpha),
+ )
def process_offset(self, name: str, tokens: list[Token]) -> None:
def offset_error(name: str, token: Token) -> None:
diff --git a/src/textual/css/model.py b/src/textual/css/model.py
index e752b3fc1d..05c64d9a79 100644
--- a/src/textual/css/model.py
+++ b/src/textual/css/model.py
@@ -56,7 +56,7 @@ def _check_universal(name: str, node: DOMNode) -> bool:
Returns:
`True` if the selector matches.
"""
- return True
+ return not node.has_class("-textual-system")
def _check_type(name: str, node: DOMNode) -> bool:
diff --git a/src/textual/css/parse.py b/src/textual/css/parse.py
index e7fd0c0353..d0365c825f 100644
--- a/src/textual/css/parse.py
+++ b/src/textual/css/parse.py
@@ -170,7 +170,6 @@ def parse_rule_set(
while True:
token = next(tokens)
-
token_name = token.name
if token_name in ("whitespace", "declaration_end"):
continue
diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py
index e2353b9568..244c2d2616 100644
--- a/src/textual/css/styles.py
+++ b/src/textual/css/styles.py
@@ -69,9 +69,9 @@
from textual.geometry import Offset, Spacing
if TYPE_CHECKING:
- from textual._layout import Layout
from textual.css.types import CSSLocation
from textual.dom import DOMNode
+ from textual.layout import Layout
class RulesMap(TypedDict, total=False):
diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py
index 6ad76e44db..b366efe603 100644
--- a/src/textual/css/stylesheet.py
+++ b/src/textual/css/stylesheet.py
@@ -255,6 +255,7 @@ def _parse_rules(
tie_breaker=tie_breaker,
)
)
+
except TokenError:
raise
except Exception as error:
diff --git a/src/textual/demo2/__main__.py b/src/textual/demo2/__main__.py
new file mode 100644
index 0000000000..d254f85c41
--- /dev/null
+++ b/src/textual/demo2/__main__.py
@@ -0,0 +1,5 @@
+from textual.demo2.demo_app import DemoApp
+
+if __name__ == "__main__":
+ app = DemoApp()
+ app.run()
diff --git a/src/textual/demo2/data.py b/src/textual/demo2/data.py
new file mode 100644
index 0000000000..6023ec83e2
--- /dev/null
+++ b/src/textual/demo2/data.py
@@ -0,0 +1,198 @@
+COUNTRIES = [
+ "Afghanistan",
+ "Albania",
+ "Algeria",
+ "Andorra",
+ "Angola",
+ "Antigua and Barbuda",
+ "Argentina",
+ "Armenia",
+ "Australia",
+ "Austria",
+ "Azerbaijan",
+ "Bahamas",
+ "Bahrain",
+ "Bangladesh",
+ "Barbados",
+ "Belarus",
+ "Belgium",
+ "Belize",
+ "Benin",
+ "Bhutan",
+ "Bolivia",
+ "Bosnia and Herzegovina",
+ "Botswana",
+ "Brazil",
+ "Brunei",
+ "Bulgaria",
+ "Burkina Faso",
+ "Burundi",
+ "Cabo Verde",
+ "Cambodia",
+ "Cameroon",
+ "Canada",
+ "Central African Republic",
+ "Chad",
+ "Chile",
+ "China",
+ "Colombia",
+ "Comoros",
+ "Congo",
+ "Costa Rica",
+ "Croatia",
+ "Cuba",
+ "Cyprus",
+ "Czech Republic",
+ "Democratic Republic of the Congo",
+ "Denmark",
+ "Djibouti",
+ "Dominica",
+ "Dominican Republic",
+ "East Timor",
+ "Ecuador",
+ "Egypt",
+ "El Salvador",
+ "Equatorial Guinea",
+ "Eritrea",
+ "Estonia",
+ "Eswatini",
+ "Ethiopia",
+ "Fiji",
+ "Finland",
+ "France",
+ "Gabon",
+ "Gambia",
+ "Georgia",
+ "Germany",
+ "Ghana",
+ "Greece",
+ "Grenada",
+ "Guatemala",
+ "Guinea",
+ "Guinea-Bissau",
+ "Guyana",
+ "Haiti",
+ "Honduras",
+ "Hungary",
+ "Iceland",
+ "India",
+ "Indonesia",
+ "Iran",
+ "Iraq",
+ "Ireland",
+ "Israel",
+ "Italy",
+ "Ivory Coast",
+ "Jamaica",
+ "Japan",
+ "Jordan",
+ "Kazakhstan",
+ "Kenya",
+ "Kiribati",
+ "Kuwait",
+ "Kyrgyzstan",
+ "Laos",
+ "Latvia",
+ "Lebanon",
+ "Lesotho",
+ "Liberia",
+ "Libya",
+ "Liechtenstein",
+ "Lithuania",
+ "Luxembourg",
+ "Madagascar",
+ "Malawi",
+ "Malaysia",
+ "Maldives",
+ "Mali",
+ "Malta",
+ "Marshall Islands",
+ "Mauritania",
+ "Mauritius",
+ "Mexico",
+ "Micronesia",
+ "Moldova",
+ "Monaco",
+ "Mongolia",
+ "Montenegro",
+ "Morocco",
+ "Mozambique",
+ "Myanmar",
+ "Namibia",
+ "Nauru",
+ "Nepal",
+ "Netherlands",
+ "New Zealand",
+ "Nicaragua",
+ "Niger",
+ "Nigeria",
+ "North Korea",
+ "North Macedonia",
+ "Norway",
+ "Oman",
+ "Pakistan",
+ "Palau",
+ "Palestine",
+ "Panama",
+ "Papua New Guinea",
+ "Paraguay",
+ "Peru",
+ "Philippines",
+ "Poland",
+ "Portugal",
+ "Qatar",
+ "Romania",
+ "Russia",
+ "Rwanda",
+ "Saint Kitts and Nevis",
+ "Saint Lucia",
+ "Saint Vincent and the Grenadines",
+ "Samoa",
+ "San Marino",
+ "Sao Tome and Principe",
+ "Saudi Arabia",
+ "Senegal",
+ "Serbia",
+ "Seychelles",
+ "Sierra Leone",
+ "Singapore",
+ "Slovakia",
+ "Slovenia",
+ "Solomon Islands",
+ "Somalia",
+ "South Africa",
+ "South Korea",
+ "South Sudan",
+ "Spain",
+ "Sri Lanka",
+ "Sudan",
+ "Suriname",
+ "Sweden",
+ "Switzerland",
+ "Syria",
+ "Taiwan",
+ "Tajikistan",
+ "Tanzania",
+ "Thailand",
+ "Togo",
+ "Tonga",
+ "Trinidad and Tobago",
+ "Tunisia",
+ "Turkey",
+ "Turkmenistan",
+ "Tuvalu",
+ "Uganda",
+ "Ukraine",
+ "United Arab Emirates",
+ "United Kingdom",
+ "United States",
+ "Uruguay",
+ "Uzbekistan",
+ "Vanuatu",
+ "Vatican City",
+ "Venezuela",
+ "Vietnam",
+ "Yemen",
+ "Zambia",
+ "Zimbabwe",
+]
diff --git a/src/textual/demo2/demo_app.py b/src/textual/demo2/demo_app.py
new file mode 100644
index 0000000000..28307d30b1
--- /dev/null
+++ b/src/textual/demo2/demo_app.py
@@ -0,0 +1,43 @@
+from textual.app import App
+from textual.binding import Binding
+from textual.demo2.home import HomeScreen
+from textual.demo2.projects import ProjectsScreen
+from textual.demo2.widgets import WidgetsScreen
+
+
+class DemoApp(App):
+ """The demo app defines the modes and sets a few bindings."""
+
+ CSS = """
+ .column {
+ align: center top;
+ &>*{ max-width: 100; }
+ }
+ """
+
+ MODES = {
+ "home": HomeScreen,
+ "projects": ProjectsScreen,
+ "widgets": WidgetsScreen,
+ }
+ DEFAULT_MODE = "home"
+ BINDINGS = [
+ Binding(
+ "h",
+ "app.switch_mode('home')",
+ "home",
+ tooltip="Show the home screen",
+ ),
+ Binding(
+ "p",
+ "app.switch_mode('projects')",
+ "projects",
+ tooltip="A selection of Textual projects",
+ ),
+ Binding(
+ "w",
+ "app.switch_mode('widgets')",
+ "widgets",
+ tooltip="Test the builtin widgets",
+ ),
+ ]
diff --git a/src/textual/demo2/home.py b/src/textual/demo2/home.py
new file mode 100644
index 0000000000..53611c7aaf
--- /dev/null
+++ b/src/textual/demo2/home.py
@@ -0,0 +1,233 @@
+import asyncio
+from importlib.metadata import version
+
+import httpx
+
+from textual import work
+from textual.app import ComposeResult
+from textual.containers import Horizontal, Vertical, VerticalScroll
+from textual.demo2.page import PageScreen
+from textual.reactive import reactive
+from textual.widgets import Collapsible, Digits, Footer, Label, Markdown
+
+WHAT_IS_TEXTUAL_MD = """\
+# What is Textual?
+
+Snappy, keyboard-centric, applications that run in the terminal and [the web](https://github.com/Textualize/textual-web).
+
+๐ All you need is Python!
+
+"""
+
+WELCOME_MD = """\
+## Welcome keyboard warriors!
+
+This is a Textual app. Here's what you need to know:
+
+* **enter** `toggle this collapsible widget`
+* **tab** `focus the next widget`
+* **shift+tab** `focus the previous widget`
+* **ctrl+p** `summon the command palette`
+
+
+๐ Also see the footer below.
+
+`Orโฆ click away with the mouse (no judgement).`
+
+"""
+
+ABOUT_MD = """\
+The retro look is not just an aesthetic choice! Textual apps have some unique properties that make them preferable for many tasks.
+
+## Textual interfaces are *snappy*
+Even the most modern of web apps can leave the user waiting hundreds of milliseconds or more for a response.
+Given their low graphical requirements, Textual interfaces can be far more responsiveโno waiting required.
+
+## Reward repeated use
+Use the mouse to explore, but Textual apps are keyboard-centric and reward repeated use.
+An experience user can operate a Textual app far faster than their web / GUI counterparts.
+
+## Command palette
+A builtin command palette with fuzzy searching, puts powerful commands at your fingertips.
+
+**Try it:** Press **ctrl+p** now.
+
+"""
+
+API_MD = """\
+A modern Python API from the developer of [Rich](https://github.com/Textualize/rich).
+
+Well documented, typed, and intuitive.
+Textual's API is accessible to Python developers of all skill levels.
+
+**Hint:** press **C** to view the code for this page.
+
+## Built on Rich
+
+With over 1.4 *billion* downloads, Rich is the most popular terminal library out there.
+Textual builds on Rich to add interactivity, and is compatible with Rich renderables.
+
+## Re-usable widgets
+
+Textual's widgets are self-contained and re-usable across projects.
+Virtually all aspects of a widget's look and feel can be customized to your requirements.
+
+## Builtin widgets
+
+A large [library of builtin widgets](https://textual.textualize.io/widget_gallery/), and a growing ecosystem of third party widgets on pyPi
+(this content is generated by the builtin [Markdown](https://textual.textualize.io/widget_gallery/#markdown) widget).
+
+## Reactive variables
+
+[Reactivity](https://textual.textualize.io/guide/reactivity/) using Python idioms, keeps your logic separate from display code.
+
+## Async support
+
+Built on asyncio, you can easily integrate async libraries while keeping your UI responsive.
+
+## Concurrency
+
+Textual's [Workers](https://textual.textualize.io/guide/workers/) provide a far-less error prone interface to
+concurrency: both async and threads.
+
+## Testing
+
+With a comprehensive [testing framework](https://textual.textualize.io/guide/testing/), you can release reliable software, that can be maintained indefinitely.
+
+## Docs
+
+Textual has [amazing docs](https://textual.textualize.io/)!
+
+"""
+
+DEPLOY_MD = """\
+There are a number of ways to deploy and share Textual apps.
+
+## As a Python library
+
+Textual apps make be pip installed, via tools such as `pipx` or `uvx`, and other package managers.
+
+## As a web application
+
+It takes two lines of code to [serve your Textual app](https://github.com/Textualize/textual-serve) as web application.
+
+## Managed web application
+
+With [Textual web](https://github.com/Textualize/textual-serve) you can serve multiple Textual apps on the web,
+with zero configuration. Even behind a firewall.
+"""
+
+
+class StarCount(Vertical):
+ """Widget to get and display GitHub star count."""
+
+ DEFAULT_CSS = """
+ StarCount {
+ dock: top;
+ height: 6;
+ border-bottom: hkey $background;
+ border-top: hkey $background;
+ layout: horizontal;
+ background: $boost;
+ padding: 0 1;
+ color: $warning;
+ #stars { align: center top; }
+ #forks { align: right top; }
+ Label { text-style: bold; }
+ LoadingIndicator { background: transparent !important; }
+ Digits { width: auto; margin-right: 1; }
+ Label { margin-right: 1; }
+ align: center top;
+ &>Horizontal { max-width: 100;}
+ }
+ """
+ stars = reactive(25251, recompose=True)
+ forks = reactive(776, recompose=True)
+
+ @work
+ async def get_stars(self):
+ """Worker to get stars from GitHub API."""
+ self.loading = True
+ try:
+ await asyncio.sleep(1) # Time to admire the loading indicator
+ async with httpx.AsyncClient() as client:
+ repository_json = (
+ await client.get("https://api.github.com/repos/textualize/textual")
+ ).json()
+ self.stars = repository_json["stargazers_count"]
+ self.forks = repository_json["forks"]
+ except Exception:
+ self.notify(
+ "Unable to update star count (maybe rate-limited)",
+ title="GitHub stars",
+ severity="error",
+ )
+ self.loading = False
+
+ def compose(self) -> ComposeResult:
+ with Horizontal():
+ with Vertical(id="version"):
+ yield Label("Version")
+ yield Digits(version("textual"))
+ with Vertical(id="stars"):
+ yield Label("GitHub โ
")
+ stars = f"{self.stars / 1000:.1f}K"
+ yield Digits(stars).with_tooltip(f"{self.stars} GitHub stars")
+ with Vertical(id="forks"):
+ yield Label("Forks")
+ yield Digits(str(self.forks)).with_tooltip(f"{self.forks} Forks")
+
+ def on_mount(self) -> None:
+ self.tooltip = "Click to refresh"
+ self.get_stars()
+
+ def on_click(self) -> None:
+ self.get_stars()
+
+
+class Content(VerticalScroll, can_focus=False):
+ """Non focusable vertical scroll."""
+
+
+class HomeScreen(PageScreen):
+ DEFAULT_CSS = """
+ HomeScreen {
+
+ Content {
+ align-horizontal: center;
+ & > * {
+ max-width: 100;
+ }
+ margin: 0 1;
+ overflow-y: auto;
+ height: 1fr;
+ scrollbar-gutter: stable;
+ MarkdownFence {
+ height: auto;
+ max-height: initial;
+ }
+ Collapsible {
+ padding-right: 0;
+ &.-collapsed { padding-bottom: 1; }
+ }
+ Markdown {
+ margin-right: 1;
+ padding-right: 1;
+ }
+ }
+ }
+ """
+
+ def compose(self) -> ComposeResult:
+ yield StarCount()
+ with Content():
+ yield Markdown(WHAT_IS_TEXTUAL_MD)
+ with Collapsible(title="Welcome", collapsed=False):
+ yield Markdown(WELCOME_MD)
+ with Collapsible(title="Textual Interfaces"):
+ yield Markdown(ABOUT_MD)
+ with Collapsible(title="Textual API"):
+ yield Markdown(API_MD)
+ with Collapsible(title="Deploying Textual apps"):
+ yield Markdown(DEPLOY_MD)
+ yield Footer()
diff --git a/src/textual/demo2/page.py b/src/textual/demo2/page.py
new file mode 100644
index 0000000000..f275c093fb
--- /dev/null
+++ b/src/textual/demo2/page.py
@@ -0,0 +1,91 @@
+import inspect
+
+from rich.syntax import Syntax
+
+from textual import work
+from textual.app import ComposeResult
+from textual.binding import Binding
+from textual.containers import ScrollableContainer
+from textual.screen import ModalScreen, Screen
+from textual.widgets import Static
+
+
+class CodeScreen(ModalScreen):
+ DEFAULT_CSS = """
+ CodeScreen {
+ #code {
+ border: heavy $accent;
+ margin: 2 4;
+ scrollbar-gutter: stable;
+ Static {
+ width: auto;
+ }
+ }
+ }
+ """
+ BINDINGS = [("escape", "dismiss", "Dismiss code")]
+
+ def __init__(self, title: str, code: str) -> None:
+ super().__init__()
+ self.code = code
+ self.title = title
+
+ def compose(self) -> ComposeResult:
+ with ScrollableContainer(id="code"):
+ yield Static(
+ Syntax(
+ self.code, lexer="python", indent_guides=True, line_numbers=True
+ ),
+ expand=True,
+ )
+
+ def on_mount(self):
+ code_widget = self.query_one("#code")
+ code_widget.border_title = self.title
+ code_widget.border_subtitle = "Escape to close"
+
+
+class PageScreen(Screen):
+ DEFAULT_CSS = """
+ PageScreen {
+ width: 100%;
+ height: 1fr;
+ overflow-y: auto;
+ }
+ """
+ BINDINGS = [
+ Binding(
+ "c",
+ "show_code",
+ "show code",
+ tooltip="Show the code used to generate this screen",
+ )
+ ]
+
+ @work(thread=True)
+ def get_code(self, source_file: str) -> str | None:
+ try:
+ with open(source_file, "rt") as file_:
+ return file_.read()
+ except Exception:
+ return None
+
+ async def action_show_code(self):
+ source_file = inspect.getsourcefile(self.__class__)
+ if source_file is None:
+ self.notify(
+ "Could not get the code for this page",
+ title="Show code",
+ severity="error",
+ )
+ return
+
+ code = await self.get_code(source_file).wait()
+ if code is None:
+ self.notify(
+ "Could not get the code for this page",
+ title="Show code",
+ severity="error",
+ )
+ else:
+ self.app.push_screen(CodeScreen("Code for this page", code))
diff --git a/src/textual/demo2/projects.py b/src/textual/demo2/projects.py
new file mode 100644
index 0000000000..e5fa217491
--- /dev/null
+++ b/src/textual/demo2/projects.py
@@ -0,0 +1,220 @@
+from dataclasses import dataclass
+
+from textual import events, on
+from textual.app import ComposeResult
+from textual.binding import Binding
+from textual.containers import Center, Horizontal, ItemGrid, Vertical, VerticalScroll
+from textual.demo2.page import PageScreen
+from textual.widgets import Footer, Label, Link, Markdown, Static
+
+
+@dataclass
+class ProjectInfo:
+ """Dataclass for storing project information."""
+
+ title: str
+ author: str
+ url: str
+ description: str
+ stars: str
+
+
+PROJECTS_MD = """\
+# Projects
+
+There are many amazing Open Source Textual apps available for download.
+And many more still in development.
+
+See below for a small selection!
+"""
+
+PROJECTS = [
+ ProjectInfo(
+ "Posting",
+ "Darren Burns",
+ "https://posting.sh/",
+ """Posting is an HTTP client, not unlike Postman and Insomnia. As a TUI application, it can be used over SSH and enables efficient keyboard-centric workflows. """,
+ "4.7k",
+ ),
+ ProjectInfo(
+ "Memray",
+ "Bloomberg",
+ "https://github.com/bloomberg/memray",
+ """Memray is a memory profiler for Python. It can track memory allocations in Python code, in native extension modules, and in the Python interpreter itself.""",
+ "13.2k",
+ ),
+ ProjectInfo(
+ "Toolong",
+ "Will McGugan",
+ "https://github.com/Textualize/toolong",
+ """A terminal application to view, tail, merge, and search log files (plus JSONL).""",
+ "3.1k",
+ ),
+ ProjectInfo(
+ "Dolphie",
+ "Charles Thompson",
+ "https://github.com/charles-001/dolphie",
+ "Your single pane of glass for real-time analytics into MySQL/MariaDB & ProxySQL",
+ "608",
+ ),
+ ProjectInfo(
+ "Harlequin",
+ "Ted Conbeer",
+ "https://harlequin.sh/",
+ """Portable, powerful, colorful. An easy, fast, and beautiful database client for the terminal.""",
+ "3.7k",
+ ),
+ ProjectInfo(
+ "Elia",
+ "Darren Burns",
+ "https://github.com/darrenburns/elia",
+ """A snappy, keyboard-centric terminal user interface for interacting with large language models.
+Chat with Claude 3, ChatGPT, and local models like Llama 3, Phi 3, Mistral and Gemma.""",
+ "1.8k",
+ ),
+ ProjectInfo(
+ "Trogon",
+ "Textualize",
+ "https://github.com/Textualize/trogon",
+ "Auto-generate friendly terminal user interfaces for command line apps.",
+ "2.5k",
+ ),
+ ProjectInfo(
+ "TFTUI - The Terraform textual UI",
+ "Ido Avraham",
+ "https://github.com/idoavrah/terraform-tui",
+ "TFTUI is a powerful textual UI that empowers users to effortlessly view and interact with their Terraform state.",
+ "1k",
+ ),
+ ProjectInfo(
+ "RecoverPy",
+ "Pablo Lecolinet",
+ "https://github.com/PabloLec/RecoverPy",
+ """RecoverPy is a powerful tool that leverages your system capabilities to recover lost files.""",
+ "1.3k",
+ ),
+ ProjectInfo(
+ "Frogmouth",
+ "Dave Pearson",
+ "https://github.com/Textualize/frogmouth",
+ """Frogmouth is a Markdown viewer / browser for your terminal, built with Textual.""",
+ "2.5k",
+ ),
+ ProjectInfo(
+ "oterm",
+ "Yiorgis Gozadinos",
+ "https://github.com/ggozad/oterm",
+ "The text-based terminal client for Ollama.",
+ "1k",
+ ),
+ ProjectInfo(
+ "logmerger",
+ "Paul McGuire",
+ "https://github.com/ptmcg/logmerger",
+ "logmerger is a TUI for viewing a merged display of multiple log files, merged by timestamp.",
+ "162",
+ ),
+ ProjectInfo(
+ "doit",
+ "Murli Tawari",
+ "https://github.com/kraanzu/dooit",
+ "A todo manager that you didn't ask for, but needed!",
+ "2.1k",
+ ),
+]
+
+
+class Project(Vertical, can_focus=True, can_focus_children=False):
+ """Display project information and open repo links."""
+
+ ALLOW_MAXIMIZE = True
+ DEFAULT_CSS = """
+ Project {
+ width: 1fr;
+ height: auto;
+ padding: 0 1;
+ border: tall transparent;
+ opacity: 0.8;
+ box-sizing: border-box;
+ &:focus {
+ border: tall $accent;
+ background: $primary 40%;
+ opacity: 1.0;
+ }
+ #title { text-style: bold; width: 1fr; }
+ #author { text-style: italic; }
+ .stars {
+ color: $secondary;
+ text-align: right;
+ text-style: bold;
+ width: auto;
+ }
+ .header { height: 1; }
+ .link {
+ color: $accent;
+ text-style: underline;
+ }
+ .description { color: $text-muted; }
+ &.-hover { opacity: 1; }
+ }
+ """
+
+ BINDINGS = [
+ Binding(
+ "enter",
+ "open_repository",
+ "open repo",
+ tooltip="Open the GitHub repository in your browser",
+ )
+ ]
+
+ def __init__(self, project_info: ProjectInfo) -> None:
+ self.project_info = project_info
+ super().__init__()
+
+ def compose(self) -> ComposeResult:
+ info = self.project_info
+ with Horizontal(classes="header"):
+ yield Label(info.title, id="title")
+ yield Label(f"โ
{info.stars}", classes="stars")
+ yield Label(info.author, id="author")
+ yield Link(info.url, tooltip="Click to open project repository")
+ yield Static(info.description, classes="description")
+
+ @on(events.Enter)
+ @on(events.Leave)
+ def on_enter(self, event: events.Enter):
+ event.stop()
+ self.set_class(self.is_mouse_over, "-hover")
+
+ def action_open_repository(self) -> None:
+ self.app.open_url(self.project_info.url)
+
+
+class ProjectsScreen(PageScreen):
+ AUTO_FOCUS = None
+ CSS = """
+ ProjectsScreen {
+ align-horizontal: center;
+ ItemGrid {
+ margin: 2 4;
+ padding: 1 2;
+ background: $boost;
+ width: 1fr;
+ height: auto;
+ grid-gutter: 1 1;
+ grid-rows: auto;
+ keyline:thin $foreground 50%;
+ }
+ Markdown { margin: 0; padding: 0 2; max-width: 100;}
+ }
+ """
+
+ def compose(self) -> ComposeResult:
+ with VerticalScroll():
+ with Center():
+ yield Markdown(PROJECTS_MD)
+ with ItemGrid(min_column_width=40):
+ for project in PROJECTS:
+ yield Project(project)
+ yield Footer()
diff --git a/src/textual/demo2/widgets.py b/src/textual/demo2/widgets.py
new file mode 100644
index 0000000000..6a1eded388
--- /dev/null
+++ b/src/textual/demo2/widgets.py
@@ -0,0 +1,272 @@
+from textual import containers
+from textual.app import ComposeResult
+from textual.demo2.data import COUNTRIES
+from textual.demo2.page import PageScreen
+from textual.suggester import SuggestFromList
+from textual.widgets import (
+ Button,
+ Checkbox,
+ DataTable,
+ Digits,
+ Footer,
+ Input,
+ Label,
+ ListItem,
+ ListView,
+ Markdown,
+ MaskedInput,
+ OptionList,
+ RadioButton,
+ RadioSet,
+)
+
+WIDGETS_MD = """\
+# Widgets
+
+The Textual library includes a large number of builtin widgets.
+
+The following list is *not* exhaustiveโฆ
+
+"""
+
+
+class Buttons(containers.VerticalGroup):
+ """Buttons demo."""
+
+ DEFAULT_CLASSES = "column"
+ DEFAULT_CSS = """
+ Buttons {
+ ItemGrid { margin-bottom: 1;}
+ Button { width: 1fr; }
+ }
+ """
+
+ BUTTONS_MD = """\
+## Buttons
+
+A simple button, with a number of semantic styles.
+May be rendered unclickable by setting `disabled=True`.
+
+ """
+
+ def compose(self) -> ComposeResult:
+ yield Markdown(self.BUTTONS_MD)
+ with containers.ItemGrid(min_column_width=20, regular=True):
+ yield Button(
+ "Default",
+ tooltip="The default button style",
+ action="notify('you pressed Default')",
+ )
+ yield Button(
+ "Primary",
+ variant="primary",
+ tooltip="The primary button style - carry out the core action of the dialog",
+ action="notify('you pressed Primary')",
+ )
+ yield Button(
+ "Warning",
+ variant="warning",
+ tooltip="The warning button style - warn the user that this isn't a typical button",
+ action="notify('you pressed Warning')",
+ )
+ yield Button(
+ "Error",
+ variant="error",
+ tooltip="The error button style - clicking is a destructive action",
+ action="notify('you pressed Error')",
+ )
+ with containers.ItemGrid(min_column_width=20, regular=True):
+ yield Button("Default", disabled=True)
+ yield Button("Primary", variant="primary", disabled=True)
+ yield Button("Warning", variant="warning", disabled=True)
+ yield Button("Error", variant="error", disabled=True)
+
+
+class Checkboxes(containers.VerticalGroup):
+ DEFAULT_CLASSES = "column"
+ DEFAULT_CSS = """
+ Checkboxes {
+ height: auto;
+ Checkbox, RadioButton { width: 1fr; }
+ &>HorizontalGroup > * { width: 1fr; }
+ }
+
+ """
+
+ CHECKBOXES_MD = """\
+## Checkboxes, Radio buttons, and Radio sets
+
+Checkboxes to toggle booleans.
+Radio buttons for exclusive booleans.
+Radio sets for a managed set of options where only a single option may be selected.
+
+ """
+
+ def compose(self) -> ComposeResult:
+ yield Markdown(self.CHECKBOXES_MD)
+ with containers.HorizontalGroup():
+ with containers.VerticalGroup():
+ yield Checkbox("Arrakis")
+ yield Checkbox("Caladan")
+ yield RadioButton("Chusuk")
+ yield RadioButton("Giedi Prime")
+ yield RadioSet(
+ "Amanda",
+ "Connor MacLeod",
+ "Duncan MacLeod",
+ "Heather MacLeod",
+ "Joe Dawson",
+ "Kurgan, [bold italic red]The[/]",
+ "Methos",
+ "Rachel Ellenstein",
+ "Ramรญrez",
+ )
+
+
+class Datatables(containers.VerticalGroup):
+ DEFAULT_CLASSES = "column"
+ DATATABLES_MD = """\
+## Datatables
+
+A fully-featured DataTable, with cell, row, and columns cursors.
+Cells may be individually styled, and may include Rich renderables.
+
+"""
+ ROWS = [
+ ("lane", "swimmer", "country", "time"),
+ (4, "Joseph Schooling", "Singapore", 50.39),
+ (2, "Michael Phelps", "United States", 51.14),
+ (5, "Chad le Clos", "South Africa", 51.14),
+ (6, "Lรกszlรณ Cseh", "Hungary", 51.14),
+ (3, "Li Zhuhao", "China", 51.26),
+ (8, "Mehdy Metella", "France", 51.58),
+ (7, "Tom Shields", "United States", 51.73),
+ (1, "Aleksandr Sadovnikov", "Russia", 51.84),
+ (10, "Darren Burns", "Scotland", 51.84),
+ ]
+
+ def compose(self) -> ComposeResult:
+ yield Markdown(self.DATATABLES_MD)
+ with containers.Center():
+ yield DataTable()
+
+ def on_mount(self) -> None:
+ table = self.query_one(DataTable)
+ table.add_columns(*self.ROWS[0])
+ table.add_rows(self.ROWS[1:])
+
+
+class Inputs(containers.VerticalGroup):
+ DEFAULT_CLASSES = "column"
+ INPUTS_MD = """\
+## Inputs and MaskedInputs
+
+Text input fields, with placeholder text, validation, and auto-complete.
+Build for intuitive and user-friendly forms.
+
+"""
+ DEFAULT_CSS = """
+ Inputs {
+ Grid {
+ background: $boost;
+ padding: 1 2;
+ height: auto;
+ grid-size: 2;
+ grid-gutter: 1;
+ grid-columns: auto 1fr;
+ border: tall blank;
+ &:focus-within {
+ border: tall $accent;
+ }
+ Label {
+ width: 100%;
+ padding: 1;
+ text-align: right;
+ }
+ }
+ }
+ """
+
+ def compose(self) -> ComposeResult:
+ yield Markdown(self.INPUTS_MD)
+ with containers.Grid():
+ yield Label("Free")
+ yield Input(placeholder="Type anything here")
+ yield Label("Number")
+ yield Input(
+ type="number", placeholder="Type a number here", valid_empty=True
+ )
+ yield Label("Credit card")
+ yield MaskedInput(
+ "9999-9999-9999-9999;0",
+ tooltip="Obviously not your real credit card!",
+ valid_empty=True,
+ )
+ yield Label("Country")
+ yield Input(
+ suggester=SuggestFromList(COUNTRIES, case_sensitive=False),
+ placeholder="Country",
+ )
+
+
+class ListViews(containers.VerticalGroup):
+ DEFAULT_CLASSES = "column"
+ LISTS_MD = """\
+## List Views and Option Lists
+
+A List View turns any widget in to a user-navigable and selectable list.
+An Option List for a for field to present a list of strings to select from.
+
+ """
+
+ DEFAULT_CSS = """\
+ ListViews {
+ ListView {
+ width: 1fr;
+ height: auto;
+ margin: 0 2;
+ background: $panel;
+ }
+ OptionList { max-height: 15; }
+ Digits { padding: 1 2; width: 1fr; }
+ }
+
+ """
+
+ def compose(self) -> ComposeResult:
+ yield Markdown(self.LISTS_MD)
+ with containers.HorizontalGroup():
+ yield ListView(
+ ListItem(Digits("$50.00")),
+ ListItem(Digits("ยฃ100.00")),
+ ListItem(Digits("โฌ500.00")),
+ )
+ yield OptionList(*COUNTRIES)
+
+
+class WidgetsScreen(PageScreen):
+ CSS = """
+ WidgetsScreen {
+ align-horizontal: center;
+ & > VerticalScroll > * {
+ &:last-of-type { margin-bottom: 2; }
+ &:even { background: $boost; }
+ padding-bottom: 1;
+ }
+ }
+ """
+
+ BINDINGS = [("escape", "unfocus")]
+
+ def compose(self) -> ComposeResult:
+ with containers.VerticalScroll():
+ yield Markdown(WIDGETS_MD, classes="column")
+ yield Buttons()
+ yield Checkboxes()
+ yield Datatables()
+ yield Inputs()
+ yield ListViews()
+ yield Footer()
+
+ def action_unfocus(self) -> None:
+ self.set_focus(None)
diff --git a/src/textual/dom.py b/src/textual/dom.py
index 89be69a9fb..aa2fdb3983 100644
--- a/src/textual/dom.py
+++ b/src/textual/dom.py
@@ -740,8 +740,13 @@ def screen(self) -> "Screen[object]":
from textual.screen import Screen
node: MessagePump | None = self
- while node is not None and not isinstance(node, Screen):
- node = node._parent
+ try:
+ while node is not None and not isinstance(node, Screen):
+ node = node._parent
+ except AttributeError:
+ raise RuntimeError(
+ "Widget is missing attributes; have you called the constructor in your widget class?"
+ ) from None
if not isinstance(node, Screen):
raise NoScreen("node has no screen")
return node
diff --git a/src/textual/_layout.py b/src/textual/layout.py
similarity index 96%
rename from src/textual/_layout.py
rename to src/textual/layout.py
index 9d261129e1..adb7ab5c2d 100644
--- a/src/textual/_layout.py
+++ b/src/textual/layout.py
@@ -19,6 +19,8 @@
@dataclass
class DockArrangeResult:
+ """Result of [Layout.arrange][textual.layout.Layout.arrange]."""
+
placements: list[WidgetPlacement]
"""A `WidgetPlacement` for every widget to describe its location on screen."""
widgets: set[Widget]
@@ -125,7 +127,7 @@ def get_bounds(cls, placements: Iterable[WidgetPlacement]) -> Region:
class Layout(ABC):
- """Responsible for arranging Widgets in a view and rendering them."""
+ """Base class of the object responsible for arranging Widgets within a container."""
name: ClassVar[str] = ""
@@ -212,6 +214,8 @@ def render_keyline(self, container: Widget) -> StripRenderable:
canvas = Canvas(width, height)
line_style, keyline_color = container.styles.keyline
+ if keyline_color:
+ keyline_color = container.background_colors[0] + keyline_color
container_offset = container.content_region.offset
diff --git a/src/textual/layouts/factory.py b/src/textual/layouts/factory.py
index e6b0cfb2e1..8667363737 100644
--- a/src/textual/layouts/factory.py
+++ b/src/textual/layouts/factory.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from textual._layout import Layout
+from textual.layout import Layout
from textual.layouts.grid import GridLayout
from textual.layouts.horizontal import HorizontalLayout
from textual.layouts.vertical import VerticalLayout
diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py
index 506c45e6d2..d7dfc95896 100644
--- a/src/textual/layouts/grid.py
+++ b/src/textual/layouts/grid.py
@@ -3,10 +3,10 @@
from fractions import Fraction
from typing import TYPE_CHECKING, Iterable
-from textual._layout import ArrangeResult, Layout, WidgetPlacement
from textual._resolve import resolve
from textual.css.scalar import Scalar
from textual.geometry import Region, Size, Spacing
+from textual.layout import ArrangeResult, Layout, WidgetPlacement
if TYPE_CHECKING:
from textual.widget import Widget
@@ -17,9 +17,15 @@ class GridLayout(Layout):
name = "grid"
+ def __init__(self) -> None:
+ self.min_column_width: int | None = None
+ self.stretch_height: bool = False
+ self.regular = False
+
def arrange(
self, parent: Widget, children: list[Widget], size: Size
) -> ArrangeResult:
+ parent.pre_layout(self)
styles = parent.styles
row_scalars = styles.grid_rows or (
[Scalar.parse("1fr")] if size.height else [Scalar.parse("auto")]
@@ -27,10 +33,25 @@ def arrange(
column_scalars = styles.grid_columns or [Scalar.parse("1fr")]
gutter_horizontal = styles.grid_gutter_horizontal
gutter_vertical = styles.grid_gutter_vertical
+
table_size_columns = max(1, styles.grid_size_columns)
+ min_column_width = self.min_column_width
+
+ if min_column_width is not None:
+ container_width = size.width
+ table_size_columns = max(
+ 1,
+ (container_width + gutter_horizontal)
+ // (min_column_width + gutter_horizontal),
+ )
+ table_size_columns = min(table_size_columns, len(children))
+ if self.regular:
+ while len(children) % table_size_columns and table_size_columns > 1:
+ table_size_columns -= 1
+
table_size_rows = styles.grid_size_rows
viewport = parent.screen.size
- keyline_style, keyline_color = styles.keyline
+ keyline_style, _keyline_color = styles.keyline
offset = (0, 0)
gutter_spacing: Spacing | None
if keyline_style == "none":
@@ -199,7 +220,13 @@ def apply_height_limits(widget: Widget, height: int) -> int:
)
column_scalars[column] = Scalar.from_number(width)
- columns = resolve(column_scalars, size.width, gutter_vertical, size, viewport)
+ columns = resolve(
+ column_scalars,
+ size.width,
+ gutter_vertical,
+ size,
+ viewport,
+ )
# Handle any auto rows
for row, scalar in enumerate(row_scalars):
@@ -251,6 +278,12 @@ def apply_height_limits(widget: Widget, height: int) -> int:
Fraction(cell_size.width),
Fraction(cell_size.height),
)
+ if self.stretch_height and len(children) > 1:
+ height = (
+ height
+ if (height > cell_size.height)
+ else Fraction(cell_size.height)
+ )
region = (
Region(x, y, int(width + margin.width), int(height + margin.height))
.crop_size(cell_size)
diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py
index 3ac0d3ca0c..ce032d27a5 100644
--- a/src/textual/layouts/horizontal.py
+++ b/src/textual/layouts/horizontal.py
@@ -3,9 +3,9 @@
from fractions import Fraction
from typing import TYPE_CHECKING
-from textual._layout import ArrangeResult, Layout, WidgetPlacement
from textual._resolve import resolve_box_models
from textual.geometry import Region, Size
+from textual.layout import ArrangeResult, Layout, WidgetPlacement
if TYPE_CHECKING:
from textual.geometry import Spacing
diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py
index 31c04977a7..c98b0aa35d 100644
--- a/src/textual/layouts/vertical.py
+++ b/src/textual/layouts/vertical.py
@@ -3,9 +3,9 @@
from fractions import Fraction
from typing import TYPE_CHECKING
-from textual._layout import ArrangeResult, Layout, WidgetPlacement
from textual._resolve import resolve_box_models
from textual.geometry import Region, Size
+from textual.layout import ArrangeResult, Layout, WidgetPlacement
if TYPE_CHECKING:
from textual.geometry import Spacing
diff --git a/src/textual/renderables/digits.py b/src/textual/renderables/digits.py
index 9fd2044404..c6d982f5dc 100644
--- a/src/textual/renderables/digits.py
+++ b/src/textual/renderables/digits.py
@@ -5,7 +5,7 @@
from rich.segment import Segment
from rich.style import Style, StyleType
-DIGITS = " 0123456789+-^x:ABCDEF"
+DIGITS = " 0123456789+-^x:ABCDEF$ยฃโฌ()"
DIGITS3X3_BOLD = """\
@@ -73,7 +73,21 @@
โญโโด
โโ
โต
-
+โญโซโฎ
+โฐโซโฎ
+โฐโซโฏ
+โญโโฎ
+โชโ
+โดโโด
+โญโโฎ
+โชโ
+โฐโโฏ
+โญโด
+โ
+โฐโด
+ โถโฎ
+ โ
+ โถโฏ
""".splitlines()
@@ -144,7 +158,21 @@
โญโโด
โโ
โต
-
+โญโซโฎ
+โฐโซโฎ
+โฐโซโฏ
+โญโโฎ
+โชโ
+โดโโด
+โญโโฎ
+โชโ
+โฐโโฏ
+โญโด
+โ
+โฐโด
+ โถโฎ
+ โ
+ โถโฏ
""".splitlines()
@@ -157,6 +185,8 @@ class Digits:
"""
+ REPLACEMENTS = str.maketrans({".": "โข"})
+
def __init__(self, text: str, style: StyleType = "") -> None:
self._text = text
self._style = style
@@ -186,7 +216,7 @@ def render(self, style: Style) -> RenderResult:
else:
digits = DIGITS3X3
- for character in self._text:
+ for character in self._text.translate(self.REPLACEMENTS):
try:
position = DIGITS.index(character) * 3
except ValueError:
diff --git a/src/textual/screen.py b/src/textual/screen.py
index c25d17c24e..f60cad4aa9 100644
--- a/src/textual/screen.py
+++ b/src/textual/screen.py
@@ -34,7 +34,6 @@
from textual._callback import invoke
from textual._compositor import Compositor, MapGeometry
from textual._context import active_message_pump, visible_screen_stack
-from textual._layout import DockArrangeResult
from textual._path import (
CSSPathType,
_css_path_type_as_list,
@@ -50,6 +49,7 @@
from textual.errors import NoWidget
from textual.geometry import Offset, Region, Size
from textual.keys import key_to_character
+from textual.layout import DockArrangeResult
from textual.reactive import Reactive
from textual.renderables.background_screen import BackgroundScreen
from textual.renderables.blank import Blank
@@ -1205,8 +1205,8 @@ def _get_inline_height(self, size: Size) -> int:
def _screen_resized(self, size: Size):
"""Called by App when the screen is resized."""
+ self._compositor_refresh()
self._refresh_layout(size)
- self.refresh()
def _on_screen_resume(self) -> None:
"""Screen has resumed."""
diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py
index 79ee1c5d9e..78aa968d00 100644
--- a/src/textual/scrollbar.py
+++ b/src/textual/scrollbar.py
@@ -246,6 +246,8 @@ class MyScrollBarRender(ScrollBarRender): ...
```
"""
+ DEFAULT_CLASSES = "-textual-system"
+
def __init__(
self, vertical: bool = True, name: str | None = None, *, thickness: int = 1
) -> None:
diff --git a/src/textual/widget.py b/src/textual/widget.py
index cf072cba6e..ccc9e14f25 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -53,7 +53,6 @@
from textual._debug import get_caller_file_and_line
from textual._dispatch_key import dispatch_key
from textual._easing import DEFAULT_SCROLL_EASING
-from textual._layout import Layout
from textual._segment_tools import align_lines
from textual._styles_cache import StylesCache
from textual._types import AnimationLevel
@@ -77,6 +76,7 @@
Spacing,
clamp,
)
+from textual.layout import Layout
from textual.layouts.vertical import VerticalLayout
from textual.message import Message
from textual.messages import CallbackType, Prune
@@ -692,6 +692,24 @@ def tooltip(self, tooltip: RenderableType | None):
except NoScreen:
pass
+ def with_tooltip(self, tooltip: RenderableType | None) -> Self:
+ """Chainable method to set a tooltip.
+
+ Example:
+ ```python
+ def compose(self) -> ComposeResult:
+ yield Label("Hello").with_tooltip("A greeting")
+ ```
+
+ Args:
+ tooltip: New tooltip, or `None` to clear the tooltip.
+
+ Returns:
+ Self.
+ """
+ self.tooltip = tooltip
+ return self
+
def allow_focus(self) -> bool:
"""Check if the widget is permitted to focus.
@@ -1508,7 +1526,7 @@ def get_content_width(self, container: Size, viewport: Size) -> int:
"""
if self.is_container:
- width = self._layout.get_content_width(self, container, viewport)
+ width = self.layout.get_content_width(self, container, viewport)
return width
cache_key = container.width
@@ -1542,8 +1560,8 @@ def get_content_height(self, container: Size, viewport: Size, width: int) -> int
The height of the content.
"""
if self.is_container:
- assert self._layout is not None
- height = self._layout.get_content_height(
+ assert self.layout is not None
+ height = self.layout.get_content_height(
self,
container,
viewport,
@@ -2126,7 +2144,7 @@ async def stop_animation(self, attribute: str, complete: bool = True) -> None:
await self.app.animator.stop_animation(self, attribute, complete)
@property
- def _layout(self) -> Layout:
+ def layout(self) -> Layout:
"""Get the layout object if set in styles, or a default layout.
Returns:
@@ -2335,8 +2353,22 @@ def _scroll_to(
if on_complete is not None:
self.call_after_refresh(on_complete)
+ if scrolled_x or scrolled_y:
+ self.app._pause_hover_effects()
+
return scrolled_x or scrolled_y
+ def pre_layout(self, layout: Layout) -> None:
+ """This method id called prior to a layout operation.
+
+ Implement this method if you want to make updates that should impact
+ the layout.
+
+ Args:
+ layout: The [Layout][textual.layout.Layout] instance that will be used to arrange this widget's children.
+
+ """
+
def scroll_to(
self,
x: float | None = None,
@@ -3734,7 +3766,7 @@ def render(self) -> RenderableType:
if self.is_container:
if self.styles.layout and self.styles.keyline[0] != "none":
- return self._layout.render_keyline(self)
+ return self.layout.render_keyline(self)
else:
return Blank(self.background_colors[1])
return self.css_identifier_styled
@@ -4193,3 +4225,8 @@ def notify(
severity=severity,
timeout=timeout,
)
+
+ def action_notify(
+ self, message: str, title: str = "", severity: str = "information"
+ ) -> None:
+ self.notify(message, title=title, severity=severity)
diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py
index bcaac621c0..ffc861dad1 100644
--- a/src/textual/widgets/__init__.py
+++ b/src/textual/widgets/__init__.py
@@ -23,6 +23,7 @@
from textual.widgets._input import Input
from textual.widgets._key_panel import KeyPanel
from textual.widgets._label import Label
+ from textual.widgets._link import Link
from textual.widgets._list_item import ListItem
from textual.widgets._list_view import ListView
from textual.widgets._loading_indicator import LoadingIndicator
@@ -63,6 +64,7 @@
"Input",
"KeyPanel",
"Label",
+ "Link",
"ListItem",
"ListView",
"LoadingIndicator",
diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi
index b9df9b9195..907ae843b8 100644
--- a/src/textual/widgets/__init__.pyi
+++ b/src/textual/widgets/__init__.pyi
@@ -12,12 +12,14 @@ from ._help_panel import HelpPanel as HelpPanel
from ._input import Input as Input
from ._key_panel import KeyPanel as KeyPanel
from ._label import Label as Label
+from ._link import Link as Link
from ._list_item import ListItem as ListItem
from ._list_view import ListView as ListView
from ._loading_indicator import LoadingIndicator as LoadingIndicator
from ._log import Log as Log
from ._markdown import Markdown as Markdown
from ._markdown import MarkdownViewer as MarkdownViewer
+from ._masked_input import MaskedInput as MaskedInput
from ._option_list import OptionList as OptionList
from ._placeholder import Placeholder as Placeholder
from ._pretty import Pretty as Pretty
diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py
index dc9b4c84f6..67123d8d59 100644
--- a/src/textual/widgets/_button.py
+++ b/src/textual/widgets/_button.py
@@ -36,7 +36,12 @@ class InvalidButtonVariant(Exception):
class Button(Widget, can_focus=True):
- """A simple clickable button."""
+ """A simple clickable button.
+
+ Clicking the button will send a [Button.Pressed][textual.widgets.Button.Pressed] message,
+ unless the `action` parameter is provided.
+
+ """
DEFAULT_CSS = """
Button {
@@ -155,7 +160,7 @@ class Button(Widget, can_focus=True):
"""The variant name for the button."""
class Pressed(Message):
- """Event sent when a `Button` is pressed.
+ """Event sent when a `Button` is pressed and there is no Button action.
Can be handled using `on_button_pressed` in a subclass of
[`Button`][textual.widgets.Button] or in a parent widget in the DOM.
@@ -184,6 +189,7 @@ def __init__(
classes: str | None = None,
disabled: bool = False,
tooltip: RenderableType | None = None,
+ action: str | None = None,
):
"""Create a Button widget.
@@ -195,6 +201,7 @@ def __init__(
classes: The CSS classes of the button.
disabled: Whether the button is disabled or not.
tooltip: Optional tooltip.
+ action: Optional action to run when clicked.
"""
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
@@ -203,8 +210,10 @@ def __init__(
self.label = label
self.variant = variant
+ self.action = action
self.active_effect_duration = 0.2
"""Amount of time in seconds the button 'press' animation lasts."""
+
if tooltip is not None:
self.tooltip = tooltip
@@ -269,7 +278,12 @@ def press(self) -> Self:
# Manage the "active" effect:
self._start_active_affect()
# ...and let other components know that we've just been clicked:
- self.post_message(Button.Pressed(self))
+ if self.action is None:
+ self.post_message(Button.Pressed(self))
+ else:
+ self.call_later(
+ self.app.run_action, self.action, default_namespace=self._parent
+ )
return self
def _start_active_affect(self) -> None:
diff --git a/src/textual/widgets/_collapsible.py b/src/textual/widgets/_collapsible.py
index d7b7359f65..0cb791a7a7 100644
--- a/src/textual/widgets/_collapsible.py
+++ b/src/textual/widgets/_collapsible.py
@@ -92,6 +92,7 @@ def _watch_collapsed(self, collapsed: bool) -> None:
class Collapsible(Widget):
"""A collapsible container."""
+ ALLOW_MAXIMIZE = True
collapsed = reactive(True, init=False)
title = reactive("Toggle")
@@ -202,6 +203,7 @@ def _watch_collapsed(self, collapsed: bool) -> None:
self.post_message(self.Collapsed(self))
else:
self.post_message(self.Expanded(self))
+ self.call_later(self.scroll_visible)
def _update_collapsed(self, collapsed: bool) -> None:
"""Update children to match collapsed state."""
diff --git a/src/textual/widgets/_link.py b/src/textual/widgets/_link.py
new file mode 100644
index 0000000000..a7fa41f7e6
--- /dev/null
+++ b/src/textual/widgets/_link.py
@@ -0,0 +1,70 @@
+from __future__ import annotations
+
+from textual.binding import Binding
+from textual.reactive import reactive
+from textual.widgets import Static
+
+
+class Link(Static, can_focus=True):
+ """A simple, clickable link that opens a URL."""
+
+ DEFAULT_CSS = """
+ Link {
+ width: auto;
+ height: auto;
+ min-height: 1;
+ color: $accent;
+ text-style: underline;
+ &:hover { color: $accent-lighten-1; }
+ &:focus { text-style: bold reverse; }
+ }
+ """
+
+ BINDINGS = [Binding("enter", "select", "Open link")]
+ """
+ | Key(s) | Description |
+ | :- | :- |
+ | enter | Open the link in the browser. |
+ """
+
+ text: reactive[str] = reactive("", layout=True)
+ url: reactive[str] = reactive("")
+
+ def __init__(
+ self,
+ text: str,
+ *,
+ url: str | None = None,
+ tooltip: str | None = None,
+ name: str | None = None,
+ id: str | None = None,
+ classes: str | None = None,
+ disabled: bool = False,
+ ) -> None:
+ """A link widget.
+
+ Args:
+ text: Text of the link.
+ url: A URL to open, when clicked. If `None`, the `text` parameter will also be used as the url.
+ tooltip: Optional tooltip.
+ name: Name of widget.
+ id: ID of Widget.
+ classes: Space separated list of class names.
+ disabled: Whether the static is disabled or not.
+ """
+ super().__init__(
+ text, name=name, id=id, classes=classes, disabled=disabled, markup=False
+ )
+ self.set_reactive(Link.text, text)
+ self.set_reactive(Link.url, text if url is None else url)
+ self.tooltip = tooltip
+
+ def watch_text(self, text: str) -> None:
+ self.update(text)
+
+ def on_click(self) -> None:
+ self.action_open_link()
+
+ def action_open_link(self) -> None:
+ if self.url:
+ self.app.open_url(self.url)
diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py
index 3e72e4edb3..8d0813cfd6 100644
--- a/src/textual/widgets/_markdown.py
+++ b/src/textual/widgets/_markdown.py
@@ -421,7 +421,7 @@ class MarkdownOrderedList(MarkdownList):
MarkdownOrderedList Vertical {
height: auto;
- width: 1fr;
+ width: 1fr;
}
"""
@@ -606,8 +606,6 @@ class MarkdownFence(MarkdownBlock):
height: auto;
max-height: 20;
color: rgb(210,210,210);
-
-
}
MarkdownFence > * {
@@ -720,6 +718,7 @@ def __init__(
id: str | None = None,
classes: str | None = None,
parser_factory: Callable[[], MarkdownIt] | None = None,
+ open_links: bool = True,
):
"""A Markdown widget.
@@ -729,11 +728,13 @@ def __init__(
id: The ID of the widget in the DOM.
classes: The CSS classes of the widget.
parser_factory: A factory function to return a configured MarkdownIt instance. If `None`, a "gfm-like" parser is used.
+ open_links: Open links automatically. If you set this to `False`, you can handle the [`LinkClicked`][textual.widgets.markdown.Markdown.LinkClicked] events.
"""
super().__init__(name=name, id=id, classes=classes)
self._markdown = markdown
self._parser_factory = parser_factory
self._table_of_contents: TableOfContentsType | None = None
+ self._open_links = open_links
class TableOfContentsUpdated(Message):
"""The table of contents was updated."""
@@ -798,6 +799,10 @@ async def _on_mount(self, _: Mount) -> None:
if self._markdown is not None:
await self.update(self._markdown)
+ def on_markdown_link_clicked(self, event: LinkClicked) -> None:
+ if self._open_links:
+ self.app.open_url(event.href)
+
def _watch_code_dark_theme(self) -> None:
"""React to the dark theme being changed."""
if self.app.dark:
@@ -1154,6 +1159,7 @@ def __init__(
id: str | None = None,
classes: str | None = None,
parser_factory: Callable[[], MarkdownIt] | None = None,
+ open_links: bool = True,
):
"""Create a Markdown Viewer object.
@@ -1164,11 +1170,13 @@ def __init__(
id: The ID of the widget in the DOM.
classes: The CSS classes of the widget.
parser_factory: A factory function to return a configured MarkdownIt instance. If `None`, a "gfm-like" parser is used.
+ open_links: Open links automatically. If you set this to `False`, you can handle the [`LinkClicked`][textual.widgets.markdown.Markdown.LinkClicked] events.
"""
super().__init__(name=name, id=id, classes=classes)
self.show_table_of_contents = show_table_of_contents
self._markdown = markdown
self._parser_factory = parser_factory
+ self._open_links = open_links
@property
def document(self) -> Markdown:
@@ -1215,7 +1223,9 @@ def watch_show_table_of_contents(self, show_table_of_contents: bool) -> None:
self.set_class(show_table_of_contents, "-show-table-of-contents")
def compose(self) -> ComposeResult:
- markdown = Markdown(parser_factory=self._parser_factory)
+ markdown = Markdown(
+ parser_factory=self._parser_factory, open_links=self._open_links
+ )
yield MarkdownTableOfContents(markdown)
yield markdown
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_collapsible_expanded.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_collapsible_expanded.svg
index f734738d0b..7051555a72 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_collapsible_expanded.svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_collapsible_expanded.svg
@@ -19,142 +19,141 @@
font-weight: 700;
}
- .terminal-938527833-matrix {
+ .terminal-3191182536-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-938527833-title {
+ .terminal-3191182536-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-938527833-r1 { fill: #121212 }
-.terminal-938527833-r2 { fill: #1e1e1e }
-.terminal-938527833-r3 { fill: #c5c8c6 }
-.terminal-938527833-r4 { fill: #ddedf9 }
-.terminal-938527833-r5 { fill: #e2e2e2 }
-.terminal-938527833-r6 { fill: #4ebf71;font-weight: bold }
-.terminal-938527833-r7 { fill: #14191f }
-.terminal-938527833-r8 { fill: #e1e1e1 }
-.terminal-938527833-r9 { fill: #fea62b;font-weight: bold }
-.terminal-938527833-r10 { fill: #a7a9ab }
-.terminal-938527833-r11 { fill: #e2e3e3 }
-.terminal-938527833-r12 { fill: #4c5055 }
+ .terminal-3191182536-r1 { fill: #c5c8c6 }
+.terminal-3191182536-r2 { fill: #e1e1e1 }
+.terminal-3191182536-r3 { fill: #121212 }
+.terminal-3191182536-r4 { fill: #e2e2e2 }
+.terminal-3191182536-r5 { fill: #23568b }
+.terminal-3191182536-r6 { fill: #1e1e1e }
+.terminal-3191182536-r7 { fill: #4ebf71;font-weight: bold }
+.terminal-3191182536-r8 { fill: #fea62b;font-weight: bold }
+.terminal-3191182536-r9 { fill: #a7a9ab }
+.terminal-3191182536-r10 { fill: #e2e3e3 }
+.terminal-3191182536-r11 { fill: #4c5055 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- CollapsibleApp
+ CollapsibleApp
-
-
-
- โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-โผ 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โโ
-
-
-
- c Collapse All e Expand All โ^p palette
+
+
+
+
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+โผ Jessica
+
+โโ
+
+Lady Jessica
+
+ Bene Gesserit and concubine of Leto, and mother of Paul and Alia.
+
+
+
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+โผ Paul
+
+
+
+Paul Atreides
+
+ Son of Leto and Jessica.
+
+
+
+ c Collapse All e Expand All โ^p palette
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_digits.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_digits.svg
index 238accbed3..25cf2879e4 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_digits.svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_digits.svg
@@ -19,133 +19,133 @@
font-weight: 700;
}
- .terminal-2016553667-matrix {
+ .terminal-876286072-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-2016553667-title {
+ .terminal-876286072-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-2016553667-r1 { fill: #e1e1e1 }
-.terminal-2016553667-r2 { fill: #c5c8c6 }
-.terminal-2016553667-r3 { fill: #e1e1e1;font-weight: bold }
+ .terminal-876286072-r1 { fill: #e1e1e1 }
+.terminal-876286072-r2 { fill: #c5c8c6 }
+.terminal-876286072-r3 { fill: #e1e1e1;font-weight: bold }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- DigitApp
+ DigitApp
-
+
-
- โถโโฎ โถโฎ โท โทโถโโฎโถโโ
- โโค โ โฐโโคโโโ โ
-โถโโฏ.โถโดโด โตโฐโโด โต
- โญโโฎโถโฎ โถโโฎโถโโฎโท โทโญโโดโญโโดโถโโโญโโฎโญโโฎ โญโโฎโโโฎโญโโฎโโโฎโญโโดโญโโด
- โ โ โ โโโ โโคโฐโโคโฐโโฎโโโฎ โโโโคโฐโโคโถโผโดโถโโด โโโคโโโคโ โ โโโ โโ
- โฐโโฏโถโดโดโฐโโดโถโโฏ โตโถโโฏโฐโโฏ โตโฐโโฏโถโโฏ .,โต โตโโโฏโฐโโฏโโโฏโฐโโดโต
- โโโ โ โบโโโบโโโป โปโโโธโโโธโบโโโโโโโโ โญโโฎโโโฎโญโโฎโโโฎโญโโดโญโโด
- โ โ โ โโโ โโซโโโซโโโโฃโโ โโฃโโซโโโซโบโโธโบโโธ โโโคโโโคโ โ โโโ โโ
- โโโโบโปโธโโโธโบโโ โนโบโโโโโ โนโโโโบโโ .,โต โตโโโฏโฐโโฏโโโฏโฐโโดโต
- โถโโฎ โถโฎ โญโโฎ ^ โท โท
- โโค ร โ โ โ โฐโโค
- โถโโฏ โถโดโดโฐโโฏ โต
-
-
-
-
-
-
-
-
-
-
-
-
+
+ โถโโฎ โถโฎ โท โทโถโฎ โญโโดโญโโฎโถโโฎโญโโดโญโโดโถโโฎโญโโดโญโโฎ
+ โโค โ โฐโโค โ โฐโโฎโฐโโคโโโโโโฎโฐโโฎ โโคโฐโโฎโฐโโค
+โถโโฏโขโถโดโด โตโถโดโดโถโโฏโถโโฏโฐโโดโฐโโฏโถโโฏโถโโฏโถโโฏโถโโฏ
+ โญโโฎโถโฎ โถโโฎโถโโฎโท โทโญโโดโญโโดโถโโโญโโฎโญโโฎ โญโโฎโโโฎโญโโฎโโโฎโญโโดโญโโด
+ โ โ โ โโโ โโคโฐโโคโฐโโฎโโโฎ โโโโคโฐโโคโถโผโดโถโโด โโโคโโโคโ โ โโโ โโ
+ โฐโโฏโถโดโดโฐโโดโถโโฏ โตโถโโฏโฐโโฏ โตโฐโโฏโถโโฏ โข,โต โตโโโฏโฐโโฏโโโฏโฐโโดโต
+ โโโ โ โบโโโบโโโป โปโโโธโโโธโบโโโโโโโโ โญโโฎโโโฎโญโโฎโโโฎโญโโดโญโโด
+ โ โ โ โโโ โโซโโโซโโโโฃโโ โโฃโโซโโโซโบโโธโบโโธ โโโคโโโคโ โ โโโ โโ
+ โโโโบโปโธโโโธโบโโ โนโบโโโโโ โนโโโโบโโ โข,โต โตโโโฏโฐโโฏโโโฏโฐโโดโต
+ โถโโฎ โถโฎ โญโโฎ ^ โท โท
+ โโค ร โ โ โ โฐโโค
+ โถโโฏ โถโดโดโฐโโฏ โต
+ โถโโฎ โถโฎ โญโโฎ ^ โท โท
+ โโค ร โ โ โ โฐโโค
+ โถโโฏ โถโดโดโฐโโฏ โต
+โญโด โญโซโฎโถโฎ โถโโฎโถโโฎ โท โทโญโโด โถโฎ
+โ โฐโซโฎ โ โโโ โโค โฐโโคโฐโโฎ โ
+โฐโด โฐโซโฏโถโดโดโฐโโดโถโโฏโข โตโถโโฏ โถโฏ
+โญโโฎโถโฎ โถโโฎโถโโฎ โท โทโญโโด
+โชโ โ โโโ โโค โฐโโคโฐโโฎ
+โดโโดโถโดโดโฐโโดโถโโฏโข โตโถโโฏ
+โญโโฎโถโฎ โถโโฎโถโโฎ โท โทโญโโด
+โชโ โ โโโ โโค โฐโโคโฐโโฎ
+โฐโโฏโถโดโดโฐโโดโถโโฏโข โตโถโโฏ
diff --git a/tests/snapshot_tests/snapshot_apps/digits.py b/tests/snapshot_tests/snapshot_apps/digits.py
index 93d7c5df32..0eb16e9da2 100644
--- a/tests/snapshot_tests/snapshot_apps/digits.py
+++ b/tests/snapshot_tests/snapshot_apps/digits.py
@@ -19,10 +19,14 @@ class DigitApp(App):
"""
def compose(self) -> ComposeResult:
- yield Digits("3.1427", classes="left")
+ yield Digits("3.14159265359", classes="left")
yield Digits(" 0123456789+-.,ABCDEF", classes="center")
yield Digits(" 0123456789+-.,ABCDEF", classes="center bold")
yield Digits("3x10^4", classes="right")
+ yield Digits("3x10^4", classes="right")
+ yield Digits("($123.45)")
+ yield Digits("ยฃ123.45")
+ yield Digits("โฌ123.45")
if __name__ == "__main__":
diff --git a/tests/snapshot_tests/snapshot_apps/dock_scroll_off_by_one.py b/tests/snapshot_tests/snapshot_apps/dock_scroll_off_by_one.py
index f9a5a00fd0..3a3f531768 100644
--- a/tests/snapshot_tests/snapshot_apps/dock_scroll_off_by_one.py
+++ b/tests/snapshot_tests/snapshot_apps/dock_scroll_off_by_one.py
@@ -3,6 +3,8 @@
class ScrollOffByOne(App):
+ HOVER_EFFECTS_SCROLL_PAUSE = 0.0
+
def compose(self) -> ComposeResult:
for number in range(1, 100):
yield Checkbox(str(number))
diff --git a/tests/snapshot_tests/snapshot_apps/scroll_to.py b/tests/snapshot_tests/snapshot_apps/scroll_to.py
index 9dd21a3fc4..b3673eb269 100644
--- a/tests/snapshot_tests/snapshot_apps/scroll_to.py
+++ b/tests/snapshot_tests/snapshot_apps/scroll_to.py
@@ -5,6 +5,8 @@
class ScrollOffByOne(App):
"""Scroll to item 50."""
+ HOVER_EFFECTS_SCROLL_PAUSE = 0
+
def compose(self) -> ComposeResult:
for number in range(1, 100):
yield Checkbox(str(number), id=f"number-{number}")
diff --git a/tests/test_arrange.py b/tests/test_arrange.py
index ffedb6d787..4b671b8334 100644
--- a/tests/test_arrange.py
+++ b/tests/test_arrange.py
@@ -1,9 +1,9 @@
import pytest
from textual._arrange import TOP_Z, arrange
-from textual._layout import WidgetPlacement
from textual.app import App
from textual.geometry import Region, Size, Spacing
+from textual.layout import WidgetPlacement
from textual.widget import Widget