diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f7b8a909d..8100eaf86e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,33 @@ 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 +- Renamed `Screen.can_view` and `Widget.can_view` to `Screen.can_view_entire` and `Widget.can_view_entire` https://github.com/Textualize/textual/pull/5174 + ### 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 +- Added `immediate` parameter to scroll methods https://github.com/Textualize/textual/pull/5164 +- Added `textual._loop.loop_from_index` https://github.com/Textualize/textual/pull/5164 +- Added `min_color` and `max_color` to Sparklines constructor, which take precedence over CSS https://github.com/Textualize/textual/pull/5174 +- Added new demo `python -m textual`, not *quite* finished but better than the old one https://github.com/Textualize/textual/pull/5174 +- Added `Screen.can_view_partial` and `Widget.can_view_partial` https://github.com/Textualize/textual/pull/5174 +- Added `App.is_web` property to indicate if the app is running via a web browser https://github.com/Textualize/textual/pull/5128 - `Enter` and `Leave` events can now be used with the `on` decorator https://github.com/Textualize/textual/pull/5159 +### Fixed + +- Fixed glitchy ListView https://github.com/Textualize/textual/issues/5163 + ## [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/__main__.py b/src/textual/__main__.py index 3bf8ea235c..9da832d968 100644 --- a/src/textual/__main__.py +++ b/src/textual/__main__.py @@ -1,4 +1,4 @@ -from textual.demo import DemoApp +from textual.demo.demo_app import DemoApp if __name__ == "__main__": app = DemoApp() 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/_loop.py b/src/textual/_loop.py index 7a057fc214..67546999c0 100644 --- a/src/textual/_loop.py +++ b/src/textual/_loop.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Iterable, TypeVar +from typing import Iterable, Literal, Sequence, TypeVar T = TypeVar("T") @@ -43,3 +43,44 @@ def loop_first_last(values: Iterable[T]) -> Iterable[tuple[bool, bool, T]]: first = False previous_value = value yield first, True, previous_value + + +def loop_from_index( + values: Sequence[T], + index: int, + direction: Literal[-1, +1] = +1, + wrap: bool = True, +) -> Iterable[tuple[int, T]]: + """Iterate over values in a sequence from a given starting index, potentially wrapping the index + if it would go out of bounds. + + Note that the first value to be yielded is a step from `index`, and `index` will be yielded *last*. + + + Args: + values: A sequence of values. + index: Starting index. + direction: Direction to move index (+1 for forward, -1 for backward). + bool: Should the index wrap when out of bounds? + + Yields: + A tuple of index and value from the sequence. + """ + # Sanity check for devs who miss the typing errors + assert direction in (-1, +1), "direction must be -1 or +1" + count = len(values) + if wrap: + for _ in range(count): + index = (index + direction) % count + yield (index, values[index]) + else: + if direction == +1: + for _ in range(count): + if (index := index + 1) >= count: + break + yield (index, values[index]) + else: + for _ in range(count): + if (index := index - 1) < 0: + break + yield (index, values[index]) diff --git a/src/textual/_profile.py b/src/textual/_profile.py index 279a873ec0..3e880cf15c 100644 --- a/src/textual/_profile.py +++ b/src/textual/_profile.py @@ -16,4 +16,4 @@ def timer(subject: str = "time") -> Generator[None, None, None]: yield elapsed = perf_counter() - start elapsed_ms = elapsed * 1000 - log(f"{subject} elapsed {elapsed_ms:.2f}ms") + log(f"{subject} elapsed {elapsed_ms:.4f}ms") 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/_widget_navigation.py b/src/textual/_widget_navigation.py index c547d01be0..302a332090 100644 --- a/src/textual/_widget_navigation.py +++ b/src/textual/_widget_navigation.py @@ -8,12 +8,13 @@ from __future__ import annotations -from functools import partial from itertools import count from typing import Literal, Protocol, Sequence from typing_extensions import TypeAlias +from textual._loop import loop_from_index + class Disableable(Protocol): """Non-widgets that have an enabled/disabled status.""" @@ -105,7 +106,6 @@ def find_next_enabled( candidates: Sequence[Disableable], anchor: int | None, direction: Direction, - with_anchor: bool = False, ) -> int | None: """Find the next enabled object if we're currently at the given anchor. @@ -118,8 +118,6 @@ def find_next_enabled( enabled object. direction: The direction in which to traverse the candidates when looking for the next enabled candidate. - with_anchor: Consider the anchor position as the first valid position instead of - the last one. Returns: The next enabled object. If none are available, return the anchor. @@ -134,17 +132,10 @@ def find_next_enabled( ) return None - start = anchor + direction if not with_anchor else anchor - key_function = partial( - get_directed_distance, - start=start, - direction=direction, - wrap_at=len(candidates), - ) - enabled_candidates = [ - index for index, candidate in enumerate(candidates) if not candidate.disabled - ] - return min(enabled_candidates, key=key_function, default=anchor) + for index, candidate in loop_from_index(candidates, anchor, direction, wrap=True): + if not candidate.disabled: + return index + return anchor def find_next_enabled_no_wrap( diff --git a/src/textual/app.py b/src/textual/app.py index 850a60e14b..16ed1acdec 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"}: @@ -991,6 +1002,11 @@ def is_inline(self) -> bool: """Is the app running in 'inline' mode?""" return False if self._driver is None else self._driver.is_inline + @property + def is_web(self) -> bool: + """Is the app running in 'web' mode via a browser?""" + return False if self._driver is None else self._driver.is_web + @property def screen_stack(self) -> list[Screen[Any]]: """A snapshot of the current screen stack. @@ -2171,8 +2187,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 +2203,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 +2230,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 +2709,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 +3572,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/demo.py b/src/textual/demo.py deleted file mode 100644 index 9a5acf176d..0000000000 --- a/src/textual/demo.py +++ /dev/null @@ -1,400 +0,0 @@ -from __future__ import annotations - -from importlib.metadata import version -from pathlib import Path -from typing import cast - -from rich import box -from rich.console import RenderableType -from rich.json import JSON -from rich.markdown import Markdown -from rich.markup import escape -from rich.pretty import Pretty -from rich.syntax import Syntax -from rich.table import Table -from rich.text import Text - -from textual.app import App, ComposeResult -from textual.binding import Binding -from textual.containers import Container, Horizontal, ScrollableContainer -from textual.reactive import reactive -from textual.widgets import ( - Button, - DataTable, - Footer, - Header, - Input, - RichLog, - Static, - Switch, -) - -from_markup = Text.from_markup - -example_table = Table( - show_edge=False, - show_header=True, - expand=True, - row_styles=["none", "dim"], - box=box.SIMPLE, -) -example_table.add_column(from_markup("[green]Date"), style="green", no_wrap=True) -example_table.add_column(from_markup("[blue]Title"), style="blue") - -example_table.add_column( - from_markup("[magenta]Box Office"), - style="magenta", - justify="right", - no_wrap=True, -) -example_table.add_row( - "Dec 20, 2019", - "Star Wars: The Rise of Skywalker", - "$375,126,118", -) -example_table.add_row( - "May 25, 2018", - from_markup("[b]Solo[/]: A Star Wars Story"), - "$393,151,347", -) -example_table.add_row( - "Dec 15, 2017", - "Star Wars Ep. VIII: The Last Jedi", - from_markup("[bold]$1,332,539,889[/bold]"), -) -example_table.add_row( - "May 19, 1999", - from_markup("Star Wars Ep. [b]I[/b]: [i]The phantom Menace"), - "$1,027,044,677", -) - - -WELCOME_MD = """ - -## Textual Demo - -**Welcome**! Textual is a framework for creating sophisticated applications with the terminal. -""" - - -RICH_MD = """ - -Textual is built on **Rich**, the popular Python library for advanced terminal output. - -Add content to your Textual App with Rich *renderables* (this text is written in Markdown and formatted with Rich's Markdown class). - -Here are some examples: -""" - -CSS_MD = """ - -Textual uses Cascading Stylesheets (CSS) to create Rich interactive User Interfaces. - -- **Easy to learn** - much simpler than browser CSS -- **Live editing** - see your changes without restarting the app! - -Here's an example of some CSS used in this app: -""" - -DATA = { - "foo": [ - 3.1427, - ( - "Paul Atreides", - "Vladimir Harkonnen", - "Thufir Hawat", - "Gurney Halleck", - "Duncan Idaho", - ), - ], -} - -WIDGETS_MD = """ - -Textual widgets are powerful interactive components. - -Build your own or use the builtin widgets. - -- **Input** Text / Password input. -- **Button** Clickable button with a number of styles. -- **Switch** A switch to toggle between states. -- **DataTable** A spreadsheet-like widget for navigating data. Cells may contain text or Rich renderables. -- **Tree** An generic tree with expandable nodes. -- **DirectoryTree** A tree of file and folders. -- *... many more planned ...* -""" - - -MESSAGE = """ -We hope you enjoy using Textual. - -Here are some links. You can click these! - -[@click="app.open_link('https://textual.textualize.io')"]Textual Docs[/] - -[@click="app.open_link('https://github.com/Textualize/textual')"]Textual GitHub Repository[/] - -[@click="app.open_link('https://github.com/Textualize/rich')"]Rich GitHub Repository[/] - - -Built with โ™ฅ by [@click="app.open_link('https://www.textualize.io')"]Textualize.io[/] -""" - - -JSON_EXAMPLE = """{ - "glossary": { - "title": "example glossary", - "GlossDiv": { - "title": "S", - "GlossList": { - "GlossEntry": { - "ID": "SGML", - "SortAs": "SGML", - "GlossTerm": "Standard Generalized Markup Language", - "Acronym": "SGML", - "Abbrev": "ISO 8879:1986", - "GlossDef": { - "para": "A meta-markup language, used to create markup languages such as DocBook.", - "GlossSeeAlso": ["GML", "XML"] - }, - "GlossSee": "markup" - } - } - } - } -} -""" - - -class Body(ScrollableContainer): - pass - - -class Title(Static): - pass - - -class DarkSwitch(Horizontal): - def compose(self) -> ComposeResult: - yield Switch(value=self.app.dark) - yield Static("Dark mode toggle", classes="label") - - def on_mount(self) -> None: - self.watch(self.app, "dark", self.on_dark_change, init=False) - - def on_dark_change(self) -> None: - self.query_one(Switch).value = self.app.dark - - def on_switch_changed(self, event: Switch.Changed) -> None: - self.app.dark = event.value - - -class Welcome(Container): - ALLOW_MAXIMIZE = True - - def compose(self) -> ComposeResult: - yield Static(Markdown(WELCOME_MD)) - yield Button("Start", variant="success") - - def on_button_pressed(self, event: Button.Pressed) -> None: - app = cast(DemoApp, self.app) - app.add_note("[b magenta]Start!") - app.query_one(".location-first").scroll_visible(duration=0.5, top=True) - - -class OptionGroup(Container): - pass - - -class SectionTitle(Static): - pass - - -class Message(Static): - pass - - -class Version(Static): - def render(self) -> RenderableType: - return f"[b]v{version('textual')}" - - -class Sidebar(Container): - def compose(self) -> ComposeResult: - yield Title("Textual Demo") - yield OptionGroup(Message(MESSAGE), Version()) - yield DarkSwitch() - - -class AboveFold(Container): - pass - - -class Section(Container): - pass - - -class Column(Container): - pass - - -class TextContent(Static): - pass - - -class QuickAccess(Container): - pass - - -class LocationLink(Static): - def __init__(self, label: str, reveal: str) -> None: - super().__init__(label) - self.reveal = reveal - - def on_click(self) -> None: - app = cast(DemoApp, self.app) - app.query_one(self.reveal).scroll_visible(top=True, duration=0.5) - app.add_note(f"Scrolling to [b]{self.reveal}[/b]") - - -class LoginForm(Container): - ALLOW_MAXIMIZE = True - - def compose(self) -> ComposeResult: - yield Static("Username", classes="label") - yield Input(placeholder="Username") - yield Static("Password", classes="label") - yield Input(placeholder="Password", password=True) - yield Static() - yield Button("Login", variant="primary") - - -class Window(Container): - pass - - -class SubTitle(Static): - pass - - -class DemoApp(App[None]): - CSS_PATH = "demo.tcss" - TITLE = "Textual Demo" - BINDINGS = [ - ("ctrl+b", "toggle_sidebar", "Sidebar"), - ("ctrl+t", "app.toggle_dark", "Toggle Dark mode"), - ("ctrl+s", "app.screenshot()", "Screenshot"), - ("f1", "app.toggle_class('RichLog', '-hidden')", "Notes"), - Binding("ctrl+q", "app.quit", "Quit", show=True), - ] - - show_sidebar = reactive(False) - - def add_note(self, renderable: RenderableType) -> None: - self.query_one(RichLog).write(renderable) - - def compose(self) -> ComposeResult: - example_css = Path(self.css_path[0]).read_text() - yield Container( - Sidebar(classes="-hidden"), - Header(show_clock=False), - RichLog(classes="-hidden", wrap=False, highlight=True, markup=True), - Body( - QuickAccess( - LocationLink("TOP", ".location-top"), - LocationLink("Widgets", ".location-widgets"), - LocationLink("Rich content", ".location-rich"), - LocationLink("CSS", ".location-css"), - ), - AboveFold(Welcome(), classes="location-top"), - Column( - Section( - SectionTitle("Widgets"), - TextContent(Markdown(WIDGETS_MD)), - LoginForm(), - DataTable(), - ), - classes="location-widgets location-first", - ), - Column( - Section( - SectionTitle("Rich"), - TextContent(Markdown(RICH_MD)), - SubTitle("Pretty Printed data (try resizing the terminal)"), - Static(Pretty(DATA, indent_guides=True), classes="pretty pad"), - SubTitle("JSON"), - Window(Static(JSON(JSON_EXAMPLE), expand=True), classes="pad"), - SubTitle("Tables"), - Static(example_table, classes="table pad"), - ), - classes="location-rich", - ), - Column( - Section( - SectionTitle("CSS"), - TextContent(Markdown(CSS_MD)), - Window( - Static( - Syntax( - example_css, - "css", - theme="material", - line_numbers=True, - ), - expand=True, - ) - ), - ), - classes="location-css", - ), - ), - ) - yield Footer() - - def action_open_link(self, link: str) -> None: - self.app.bell() - import webbrowser - - webbrowser.open(link) - - def action_toggle_sidebar(self) -> None: - sidebar = self.query_one(Sidebar) - self.set_focus(None) - if sidebar.has_class("-hidden"): - sidebar.remove_class("-hidden") - else: - if sidebar.query("*:focus"): - self.screen.set_focus(None) - sidebar.add_class("-hidden") - - def on_mount(self) -> None: - self.add_note("Textual Demo app is running") - table = self.query_one(DataTable) - table.add_column("Foo", width=20) - table.add_column("Bar", width=20) - table.add_column("Baz", width=20) - table.add_column("Foo", width=20) - table.add_column("Bar", width=20) - table.add_column("Baz", width=20) - table.zebra_stripes = True - for n in range(20): - table.add_row(*[f"Cell ([b]{n}[/b], {col})" for col in range(6)]) - self.query_one("Welcome Button", Button).focus() - - def action_screenshot(self, filename: str | None = None, path: str = "./") -> None: - """Save an SVG "screenshot". This action will save an SVG file containing the current contents of the screen. - - Args: - filename: Filename of screenshot, or None to auto-generate. - path: Path to directory. - """ - self.bell() - path = self.save_screenshot(filename, path) - message = f"Screenshot saved to [bold green]'{escape(str(path))}'[/]" - self.add_note(Text.from_markup(message)) - self.notify(message) - - -app = DemoApp() -if __name__ == "__main__": - app.run() diff --git a/src/textual/demo.tcss b/src/textual/demo.tcss deleted file mode 100644 index 4febd734be..0000000000 --- a/src/textual/demo.tcss +++ /dev/null @@ -1,271 +0,0 @@ -* { - transition: background 500ms in_out_cubic, color 500ms in_out_cubic; -} - -Screen { - layers: base overlay notes notifications; - overflow: hidden; - &:inline { - height: 50vh; - } - &.-maximized-view { - overflow: auto; - } -} - - -Notification { - dock: bottom; - layer: notification; - width: auto; - margin: 2 4; - padding: 1 2; - background: $background; - color: $text; - height: auto; - -} - -Sidebar { - width: 40; - background: $panel; - transition: offset 500ms in_out_cubic; - layer: overlay; - -} - -Sidebar:focus-within { - offset: 0 0 !important; -} - -Sidebar.-hidden { - offset-x: -100%; -} - -Sidebar Title { - background: $boost; - color: $secondary; - padding: 2 4; - border-right: vkey $background; - dock: top; - text-align: center; - text-style: bold; -} - - -OptionGroup { - background: $boost; - color: $text; - height: 1fr; - border-right: vkey $background; -} - -Option { - margin: 1 0 0 1; - height: 3; - padding: 1 2; - background: $boost; - border: tall $panel; - text-align: center; -} - -Option:hover { - background: $primary 20%; - color: $text; -} - -Body { - height: 100%; - overflow-y: scroll; - width: 100%; - background: $surface; - -} - -AboveFold { - width: 100%; - height: 100%; - align: center middle; -} - -Welcome { - background: $boost; - height: auto; - max-width: 100; - min-width: 40; - border: wide $primary; - padding: 1 2; - margin: 1 2; - box-sizing: border-box; -} - -Welcome Button { - width: 100%; - margin-top: 1; -} - -Column { - height: auto; - min-height: 100vh; - align: center top; - overflow: hidden; -} - - -DarkSwitch { - background: $panel; - padding: 1; - dock: bottom; - height: auto; - border-right: vkey $background; -} - -DarkSwitch .label { - width: 1fr; - padding: 1 2; - color: $text-muted; -} - -DarkSwitch Switch { - background: $boost; - dock: left; -} - - -Screen>Container { - height: 100%; - overflow: hidden; -} - -RichLog { - background: $surface; - color: $text; - height: 50vh; - dock: bottom; - layer: notes; - border-top: hkey $primary; - offset-y: 0; - transition: offset 400ms in_out_cubic; - padding: 0 1 1 1; -} - - -RichLog:focus { - offset: 0 0 !important; -} - -RichLog.-hidden { - offset-y: 100%; -} - - - -Section { - height: auto; - min-width: 40; - margin: 1 2 4 2; - -} - -SectionTitle { - padding: 1 2; - background: $boost; - text-align: center; - text-style: bold; -} - -SubTitle { - padding-top: 1; - border-bottom: heavy $panel; - color: $text; - text-style: bold; -} - -TextContent { - margin: 1 0; -} - -QuickAccess { - width: 30; - dock: left; - -} - -LocationLink { - margin: 1 0 0 1; - height: 1; - padding: 1 2; - background: $boost; - color: $text; - box-sizing: content-box; - content-align: center middle; -} - -LocationLink:hover { - background: $accent; - color: $text; - text-style: bold; -} - - -.pad { - margin: 1 0; -} - -DataTable { - height: 16; - max-height: 16; -} - - -LoginForm { - height: auto; - margin: 1 0; - padding: 1 2; - layout: grid; - grid-size: 2; - grid-rows: 4; - grid-columns: 12 1fr; - background: $boost; - border: wide $background; -} - -LoginForm Button { - margin: 0 1; - width: 100%; -} - -LoginForm .label { - padding: 1 2; - text-align: right; -} - -Message { - margin: 0 1; - -} - - -Tree { - margin: 1 0; -} - - -Window { - background: $boost; - overflow: auto; - height: auto; - max-height: 16; -} - -Window>Static { - width: auto; -} - - -Version { - color: $text-disabled; - dock: bottom; - text-align: center; - padding: 1; -} diff --git a/src/textual/demo/__main__.py b/src/textual/demo/__main__.py new file mode 100644 index 0000000000..d254f85c41 --- /dev/null +++ b/src/textual/demo/__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/demo/data.py b/src/textual/demo/data.py new file mode 100644 index 0000000000..6023ec83e2 --- /dev/null +++ b/src/textual/demo/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/demo/demo_app.py b/src/textual/demo/demo_app.py new file mode 100644 index 0000000000..d3be0a33c2 --- /dev/null +++ b/src/textual/demo/demo_app.py @@ -0,0 +1,49 @@ +from textual.app import App +from textual.binding import Binding +from textual.demo.home import HomeScreen +from textual.demo.projects import ProjectsScreen +from textual.demo.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", + ), + Binding( + "ctrl+s", + "app.screenshot", + "Screenshot", + tooltip="Save an SVG 'screenshot' of the current screen", + ), + ] diff --git a/src/textual/demo/home.py b/src/textual/demo/home.py new file mode 100644 index 0000000000..06a76b6aa5 --- /dev/null +++ b/src/textual/demo/home.py @@ -0,0 +1,238 @@ +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.demo.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). + +```python +# Start building! +import textual +``` + +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/demo/page.py b/src/textual/demo/page.py new file mode 100644 index 0000000000..f275c093fb --- /dev/null +++ b/src/textual/demo/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/demo/projects.py b/src/textual/demo/projects.py new file mode 100644 index 0000000000..d352fbcbd3 --- /dev/null +++ b/src/textual/demo/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.demo.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/demo/widgets.py b/src/textual/demo/widgets.py new file mode 100644 index 0000000000..7432dbdde8 --- /dev/null +++ b/src/textual/demo/widgets.py @@ -0,0 +1,464 @@ +import csv +import io +from math import sin + +from rich.syntax import Syntax +from rich.table import Table +from rich.traceback import Traceback + +from textual import containers +from textual.app import ComposeResult +from textual.demo.data import COUNTRIES +from textual.demo.page import PageScreen +from textual.reactive import reactive, var +from textual.suggester import SuggestFromList +from textual.widgets import ( + Button, + Checkbox, + DataTable, + Digits, + Footer, + Input, + Label, + ListItem, + ListView, + Log, + Markdown, + MaskedInput, + OptionList, + RadioButton, + RadioSet, + RichLog, + Sparkline, + TabbedContent, +) + +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): + """Demonstrates Checkboxes.""" + + 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): + """Demonstrates DataTables.""" + + 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): + """Demonstrates Inputs.""" + + 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): + """Demonstrates List Views and Option Lists.""" + + 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 Logs(containers.VerticalGroup): + """Demonstrates Logs.""" + + DEFAULT_CLASSES = "column" + LOGS_MD = """\ +## Logs and Rich Logs + +A Log widget to efficiently display a scrolling view of text, with optional highlighted. +And a RichLog widget to display Rich renderables. + +""" + DEFAULT_CSS = """ + Logs { + Log, RichLog { + width: 1fr; + height: 20; + border: blank; + padding: 0; + overflow-x: auto; + &:focus { + border: heavy $accent; + } + } + TabPane { padding: 0; } + } + """ + + TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me. +And when it has gone past, I will turn the inner eye to see its path. +Where the fear has gone there will be nothing. Only I will remain.""".splitlines() + + CSV = """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""" + CSV_ROWS = list(csv.reader(io.StringIO(CSV))) + + CODE = '''\ +def loop_first_last(values: Iterable[T]) -> Iterable[tuple[bool, bool, T]]: + """Iterate and generate a tuple with a flag for first and last value.""" + iter_values = iter(values) + try: + previous_value = next(iter_values) + except StopIteration: + return + first = True + for value in iter_values: + yield first, False, previous_value + first = False + previous_value = value + yield first, True, previous_value\ +''' + log_count = var(0) + rich_log_count = var(0) + + def compose(self) -> ComposeResult: + yield Markdown(self.LOGS_MD) + with TabbedContent("Log", "RichLog"): + yield Log(max_lines=10_000, highlight=True) + yield RichLog(max_lines=10_000) + + def on_mount(self) -> None: + log = self.query_one(Log) + rich_log = self.query_one(RichLog) + log.write("I am a Log Widget") + rich_log.write("I am a Rich Log Widget") + self.set_interval(0.25, self.update_log) + self.set_interval(1, self.update_rich_log) + + def update_log(self) -> None: + """Update the Log with new content.""" + log = self.query_one(Log) + if not self.screen.can_view_partial(log) or not self.screen.is_active: + return + self.log_count += 1 + line_no = self.log_count % len(self.TEXT) + line = self.TEXT[self.log_count % len(self.TEXT)] + log.write_line(f"fear[{line_no}] = {line!r}") + + def update_rich_log(self) -> None: + """Update the Rich Log with content.""" + rich_log = self.query_one(RichLog) + if not self.screen.can_view_partial(rich_log) or not self.screen.is_active: + return + self.rich_log_count += 1 + log_option = self.rich_log_count % 3 + if log_option == 0: + rich_log.write("Syntax highlighted code", animate=True) + rich_log.write(Syntax(self.CODE, lexer="python"), animate=True) + elif log_option == 1: + rich_log.write("A Rich Table", animate=True) + table = Table(*self.CSV_ROWS[0]) + for row in self.CSV_ROWS[1:]: + table.add_row(*row) + rich_log.write(table, animate=True) + elif log_option == 2: + rich_log.write("A Rich Traceback", animate=True) + try: + 1 / 0 + except Exception: + traceback = Traceback() + rich_log.write(traceback, animate=True) + + +class Sparklines(containers.VerticalGroup): + """Demonstrates sparklines.""" + + DEFAULT_CLASSES = "column" + LOGS_MD = """\ +## Sparklines + +A low-res summary of time-series data. + +For detailed graphs, see [textual-plotext](https://github.com/Textualize/textual-plotext). +""" + DEFAULT_CSS = """ + Sparklines { + Sparkline { + width: 1fr; + margin: 1; + &#first > .sparkline--min-color { color: $success; } + &#first > .sparkline--max-color { color: $warning; } + &#second > .sparkline--min-color { color: $warning; } + &#second > .sparkline--max-color { color: $error; } + &#third > .sparkline--min-color { color: $primary; } + &#third > .sparkline--max-color { color: $accent; } + } + } + + """ + + count = var(0) + data: reactive[list[float]] = reactive(list) + + def compose(self) -> ComposeResult: + yield Markdown(self.LOGS_MD) + yield Sparkline([], summary_function=max, id="first").data_bind( + Sparklines.data, + ) + yield Sparkline([], summary_function=max, id="second").data_bind( + Sparklines.data, + ) + yield Sparkline([], summary_function=max, id="third").data_bind( + Sparklines.data, + ) + + def on_mount(self) -> None: + self.set_interval(0.2, self.update_sparks) + + def update_sparks(self) -> None: + """Update the sparks data.""" + if not self.screen.can_view_partial(self) or not self.screen.is_active: + return + self.count += 1 + offset = self.count * 40 + self.data = [abs(sin(x / 3.14)) for x in range(offset, offset + 360 * 6, 20)] + + +class WidgetsScreen(PageScreen): + """The Widgets screen""" + + 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 Logs() + yield Sparklines() + 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/driver.py b/src/textual/driver.py index 6a4f2e5f6b..3a48f7d59d 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -54,6 +54,11 @@ def is_inline(self) -> bool: """Is the driver 'inline' (not full-screen)?""" return False + @property + def is_web(self) -> bool: + """Is the driver 'web' (running via a browser)?""" + return False + @property def can_suspend(self) -> bool: """Can this driver be suspended?""" diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index 3b67c0981e..7e1e0ff0b4 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -69,6 +69,10 @@ def __init__( """Maps delivery keys to file-like objects, used for delivering files to the browser.""" + @property + def is_web(self) -> bool: + return True + def write(self, data: str) -> None: """Write string data to the output device, which may be piped to the parent process (i.e. textual-web/textual-serve). 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..ab9b187efa 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 @@ -896,7 +896,7 @@ def set_focus(self, widget: Widget | None, scroll_visible: bool = True) -> None: def scroll_to_center(widget: Widget) -> None: """Scroll to center (after a refresh).""" - if self.focused is widget and not self.can_view(widget): + if self.focused is widget and not self.can_view_entire(widget): self.scroll_to_center(widget, origin_visible=True) self.call_later(scroll_to_center, widget) @@ -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.""" @@ -1480,24 +1480,44 @@ async def action_dismiss(self, result: ScreenResultType | None = None) -> None: await self._flush_next_callbacks() self.dismiss(result) - def can_view(self, widget: Widget) -> bool: - """Check if a given widget is in the current view (scrollable area). + def can_view_entire(self, widget: Widget) -> bool: + """Check if a given widget is fully within the current screen. Note: This doesn't necessarily equate to a widget being visible. There are other reasons why a widget may not be visible. Args: - widget: A widget that is a descendant of self. + widget: A widget. + + Returns: + `True` if the entire widget is in view, `False` if it is partially visible or not in view. + """ + if widget not in self._compositor.visible_widgets: + return False + # If the widget is one that overlays the screen... + if widget.styles.overlay == "screen": + # ...simply check if it's within the screen's region. + return widget.region in self.region + # Failing that fall back to normal checking. + return super().can_view_entire(widget) + + def can_view_partial(self, widget: Widget) -> bool: + """Check if a given widget is at least partially within the current view. + + Args: + widget: A widget. Returns: - True if the entire widget is in view, False if it is partially visible or not in view. + `True` if the any part of the widget is in view, `False` if it is completely outside of the screen. """ + if widget not in self._compositor.visible_widgets: + return False # If the widget is one that overlays the screen... if widget.styles.overlay == "screen": # ...simply check if it's within the screen's region. return widget.region in self.region # Failing that fall back to normal checking. - return super().can_view(widget) + return super().can_view_partial(widget) def validate_title(self, title: Any) -> str | None: """Ensure the title is a string or `None`.""" diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py index 56b947172f..42c89ebb23 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -123,6 +123,7 @@ def scroll_to( force: bool = False, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", + immediate: bool = False, ) -> None: """Scroll to a given (absolute) coordinate, optionally animating. @@ -136,6 +137,8 @@ def scroll_to( force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). + immediate: If `False` the scroll will be deferred until after a screen refresh, + set to `True` to scroll immediately. """ self._scroll_to( 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..7c71bb885c 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, @@ -1629,12 +1647,12 @@ def max_scroll_y(self) -> int: @property def is_vertical_scroll_end(self) -> bool: """Is the vertical scroll position at the maximum?""" - return self.scroll_offset.y == self.max_scroll_y + return self.scroll_offset.y == self.max_scroll_y or not self.size @property def is_horizontal_scroll_end(self) -> bool: """Is the horizontal scroll position at the maximum?""" - return self.scroll_offset.x == self.max_scroll_x + return self.scroll_offset.x == self.max_scroll_x or not self.size @property def is_vertical_scrollbar_grabbed(self) -> bool: @@ -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, @@ -2349,6 +2381,7 @@ def scroll_to( force: bool = False, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", + immediate: bool = False, ) -> None: """Scroll to a given (absolute) coordinate, optionally animating. @@ -2362,22 +2395,37 @@ def scroll_to( force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). + immediate: If `False` the scroll will be deferred until after a screen refresh, + set to `True` to scroll immediately. Note: The call to scroll is made after the next refresh. """ - self.call_after_refresh( - self._scroll_to, - x, - y, - animate=animate, - speed=speed, - duration=duration, - easing=easing, - force=force, - on_complete=on_complete, - level=level, - ) + if immediate: + self._scroll_to( + x, + y, + animate=animate, + speed=speed, + duration=duration, + easing=easing, + force=force, + on_complete=on_complete, + level=level, + ) + else: + self.call_after_refresh( + self._scroll_to, + x, + y, + animate=animate, + speed=speed, + duration=duration, + easing=easing, + force=force, + on_complete=on_complete, + level=level, + ) def scroll_relative( self, @@ -2391,6 +2439,7 @@ def scroll_relative( force: bool = False, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", + immediate: bool = False, ) -> None: """Scroll relative to current position. @@ -2404,6 +2453,8 @@ def scroll_relative( force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). + immediate: If `False` the scroll will be deferred until after a screen refresh, + set to `True` to scroll immediately. """ self.scroll_to( None if x is None else (self.scroll_x + x), @@ -2415,6 +2466,7 @@ def scroll_relative( force=force, on_complete=on_complete, level=level, + immediate=immediate, ) def scroll_home( @@ -2427,6 +2479,9 @@ def scroll_home( force: bool = False, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", + immediate: bool = False, + x_axis: bool = True, + y_axis: bool = True, ) -> None: """Scroll to home position. @@ -2438,12 +2493,16 @@ def scroll_home( force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). + immediate: If `False` the scroll will be deferred until after a screen refresh, + set to `True` to scroll immediately. + x_axis: Allow scrolling on X axis? + y_axis: Allow scrolling on Y axis? """ if speed is None and duration is None: duration = 1.0 self.scroll_to( - 0, - 0, + 0 if x_axis else None, + 0 if y_axis else None, animate=animate, speed=speed, duration=duration, @@ -2451,6 +2510,7 @@ def scroll_home( force=force, on_complete=on_complete, level=level, + immediate=immediate, ) def scroll_end( @@ -2463,6 +2523,9 @@ def scroll_end( force: bool = False, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", + immediate: bool = False, + x_axis: bool = True, + y_axis: bool = True, ) -> None: """Scroll to the end of the container. @@ -2474,6 +2537,11 @@ def scroll_end( force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). + immediate: If `False` the scroll will be deferred until after a screen refresh, + set to `True` to scroll immediately. + x_axis: Allow scrolling on X axis? + y_axis: Allow scrolling on Y axis? + """ if speed is None and duration is None: duration = 1.0 @@ -2487,8 +2555,8 @@ def scroll_end( def _lazily_scroll_end() -> None: """Scroll to the end of the widget.""" self._scroll_to( - 0, - self.max_scroll_y, + 0 if x_axis else None, + self.max_scroll_y if y_axis else None, animate=animate, speed=speed, duration=duration, @@ -2498,7 +2566,10 @@ def _lazily_scroll_end() -> None: level=level, ) - self.call_after_refresh(_lazily_scroll_end) + if immediate: + _lazily_scroll_end() + else: + self.call_after_refresh(_lazily_scroll_end) def scroll_left( self, @@ -2510,6 +2581,7 @@ def scroll_left( force: bool = False, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", + immediate: bool = False, ) -> None: """Scroll one cell left. @@ -2521,6 +2593,8 @@ def scroll_left( force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). + immediate: If `False` the scroll will be deferred until after a screen refresh, + set to `True` to scroll immediately. """ self.scroll_to( x=self.scroll_target_x - 1, @@ -2531,6 +2605,7 @@ def scroll_left( force=force, on_complete=on_complete, level=level, + immediate=immediate, ) def _scroll_left_for_pointer( @@ -2583,6 +2658,7 @@ def scroll_right( force: bool = False, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", + immediate: bool = False, ) -> None: """Scroll one cell right. @@ -2594,6 +2670,8 @@ def scroll_right( force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). + immediate: If `False` the scroll will be deferred until after a screen refresh, + set to `True` to scroll immediately. """ self.scroll_to( x=self.scroll_target_x + 1, @@ -2604,6 +2682,7 @@ def scroll_right( force=force, on_complete=on_complete, level=level, + immediate=immediate, ) def _scroll_right_for_pointer( @@ -2656,6 +2735,7 @@ def scroll_down( force: bool = False, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", + immediate: bool = False, ) -> None: """Scroll one line down. @@ -2667,6 +2747,8 @@ def scroll_down( force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). + immediate: If `False` the scroll will be deferred until after a screen refresh, + set to `True` to scroll immediately. """ self.scroll_to( y=self.scroll_target_y + 1, @@ -2677,6 +2759,7 @@ def scroll_down( force=force, on_complete=on_complete, level=level, + immediate=immediate, ) def _scroll_down_for_pointer( @@ -2729,6 +2812,7 @@ def scroll_up( force: bool = False, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", + immediate: bool = False, ) -> None: """Scroll one line up. @@ -2740,6 +2824,8 @@ def scroll_up( force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). + immediate: If `False` the scroll will be deferred until after a screen refresh, + set to `True` to scroll immediately. """ self.scroll_to( y=self.scroll_target_y - 1, @@ -2942,6 +3028,7 @@ def scroll_to_widget( force: bool = False, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", + immediate: bool = False, ) -> bool: """Scroll scrolling to bring a widget in to view. @@ -2956,6 +3043,8 @@ def scroll_to_widget( force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). + immediate: If `False` the scroll will be deferred until after a screen refresh, + set to `True` to scroll immediately. Returns: `True` if any scrolling has occurred in any descendant, otherwise `False`. @@ -2982,6 +3071,7 @@ def scroll_to_widget( force=force, on_complete=on_complete, level=level, + immediate=immediate, ) if scroll_offset: scrolled = True @@ -3021,6 +3111,7 @@ def scroll_to_region( level: AnimationLevel = "basic", x_axis: bool = True, y_axis: bool = True, + immediate: bool = False, ) -> Offset: """Scrolls a given region in to view, if required. @@ -3041,6 +3132,8 @@ def scroll_to_region( level: Minimum level required for the animation to take place (inclusive). x_axis: Allow scrolling on X axis? y_axis: Allow scrolling on Y axis? + immediate: If `False` the scroll will be deferred until after a screen refresh, + set to `True` to scroll immediately. Returns: The distance that was scrolled. @@ -3101,6 +3194,7 @@ def clamp_delta(delta: Offset) -> Offset: force=force, on_complete=on_complete, level=level, + immediate=immediate, ) return delta @@ -3115,6 +3209,7 @@ def scroll_visible( force: bool = False, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", + immediate: bool = False, ) -> None: """Scroll the container to make this widget visible. @@ -3127,21 +3222,40 @@ def scroll_visible( force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). + immediate: If `False` the scroll will be deferred until after a screen refresh, + set to `True` to scroll immediately. """ parent = self.parent if isinstance(parent, Widget): - self.call_after_refresh( - self.screen.scroll_to_widget, - self, - animate=animate, - speed=speed, - duration=duration, - top=top, - easing=easing, - force=force, - on_complete=on_complete, - level=level, - ) + if self.region: + self.screen.scroll_to_widget( + self, + animate=animate, + speed=speed, + duration=duration, + top=top, + easing=easing, + force=force, + on_complete=on_complete, + level=level, + immediate=immediate, + ) + else: + # self.region is falsey which may indicate the widget hasn't been through a layout operation + # We can potentially make it do the right thing by postponing the scroll to after a refresh + self.call_after_refresh( + self.screen.scroll_to_widget, + self, + animate=animate, + speed=speed, + duration=duration, + top=top, + easing=easing, + force=force, + on_complete=on_complete, + level=level, + immediate=immediate, + ) def scroll_to_center( self, @@ -3155,6 +3269,7 @@ def scroll_to_center( origin_visible: bool = True, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", + immediate: bool = False, ) -> None: """Scroll this widget to the center of self. @@ -3170,9 +3285,11 @@ def scroll_to_center( origin_visible: Ensure that the top left corner of the widget remains visible after the scroll. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). + immediate: If `False` the scroll will be deferred until after a screen refresh, + set to `True` to scroll immediately. """ - self.call_after_refresh( - self.scroll_to_widget, + + self.scroll_to_widget( widget=widget, animate=animate, speed=speed, @@ -3183,10 +3300,11 @@ def scroll_to_center( origin_visible=origin_visible, on_complete=on_complete, level=level, + immediate=immediate, ) - def can_view(self, widget: Widget) -> bool: - """Check if a given widget is in the current view (scrollable area). + def can_view_entire(self, widget: Widget) -> bool: + """Check if a given widget is *fully* within the current view (scrollable area). Note: This doesn't necessarily equate to a widget being visible. There are other reasons why a widget may not be visible. @@ -3195,11 +3313,14 @@ def can_view(self, widget: Widget) -> bool: widget: A widget that is a descendant of self. Returns: - True if the entire widget is in view, False if it is partially visible or not in view. + `True` if the entire widget is in view, `False` if it is partially visible or not in view. """ if widget is self: return True + if widget not in self.screen._compositor.visible_widgets: + return False + region = widget.region node: Widget = widget @@ -3209,6 +3330,30 @@ def can_view(self, widget: Widget) -> bool: node = node.parent return True + def can_view_partial(self, widget: Widget) -> bool: + """Check if a given widget at least partially visible within the current view (scrollable area). + + Args: + widget: A widget that is a descendant of self. + + Returns: + `True` if any part of the widget is visible, `False` if it is outside of the viewable area. + """ + if widget is self: + return True + + if widget not in self.screen._compositor.visible_widgets or not widget.display: + return False + + region = widget.region + node: Widget = widget + + while isinstance(node.parent, Widget) and node is not self: + if not region.overlaps(node.parent.scrollable_content_region): + return False + node = node.parent + return True + def __init_subclass__( cls, can_focus: bool | None = None, @@ -3734,7 +3879,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 @@ -4101,13 +4246,13 @@ def action_scroll_home(self) -> None: if not self._allow_scroll: raise SkipAction() self._clear_anchor() - self.scroll_home() + self.scroll_home(x_axis=self.scroll_y == 0) def action_scroll_end(self) -> None: if not self._allow_scroll: raise SkipAction() self._clear_anchor() - self.scroll_end() + self.scroll_end(x_axis=self.scroll_y == self.is_vertical_scroll_end) def action_scroll_left(self) -> None: if not self.allow_horizontal_scroll: @@ -4193,3 +4338,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..55b181e528 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_after_refresh(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/_list_view.py b/src/textual/widgets/_list_view.py index a60ddf8468..b92f1bf773 100644 --- a/src/textual/widgets/_list_view.py +++ b/src/textual/widgets/_list_view.py @@ -4,7 +4,7 @@ from typing_extensions import TypeGuard -from textual import _widget_navigation +from textual._loop import loop_from_index from textual.await_remove import AwaitRemove from textual.binding import Binding, BindingType from textual.containers import VerticalScroll @@ -38,7 +38,7 @@ class ListView(VerticalScroll, can_focus=True, can_focus_children=False): | down | Move the cursor down. | """ - index = reactive[Optional[int]](0, always_update=True, init=False) + index = reactive[Optional[int]](None, init=False) """The index of the currently highlighted item.""" class Highlighted(Message): @@ -117,17 +117,20 @@ def __init__( super().__init__( *children, name=name, id=id, classes=classes, disabled=disabled ) - # Set the index to the given initial index, or the first available index after. - self._index = _widget_navigation.find_next_enabled( - children, - anchor=initial_index if initial_index is not None else None, - direction=1, - with_anchor=True, - ) + self._initial_index = initial_index def _on_mount(self, _: Mount) -> None: """Ensure the ListView is fully-settled after mounting.""" - self.index = self._index + + if self._initial_index is not None and self.children: + index = self._initial_index + if index >= len(self.children): + index = 0 + if self._nodes[index].disabled: + for index, node in loop_from_index(self._nodes, index, wrap=True): + if not node.disabled: + break + self.index = index @property def highlighted_child(self) -> ListItem | None: @@ -165,16 +168,30 @@ def _is_valid_index(self, index: int | None) -> TypeGuard[int]: def watch_index(self, old_index: int | None, new_index: int | None) -> None: """Updates the highlighting when the index changes.""" + + if new_index is not None: + selected_widget = self._nodes[new_index] + if selected_widget.region: + self.scroll_to_widget(self._nodes[new_index], animate=False) + else: + # Call after refresh to permit a refresh operation + self.call_after_refresh( + self.scroll_to_widget, selected_widget, animate=False + ) + if self._is_valid_index(old_index): old_child = self._nodes[old_index] assert isinstance(old_child, ListItem) old_child.highlighted = False - if self._is_valid_index(new_index) and not self._nodes[new_index].disabled: + if ( + new_index is not None + and self._is_valid_index(new_index) + and not self._nodes[new_index].disabled + ): new_child = self._nodes[new_index] assert isinstance(new_child, ListItem) new_child.highlighted = True - self._scroll_highlighted_region() self.post_message(self.Highlighted(self, new_child)) else: self.post_message(self.Highlighted(self, None)) @@ -190,8 +207,6 @@ def extend(self, items: Iterable[ListItem]) -> AwaitMount: until the DOM has been updated with the new child items. """ await_mount = self.mount(*items) - if len(self) == 1: - self.index = 0 return await_mount def append(self, item: ListItem) -> AwaitMount: @@ -271,27 +286,28 @@ def action_select_cursor(self) -> None: def action_cursor_down(self) -> None: """Highlight the next item in the list.""" - candidate = _widget_navigation.find_next_enabled( - self._nodes, - anchor=self.index, - direction=1, - ) - if self.index is not None and candidate is not None and candidate < self.index: - return # Avoid wrapping around. - - self.index = candidate + if self.index is None: + if self._nodes: + self.index = 0 + else: + index = self.index + for index, item in loop_from_index(self._nodes, self.index, wrap=False): + if not item.disabled: + self.index = index + break def action_cursor_up(self) -> None: """Highlight the previous item in the list.""" - candidate = _widget_navigation.find_next_enabled( - self._nodes, - anchor=self.index, - direction=-1, - ) - if self.index is not None and candidate is not None and candidate > self.index: - return # Avoid wrapping around. - - self.index = candidate + if self.index is None: + if self._nodes: + self.index = len(self._nodes) - 1 + else: + for index, item in loop_from_index( + self._nodes, self.index, direction=-1, wrap=False + ): + if not item.disabled: + self.index = index + break def _on_list_item__child_clicked(self, event: ListItem._ChildClicked) -> None: event.stop() @@ -299,13 +315,6 @@ def _on_list_item__child_clicked(self, event: ListItem._ChildClicked) -> None: self.index = self._nodes.index(event.item) self.post_message(self.Selected(self, event.item)) - def _scroll_highlighted_region(self) -> None: - """Used to keep the highlighted index within vision""" - if self.highlighted_child is not None: - self.call_after_refresh( - self.scroll_to_widget, self.highlighted_child, animate=False - ) - def __len__(self) -> int: """Compute the length (in number of items) of the list view.""" return len(self._nodes) diff --git a/src/textual/widgets/_log.py b/src/textual/widgets/_log.py index ee50fd87ed..0441a2c74e 100644 --- a/src/textual/widgets/_log.py +++ b/src/textual/widgets/_log.py @@ -163,7 +163,7 @@ def write( Returns: The `Log` instance. """ - + is_vertical_scroll_end = self.is_vertical_scroll_end if data: if not self._lines: self._lines.append("") @@ -181,8 +181,12 @@ def write( self._prune_max_lines() auto_scroll = self.auto_scroll if scroll_end is None else scroll_end - if auto_scroll and not self.is_vertical_scrollbar_grabbed: - self.scroll_end(animate=False) + if ( + auto_scroll + and not self.is_vertical_scrollbar_grabbed + and is_vertical_scroll_end + ): + self.scroll_end(animate=False, immediate=True, x_axis=False) return self def write_line(self, line: str) -> Self: @@ -211,6 +215,7 @@ def write_lines( Returns: The `Log` instance. """ + is_vertical_scroll_end = self.is_vertical_scroll_end auto_scroll = self.auto_scroll if scroll_end is None else scroll_end new_lines = [] for line in lines: @@ -222,8 +227,12 @@ def write_lines( self.virtual_size = Size(self._width, len(self._lines)) self._update_size(self._updates, new_lines) self.refresh_lines(start_line, len(new_lines)) - if auto_scroll and not self.is_vertical_scrollbar_grabbed: - self.scroll_end(animate=False) + if ( + auto_scroll + and not self.is_vertical_scrollbar_grabbed + and is_vertical_scroll_end + ): + self.scroll_end(animate=False, immediate=True, x_axis=False) else: self.refresh() return self 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/src/textual/widgets/_rich_log.py b/src/textual/widgets/_rich_log.py index d7aa4bd3e4..f0585c9dfe 100644 --- a/src/textual/widgets/_rich_log.py +++ b/src/textual/widgets/_rich_log.py @@ -169,6 +169,7 @@ def write( expand: bool = False, shrink: bool = True, scroll_end: bool | None = None, + animate: bool = False, ) -> Self: """Write a string or a Rich renderable to the bottom of the log. @@ -186,6 +187,7 @@ def write( shrink: Permit shrinking of content to fit within the content region of the RichLog. If `width` is specified, then `shrink` will be ignored. scroll_end: Enable automatic scroll to end, or `None` to use `self.auto_scroll`. + animate: Enable animation if the log will scroll. Returns: The `RichLog` instance. @@ -200,6 +202,7 @@ def write( ) return self + is_vertical_scroll_end = self.is_vertical_scroll_end renderable = self._make_renderable(content) auto_scroll = self.auto_scroll if scroll_end is None else scroll_end @@ -266,8 +269,12 @@ def write( # the new line(s), and the height will definitely have changed. self.virtual_size = Size(self._widest_line_width, len(self.lines)) - if auto_scroll: - self.scroll_end(animate=False) + if ( + auto_scroll + and not self.is_vertical_scrollbar_grabbed + and is_vertical_scroll_end + ): + self.scroll_end(animate=animate, immediate=False, x_axis=False) return self diff --git a/src/textual/widgets/_sparkline.py b/src/textual/widgets/_sparkline.py index 13e302a361..5eb284b591 100644 --- a/src/textual/widgets/_sparkline.py +++ b/src/textual/widgets/_sparkline.py @@ -3,6 +3,7 @@ from typing import Callable, ClassVar, Optional, Sequence from textual.app import RenderResult +from textual.color import Color from textual.reactive import reactive from textual.renderables.sparkline import Sparkline as SparklineRenderable from textual.widget import Widget @@ -56,6 +57,8 @@ def __init__( self, data: Sequence[float] | None = None, *, + min_color: Color | str | None = None, + max_color: Color | str | None = None, summary_function: Callable[[Sequence[float]], float] | None = None, name: str | None = None, id: str | None = None, @@ -66,6 +69,8 @@ def __init__( Args: data: The initial data to populate the sparkline with. + min_color: The color of the minimum value, or `None` to take from CSS. + max_color: the color of the maximum value, or `None` to take from CSS. summary_function: Summarizes bar values into a single value used to represent each bar. name: The name of the widget. @@ -74,6 +79,8 @@ def __init__( disabled: Whether the widget is disabled or not. """ super().__init__(name=name, id=id, classes=classes, disabled=disabled) + self.min_color = None if min_color is None else Color.parse(min_color) + self.max_color = None if max_color is None else Color.parse(max_color) self.data = data if summary_function is not None: self.summary_function = summary_function @@ -83,14 +90,20 @@ def render(self) -> RenderResult: if not self.data: return "" _, base = self.background_colors + min_color = base + ( + self.get_component_styles("sparkline--min-color").color + if self.min_color is None + else self.min_color + ) + max_color = base + ( + self.get_component_styles("sparkline--max-color").color + if self.max_color is None + else self.max_color + ) return SparklineRenderable( self.data, width=self.size.width, - min_color=( - base + self.get_component_styles("sparkline--min-color").color - ).rich_color, - max_color=( - base + self.get_component_styles("sparkline--max-color").color - ).rich_color, + min_color=min_color.rich_color, + max_color=max_color.rich_color, summary_function=self.summary_function, ) diff --git a/tests/listview/test_listview_initial_index.py b/tests/listview/test_listview_initial_index.py index 515de4f044..ab2eed573e 100644 --- a/tests/listview/test_listview_initial_index.py +++ b/tests/listview/test_listview_initial_index.py @@ -39,4 +39,4 @@ def compose(self) -> ComposeResult: app = ListViewDisabledItemsApp() async with app.run_test() as pilot: list_view = pilot.app.query_one(ListView) - assert list_view._index == expected_index + assert list_view.index == expected_index 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_demo.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_demo.svg deleted file mode 100644 index 44fe56e48f..0000000000 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_demo.svg +++ /dev/null @@ -1,189 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Textual Demo - - - - - - - - - - โญ˜Textual Demo - - -TOP - -โ–†โ–† - -Widgets -โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– -โ–Žโ–Š -โ–Žโ–Š -Rich contentโ–ŽTextual Demoโ–Š -โ–Žโ–Š -โ–ŽWelcome! Textual is a framework for creating sophisticatedโ–Š -โ–Žapplications with the terminal.                           โ–Š -CSSโ–Žโ–Š -โ–Žโ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–Š -โ–Ž Start โ–Š -โ–Žโ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–Š -โ–Žโ–Š -โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” - - - - - - - - - ^b Sidebar  ^t Toggle Dark mode  ^s Screenshot  f1 Notes  ^q Quit โ–^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..563cd0aba9 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,9 @@ class ScrollOffByOne(App): + AUTO_FOCUS = None + 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/listview_index.py b/tests/snapshot_tests/snapshot_apps/listview_index.py index 1aa88aec3b..9027e2a3e0 100644 --- a/tests/snapshot_tests/snapshot_apps/listview_index.py +++ b/tests/snapshot_tests/snapshot_apps/listview_index.py @@ -22,7 +22,9 @@ def compose(self) -> ComposeResult: async def watch_data(self, data: "list[int]") -> None: await self._menu.remove_children() await self._menu.extend((ListItem(Label(str(value))) for value in data)) - self._menu.index = len(self._menu) - 1 + + new_index = len(self._menu) - 1 + self._menu.index = new_index async def on_ready(self): self.data = list(range(0, 30, 2)) diff --git a/tests/snapshot_tests/snapshot_apps/scroll_to.py b/tests/snapshot_tests/snapshot_apps/scroll_to.py index 9dd21a3fc4..bded6e5ff4 100644 --- a/tests/snapshot_tests/snapshot_apps/scroll_to.py +++ b/tests/snapshot_tests/snapshot_apps/scroll_to.py @@ -5,6 +5,9 @@ class ScrollOffByOne(App): """Scroll to item 50.""" + AUTO_FOCUS = None + 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/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 31d45ad122..0fa1678090 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -599,14 +599,6 @@ def test_key_display(snap_compare): assert snap_compare(SNAPSHOT_APPS_DIR / "key_display.py") -def test_demo(snap_compare): - """Test the demo app (python -m textual)""" - assert snap_compare( - Path("../../src/textual/demo.py"), - terminal_size=(100, 30), - ) - - def test_label_widths(snap_compare): """Test renderable widths are calculate correctly.""" assert snap_compare(SNAPSHOT_APPS_DIR / "label_widths.py") @@ -937,7 +929,6 @@ def test_dock_scroll_off_by_one(snap_compare): assert snap_compare( SNAPSHOT_APPS_DIR / "dock_scroll_off_by_one.py", terminal_size=(80, 25), - press=["_"], ) @@ -962,9 +953,7 @@ def compose(self) -> ComposeResult: def test_scroll_to(snap_compare): # https://github.com/Textualize/textual/issues/2525 - assert snap_compare( - SNAPSHOT_APPS_DIR / "scroll_to.py", terminal_size=(80, 25), press=["_"] - ) + assert snap_compare(SNAPSHOT_APPS_DIR / "scroll_to.py", terminal_size=(80, 25)) def test_auto_fr(snap_compare): 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 diff --git a/tests/test_loop.py b/tests/test_loop.py index 87ef97da76..65b0aa0097 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -1,4 +1,4 @@ -from textual._loop import loop_first, loop_first_last, loop_last +from textual._loop import loop_first, loop_first_last, loop_from_index, loop_last def test_loop_first(): @@ -26,3 +26,40 @@ def test_loop_first_last(): assert next(iterable) == (False, False, "oranges") assert next(iterable) == (False, False, "pears") assert next(iterable) == (False, True, "lemons") + + +def test_loop_from_index(): + assert list(loop_from_index("abcdefghij", 3)) == [ + (4, "e"), + (5, "f"), + (6, "g"), + (7, "h"), + (8, "i"), + (9, "j"), + (0, "a"), + (1, "b"), + (2, "c"), + (3, "d"), + ] + + assert list(loop_from_index("abcdefghij", 3, direction=-1)) == [ + (2, "c"), + (1, "b"), + (0, "a"), + (9, "j"), + (8, "i"), + (7, "h"), + (6, "g"), + (5, "f"), + (4, "e"), + (3, "d"), + ] + + assert list(loop_from_index("abcdefghij", 3, wrap=False)) == [ + (4, "e"), + (5, "f"), + (6, "g"), + (7, "h"), + (8, "i"), + (9, "j"), + ] diff --git a/tests/test_markdown.py b/tests/test_markdown.py index ed99b2feef..01ee498a5c 100644 --- a/tests/test_markdown.py +++ b/tests/test_markdown.py @@ -184,7 +184,7 @@ class MarkdownTableApp(App): messages = [] def compose(self) -> ComposeResult: - yield Markdown(markdown_table) + yield Markdown(markdown_table, open_links=False) @on(Markdown.LinkClicked) def log_markdown_link_clicked( @@ -205,7 +205,7 @@ async def test_markdown_quoting(): class MyApp(App): def compose(self) -> ComposeResult: - self.md = Markdown(markdown="[tรฉtรฉ](tรฉtรฉ)") + self.md = Markdown(markdown="[tรฉtรฉ](tรฉtรฉ)", open_links=False) yield self.md def on_markdown_link_clicked(self, message: Markdown.LinkClicked): diff --git a/tests/test_markdownviewer.py b/tests/test_markdownviewer.py index 8d94d4b946..a1646f5a2e 100644 --- a/tests/test_markdownviewer.py +++ b/tests/test_markdownviewer.py @@ -29,7 +29,7 @@ def __init__(self, markdown_file: Path) -> None: markdown_file.write_text(TEST_MARKDOWN.replace("{{file}}", markdown_file.name)) def compose(self) -> ComposeResult: - yield MarkdownViewer() + yield MarkdownViewer(open_links=False) async def on_mount(self) -> None: self.query_one(MarkdownViewer).show_table_of_contents = False @@ -52,7 +52,7 @@ def __init__(self, markdown_string: str) -> None: super().__init__() def compose(self) -> ComposeResult: - yield MarkdownViewer(self.markdown_string) + yield MarkdownViewer(self.markdown_string, open_links=False) async def on_mount(self) -> None: self.query_one(MarkdownViewer).show_table_of_contents = False diff --git a/tests/test_widget_navigation.py b/tests/test_widget_navigation.py index a322f3846f..44f8b17159 100644 --- a/tests/test_widget_navigation.py +++ b/tests/test_widget_navigation.py @@ -142,16 +142,10 @@ def test_find_next_enabled_no_wrap(candidates, anchor, direction, result): @pytest.mark.parametrize( ["function", "start", "direction"], [ - (find_next_enabled, 0, 1), - (find_next_enabled, 0, -1), (find_next_enabled_no_wrap, 0, 1), (find_next_enabled_no_wrap, 0, -1), - (find_next_enabled, 1, 1), - (find_next_enabled, 1, -1), (find_next_enabled_no_wrap, 1, 1), (find_next_enabled_no_wrap, 1, -1), - (find_next_enabled, 2, 1), - (find_next_enabled, 2, -1), (find_next_enabled_no_wrap, 2, 1), (find_next_enabled_no_wrap, 2, -1), ],