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