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
+ '''
+
+
+ '''
+# ---
# name: test_dock_layout_sidebar
'''
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