diff --git a/CHANGELOG.md b/CHANGELOG.md index 186054a1f..088f7ad6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,31 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## Unreleased + +## [0.12.0] - Unreleased + +### Added + +- Added `App.batch_update` https://github.com/Textualize/textual/pull/1832 +- Added horizontal rule to Markdown https://github.com/Textualize/textual/pull/1832 +- Added `Widget.disabled` https://github.com/Textualize/textual/pull/1785 ### Changed +- Scrolling by page now adds to current position. +- Markdown lists have been polished: a selection of bullets, better alignment of numbers, style tweaks https://github.com/Textualize/textual/pull/1832 - Added alternative method of composing Widgets https://github.com/Textualize/textual/pull/1847 +### Removed + +- Removed `screen.visible_widgets` and `screen.widgets` + + +### Fixed + +- Numbers in a descendant-combined selector no longer cause an error https://github.com/Textualize/textual/issues/1836 + + ## [0.11.1] - 2023-02-17 ### Fixed diff --git a/docs/examples/styles/height_comparison.css b/docs/examples/styles/height_comparison.css index 10902dda5..d5da04f78 100644 --- a/docs/examples/styles/height_comparison.css +++ b/docs/examples/styles/height_comparison.css @@ -28,12 +28,12 @@ Screen { layers: ruler; + overflow: hidden; } Ruler { layer: ruler; dock: right; - overflow: hidden; width: 1; background: $accent; } diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md index 80753ddbd..985c4b41f 100644 --- a/docs/guide/CSS.md +++ b/docs/guide/CSS.md @@ -315,6 +315,8 @@ The `background: green` is only applied to the Button underneath the mouse curso Here are some other pseudo classes: +- `:disabled` Matches widgets which are in a disabled state. +- `:enabled` Matches widgets which are in an enabled state. - `:focus` Matches widgets which have input focus. - `:focus-within` Matches widgets with a focused a child widget. diff --git a/examples/dictionary.css b/examples/dictionary.css index 6bca8b9f5..151fa019d 100644 --- a/examples/dictionary.css +++ b/examples/dictionary.css @@ -8,9 +8,9 @@ Input { } #results { - width: auto; - min-height: 100%; - padding: 0 1; + width: 100%; + height: auto; + } #results-container { diff --git a/examples/dictionary.py b/examples/dictionary.py index 97902e4b3..376790545 100644 --- a/examples/dictionary.py +++ b/examples/dictionary.py @@ -7,11 +7,10 @@ try: except ImportError: raise ImportError("Please install httpx with 'pip install httpx' ") -from rich.markdown import Markdown from textual.app import App, ComposeResult from textual.containers import Content -from textual.widgets import Input, Static +from textual.widgets import Input, Markdown class DictionaryApp(App): @@ -36,17 +35,22 @@ class DictionaryApp(App): asyncio.create_task(self.lookup_word(message.value)) else: # Clear the results - self.query_one("#results", Static).update() + await self.query_one("#results", Markdown).update("") async def lookup_word(self, word: str) -> None: """Looks up a word.""" url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}" async with httpx.AsyncClient() as client: - results = (await client.get(url)).json() + response = await client.get(url) + try: + results = response.json() + except Exception: + self.query_one("#results", Static).update(response.text) + return if word == self.query_one(Input).value: markdown = self.make_word_markdown(results) - self.query_one("#results", Static).update(Markdown(markdown)) + await self.query_one("#results", Markdown).update(markdown) def make_word_markdown(self, results: object) -> str: """Convert the results in to markdown.""" diff --git a/examples/example.md b/examples/example.md index 83495ba2f..e79234025 100644 --- a/examples/example.md +++ b/examples/example.md @@ -42,6 +42,32 @@ Two tildes indicates strikethrough, e.g. `~~cross out~~` render ~~cross out~~. Inline code is indicated by backticks. e.g. `import this`. +## Lists + +1. Lists can be ordered +2. Lists can be unordered + - 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. + +### Longer list + +1. **Duke Leto I Atreides**, head of House Atreides +2. **Lady Jessica**, Bene Gesserit and concubine of Leto, and mother of Paul and Alia +3. **Paul Atreides**, son of Leto and Jessica +4. **Alia Atreides**, daughter of Leto and Jessica +5. **Gurney Halleck**, troubadour warrior of House Atreides +6. **Thufir Hawat**, Mentat and Master of Assassins of House Atreides +7. **Duncan Idaho**, swordmaster of House Atreides +8. **Dr. Wellington Yueh**, Suk doctor of House Atreides +9. **Leto**, first son of Paul and Chani who dies as a toddler +10. **Esmar Tuek**, a smuggler on Arrakis +11. **Staban Tuek**, son of Esmar + ## Fences Fenced code blocks are introduced with three back-ticks and the optional parser. Here we are rendering the code in a sub-widget with syntax highlighting and indent guides. diff --git a/examples/five_by_five.py b/examples/five_by_five.py index f5e787dc2..96ce95ac3 100644 --- a/examples/five_by_five.py +++ b/examples/five_by_five.py @@ -1,7 +1,7 @@ -from __future__ import annotations - """Simple version of 5x5, developed for/with Textual.""" +from __future__ import annotations + from pathlib import Path from typing import TYPE_CHECKING, cast @@ -192,8 +192,7 @@ class Game(Screen): Args: playable (bool): Should the game currently be playable? """ - for cell in self.query(GameCell): - cell.disabled = not playable + self.query_one(GameGrid).disabled = not playable def cell(self, row: int, col: int) -> GameCell: """Get the cell at a given location. diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index d14f0ddc4..f1035688e 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -128,4 +128,4 @@ def arrange( placements.extend(layout_placements) - return placements, arrange_widgets, scroll_spacing + return DockArrangeResult(placements, arrange_widgets, scroll_spacing) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 5489c8687..245eb3f34 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -167,6 +167,7 @@ class Compositor: def __init__(self) -> None: # A mapping of Widget on to its "render location" (absolute position / depth) self.map: CompositorMap = {} + self._full_map: CompositorMap | None = None self._layers: list[tuple[Widget, MapGeometry]] | None = None # All widgets considered in the arrangement @@ -241,29 +242,27 @@ class Compositor: size: Size of the area to be filled. Returns: - Hidden shown and resized widgets. + Hidden, shown, and resized widgets. """ self._cuts = None self._layers = None self._layers_visible = None self._visible_widgets = None + self._full_map = None self.root = parent self.size = size # Keep a copy of the old map because we're going to compare it with the update - old_map = self.map.copy() + old_map = self.map old_widgets = old_map.keys() map, widgets = self._arrange_root(parent, size) - new_widgets = map.keys() - # Newly visible widgets - shown_widgets = new_widgets - old_widgets - # Newly hidden widgets - hidden_widgets = old_widgets - new_widgets + new_widgets = map.keys() # Replace map and widgets self.map = map + self._full_map = map self.widgets = widgets # Contains widgets + geometry for every widget that changed (added, removed, or updated) @@ -272,13 +271,7 @@ class Compositor: # Widgets in both new and old common_widgets = old_widgets & new_widgets - # Widgets with changed size - resized_widgets = { - widget - for widget, (region, *_) in changes - if (widget in common_widgets and old_map[widget].region[2:] != region[2:]) - } - + # Mark dirty regions. screen_region = size.region if screen_region not in self._dirty_regions: regions = { @@ -291,12 +284,80 @@ class Compositor: } self._dirty_regions.update(regions) + resized_widgets = { + widget + for widget, (region, *_) in changes + if (widget in common_widgets and old_map[widget].region[2:] != region[2:]) + } + # Newly visible widgets + shown_widgets = new_widgets - old_widgets + # Newly hidden widgets + hidden_widgets = self.widgets - widgets return ReflowResult( hidden=hidden_widgets, shown=shown_widgets, resized=resized_widgets, ) + def reflow_visible(self, parent: Widget, size: Size) -> set[Widget]: + """Reflow only the visible children. + + This is a fast-path for scrolling. + + Args: + parent: The root widget. + size: Size of the area to be filled. + + Returns: + Set of widgets that were exposed by the scroll. + + """ + self._cuts = None + self._layers = None + self._layers_visible = None + self._visible_widgets = None + self._full_map = None + self.root = parent + self.size = size + + # Keep a copy of the old map because we're going to compare it with the update + old_map = self.map + map, widgets = self._arrange_root(parent, size, visible_only=True) + + exposed_widgets = map.keys() - old_map.keys() + # Replace map and widgets + self.map = map + self.widgets = widgets + + # Contains widgets + geometry for every widget that changed (added, removed, or updated) + changes = map.items() ^ old_map.items() + + # Mark dirty regions. + screen_region = size.region + if screen_region not in self._dirty_regions: + regions = { + region + for region in ( + map_geometry.clip.intersection(map_geometry.region) + for _, map_geometry in changes + ) + if region + } + self._dirty_regions.update(regions) + + return exposed_widgets + + @property + def full_map(self) -> CompositorMap: + """Lazily built compositor map that covers all widgets.""" + if self.root is None or not self.map: + return {} + if self._full_map is None: + map, widgets = self._arrange_root(self.root, self.size, visible_only=False) + self._full_map = map + + return self._full_map + @property def visible_widgets(self) -> dict[Widget, tuple[Region, Region]]: """Get a mapping of widgets on to region and clip. @@ -322,9 +383,9 @@ class Compositor: return self._visible_widgets def _arrange_root( - self, root: Widget, size: Size + self, root: Widget, size: Size, visible_only: bool = True ) -> tuple[CompositorMap, set[Widget]]: - """Arrange a widgets children based on its layout attribute. + """Arrange a widget's children based on its layout attribute. Args: root: Top level widget. @@ -337,6 +398,7 @@ class Compositor: map: CompositorMap = {} widgets: set[Widget] = set() + add_new_widget = widgets.add layer_order: int = 0 def add_widget( @@ -362,7 +424,7 @@ class Compositor: visible = visibility == "visible" if visible: - widgets.add(widget) + add_new_widget(widget) styles_offset = widget.styles.offset layout_offset = ( styles_offset.resolve(region.size, clip.size) @@ -389,69 +451,75 @@ class Compositor: if widget.is_container: # Arrange the layout - placements, arranged_widgets, spacing = widget._arrange( - child_region.size - ) + arrange_result = widget._arrange(child_region.size) + arranged_widgets = arrange_result.widgets + spacing = arrange_result.spacing widgets.update(arranged_widgets) - if placements: - # An offset added to all placements - placement_offset = container_region.offset - placement_scroll_offset = ( - placement_offset - widget.scroll_offset + if visible_only: + placements = arrange_result.get_visible_placements( + container_size.region + widget.scroll_offset + ) + else: + placements = arrange_result.placements + total_region = total_region.union(arrange_result.total_region) + + # An offset added to all placements + placement_offset = container_region.offset + placement_scroll_offset = placement_offset - widget.scroll_offset + + _layers = widget.layers + layers_to_index = { + layer_name: index for index, layer_name in enumerate(_layers) + } + get_layer_index = layers_to_index.get + + # Add all the widgets + for sub_region, margin, sub_widget, z, fixed in reversed( + placements + ): + # Combine regions with children to calculate the "virtual size" + if fixed: + widget_region = sub_region + placement_offset + else: + total_region = total_region.union( + sub_region.grow(spacing + margin) + ) + widget_region = sub_region + placement_scroll_offset + + widget_order = ( + *order, + get_layer_index(sub_widget.layer, 0), + z, + layer_order, ) - _layers = widget.layers - layers_to_index = { - layer_name: index - for index, layer_name in enumerate(_layers) - } - get_layer_index = layers_to_index.get + add_widget( + sub_widget, + sub_region, + widget_region, + widget_order, + layer_order, + sub_clip, + visible, + ) - # Add all the widgets - for sub_region, margin, sub_widget, z, fixed in reversed( - placements - ): - # Combine regions with children to calculate the "virtual size" - if fixed: - widget_region = sub_region + placement_offset - else: - total_region = total_region.union( - sub_region.grow(spacing + margin) - ) - widget_region = sub_region + placement_scroll_offset - - widget_order = ( - *order, - get_layer_index(sub_widget.layer, 0), - z, - layer_order, - ) - - add_widget( - sub_widget, - sub_region, - widget_region, - widget_order, - layer_order, - sub_clip, - visible, - ) - layer_order -= 1 + layer_order -= 1 if visible: # Add any scrollbars - for chrome_widget, chrome_region in widget._arrange_scrollbars( - container_region - ): - map[chrome_widget] = _MapGeometry( - chrome_region + layout_offset, - order, - clip, - container_size, - container_size, - chrome_region, - ) + if any(widget.scrollbars_enabled): + for chrome_widget, chrome_region in widget._arrange_scrollbars( + container_region + ): + map[chrome_widget] = _MapGeometry( + chrome_region + layout_offset, + order, + clip, + container_size, + container_size, + chrome_region, + ) map[widget] = _MapGeometry( region + layout_offset, @@ -519,7 +587,10 @@ class Compositor: try: return self.map[widget].region.offset except KeyError: - raise errors.NoWidget("Widget is not in layout") + try: + return self.full_map[widget].region.offset + except KeyError: + raise errors.NoWidget("Widget is not in layout") def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: """Get the widget under a given coordinate. @@ -601,10 +672,15 @@ class Compositor: Widget's composition information. """ + if self.root is None or not self.map: + raise errors.NoWidget("Widget is not in layout") try: region = self.map[widget] except KeyError: - raise errors.NoWidget("Widget is not in layout") + try: + return self.full_map[widget] + except KeyError: + raise errors.NoWidget("Widget is not in layout") else: return region @@ -788,6 +864,7 @@ class Compositor: widget: Widget to update. """ + self._full_map = None regions: list[Region] = [] add_region = regions.append get_widget = self.visible_widgets.__getitem__ diff --git a/src/textual/_layout.py b/src/textual/_layout.py index 5123d832e..f7ab16312 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -1,8 +1,10 @@ from __future__ import annotations from abc import ABC, abstractmethod +from dataclasses import dataclass from typing import TYPE_CHECKING, ClassVar, NamedTuple +from ._spatial_map import SpatialMap from .geometry import Region, Size, Spacing if TYPE_CHECKING: @@ -11,7 +13,55 @@ if TYPE_CHECKING: from .widget import Widget ArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget]]" -DockArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget], Spacing]" + + +@dataclass +class DockArrangeResult: + placements: list[WidgetPlacement] + """A `WidgetPlacement` for every widget to describe it's location on screen.""" + widgets: set[Widget] + """A set of widgets in the arrangement.""" + spacing: Spacing + """Shared spacing around the widgets.""" + + _spatial_map: SpatialMap[WidgetPlacement] | None = None + + @property + def spatial_map(self) -> SpatialMap[WidgetPlacement]: + """A lazy-calculated spatial map.""" + if self._spatial_map is None: + self._spatial_map = SpatialMap() + self._spatial_map.insert( + ( + placement.region.grow(placement.margin), + placement.fixed, + placement, + ) + for placement in self.placements + ) + + return self._spatial_map + + @property + def total_region(self) -> Region: + """The total area occupied by the arrangement. + + Returns: + A Region. + """ + return self.spatial_map.total_region + + def get_visible_placements(self, region: Region) -> list[WidgetPlacement]: + """Get the placements visible within the given region. + + Args: + region: A region. + + Returns: + Set of placements. + """ + visible_placements = self.spatial_map.get_values_in_region(region) + return visible_placements class WidgetPlacement(NamedTuple): @@ -61,7 +111,7 @@ class Layout(ABC): width = 0 else: # Use a size of 0, 0 to ignore relative sizes, since those are flexible anyway - placements, _, _ = widget._arrange(Size(0, 0)) + placements = widget._arrange(Size(0, 0)).placements width = max( [ placement.region.right + placement.margin.right @@ -89,7 +139,7 @@ class Layout(ABC): height = 0 else: # Use a height of zero to ignore relative heights - placements, _, _ = widget._arrange(Size(width, 0)) + placements = widget._arrange(Size(width, 0)).placements height = max( [ placement.region.bottom + placement.margin.bottom diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py new file mode 100644 index 000000000..93e8c4004 --- /dev/null +++ b/src/textual/_spatial_map.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from collections import defaultdict +from itertools import product +from typing import Generic, Iterable, TypeVar + +from typing_extensions import TypeAlias + +from .geometry import Region + +ValueType = TypeVar("ValueType") +GridCoordinate: TypeAlias = "tuple[int, int]" + + +class SpatialMap(Generic[ValueType]): + """A spatial map allows for data to be associated with rectangular regions + in Euclidean space, and efficiently queried. + + When the SpatialMap is populated, a reference to each value is placed into one or + more buckets associated with a regular grid that covers 2D space. + + The SpatialMap is able to quickly retrieve the values under a given "window" region + by combining the values in the grid squares under the visible area. + + """ + + def __init__(self, grid_width: int = 100, grid_height: int = 20) -> None: + """Create a spatial map with the given grid size. + + Args: + grid_width: Width of a grid square. + grid_height: Height of a grid square. + """ + self._grid_size = (grid_width, grid_height) + self.total_region = Region() + self._map: defaultdict[GridCoordinate, list[ValueType]] = defaultdict(list) + self._fixed: list[ValueType] = [] + + def _region_to_grid_coordinates(self, region: Region) -> Iterable[GridCoordinate]: + """Get the grid squares under a region. + + Args: + region: A region. + + Returns: + Iterable of grid coordinates (tuple of 2 values). + """ + # (x1, y1) is the coordinate of the top left cell + # (x2, y2) is the coordinate of the bottom right cell + x1, y1, width, height = region + x2 = x1 + width - 1 + y2 = y1 + height - 1 + grid_width, grid_height = self._grid_size + + return product( + range(x1 // grid_width, x2 // grid_width + 1), + range(y1 // grid_height, y2 // grid_height + 1), + ) + + def insert( + self, regions_and_values: Iterable[tuple[Region, bool, ValueType]] + ) -> None: + """Insert values into the Spatial map. + + Values are associated with their region in Euclidean space, and a boolean that + indicates fixed regions. Fixed regions don't scroll and are always visible. + + Args: + regions_and_values: An iterable of (REGION, FIXED, VALUE). + """ + append_fixed = self._fixed.append + get_grid_list = self._map.__getitem__ + _region_to_grid = self._region_to_grid_coordinates + total_region = self.total_region + for region, fixed, value in regions_and_values: + total_region = total_region.union(region) + if fixed: + append_fixed(value) + else: + for grid in _region_to_grid(region): + get_grid_list(grid).append(value) + self.total_region = total_region + + def get_values_in_region(self, region: Region) -> list[ValueType]: + """Get a superset of all the values that intersect with a given region. + + Note that this may return false positives. + + Args: + region: A region. + + Returns: + Values under the region. + """ + results: list[ValueType] = self._fixed.copy() + add_results = results.extend + get_grid_values = self._map.get + for grid_coordinate in self._region_to_grid_coordinates(region): + grid_values = get_grid_values(grid_coordinate) + if grid_values is not None: + add_results(grid_values) + unique_values = list(dict.fromkeys(results)) + return unique_values diff --git a/src/textual/app.py b/src/textual/app.py index 5572a10cf..318dc3223 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -11,7 +11,12 @@ import unicodedata import warnings from asyncio import Task from concurrent.futures import Future -from contextlib import asynccontextmanager, redirect_stderr, redirect_stdout +from contextlib import ( + asynccontextmanager, + contextmanager, + redirect_stderr, + redirect_stdout, +) from datetime import datetime from functools import partial from pathlib import Path, PurePath @@ -22,6 +27,7 @@ from typing import ( Any, Awaitable, Callable, + Generator, Generic, Iterable, List, @@ -242,6 +248,11 @@ class App(Generic[ReturnType], DOMNode): background: $background; color: $text; } + + *:disabled { + opacity: 0.6; + text-opacity: 0.8; + } """ SCREENS: dict[str, Screen | Callable[[], Screen]] = {} @@ -415,6 +426,7 @@ class App(Generic[ReturnType], DOMNode): self._screenshot: str | None = None self._dom_lock = asyncio.Lock() self._dom_ready = False + self._batch_count = 0 self.set_class(self.dark, "-dark-mode") @property @@ -430,6 +442,30 @@ class App(Generic[ReturnType], DOMNode): except ScreenError: return () + @contextmanager + def batch_update(self) -> Generator[None, None, None]: + """Suspend all repaints until the end of the batch.""" + self._begin_batch() + try: + yield + finally: + self._end_batch() + + def _begin_batch(self) -> None: + """Begin a batch update.""" + self._batch_count += 1 + + def _end_batch(self) -> None: + """End a batch update.""" + self._batch_count -= 1 + assert self._batch_count >= 0, "This won't happen if you use `batch_update`" + if not self._batch_count: + try: + self.screen.check_idle() + except ScreenStackError: + pass + self.check_idle() + def animate( self, attribute: str, @@ -1508,28 +1544,29 @@ class App(Generic[ReturnType], DOMNode): if inspect.isawaitable(ready_result): await ready_result - try: + with self.batch_update(): try: - await self._dispatch_message(events.Compose(sender=self)) - await self._dispatch_message(events.Mount(sender=self)) + try: + await self._dispatch_message(events.Compose(sender=self)) + await self._dispatch_message(events.Mount(sender=self)) + finally: + self._mounted_event.set() + + Reactive._initialize_object(self) + + self.stylesheet.update(self) + self.refresh() + + await self.animator.start() + + except Exception: + await self.animator.stop() + raise + finally: - self._mounted_event.set() - - Reactive._initialize_object(self) - - self.stylesheet.update(self) - self.refresh() - - await self.animator.start() - - except Exception: - await self.animator.stop() - raise - - finally: - self._running = True - await self._ready() - await invoke_ready_callback() + self._running = True + await self._ready() + await invoke_ready_callback() try: await self._process_messages_loop() @@ -1615,11 +1652,12 @@ class App(Generic[ReturnType], DOMNode): raise TypeError( f"{self!r} compose() returned an invalid response; {error}" ) from error + await self.mount_all(widgets) def _on_idle(self) -> None: """Perform actions when there are no messages in the queue.""" - if self._require_stylesheet_update: + if self._require_stylesheet_update and not self._batch_count: nodes: set[DOMNode] = { child for node in self._require_stylesheet_update @@ -1782,6 +1820,7 @@ class App(Generic[ReturnType], DOMNode): await child._close_messages() async def _shutdown(self) -> None: + self._begin_update() # Prevents any layout / repaint while shutting down driver = self._driver self._running = False if driver is not None: @@ -1799,6 +1838,7 @@ class App(Generic[ReturnType], DOMNode): self._writer_thread.stop() async def _on_exit_app(self) -> None: + self._begin_batch() # Prevent repaint / layout while shutting down await self._message_queue.put(None) def refresh(self, *, repaint: bool = True, layout: bool = False) -> None: @@ -1907,7 +1947,6 @@ class App(Generic[ReturnType], DOMNode): # 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): - self.log(event) screen = Screen(id="_default") self._register(self, screen) self._screen_stack.append(screen) diff --git a/src/textual/cli/previews/colors.py b/src/textual/cli/previews/colors.py index 5423f2e3e..b9d8da3eb 100644 --- a/src/textual/cli/previews/colors.py +++ b/src/textual/cli/previews/colors.py @@ -68,7 +68,6 @@ class ColorsApp(App): content.mount(ColorsView()) def on_button_pressed(self, event: Button.Pressed) -> None: - self.bell() self.query(ColorGroup).remove_class("-active") group = self.query_one(f"#group-{event.button.id}", ColorGroup) group.add_class("-active") diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py index 2a8677f68..b2cd3d30e 100644 --- a/src/textual/css/tokenize.py +++ b/src/textual/css/tokenize.py @@ -75,7 +75,7 @@ expect_selector_continue = Expect( selector_id=r"\#[a-zA-Z_\-][a-zA-Z0-9_\-]*", selector_class=r"\.[a-zA-Z_\-][a-zA-Z0-9_\-]*", selector_universal=r"\*", - selector=r"[a-zA-Z_\-]+", + selector=IDENTIFIER, combinator_child=">", new_selector=r",", declaration_set_start=r"\{", diff --git a/src/textual/dom.py b/src/textual/dom.py index 77fc6c76d..e68939b39 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -872,6 +872,16 @@ class DOMNode(MessagePump): else: self.remove_class(*class_names) + def _update_styles(self) -> None: + """Request an update of this node's styles. + + Should be called whenever CSS classes / pseudo classes change. + """ + try: + self.app.update_styles(self) + except NoActiveAppError: + pass + def add_class(self, *class_names: str) -> None: """Add class names to this Node. @@ -884,10 +894,7 @@ class DOMNode(MessagePump): self._classes.update(class_names) if old_classes == self._classes: return - try: - self.app.update_styles(self) - except NoActiveAppError: - pass + self._update_styles() def remove_class(self, *class_names: str) -> None: """Remove class names from this Node. @@ -900,10 +907,7 @@ class DOMNode(MessagePump): self._classes.difference_update(class_names) if old_classes == self._classes: return - try: - self.app.update_styles(self) - except NoActiveAppError: - pass + self._update_styles() def toggle_class(self, *class_names: str) -> None: """Toggle class names on this Node. @@ -916,10 +920,7 @@ class DOMNode(MessagePump): self._classes.symmetric_difference_update(class_names) if old_classes == self._classes: return - try: - self.app.update_styles(self) - except NoActiveAppError: - pass + self._update_styles() def has_pseudo_class(self, *class_names: str) -> bool: """Check for pseudo classes (such as hover, focus etc) diff --git a/src/textual/messages.py b/src/textual/messages.py index fcfe2ad2c..882fe887a 100644 --- a/src/textual/messages.py +++ b/src/textual/messages.py @@ -45,12 +45,24 @@ class Update(Message, verbose=True): @rich.repr.auto class Layout(Message, verbose=True): + """Sent by Textual when a layout is required.""" + def can_replace(self, message: Message) -> bool: return isinstance(message, Layout) +@rich.repr.auto +class UpdateScroll(Message, verbose=True): + """Sent by Textual when a scroll update is required.""" + + def can_replace(self, message: Message) -> bool: + return isinstance(message, UpdateScroll) + + @rich.repr.auto class InvokeLater(Message, verbose=True, bubble=False): + """Sent by Textual to invoke a callback.""" + def __init__(self, sender: MessagePump, callback: CallbackType) -> None: self.callback = callback super().__init__(sender) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 4c7bd1d98..0553d076c 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -143,24 +143,25 @@ class Reactive(Generic[ReactiveType]): self.name = name # The internal name where the attribute's value is stored self.internal_name = f"_reactive_{name}" + self.compute_name = f"compute_{name}" default = self._default setattr(owner, f"_default_{name}", default) def __get__(self, obj: Reactable, obj_type: type[object]) -> ReactiveType: - _rich_traceback_omit = True + internal_name = self.internal_name + if not hasattr(obj, internal_name): + self._initialize_reactive(obj, self.name) - self._initialize_reactive(obj, self.name) - - value: ReactiveType - compute_method = getattr(self, f"compute_{self.name}", None) - if compute_method is not None: - old_value = getattr(obj, self.internal_name) - value = getattr(obj, f"compute_{self.name}")() - setattr(obj, self.internal_name, value) + if hasattr(obj, self.compute_name): + value: ReactiveType + old_value = getattr(obj, internal_name) + _rich_traceback_omit = True + value = getattr(obj, self.compute_name)() + setattr(obj, internal_name, value) self._check_watchers(obj, self.name, old_value) + return value else: - value = getattr(obj, self.internal_name) - return value + return getattr(obj, internal_name) def __set__(self, obj: Reactable, value: ReactiveType) -> None: _rich_traceback_omit = True diff --git a/src/textual/screen.py b/src/textual/screen.py index d1354a106..90833444e 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -80,16 +80,6 @@ class Screen(Widget): ) return self._update_timer - @property - def widgets(self) -> list[Widget]: - """Get all widgets.""" - return list(self._compositor.map.keys()) - - @property - def visible_widgets(self) -> list[Widget]: - """Get a list of visible widgets.""" - return list(self._compositor.visible_widgets) - def render(self) -> RenderableType: background = self.styles.background if background.is_transparent: @@ -159,11 +149,7 @@ class Screen(Widget): @property def focus_chain(self) -> list[Widget]: - """Get widgets that may receive focus, in focus order. - - Returns: - List of Widgets in focus order. - """ + """A list of widgets that may receive focus, in focus order.""" widgets: list[Widget] = [] add_widget = widgets.append stack: list[Iterator[Widget]] = [iter(self.focusable_children)] @@ -177,7 +163,7 @@ class Screen(Widget): else: if node.is_container and node.can_focus_children: push(iter(node.focusable_children)) - if node.can_focus: + if node.focusable: add_widget(node) return widgets @@ -314,7 +300,7 @@ class Screen(Widget): # It may have been made invisible # Move to a sibling if possible for sibling in widget.visible_siblings: - if sibling not in avoiding and sibling.can_focus: + if sibling not in avoiding and sibling.focusable: self.set_focus(sibling) break else: @@ -351,7 +337,7 @@ class Screen(Widget): self.focused.post_message_no_wait(events.Blur(self)) self.focused = None self.log.debug("focus was removed") - elif widget.can_focus: + elif widget.focusable: if self.focused != widget: if self.focused is not None: # Blur currently focused widget @@ -368,13 +354,18 @@ class Screen(Widget): # Check for any widgets marked as 'dirty' (needs a repaint) event.prevent_default() - if self.is_current: + if not self.app._batch_count and self.is_current: async with self.app._dom_lock: if self.is_current: if self._layout_required: self._refresh_layout() self._layout_required = False + self._scroll_required = False self._dirty_widgets.clear() + elif self._scroll_required: + self._refresh_layout(scroll=True) + self._scroll_required = False + if self._repaint_required: self._dirty_widgets.clear() self._dirty_widgets.add(self) @@ -423,7 +414,9 @@ class Screen(Widget): self._callbacks.append(callback) self.check_idle() - def _refresh_layout(self, size: Size | None = None, full: bool = False) -> None: + def _refresh_layout( + self, size: Size | None = None, full: bool = False, scroll: bool = False + ) -> None: """Refresh the layout (can change size and positions of widgets).""" size = self.outer_size if size is None else size if not size: @@ -431,35 +424,64 @@ class Screen(Widget): self._compositor.update_widgets(self._dirty_widgets) self.update_timer.pause() + ResizeEvent = events.Resize try: - hidden, shown, resized = self._compositor.reflow(self, size) - Hide = events.Hide - Show = events.Show + if scroll: + exposed_widgets = self._compositor.reflow_visible(self, size) + if exposed_widgets: + layers = self._compositor.layers - for widget in hidden: - widget.post_message_no_wait(Hide(self)) + for widget, ( + region, + _order, + _clip, + virtual_size, + container_size, + _, + ) in layers: + if widget in exposed_widgets: + if widget._size_updated( + region.size, + virtual_size, + container_size, + layout=False, + ): + widget.post_message_no_wait( + ResizeEvent( + self, + region.size, + virtual_size, + container_size, + ) + ) + else: + hidden, shown, resized = self._compositor.reflow(self, size) + Hide = events.Hide + Show = events.Show - # We want to send a resize event to widgets that were just added or change since last layout - send_resize = shown | resized - ResizeEvent = events.Resize + for widget in hidden: + widget.post_message_no_wait(Hide(self)) - layers = self._compositor.layers - for widget, ( - region, - _order, - _clip, - virtual_size, - container_size, - _, - ) in layers: - widget._size_updated(region.size, virtual_size, container_size) - if widget in send_resize: - widget.post_message_no_wait( - ResizeEvent(self, region.size, virtual_size, container_size) - ) + # We want to send a resize event to widgets that were just added or change since last layout + send_resize = shown | resized - for widget in shown: - widget.post_message_no_wait(Show(self)) + layers = self._compositor.layers + for widget, ( + region, + _order, + _clip, + virtual_size, + container_size, + _, + ) in layers: + widget._size_updated(region.size, virtual_size, container_size) + if widget in send_resize: + widget.post_message_no_wait( + ResizeEvent(self, region.size, virtual_size, container_size) + ) + + for widget in shown: + widget.post_message_no_wait(Show(self)) except Exception as error: self.app._handle_exception(error) @@ -484,6 +506,12 @@ class Screen(Widget): self._layout_required = True self.check_idle() + async def _on_update_scroll(self, message: messages.UpdateScroll) -> None: + message.stop() + message.prevent_default() + self._scroll_required = True + self.check_idle() + def _screen_resized(self, size: Size): """Called by App when the screen is resized.""" self._refresh_layout(size, full=True) @@ -547,7 +575,7 @@ class Screen(Widget): except errors.NoWidget: self.set_focus(None) else: - if isinstance(event, events.MouseUp) and widget.can_focus: + if isinstance(event, events.MouseUp) and widget.focusable: if self.focused is not widget: self.set_focus(widget) event.stop() diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py index 53c6c238a..1f834e839 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -69,14 +69,18 @@ class ScrollView(Widget): return self.virtual_size.height def _size_updated( - self, size: Size, virtual_size: Size, container_size: Size - ) -> None: + self, size: Size, virtual_size: Size, container_size: Size, layout: bool = True + ) -> bool: """Called when size is updated. Args: size: New size. virtual_size: New virtual size. container_size: New container size. + layout: Perform layout if required. + + Returns: + True if anything changed, or False if nothing changed. """ if self._size != size or container_size != container_size: self.refresh() @@ -90,6 +94,9 @@ class ScrollView(Widget): self._container_size = size - self.styles.gutter.totals self._scroll_update(virtual_size) self.scroll_to(self.scroll_x, self.scroll_y, animate=False) + return True + else: + return False def render(self) -> RenderableType: """Render the scrollable region (if `render_lines` is not implemented). diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 12e1fd65d..f00dbccb2 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -112,8 +112,14 @@ class ScrollBarRender: if window_size and size and virtual_size and size != virtual_size: step_size = virtual_size / size + thumb_size = window_size / step_size * len_bars + + if thumb_size < len_bars: + virtual_size += step_size + step_size = virtual_size / size + start = int(position / step_size * len_bars) - end = start + max(len_bars, int(ceil(window_size / step_size * len_bars))) + end = start + max(len_bars, ceil(thumb_size)) start_index, start_bar = divmod(max(0, start), len_bars) end_index, end_bar = divmod(max(0, end), len_bars) @@ -246,6 +252,7 @@ class ScrollBar(Widget): yield "thickness", self.thickness def render(self) -> RenderableType: + assert self.parent is not None styles = self.parent.styles if self.grabbed: background = styles.scrollbar_background_active @@ -258,11 +265,25 @@ class ScrollBar(Widget): color = styles.scrollbar_color color = background + color scrollbar_style = Style.from_color(color.rich_color, background.rich_color) + return self._render_bar(scrollbar_style) + + def _render_bar(self, scrollbar_style: Style) -> RenderableType: + """Get a renderable for the scrollbar with given style. + + Args: + scrollbar_style: Scrollbar style. + + Returns: + Scrollbar renderable. + """ + window_size = ( + self.window_size if self.window_size < self.window_virtual_size else 0 + ) + virtual_size = self.window_virtual_size + return self.renderer( - virtual_size=self.window_virtual_size, - window_size=( - self.window_size if self.window_size < self.window_virtual_size else 0 - ), + virtual_size=ceil(virtual_size), + window_size=ceil(window_size), position=self.position, thickness=self.thickness, vertical=self.vertical, @@ -311,19 +332,31 @@ class ScrollBar(Widget): x: float | None = None y: float | None = None if self.vertical: + size = self.size.height + virtual_size = self.window_virtual_size + step_size = virtual_size / size + thumb_size = self.window_size / step_size + if thumb_size < 1: + virtual_size = ceil(virtual_size + step_size) y = round( self.grabbed_position + ( (event.screen_y - self.grabbed.y) - * (self.window_virtual_size / self.window_size) + * (virtual_size / self.window_size) ) ) else: + size = self.size.width + virtual_size = self.window_virtual_size + step_size = virtual_size / size + thumb_size = self.window_size / step_size + if thumb_size < 1: + virtual_size = ceil(virtual_size + step_size) x = round( self.grabbed_position + ( (event.screen_x - self.grabbed.x) - * (self.window_virtual_size / self.window_size) + * (virtual_size / self.window_size) ) ) await self.post_message(ScrollTo(self, x=x, y=y)) diff --git a/src/textual/widget.py b/src/textual/widget.py index 206c3d526..209989616 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -41,6 +41,7 @@ from ._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction from ._arrange import DockArrangeResult, arrange from ._asyncio import create_task from ._compose import compose +from ._cache import FIFOCache from ._context import active_app from ._easing import DEFAULT_SCROLL_EASING from ._layout import Layout @@ -228,6 +229,8 @@ class Widget(DOMNode): """Rich renderable may shrink.""" auto_links = Reactive(True) """Widget will highlight links automatically.""" + disabled = Reactive(False) + """The disabled state of the widget. `True` if disabled, `False` if not.""" hover_style: Reactive[Style] = Reactive(Style, repaint=False) highlight_link_id: Reactive[str] = Reactive("") @@ -238,11 +241,13 @@ class Widget(DOMNode): name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> None: self._size = Size(0, 0) self._container_size = Size(0, 0) self._layout_required = False self._repaint_required = False + self._scroll_required = False self._default_layout = VerticalLayout() self._animate: BoundAnimator | None = None self.highlight_style: Style | None = None @@ -262,8 +267,9 @@ class Widget(DOMNode): self._content_width_cache: tuple[object, int] = (None, 0) self._content_height_cache: tuple[object, int] = (None, 0) - self._arrangement_cache_key: tuple[Size, int] = (Size(), -1) - self._cached_arrangement: DockArrangeResult | None = None + self._arrangement_cache: FIFOCache[ + tuple[Size, int], DockArrangeResult + ] = FIFOCache(4) self._styles_cache = StylesCache() self._rich_style_cache: dict[str, tuple[Style, Style]] = {} @@ -280,6 +286,7 @@ class Widget(DOMNode): raise WidgetError("A widget can't be its own parent") self._add_children(*children) + self.disabled = disabled virtual_size = Reactive(Size(0, 0), layout=True) auto_width = Reactive(True) @@ -495,14 +502,11 @@ class Widget(DOMNode): assert self.is_container cache_key = (size, self._nodes._updates) - if ( - self._arrangement_cache_key == cache_key - and self._cached_arrangement is not None - ): - return self._cached_arrangement + cached_result = self._arrangement_cache.get(cache_key) + if cached_result is not None: + return cached_result - self._arrangement_cache_key = cache_key - arrangement = self._cached_arrangement = arrange( + arrangement = self._arrangement_cache[cache_key] = arrange( self, self._nodes, size, self.screen.size ) @@ -510,7 +514,7 @@ class Widget(DOMNode): def _clear_arrangement_cache(self) -> None: """Clear arrangement cache, forcing a new arrange operation.""" - self._cached_arrangement = None + self._arrangement_cache.clear() def _get_virtual_dom(self) -> Iterable[Widget]: """Get widgets not part of the DOM. @@ -1195,6 +1199,20 @@ class Widget(DOMNode): """ return self.virtual_region.grow(self.styles.margin) + @property + def _self_or_ancestors_disabled(self) -> bool: + """Is this widget or any of its ancestors disabled?""" + return any( + node.disabled + for node in self.ancestors_with_self + if isinstance(node, Widget) + ) + + @property + def focusable(self) -> bool: + """Can this widget currently receive focus?""" + return self.can_focus and not self._self_or_ancestors_disabled + @property def focusable_children(self) -> list[Widget]: """Get the children which may be focused. @@ -1732,7 +1750,7 @@ class Widget(DOMNode): """ return self.scroll_to( - y=self.scroll_target_y - self.container_size.height, + y=self.scroll_y - self.container_size.height, animate=animate, speed=speed, duration=duration, @@ -1764,7 +1782,7 @@ class Widget(DOMNode): """ return self.scroll_to( - y=self.scroll_target_y + self.container_size.height, + y=self.scroll_y + self.container_size.height, animate=animate, speed=speed, duration=duration, @@ -1798,7 +1816,7 @@ class Widget(DOMNode): if speed is None and duration is None: duration = 0.3 return self.scroll_to( - x=self.scroll_target_x - self.container_size.width, + x=self.scroll_x - self.container_size.width, animate=animate, speed=speed, duration=duration, @@ -1832,7 +1850,7 @@ class Widget(DOMNode): if speed is None and duration is None: duration = 0.3 return self.scroll_to( - x=self.scroll_target_x + self.container_size.width, + x=self.scroll_x + self.container_size.width, animate=animate, speed=speed, duration=duration, @@ -2102,6 +2120,14 @@ class Widget(DOMNode): Names of the pseudo classes. """ + node = self + while isinstance(node, Widget): + if node.disabled: + yield "disabled" + break + node = node._parent + else: + yield "enabled" if self.mouse_over: yield "hover" if self.has_focus: @@ -2149,21 +2175,29 @@ class Widget(DOMNode): def watch_mouse_over(self, value: bool) -> None: """Update from CSS if mouse over state changes.""" if self._has_hover_style: - self.app.update_styles(self) + self._update_styles() def watch_has_focus(self, value: bool) -> None: """Update from CSS if has focus state changes.""" - self.app.update_styles(self) + self._update_styles() + + def watch_disabled(self) -> None: + """Update the styles of the widget and its children when disabled is toggled.""" + self._update_styles() def _size_updated( - self, size: Size, virtual_size: Size, container_size: Size - ) -> None: + self, size: Size, virtual_size: Size, container_size: Size, layout: bool = True + ) -> bool: """Called when the widget's size is updated. Args: size: Screen size. virtual_size: Virtual (scrollable) size. container_size: Container size (size of parent). + layout: Perform layout if required. + + Returns: + True if anything changed, or False if nothing changed. """ if ( self._size != size @@ -2171,11 +2205,16 @@ class Widget(DOMNode): or self._container_size != container_size ): self._size = size - self.virtual_size = virtual_size + if layout: + self.virtual_size = virtual_size + else: + self._reactive_virtual_size = virtual_size self._container_size = container_size if self.is_scrollable: self._scroll_update(virtual_size) - self.refresh() + return True + else: + return False def _scroll_update(self, virtual_size: Size) -> None: """Update scrollbars visibility and dimensions. @@ -2286,7 +2325,7 @@ class Widget(DOMNode): def _refresh_scroll(self) -> None: """Refreshes the scroll position.""" - self._layout_required = True + self._scroll_required = True self.check_idle() def refresh( @@ -2313,8 +2352,7 @@ class Widget(DOMNode): repaint: Repaint the widget (will call render() again). Defaults to True. layout: Also layout widgets in the view. Defaults to False. """ - - if layout: + if layout and not self._layout_required: self._layout_required = True for ancestor in self.ancestors: if not isinstance(ancestor, Widget): @@ -2395,6 +2433,9 @@ class Widget(DOMNode): except NoScreen: pass else: + if self._scroll_required: + self._scroll_required = False + screen.post_message_no_wait(messages.UpdateScroll(self)) if self._repaint_required: self._repaint_required = False screen.post_message_no_wait(messages.Update(self, self)) @@ -2443,6 +2484,18 @@ class Widget(DOMNode): """ self.app.capture_mouse(None) + def check_message_enabled(self, message: Message) -> bool: + # Do the normal checking and get out if that fails. + if not super().check_message_enabled(message): + return False + # Otherwise, if this is a mouse event, the widget receiving the + # event must not be disabled at this moment. + return ( + not self._self_or_ancestors_disabled + if isinstance(message, (events.MouseEvent, events.Enter, events.Leave)) + else True + ) + async def broker_event(self, event_name: str, event: events.Event) -> bool: return await self.app._broker_event(event_name, event, default_namespace=self) @@ -2501,11 +2554,11 @@ class Widget(DOMNode): def _on_descendant_blur(self, event: events.DescendantBlur) -> None: if self._has_focus_within: - self.app.update_styles(self) + self._update_styles() def _on_descendant_focus(self, event: events.DescendantBlur) -> None: if self._has_focus_within: - self.app.update_styles(self) + self._update_styles() def _on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None: if event.ctrl or event.shift: diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index fbeb60645..ec143323c 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -39,11 +39,6 @@ class Button(Static, can_focus=True): text-style: bold; } - Button.-disabled { - opacity: 0.4; - text-opacity: 0.7; - } - Button:focus { text-style: bold reverse; } @@ -156,9 +151,6 @@ class Button(Static, can_focus=True): variant = reactive("default") """The variant name for the button.""" - disabled = reactive(False) - """The disabled state of the button; `True` if disabled, `False` if not.""" - class Pressed(Message, bubble=True): """Event sent when a `Button` is pressed. @@ -176,45 +168,35 @@ class Button(Static, can_focus=True): def __init__( self, label: TextType | None = None, - disabled: bool = False, variant: ButtonVariant = "default", *, name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ): """Create a Button widget. Args: label: The text that appears within the button. - disabled: Whether the button is disabled or not. variant: The variant of the button. name: The name of the button. id: The ID of the button in the DOM. classes: The CSS classes of the button. + disabled: Whether the button is disabled or not. """ - super().__init__(name=name, id=id, classes=classes) + super().__init__(name=name, id=id, classes=classes, disabled=disabled) if label is None: label = self.css_identifier_styled self.label = self.validate_label(label) - self.disabled = disabled - if disabled: - self.add_class("-disabled") - self.variant = self.validate_variant(variant) def __rich_repr__(self) -> rich.repr.Result: yield from super().__rich_repr__() yield "variant", self.variant, "default" - yield "disabled", self.disabled, False - - def watch_mouse_over(self, value: bool) -> None: - """Update from CSS if mouse over state changes.""" - if self._has_hover_style and not self.disabled: - self.app.update_styles(self) def validate_variant(self, variant: str) -> str: if variant not in _VALID_BUTTON_VARIANTS: @@ -227,10 +209,6 @@ class Button(Static, can_focus=True): self.remove_class(f"-{old_variant}") self.add_class(f"-{variant}") - def watch_disabled(self, disabled: bool) -> None: - self.set_class(disabled, "-disabled") - self.can_focus = not disabled - def validate_label(self, label: RenderableType) -> RenderableType: """Parse markup for self.label""" if isinstance(label, str): @@ -272,11 +250,11 @@ class Button(Static, can_focus=True): def success( cls, label: TextType | None = None, - disabled: bool = False, *, name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> Button: """Utility constructor for creating a success Button variant. @@ -292,22 +270,22 @@ class Button(Static, can_focus=True): """ return Button( label=label, - disabled=disabled, variant="success", name=name, id=id, classes=classes, + disabled=disabled, ) @classmethod def warning( cls, label: TextType | None = None, - disabled: bool = False, *, name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> Button: """Utility constructor for creating a warning Button variant. @@ -323,22 +301,22 @@ class Button(Static, can_focus=True): """ return Button( label=label, - disabled=disabled, variant="warning", name=name, id=id, classes=classes, + disabled=disabled, ) @classmethod def error( cls, label: TextType | None = None, - disabled: bool = False, *, name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> Button: """Utility constructor for creating an error Button variant. @@ -354,9 +332,9 @@ class Button(Static, can_focus=True): """ return Button( label=label, - disabled=disabled, variant="error", name=name, id=id, classes=classes, + disabled=disabled, ) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 1b3c7e030..fe04dfa07 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -473,8 +473,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> None: - super().__init__(name=name, id=id, classes=classes) + super().__init__(name=name, id=id, classes=classes, disabled=disabled) self._data: dict[RowKey, dict[ColumnKey, CellType]] = {} """Contains the cells of the table, indexed by row key and column key. The final positioning of a cell on screen cannot be determined solely by this diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 9b3d33fc5..8b481a552 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -29,6 +29,7 @@ class DirectoryTree(Tree[DirEntry]): name: The name of the widget, or None for no name. Defaults to None. id: The ID of the widget in the DOM, or None for no ID. Defaults to None. classes: A space-separated list of classes, or None for no classes. Defaults to None. + disabled: Whether the directory tree is disabled or not. """ COMPONENT_CLASSES: ClassVar[set[str]] = { @@ -87,6 +88,7 @@ class DirectoryTree(Tree[DirEntry]): name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> None: self.path = path super().__init__( @@ -95,6 +97,7 @@ class DirectoryTree(Tree[DirEntry]): name=name, id=id, classes=classes, + disabled=disabled, ) def process_label(self, label: TextType): diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 8528e225a..eb9471889 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -110,9 +110,6 @@ class Input(Widget, can_focus=True): height: 1; min-height: 1; } - Input.-disabled { - opacity: 0.6; - } Input:focus { border: tall $accent; } @@ -179,6 +176,7 @@ class Input(Widget, can_focus=True): name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> None: """Initialise the `Input` widget. @@ -190,8 +188,9 @@ class Input(Widget, can_focus=True): name: Optional name for the input widget. id: Optional ID for the widget. classes: Optional initial classes for the widget. + disabled: Whether the input is disabled or not. """ - super().__init__(name=name, id=id, classes=classes) + super().__init__(name=name, id=id, classes=classes, disabled=disabled) if value is not None: self.value = value self.placeholder = placeholder diff --git a/src/textual/widgets/_list_view.py b/src/textual/widgets/_list_view.py index efb552f0c..af41589db 100644 --- a/src/textual/widgets/_list_view.py +++ b/src/textual/widgets/_list_view.py @@ -73,6 +73,7 @@ class ListView(Vertical, can_focus=True, can_focus_children=False): name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> None: """ Args: @@ -81,8 +82,11 @@ class ListView(Vertical, can_focus=True, can_focus_children=False): name: The name of the widget. id: The unique ID of the widget used in CSS/query selection. classes: The CSS classes of the widget. + disabled: Whether the ListView is disabled or not. """ - super().__init__(*children, name=name, id=id, classes=classes) + super().__init__( + *children, name=name, id=id, classes=classes, disabled=disabled + ) self._index = initial_index def on_mount(self) -> None: diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 50812dd9d..e2178ca2d 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -10,7 +10,7 @@ from rich.text import Text from typing_extensions import TypeAlias from ..app import ComposeResult -from ..containers import Vertical +from ..containers import Horizontal, Vertical from ..message import Message from ..reactive import reactive, var from ..widget import Widget @@ -198,6 +198,19 @@ class MarkdownH6(MarkdownHeader): """ +class MarkdownHorizontalRule(MarkdownBlock): + """A horizontal rule.""" + + DEFAULT_CSS = """ + MarkdownHorizontalRule { + border-bottom: heavy $primary; + height: 1; + padding-top: 1; + margin-bottom: 1; + } + """ + + class MarkdownParagraph(MarkdownBlock): """A paragraph Markdown block.""" @@ -225,37 +238,83 @@ class MarkdownBlockQuote(MarkdownBlock): """ -class MarkdownBulletList(MarkdownBlock): +class MarkdownList(MarkdownBlock): + DEFAULT_CSS = """ + + MarkdownList { + width: 1fr; + } + + MarkdownList MarkdownList { + margin: 0; + padding-top: 0; + } + """ + + +class MarkdownBulletList(MarkdownList): """A Bullet list Markdown block.""" DEFAULT_CSS = """ MarkdownBulletList { - margin: 0; + margin: 0 0 1 0; padding: 0 0; } - MarkdownBulletList MarkdownBulletList { - margin: 0; - padding-top: 0; + MarkdownBulletList Horizontal { + height: auto; + width: 1fr; + } + + MarkdownBulletList Vertical { + height: auto; + width: 1fr; } """ + def compose(self) -> ComposeResult: + for block in self._blocks: + if isinstance(block, MarkdownListItem): + bullet = MarkdownBullet() + bullet.symbol = block.bullet + yield Horizontal(bullet, Vertical(*block._blocks)) + self._blocks.clear() -class MarkdownOrderedList(MarkdownBlock): + +class MarkdownOrderedList(MarkdownList): """An ordered list Markdown block.""" DEFAULT_CSS = """ MarkdownOrderedList { - margin: 0; + margin: 0 0 1 0; padding: 0 0; } - Markdown OrderedList MarkdownOrderedList { - margin: 0; - padding-top: 0; + MarkdownOrderedList Horizontal { + height: auto; + width: 1fr; + } + + MarkdownOrderedList Vertical { + height: auto; + width: 1fr; } """ + def compose(self) -> ComposeResult: + symbol_size = max( + len(block.bullet) + for block in self._blocks + if isinstance(block, MarkdownListItem) + ) + for block in self._blocks: + if isinstance(block, MarkdownListItem): + bullet = MarkdownBullet() + bullet.symbol = block.bullet.rjust(symbol_size + 1) + yield Horizontal(bullet, Vertical(*block._blocks)) + + self._blocks.clear() + class MarkdownTable(MarkdownBlock): """A Table markdown Block.""" @@ -329,10 +388,12 @@ class MarkdownBullet(Widget): DEFAULT_CSS = """ MarkdownBullet { width: auto; + color: $success; + text-style: bold; } """ - symbol = reactive("●​ ") + symbol = reactive("●​") """The symbol for the bullet.""" def render(self) -> Text: @@ -359,13 +420,13 @@ class MarkdownListItem(MarkdownBlock): self.bullet = bullet super().__init__() - def compose(self) -> ComposeResult: - bullet = MarkdownBullet() - bullet.symbol = self.bullet - yield bullet - yield Vertical(*self._blocks) - self._blocks.clear() +class MarkdownOrderedListItem(MarkdownListItem): + pass + + +class MarkdownUnorderedListItem(MarkdownListItem): + pass class MarkdownFence(MarkdownBlock): @@ -439,6 +500,8 @@ class Markdown(Widget): """ COMPONENT_CLASSES = {"em", "strong", "s", "code_inline"} + BULLETS = ["⏺ ", "▪ ", "‣ ", "• ", "⭑ "] + def __init__( self, markdown: str | None = None, @@ -501,7 +564,7 @@ class Markdown(Widget): markdown = path.read_text(encoding="utf-8") except Exception: return False - await self.query("MarkdownBlock").remove() + await self.update(markdown) return True @@ -524,6 +587,8 @@ class Markdown(Widget): if token.type == "heading_open": block_id += 1 stack.append(HEADINGS[token.tag](id=f"block{block_id}")) + elif token.type == "hr": + output.append(MarkdownHorizontalRule()) elif token.type == "paragraph_open": stack.append(MarkdownParagraph()) elif token.type == "blockquote_open": @@ -533,9 +598,20 @@ class Markdown(Widget): elif token.type == "ordered_list_open": stack.append(MarkdownOrderedList()) elif token.type == "list_item_open": - stack.append( - MarkdownListItem(f"{token.info}. " if token.info else "● ") - ) + if token.info: + stack.append(MarkdownOrderedListItem(f"{token.info}. ")) + else: + item_count = sum( + 1 + for block in stack + if isinstance(block, MarkdownUnorderedListItem) + ) + stack.append( + MarkdownUnorderedListItem( + self.BULLETS[item_count % len(self.BULLETS)] + ) + ) + elif token.type == "table_open": stack.append(MarkdownTable()) elif token.type == "tbody_open": @@ -565,6 +641,8 @@ class Markdown(Widget): for child in token.children: if child.type == "text": content.append(child.content, style_stack[-1]) + if child.type == "softbreak": + content.append(" ") elif child.type == "code_inline": content.append( child.content, @@ -627,7 +705,10 @@ class Markdown(Widget): await self.post_message( Markdown.TableOfContentsUpdated(table_of_contents, sender=self) ) - await self.mount(*output) + with self.app.batch_update(): + await self.query("MarkdownBlock").remove() + await self.mount(*output) + self.refresh(layout=True) class MarkdownTableOfContents(Widget, can_focus_children=True): diff --git a/src/textual/widgets/_placeholder.py b/src/textual/widgets/_placeholder.py index d4bf01333..970e4465b 100644 --- a/src/textual/widgets/_placeholder.py +++ b/src/textual/widgets/_placeholder.py @@ -2,6 +2,7 @@ from __future__ import annotations from itertools import cycle +from rich.console import RenderableType from typing_extensions import Literal from .. import events @@ -61,10 +62,10 @@ class Placeholder(Widget): overflow: hidden; color: $text; } - Placeholder.-text { padding: 1; } + """ # Consecutive placeholders get assigned consecutive colors. @@ -73,7 +74,7 @@ class Placeholder(Widget): variant: Reactive[PlaceholderVariant] = reactive("default") - _renderables: dict[PlaceholderVariant, RenderResult] + _renderables: dict[PlaceholderVariant, str] @classmethod def reset_color_cycle(cls) -> None: @@ -119,7 +120,7 @@ class Placeholder(Widget): while next(self._variants_cycle) != self.variant: pass - def render(self) -> RenderResult: + def render(self) -> RenderableType: return self._renderables[self.variant] def cycle_variant(self) -> None: @@ -147,6 +148,6 @@ class Placeholder(Widget): def on_resize(self, event: events.Resize) -> None: """Update the placeholder "size" variant with the new placeholder size.""" - self._renderables["size"] = self._SIZE_RENDER_TEMPLATE.format(*self.size) + self._renderables["size"] = self._SIZE_RENDER_TEMPLATE.format(*event.size) if self.variant == "size": - self.refresh(layout=True) + self.refresh(layout=False) diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index a9698a954..5007f1b1d 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -36,6 +36,7 @@ class Static(Widget, inherit_bindings=False): name: Name of widget. Defaults to None. id: ID of Widget. Defaults to None. classes: Space separated list of class names. Defaults to None. + disabled: Whether the static is disabled or not. """ DEFAULT_CSS = """ @@ -56,8 +57,9 @@ class Static(Widget, inherit_bindings=False): name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> None: - super().__init__(name=name, id=id, classes=classes) + super().__init__(name=name, id=id, classes=classes, disabled=disabled) self.expand = expand self.shrink = shrink self.markup = markup diff --git a/src/textual/widgets/_switch.py b/src/textual/widgets/_switch.py index 45e856dd1..97e3e2223 100644 --- a/src/textual/widgets/_switch.py +++ b/src/textual/widgets/_switch.py @@ -100,6 +100,7 @@ class Switch(Widget, can_focus=True): name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ): """Initialise the switch. @@ -109,8 +110,9 @@ class Switch(Widget, can_focus=True): name: The name of the switch. id: The ID of the switch in the DOM. classes: The CSS classes of the switch. + disabled: Whether the switch is disabled or not. """ - super().__init__(name=name, id=id, classes=classes) + super().__init__(name=name, id=id, classes=classes, disabled=disabled) if value: self.slider_pos = 1.0 self._reactive_value = value diff --git a/src/textual/widgets/_text_log.py b/src/textual/widgets/_text_log.py index f78934e70..c7efee07a 100644 --- a/src/textual/widgets/_text_log.py +++ b/src/textual/widgets/_text_log.py @@ -43,8 +43,9 @@ class TextLog(ScrollView, can_focus=True): name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> None: - super().__init__(name=name, id=id, classes=classes) + super().__init__(name=name, id=id, classes=classes, disabled=disabled) self.max_lines = max_lines self._start_line: int = 0 self.lines: list[Strip] = [] diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index e93777248..bc7fc1833 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -473,8 +473,9 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> None: - super().__init__(name=name, id=id, classes=classes) + super().__init__(name=name, id=id, classes=classes, disabled=disabled) text_label = self.process_label(label) diff --git a/tests/css/test_parse.py b/tests/css/test_parse.py index 727d45af4..49d3368d6 100644 --- a/tests/css/test_parse.py +++ b/tests/css/test_parse.py @@ -8,7 +8,7 @@ from textual.css.parse import substitute_references from textual.css.scalar import Scalar, Unit from textual.css.stylesheet import Stylesheet, StylesheetParseError from textual.css.tokenize import tokenize -from textual.css.tokenizer import ReferencedBy, Token +from textual.css.tokenizer import ReferencedBy, Token, TokenError from textual.css.transition import Transition from textual.geometry import Spacing from textual.layouts.vertical import VerticalLayout @@ -1189,3 +1189,40 @@ class TestParseTextAlign: stylesheet = Stylesheet() stylesheet.add_source(css) assert stylesheet.rules[0].styles.text_align == "start" + + +class TestTypeNames: + def test_type_no_number(self): + stylesheet = Stylesheet() + stylesheet.add_source("TestType {}") + assert len(stylesheet.rules) == 1 + + def test_type_with_number(self): + stylesheet = Stylesheet() + stylesheet.add_source("TestType1 {}") + assert len(stylesheet.rules) == 1 + + def test_type_starts_with_number(self): + stylesheet = Stylesheet() + stylesheet.add_source("1TestType {}") + with pytest.raises(TokenError): + stylesheet.parse() + + def test_combined_type_no_number(self): + for separator in " >,": + stylesheet = Stylesheet() + stylesheet.add_source(f"StartType {separator} TestType {{}}") + assert len(stylesheet.rules) == 1 + + def test_combined_type_with_number(self): + for separator in " >,": + stylesheet = Stylesheet() + stylesheet.add_source(f"StartType {separator} TestType1 {{}}") + assert len(stylesheet.rules) == 1 + + def test_combined_type_starts_with_number(self): + for separator in " >,": + stylesheet = Stylesheet() + stylesheet.add_source(f"StartType {separator} 1TestType {{}}") + with pytest.raises(TokenError): + stylesheet.parse() diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index f149ea09f..04ed4e7c0 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -181,162 +181,162 @@ font-weight: 700; } - .terminal-3615181303-matrix { + .terminal-2059425018-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3615181303-title { + .terminal-2059425018-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3615181303-r1 { fill: #e1e1e1 } - .terminal-3615181303-r2 { fill: #c5c8c6 } - .terminal-3615181303-r3 { fill: #e1e1e1;font-weight: bold } - .terminal-3615181303-r4 { fill: #454a50 } - .terminal-3615181303-r5 { fill: #292b2e } - .terminal-3615181303-r6 { fill: #24292f;font-weight: bold } - .terminal-3615181303-r7 { fill: #555657;font-weight: bold } - .terminal-3615181303-r8 { fill: #000000 } - .terminal-3615181303-r9 { fill: #161617 } - .terminal-3615181303-r10 { fill: #507bb3 } - .terminal-3615181303-r11 { fill: #283c52 } - .terminal-3615181303-r12 { fill: #dde6ed;font-weight: bold } - .terminal-3615181303-r13 { fill: #4f5a62;font-weight: bold } - .terminal-3615181303-r14 { fill: #001541 } - .terminal-3615181303-r15 { fill: #122032 } - .terminal-3615181303-r16 { fill: #7ae998 } - .terminal-3615181303-r17 { fill: #3d6a4a } - .terminal-3615181303-r18 { fill: #0a180e;font-weight: bold } - .terminal-3615181303-r19 { fill: #1e2f23;font-weight: bold } - .terminal-3615181303-r20 { fill: #008139 } - .terminal-3615181303-r21 { fill: #1b4c2f } - .terminal-3615181303-r22 { fill: #ffcf56 } - .terminal-3615181303-r23 { fill: #775f2f } - .terminal-3615181303-r24 { fill: #211505;font-weight: bold } - .terminal-3615181303-r25 { fill: #392b18;font-weight: bold } - .terminal-3615181303-r26 { fill: #b86b00 } - .terminal-3615181303-r27 { fill: #644316 } - .terminal-3615181303-r28 { fill: #e76580 } - .terminal-3615181303-r29 { fill: #683540 } - .terminal-3615181303-r30 { fill: #f5e5e9;font-weight: bold } - .terminal-3615181303-r31 { fill: #6c595e;font-weight: bold } - .terminal-3615181303-r32 { fill: #780028 } - .terminal-3615181303-r33 { fill: #491928 } + .terminal-2059425018-r1 { fill: #e1e1e1 } + .terminal-2059425018-r2 { fill: #c5c8c6 } + .terminal-2059425018-r3 { fill: #e1e1e1;font-weight: bold } + .terminal-2059425018-r4 { fill: #454a50 } + .terminal-2059425018-r5 { fill: #313437 } + .terminal-2059425018-r6 { fill: #24292f;font-weight: bold } + .terminal-2059425018-r7 { fill: #7c7d7e;font-weight: bold } + .terminal-2059425018-r8 { fill: #000000 } + .terminal-2059425018-r9 { fill: #101011 } + .terminal-2059425018-r10 { fill: #507bb3 } + .terminal-2059425018-r11 { fill: #324f70 } + .terminal-2059425018-r12 { fill: #dde6ed;font-weight: bold } + .terminal-2059425018-r13 { fill: #75828b;font-weight: bold } + .terminal-2059425018-r14 { fill: #001541 } + .terminal-2059425018-r15 { fill: #0c1e39 } + .terminal-2059425018-r16 { fill: #7ae998 } + .terminal-2059425018-r17 { fill: #4f9262 } + .terminal-2059425018-r18 { fill: #0a180e;font-weight: bold } + .terminal-2059425018-r19 { fill: #192e1f;font-weight: bold } + .terminal-2059425018-r20 { fill: #008139 } + .terminal-2059425018-r21 { fill: #156034 } + .terminal-2059425018-r22 { fill: #ffcf56 } + .terminal-2059425018-r23 { fill: #a4823a } + .terminal-2059425018-r24 { fill: #211505;font-weight: bold } + .terminal-2059425018-r25 { fill: #3a2a13;font-weight: bold } + .terminal-2059425018-r26 { fill: #b86b00 } + .terminal-2059425018-r27 { fill: #825210 } + .terminal-2059425018-r28 { fill: #e76580 } + .terminal-2059425018-r29 { fill: #904354 } + .terminal-2059425018-r30 { fill: #f5e5e9;font-weight: bold } + .terminal-2059425018-r31 { fill: #978186;font-weight: bold } + .terminal-2059425018-r32 { fill: #780028 } + .terminal-2059425018-r33 { fill: #5b132a } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ButtonsApp + ButtonsApp - - - - - Standard ButtonsDisabled Buttons - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - DefaultDefault - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Primary!Primary! - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Success!Success! - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Warning!Warning! - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Error!Error! - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - + + + + + Standard ButtonsDisabled Buttons + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + DefaultDefault + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Primary!Primary! + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Success!Success! + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Warning!Warning! + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Error!Error! + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + @@ -10813,6 +10813,193 @@ ''' # --- +# name: test_disabled_widgets + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + WidgetDisableTestApp + + + + + + + + + + WidgetDisableTestApp + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +  Column 1  Column 2  Column 3  Column 4  +  0         0         0         0         + This is list item 0 + This is list item 1 + ▼ This is a test tree + ├── Leaf 0 + Hello, World! + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This is an empty input with a placeholder + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This is some text in an input + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▇▇ + + + + + + ''' +# --- # name: test_dock_layout_sidebar ''' @@ -13032,139 +13219,140 @@ font-weight: 700; } - .terminal-2159695446-matrix { + .terminal-2166823333-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2159695446-title { + .terminal-2166823333-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2159695446-r1 { fill: #e1e1e1 } - .terminal-2159695446-r2 { fill: #121212 } - .terminal-2159695446-r3 { fill: #c5c8c6 } - .terminal-2159695446-r4 { fill: #0053aa } - .terminal-2159695446-r5 { fill: #dde8f3;font-weight: bold } - .terminal-2159695446-r6 { fill: #939393;font-weight: bold } - .terminal-2159695446-r7 { fill: #24292f } - .terminal-2159695446-r8 { fill: #e2e3e3;font-weight: bold } - .terminal-2159695446-r9 { fill: #e1e1e1;font-style: italic; } - .terminal-2159695446-r10 { fill: #e1e1e1;font-weight: bold } + .terminal-2166823333-r1 { fill: #e1e1e1 } + .terminal-2166823333-r2 { fill: #121212 } + .terminal-2166823333-r3 { fill: #c5c8c6 } + .terminal-2166823333-r4 { fill: #0053aa } + .terminal-2166823333-r5 { fill: #dde8f3;font-weight: bold } + .terminal-2166823333-r6 { fill: #939393;font-weight: bold } + .terminal-2166823333-r7 { fill: #24292f } + .terminal-2166823333-r8 { fill: #e2e3e3;font-weight: bold } + .terminal-2166823333-r9 { fill: #4ebf71;font-weight: bold } + .terminal-2166823333-r10 { fill: #e1e1e1;font-style: italic; } + .terminal-2166823333-r11 { fill: #e1e1e1;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MarkdownExampleApp + MarkdownExampleApp - - - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - Markdown Document - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - This is an example of Textual's Markdown widget. - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - Features - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Markdown syntax and extensions are supported. - - ● Typography emphasisstronginline code etc. - ● Headers - ● Lists (bullet and ordered) - ● Syntax highlighted code blocks - ● Tables! - - - - + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + Markdown Document + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This is an example of Textual's Markdown widget. + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + Features + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Markdown syntax and extensions are supported. + + ⏺ Typography emphasisstronginline code etc. + ⏺ Headers + ⏺ Lists (bullet and ordered) + ⏺ Syntax highlighted code blocks + ⏺ Tables! + + + + @@ -13195,144 +13383,145 @@ font-weight: 700; } - .terminal-3241959168-matrix { + .terminal-3185906023-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3241959168-title { + .terminal-3185906023-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3241959168-r1 { fill: #c5c8c6 } - .terminal-3241959168-r2 { fill: #24292f } - .terminal-3241959168-r3 { fill: #121212 } - .terminal-3241959168-r4 { fill: #e1e1e1 } - .terminal-3241959168-r5 { fill: #e2e3e3 } - .terminal-3241959168-r6 { fill: #96989b } - .terminal-3241959168-r7 { fill: #0053aa } - .terminal-3241959168-r8 { fill: #008139 } - .terminal-3241959168-r9 { fill: #dde8f3;font-weight: bold } - .terminal-3241959168-r10 { fill: #939393;font-weight: bold } - .terminal-3241959168-r11 { fill: #e2e3e3;font-weight: bold } - .terminal-3241959168-r12 { fill: #14191f } - .terminal-3241959168-r13 { fill: #e1e1e1;font-style: italic; } - .terminal-3241959168-r14 { fill: #e1e1e1;font-weight: bold } + .terminal-3185906023-r1 { fill: #c5c8c6 } + .terminal-3185906023-r2 { fill: #24292f } + .terminal-3185906023-r3 { fill: #121212 } + .terminal-3185906023-r4 { fill: #e1e1e1 } + .terminal-3185906023-r5 { fill: #e2e3e3 } + .terminal-3185906023-r6 { fill: #96989b } + .terminal-3185906023-r7 { fill: #0053aa } + .terminal-3185906023-r8 { fill: #008139 } + .terminal-3185906023-r9 { fill: #dde8f3;font-weight: bold } + .terminal-3185906023-r10 { fill: #939393;font-weight: bold } + .terminal-3185906023-r11 { fill: #e2e3e3;font-weight: bold } + .terminal-3185906023-r12 { fill: #14191f } + .terminal-3185906023-r13 { fill: #4ebf71;font-weight: bold } + .terminal-3185906023-r14 { fill: #e1e1e1;font-style: italic; } + .terminal-3185906023-r15 { fill: #e1e1e1;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MarkdownExampleApp + MarkdownExampleApp - - - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▼  Markdown Viewer - ├──  FeaturesMarkdown Viewer - ├──  Tables - └──  Code Blocks▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - This is an example of Textual's MarkdownViewer - widget. - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - Features▅▅ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Markdown syntax and extensions are supported. - - ● Typography emphasisstronginline code - etc. - ● Headers - ● Lists (bullet and ordered) - ● Syntax highlighted code blocks - ● Tables! - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - Tables + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▼  Markdown Viewer + ├──  FeaturesMarkdown Viewer + ├──  Tables + └──  Code Blocks▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This is an example of Textual's MarkdownViewer + widget. + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + Features▇▇ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Markdown syntax and extensions are supported. + + ⏺ Typography emphasisstronginline code + etc. + ⏺ Headers + ⏺ Lists (bullet and ordered) + ⏺ Syntax highlighted code blocks + ⏺ Tables! + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + diff --git a/tests/snapshot_tests/snapshot_apps/disable_widgets.py b/tests/snapshot_tests/snapshot_apps/disable_widgets.py new file mode 100644 index 000000000..7a241e914 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/disable_widgets.py @@ -0,0 +1,84 @@ +from textual.app import App, ComposeResult +from textual.containers import Vertical, Horizontal +from textual.widgets import ( + Header, + Footer, + Button, + DataTable, + Input, + ListView, + ListItem, + Label, + Markdown, + MarkdownViewer, + Tree, + TextLog, +) + + +class WidgetDisableTestApp(App[None]): + + CSS = """ + Horizontal { + height: auto; + } + DataTable, ListView, Tree, TextLog { + height: 2; + } + + Markdown, MarkdownViewer { + height: 1fr; + } + """ + + @property + def data_table(self) -> DataTable: + data_table = DataTable[str]() + data_table.add_columns("Column 1", "Column 2", "Column 3", "Column 4") + data_table.add_rows( + [(str(n), str(n * 10), str(n * 100), str(n * 1000)) for n in range(100)] + ) + return data_table + + @property + def list_view(self) -> ListView: + return ListView(*[ListItem(Label(f"This is list item {n}")) for n in range(20)]) + + @property + def test_tree(self) -> Tree: + tree = Tree[None](label="This is a test tree") + for n in range(10): + tree.root.add_leaf(f"Leaf {n}") + tree.root.expand() + return tree + + def compose(self) -> ComposeResult: + yield Header() + yield Vertical( + Horizontal( + Button(), + Button(variant="primary"), + Button(variant="success"), + Button(variant="warning"), + Button(variant="error"), + ), + self.data_table, + self.list_view, + self.test_tree, + TextLog(), + Input(), + Input(placeholder="This is an empty input with a placeholder"), + Input("This is some text in an input"), + Markdown("# Hello, World!"), + MarkdownViewer("# Hello, World!"), + id="test-container", + ) + yield Footer() + + def on_mount(self) -> None: + self.query_one(TextLog).write("Hello, World!") + self.query_one("#test-container", Vertical).disabled = True + + +if __name__ == "__main__": + WidgetDisableTestApp().run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index c23564c1b..d19ac7328 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -231,3 +231,7 @@ def test_auto_width_input(snap_compare): def test_screen_switch(snap_compare): assert snap_compare(SNAPSHOT_APPS_DIR / "screen_switch.py", press=["a", "b"]) + + +def test_disabled_widgets(snap_compare): + assert snap_compare(SNAPSHOT_APPS_DIR / "disable_widgets.py") diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 000000000..221286f11 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,17 @@ +from textual.app import App + + +def test_batch_update(): + """Test `batch_update` context manager""" + app = App() + assert app._batch_count == 0 # Start at zero + + with app.batch_update(): + assert app._batch_count == 1 # Increments in context manager + + with app.batch_update(): + assert app._batch_count == 2 # Nested updates + + assert app._batch_count == 1 # Exiting decrements + + assert app._batch_count == 0 # Back to zero diff --git a/tests/test_arrange.py b/tests/test_arrange.py index 31e030b1b..582f54bc1 100644 --- a/tests/test_arrange.py +++ b/tests/test_arrange.py @@ -9,10 +9,10 @@ from textual.widget import Widget def test_arrange_empty(): container = Widget(id="container") - placements, widgets, spacing = arrange(container, [], Size(80, 24), Size(80, 24)) - assert placements == [] - assert widgets == set() - assert spacing == Spacing(0, 0, 0, 0) + result = arrange(container, [], Size(80, 24), Size(80, 24)) + assert result.placements == [] + assert result.widgets == set() + assert result.spacing == Spacing(0, 0, 0, 0) def test_arrange_dock_top(): @@ -22,17 +22,16 @@ def test_arrange_dock_top(): header.styles.dock = "top" header.styles.height = "1" - placements, widgets, spacing = arrange( - container, [child, header], Size(80, 24), Size(80, 24) - ) - assert placements == [ + result = arrange(container, [child, header], Size(80, 24), Size(80, 24)) + + assert result.placements == [ WidgetPlacement( Region(0, 0, 80, 1), Spacing(), header, order=TOP_Z, fixed=True ), WidgetPlacement(Region(0, 1, 80, 23), Spacing(), child, order=0, fixed=False), ] - assert widgets == {child, header} - assert spacing == Spacing(1, 0, 0, 0) + assert result.widgets == {child, header} + assert result.spacing == Spacing(1, 0, 0, 0) def test_arrange_dock_left(): @@ -42,17 +41,15 @@ def test_arrange_dock_left(): header.styles.dock = "left" header.styles.width = "10" - placements, widgets, spacing = arrange( - container, [child, header], Size(80, 24), Size(80, 24) - ) - assert placements == [ + result = arrange(container, [child, header], Size(80, 24), Size(80, 24)) + assert result.placements == [ WidgetPlacement( Region(0, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True ), WidgetPlacement(Region(10, 0, 70, 24), Spacing(), child, order=0, fixed=False), ] - assert widgets == {child, header} - assert spacing == Spacing(0, 0, 0, 10) + assert result.widgets == {child, header} + assert result.spacing == Spacing(0, 0, 0, 10) def test_arrange_dock_right(): @@ -62,17 +59,15 @@ def test_arrange_dock_right(): header.styles.dock = "right" header.styles.width = "10" - placements, widgets, spacing = arrange( - container, [child, header], Size(80, 24), Size(80, 24) - ) - assert placements == [ + result = arrange(container, [child, header], Size(80, 24), Size(80, 24)) + assert result.placements == [ WidgetPlacement( Region(70, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True ), WidgetPlacement(Region(0, 0, 70, 24), Spacing(), child, order=0, fixed=False), ] - assert widgets == {child, header} - assert spacing == Spacing(0, 10, 0, 0) + assert result.widgets == {child, header} + assert result.spacing == Spacing(0, 10, 0, 0) def test_arrange_dock_bottom(): @@ -82,17 +77,15 @@ def test_arrange_dock_bottom(): header.styles.dock = "bottom" header.styles.height = "1" - placements, widgets, spacing = arrange( - container, [child, header], Size(80, 24), Size(80, 24) - ) - assert placements == [ + result = arrange(container, [child, header], Size(80, 24), Size(80, 24)) + assert result.placements == [ WidgetPlacement( Region(0, 23, 80, 1), Spacing(), header, order=TOP_Z, fixed=True ), WidgetPlacement(Region(0, 0, 80, 23), Spacing(), child, order=0, fixed=False), ] - assert widgets == {child, header} - assert spacing == Spacing(0, 0, 1, 0) + assert result.widgets == {child, header} + assert result.spacing == Spacing(0, 0, 1, 0) def test_arrange_dock_badly(): diff --git a/tests/test_disabled.py b/tests/test_disabled.py new file mode 100644 index 000000000..850fcf7c7 --- /dev/null +++ b/tests/test_disabled.py @@ -0,0 +1,84 @@ +"""Test Widget.disabled.""" + +from textual.app import App, ComposeResult +from textual.containers import Vertical +from textual.widgets import ( + Button, + DataTable, + DirectoryTree, + Input, + ListView, + Markdown, + MarkdownViewer, + Switch, + TextLog, + Tree, +) + + +class DisableApp(App[None]): + """Application for testing Widget.disabled.""" + + def compose(self) -> ComposeResult: + """Compose the child widgets.""" + yield Vertical( + Button(), + DataTable(), + DirectoryTree("."), + Input(), + ListView(), + Switch(), + TextLog(), + Tree("Test"), + Markdown(), + MarkdownViewer(), + id="test-container", + ) + + +async def test_all_initially_enabled() -> None: + """All widgets should start out enabled.""" + async with DisableApp().run_test() as pilot: + assert all( + not node.disabled for node in pilot.app.screen.query("#test-container > *") + ) + + +async def test_enabled_widgets_have_enabled_pseudo_class() -> None: + """All enabled widgets should have the :enabled pseudoclass.""" + async with DisableApp().run_test() as pilot: + assert all( + node.has_pseudo_class("enabled") and not node.has_pseudo_class("disabled") + for node in pilot.app.screen.query("#test-container > *") + ) + + +async def test_all_individually_disabled() -> None: + """Post-disable all widgets should report being disabled.""" + async with DisableApp().run_test() as pilot: + for node in pilot.app.screen.query("Vertical > *"): + node.disabled = True + assert all( + node.disabled for node in pilot.app.screen.query("#test-container > *") + ) + + +async def test_disabled_widgets_have_disabled_pseudo_class() -> None: + """All disabled widgets should have the :disabled pseudoclass.""" + async with DisableApp().run_test() as pilot: + for node in pilot.app.screen.query("#test-container > *"): + node.disabled = True + assert all( + node.has_pseudo_class("disabled") and not node.has_pseudo_class("enabled") + for node in pilot.app.screen.query("#test-container > *") + ) + + +async def test_disable_via_container() -> None: + """All child widgets should appear (to CSS) as disabled by a container being disabled.""" + async with DisableApp().run_test() as pilot: + pilot.app.screen.query_one("#test-container", Vertical).disabled = True + assert all( + node.has_pseudo_class("disabled") and not node.has_pseudo_class("enabled") + for node in pilot.app.screen.query("#test-container > *") + ) diff --git a/tests/test_reactive.py b/tests/test_reactive.py index da8be66ae..9c824645e 100644 --- a/tests/test_reactive.py +++ b/tests/test_reactive.py @@ -328,6 +328,33 @@ async def test_reactive_inheritance(): assert tertiary.baz == "baz" +async def test_compute(): + """Check compute method is called.""" + + class ComputeApp(App): + count = var(0) + count_double = var(0) + + def __init__(self) -> None: + self.start = 0 + super().__init__() + + def compute_count_double(self) -> int: + return self.start + self.count * 2 + + app = ComputeApp() + + async with app.run_test(): + assert app.count_double == 0 + app.count = 1 + assert app.count_double == 2 + assert app.count_double == 2 + app.count = 2 + assert app.count_double == 4 + app.start = 10 + assert app.count_double == 14 + + async def test_watch_compute(): """Check that watching a computed attribute works.""" @@ -347,7 +374,9 @@ async def test_watch_compute(): app = Calculator() - async with app.run_test() as pilot: + # Referencing the value calls compute + # Setting any reactive values calls compute + async with app.run_test(): assert app.show_ac is True app.value = "1" assert app.show_ac is False @@ -356,4 +385,4 @@ async def test_watch_compute(): app.numbers = "123" assert app.show_ac is False - assert watch_called == [True, False, True, False] + assert watch_called == [True, True, False, False, True, True, False, False] diff --git a/tests/test_spatial_map.py b/tests/test_spatial_map.py new file mode 100644 index 000000000..413ca4cad --- /dev/null +++ b/tests/test_spatial_map.py @@ -0,0 +1,64 @@ +import pytest + +from textual._spatial_map import SpatialMap +from textual.geometry import Region + + +@pytest.mark.parametrize( + "region,grid", + [ + ( + Region(0, 0, 10, 10), + [ + (0, 0), + ], + ), + ( + Region(10, 10, 10, 10), + [ + (1, 1), + ], + ), + ( + Region(0, 0, 11, 11), + [(0, 0), (0, 1), (1, 0), (1, 1)], + ), + ( + Region(5, 5, 15, 3), + [(0, 0), (1, 0)], + ), + ( + Region(5, 5, 2, 15), + [(0, 0), (0, 1)], + ), + ], +) +def test_region_to_grid(region, grid): + spatial_map = SpatialMap(10, 10) + + assert list(spatial_map._region_to_grid_coordinates(region)) == grid + + +def test_get_values_in_region() -> None: + spatial_map: SpatialMap[str] = SpatialMap(20, 10) + + spatial_map.insert( + [ + (Region(10, 5, 5, 5), False, "foo"), + (Region(5, 20, 5, 5), False, "bar"), + (Region(0, 0, 40, 1), True, "title"), + ] + ) + + assert spatial_map.get_values_in_region(Region(0, 0, 10, 5)) == [ + "title", + "foo", + ] + assert spatial_map.get_values_in_region(Region(0, 1, 10, 5)) == ["title", "foo"] + assert spatial_map.get_values_in_region(Region(0, 10, 10, 5)) == ["title"] + assert spatial_map.get_values_in_region(Region(0, 20, 10, 5)) == ["title", "bar"] + assert spatial_map.get_values_in_region(Region(5, 5, 50, 50)) == [ + "title", + "foo", + "bar", + ] diff --git a/tests/test_visibility_change.py b/tests/test_visibility_change.py index b06ea0e17..f79f18a6f 100644 --- a/tests/test_visibility_change.py +++ b/tests/test_visibility_change.py @@ -26,21 +26,18 @@ class VisibleTester(App[None]): async def test_visibility_changes() -> None: """Test changing visibility via code and CSS.""" async with VisibleTester().run_test() as pilot: - assert len(pilot.app.screen.visible_widgets) == 5 assert pilot.app.query_one("#keep").visible is True assert pilot.app.query_one("#hide-via-code").visible is True assert pilot.app.query_one("#hide-via-css").visible is True pilot.app.query_one("#hide-via-code").styles.visibility = "hidden" await pilot.pause(0) - assert len(pilot.app.screen.visible_widgets) == 4 assert pilot.app.query_one("#keep").visible is True assert pilot.app.query_one("#hide-via-code").visible is False assert pilot.app.query_one("#hide-via-css").visible is True pilot.app.query_one("#hide-via-css").set_class(True, "hidden") await pilot.pause(0) - assert len(pilot.app.screen.visible_widgets) == 3 assert pilot.app.query_one("#keep").visible is True assert pilot.app.query_one("#hide-via-code").visible is False assert pilot.app.query_one("#hide-via-css").visible is False