mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge pull request #1823 from Textualize/optimize-scroll
Optimize scroll with a Spatial Map
This commit is contained in:
11
CHANGELOG.md
11
CHANGELOG.md
@@ -5,7 +5,16 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
## Unreleased
|
|
||||||
|
## [0.12.0] - Unreleased
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Scrolling by page now adds to current position.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Removed `screen.visible_widgets` and `screen.widgets`
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
|||||||
@@ -28,12 +28,12 @@
|
|||||||
|
|
||||||
Screen {
|
Screen {
|
||||||
layers: ruler;
|
layers: ruler;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ruler {
|
Ruler {
|
||||||
layer: ruler;
|
layer: ruler;
|
||||||
dock: right;
|
dock: right;
|
||||||
overflow: hidden;
|
|
||||||
width: 1;
|
width: 1;
|
||||||
background: $accent;
|
background: $accent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from rich.markdown import Markdown
|
|||||||
|
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.containers import Content
|
from textual.containers import Content
|
||||||
from textual.widgets import Static, Input
|
from textual.widgets import Input, Static
|
||||||
|
|
||||||
|
|
||||||
class DictionaryApp(App):
|
class DictionaryApp(App):
|
||||||
@@ -41,7 +41,12 @@ class DictionaryApp(App):
|
|||||||
"""Looks up a word."""
|
"""Looks up a word."""
|
||||||
url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}"
|
url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}"
|
||||||
async with httpx.AsyncClient() as client:
|
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:
|
if word == self.query_one(Input).value:
|
||||||
markdown = self.make_word_markdown(results)
|
markdown = self.make_word_markdown(results)
|
||||||
|
|||||||
@@ -128,4 +128,4 @@ def arrange(
|
|||||||
|
|
||||||
placements.extend(layout_placements)
|
placements.extend(layout_placements)
|
||||||
|
|
||||||
return placements, arrange_widgets, scroll_spacing
|
return DockArrangeResult(placements, arrange_widgets, scroll_spacing)
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ class Compositor:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
# A mapping of Widget on to its "render location" (absolute position / depth)
|
# A mapping of Widget on to its "render location" (absolute position / depth)
|
||||||
self.map: CompositorMap = {}
|
self.map: CompositorMap = {}
|
||||||
|
self._full_map: CompositorMap | None = None
|
||||||
self._layers: list[tuple[Widget, MapGeometry]] | None = None
|
self._layers: list[tuple[Widget, MapGeometry]] | None = None
|
||||||
|
|
||||||
# All widgets considered in the arrangement
|
# All widgets considered in the arrangement
|
||||||
@@ -241,29 +242,27 @@ class Compositor:
|
|||||||
size: Size of the area to be filled.
|
size: Size of the area to be filled.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Hidden shown and resized widgets.
|
Hidden, shown, and resized widgets.
|
||||||
"""
|
"""
|
||||||
self._cuts = None
|
self._cuts = None
|
||||||
self._layers = None
|
self._layers = None
|
||||||
self._layers_visible = None
|
self._layers_visible = None
|
||||||
self._visible_widgets = None
|
self._visible_widgets = None
|
||||||
|
self._full_map = None
|
||||||
self.root = parent
|
self.root = parent
|
||||||
self.size = size
|
self.size = size
|
||||||
|
|
||||||
# Keep a copy of the old map because we're going to compare it with the update
|
# 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()
|
old_widgets = old_map.keys()
|
||||||
|
|
||||||
map, widgets = self._arrange_root(parent, size)
|
map, widgets = self._arrange_root(parent, size)
|
||||||
new_widgets = map.keys()
|
|
||||||
|
|
||||||
# Newly visible widgets
|
new_widgets = map.keys()
|
||||||
shown_widgets = new_widgets - old_widgets
|
|
||||||
# Newly hidden widgets
|
|
||||||
hidden_widgets = old_widgets - new_widgets
|
|
||||||
|
|
||||||
# Replace map and widgets
|
# Replace map and widgets
|
||||||
self.map = map
|
self.map = map
|
||||||
|
self._full_map = map
|
||||||
self.widgets = widgets
|
self.widgets = widgets
|
||||||
|
|
||||||
# Contains widgets + geometry for every widget that changed (added, removed, or updated)
|
# Contains widgets + geometry for every widget that changed (added, removed, or updated)
|
||||||
@@ -272,13 +271,7 @@ class Compositor:
|
|||||||
# Widgets in both new and old
|
# Widgets in both new and old
|
||||||
common_widgets = old_widgets & new_widgets
|
common_widgets = old_widgets & new_widgets
|
||||||
|
|
||||||
# Widgets with changed size
|
# Mark dirty regions.
|
||||||
resized_widgets = {
|
|
||||||
widget
|
|
||||||
for widget, (region, *_) in changes
|
|
||||||
if (widget in common_widgets and old_map[widget].region[2:] != region[2:])
|
|
||||||
}
|
|
||||||
|
|
||||||
screen_region = size.region
|
screen_region = size.region
|
||||||
if screen_region not in self._dirty_regions:
|
if screen_region not in self._dirty_regions:
|
||||||
regions = {
|
regions = {
|
||||||
@@ -291,12 +284,80 @@ class Compositor:
|
|||||||
}
|
}
|
||||||
self._dirty_regions.update(regions)
|
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(
|
return ReflowResult(
|
||||||
hidden=hidden_widgets,
|
hidden=hidden_widgets,
|
||||||
shown=shown_widgets,
|
shown=shown_widgets,
|
||||||
resized=resized_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
|
@property
|
||||||
def visible_widgets(self) -> dict[Widget, tuple[Region, Region]]:
|
def visible_widgets(self) -> dict[Widget, tuple[Region, Region]]:
|
||||||
"""Get a mapping of widgets on to region and clip.
|
"""Get a mapping of widgets on to region and clip.
|
||||||
@@ -322,9 +383,9 @@ class Compositor:
|
|||||||
return self._visible_widgets
|
return self._visible_widgets
|
||||||
|
|
||||||
def _arrange_root(
|
def _arrange_root(
|
||||||
self, root: Widget, size: Size
|
self, root: Widget, size: Size, visible_only: bool = True
|
||||||
) -> tuple[CompositorMap, set[Widget]]:
|
) -> tuple[CompositorMap, set[Widget]]:
|
||||||
"""Arrange a widgets children based on its layout attribute.
|
"""Arrange a widget's children based on its layout attribute.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
root: Top level widget.
|
root: Top level widget.
|
||||||
@@ -337,6 +398,7 @@ class Compositor:
|
|||||||
|
|
||||||
map: CompositorMap = {}
|
map: CompositorMap = {}
|
||||||
widgets: set[Widget] = set()
|
widgets: set[Widget] = set()
|
||||||
|
add_new_widget = widgets.add
|
||||||
layer_order: int = 0
|
layer_order: int = 0
|
||||||
|
|
||||||
def add_widget(
|
def add_widget(
|
||||||
@@ -362,7 +424,7 @@ class Compositor:
|
|||||||
visible = visibility == "visible"
|
visible = visibility == "visible"
|
||||||
|
|
||||||
if visible:
|
if visible:
|
||||||
widgets.add(widget)
|
add_new_widget(widget)
|
||||||
styles_offset = widget.styles.offset
|
styles_offset = widget.styles.offset
|
||||||
layout_offset = (
|
layout_offset = (
|
||||||
styles_offset.resolve(region.size, clip.size)
|
styles_offset.resolve(region.size, clip.size)
|
||||||
@@ -389,22 +451,26 @@ class Compositor:
|
|||||||
|
|
||||||
if widget.is_container:
|
if widget.is_container:
|
||||||
# Arrange the layout
|
# Arrange the layout
|
||||||
placements, arranged_widgets, spacing = widget._arrange(
|
arrange_result = widget._arrange(child_region.size)
|
||||||
child_region.size
|
arranged_widgets = arrange_result.widgets
|
||||||
)
|
spacing = arrange_result.spacing
|
||||||
widgets.update(arranged_widgets)
|
widgets.update(arranged_widgets)
|
||||||
|
|
||||||
if placements:
|
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
|
# An offset added to all placements
|
||||||
placement_offset = container_region.offset
|
placement_offset = container_region.offset
|
||||||
placement_scroll_offset = (
|
placement_scroll_offset = placement_offset - widget.scroll_offset
|
||||||
placement_offset - widget.scroll_offset
|
|
||||||
)
|
|
||||||
|
|
||||||
_layers = widget.layers
|
_layers = widget.layers
|
||||||
layers_to_index = {
|
layers_to_index = {
|
||||||
layer_name: index
|
layer_name: index for index, layer_name in enumerate(_layers)
|
||||||
for index, layer_name in enumerate(_layers)
|
|
||||||
}
|
}
|
||||||
get_layer_index = layers_to_index.get
|
get_layer_index = layers_to_index.get
|
||||||
|
|
||||||
@@ -437,10 +503,12 @@ class Compositor:
|
|||||||
sub_clip,
|
sub_clip,
|
||||||
visible,
|
visible,
|
||||||
)
|
)
|
||||||
|
|
||||||
layer_order -= 1
|
layer_order -= 1
|
||||||
|
|
||||||
if visible:
|
if visible:
|
||||||
# Add any scrollbars
|
# Add any scrollbars
|
||||||
|
if any(widget.scrollbars_enabled):
|
||||||
for chrome_widget, chrome_region in widget._arrange_scrollbars(
|
for chrome_widget, chrome_region in widget._arrange_scrollbars(
|
||||||
container_region
|
container_region
|
||||||
):
|
):
|
||||||
@@ -518,6 +586,9 @@ class Compositor:
|
|||||||
"""Get the offset of a widget."""
|
"""Get the offset of a widget."""
|
||||||
try:
|
try:
|
||||||
return self.map[widget].region.offset
|
return self.map[widget].region.offset
|
||||||
|
except KeyError:
|
||||||
|
try:
|
||||||
|
return self.full_map[widget].region.offset
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise errors.NoWidget("Widget is not in layout")
|
raise errors.NoWidget("Widget is not in layout")
|
||||||
|
|
||||||
@@ -601,8 +672,13 @@ class Compositor:
|
|||||||
Widget's composition information.
|
Widget's composition information.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
if self.root is None or not self.map:
|
||||||
|
raise errors.NoWidget("Widget is not in layout")
|
||||||
try:
|
try:
|
||||||
region = self.map[widget]
|
region = self.map[widget]
|
||||||
|
except KeyError:
|
||||||
|
try:
|
||||||
|
return self.full_map[widget]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise errors.NoWidget("Widget is not in layout")
|
raise errors.NoWidget("Widget is not in layout")
|
||||||
else:
|
else:
|
||||||
@@ -788,6 +864,7 @@ class Compositor:
|
|||||||
widget: Widget to update.
|
widget: Widget to update.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
self._full_map = None
|
||||||
regions: list[Region] = []
|
regions: list[Region] = []
|
||||||
add_region = regions.append
|
add_region = regions.append
|
||||||
get_widget = self.visible_widgets.__getitem__
|
get_widget = self.visible_widgets.__getitem__
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING, ClassVar, NamedTuple
|
from typing import TYPE_CHECKING, ClassVar, NamedTuple
|
||||||
|
|
||||||
|
from ._spatial_map import SpatialMap
|
||||||
from .geometry import Region, Size, Spacing
|
from .geometry import Region, Size, Spacing
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -11,7 +13,55 @@ if TYPE_CHECKING:
|
|||||||
from .widget import Widget
|
from .widget import Widget
|
||||||
|
|
||||||
ArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[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):
|
class WidgetPlacement(NamedTuple):
|
||||||
@@ -61,7 +111,7 @@ class Layout(ABC):
|
|||||||
width = 0
|
width = 0
|
||||||
else:
|
else:
|
||||||
# Use a size of 0, 0 to ignore relative sizes, since those are flexible anyway
|
# 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(
|
width = max(
|
||||||
[
|
[
|
||||||
placement.region.right + placement.margin.right
|
placement.region.right + placement.margin.right
|
||||||
@@ -89,7 +139,7 @@ class Layout(ABC):
|
|||||||
height = 0
|
height = 0
|
||||||
else:
|
else:
|
||||||
# Use a height of zero to ignore relative heights
|
# Use a height of zero to ignore relative heights
|
||||||
placements, _, _ = widget._arrange(Size(width, 0))
|
placements = widget._arrange(Size(width, 0)).placements
|
||||||
height = max(
|
height = max(
|
||||||
[
|
[
|
||||||
placement.region.bottom + placement.margin.bottom
|
placement.region.bottom + placement.margin.bottom
|
||||||
|
|||||||
103
src/textual/_spatial_map.py
Normal file
103
src/textual/_spatial_map.py
Normal file
@@ -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
|
||||||
@@ -1783,6 +1783,7 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
await child._close_messages()
|
await child._close_messages()
|
||||||
|
|
||||||
async def _shutdown(self) -> None:
|
async def _shutdown(self) -> None:
|
||||||
|
self._begin_update() # Prevents any layout / repaint while shutting down
|
||||||
driver = self._driver
|
driver = self._driver
|
||||||
self._running = False
|
self._running = False
|
||||||
if driver is not None:
|
if driver is not None:
|
||||||
@@ -1908,7 +1909,6 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
# Handle input events that haven't been forwarded
|
# Handle input events that haven't been forwarded
|
||||||
# If the event has been forwarded it may have bubbled up back to the App
|
# If the event has been forwarded it may have bubbled up back to the App
|
||||||
if isinstance(event, events.Compose):
|
if isinstance(event, events.Compose):
|
||||||
self.log(event)
|
|
||||||
screen = Screen(id="_default")
|
screen = Screen(id="_default")
|
||||||
self._register(self, screen)
|
self._register(self, screen)
|
||||||
self._screen_stack.append(screen)
|
self._screen_stack.append(screen)
|
||||||
|
|||||||
@@ -45,12 +45,24 @@ class Update(Message, verbose=True):
|
|||||||
|
|
||||||
@rich.repr.auto
|
@rich.repr.auto
|
||||||
class Layout(Message, verbose=True):
|
class Layout(Message, verbose=True):
|
||||||
|
"""Sent by Textual when a layout is required."""
|
||||||
|
|
||||||
def can_replace(self, message: Message) -> bool:
|
def can_replace(self, message: Message) -> bool:
|
||||||
return isinstance(message, Layout)
|
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
|
@rich.repr.auto
|
||||||
class InvokeLater(Message, verbose=True, bubble=False):
|
class InvokeLater(Message, verbose=True, bubble=False):
|
||||||
|
"""Sent by Textual to invoke a callback."""
|
||||||
|
|
||||||
def __init__(self, sender: MessagePump, callback: CallbackType) -> None:
|
def __init__(self, sender: MessagePump, callback: CallbackType) -> None:
|
||||||
self.callback = callback
|
self.callback = callback
|
||||||
super().__init__(sender)
|
super().__init__(sender)
|
||||||
|
|||||||
@@ -80,16 +80,6 @@ class Screen(Widget):
|
|||||||
)
|
)
|
||||||
return self._update_timer
|
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:
|
def render(self) -> RenderableType:
|
||||||
background = self.styles.background
|
background = self.styles.background
|
||||||
if background.is_transparent:
|
if background.is_transparent:
|
||||||
@@ -370,7 +360,12 @@ class Screen(Widget):
|
|||||||
if self._layout_required:
|
if self._layout_required:
|
||||||
self._refresh_layout()
|
self._refresh_layout()
|
||||||
self._layout_required = False
|
self._layout_required = False
|
||||||
|
self._scroll_required = False
|
||||||
self._dirty_widgets.clear()
|
self._dirty_widgets.clear()
|
||||||
|
elif self._scroll_required:
|
||||||
|
self._refresh_layout(scroll=True)
|
||||||
|
self._scroll_required = False
|
||||||
|
|
||||||
if self._repaint_required:
|
if self._repaint_required:
|
||||||
self._dirty_widgets.clear()
|
self._dirty_widgets.clear()
|
||||||
self._dirty_widgets.add(self)
|
self._dirty_widgets.add(self)
|
||||||
@@ -419,7 +414,9 @@ class Screen(Widget):
|
|||||||
self._callbacks.append(callback)
|
self._callbacks.append(callback)
|
||||||
self.check_idle()
|
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)."""
|
"""Refresh the layout (can change size and positions of widgets)."""
|
||||||
size = self.outer_size if size is None else size
|
size = self.outer_size if size is None else size
|
||||||
if not size:
|
if not size:
|
||||||
@@ -427,7 +424,37 @@ class Screen(Widget):
|
|||||||
|
|
||||||
self._compositor.update_widgets(self._dirty_widgets)
|
self._compositor.update_widgets(self._dirty_widgets)
|
||||||
self.update_timer.pause()
|
self.update_timer.pause()
|
||||||
|
ResizeEvent = events.Resize
|
||||||
try:
|
try:
|
||||||
|
if scroll:
|
||||||
|
exposed_widgets = self._compositor.reflow_visible(self, size)
|
||||||
|
if exposed_widgets:
|
||||||
|
layers = self._compositor.layers
|
||||||
|
|
||||||
|
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)
|
hidden, shown, resized = self._compositor.reflow(self, size)
|
||||||
Hide = events.Hide
|
Hide = events.Hide
|
||||||
Show = events.Show
|
Show = events.Show
|
||||||
@@ -437,7 +464,6 @@ class Screen(Widget):
|
|||||||
|
|
||||||
# We want to send a resize event to widgets that were just added or change since last layout
|
# We want to send a resize event to widgets that were just added or change since last layout
|
||||||
send_resize = shown | resized
|
send_resize = shown | resized
|
||||||
ResizeEvent = events.Resize
|
|
||||||
|
|
||||||
layers = self._compositor.layers
|
layers = self._compositor.layers
|
||||||
for widget, (
|
for widget, (
|
||||||
@@ -480,6 +506,12 @@ class Screen(Widget):
|
|||||||
self._layout_required = True
|
self._layout_required = True
|
||||||
self.check_idle()
|
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):
|
def _screen_resized(self, size: Size):
|
||||||
"""Called by App when the screen is resized."""
|
"""Called by App when the screen is resized."""
|
||||||
self._refresh_layout(size, full=True)
|
self._refresh_layout(size, full=True)
|
||||||
|
|||||||
@@ -69,14 +69,18 @@ class ScrollView(Widget):
|
|||||||
return self.virtual_size.height
|
return self.virtual_size.height
|
||||||
|
|
||||||
def _size_updated(
|
def _size_updated(
|
||||||
self, size: Size, virtual_size: Size, container_size: Size
|
self, size: Size, virtual_size: Size, container_size: Size, layout: bool = True
|
||||||
) -> None:
|
) -> bool:
|
||||||
"""Called when size is updated.
|
"""Called when size is updated.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
size: New size.
|
size: New size.
|
||||||
virtual_size: New virtual size.
|
virtual_size: New virtual size.
|
||||||
container_size: New container 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:
|
if self._size != size or container_size != container_size:
|
||||||
self.refresh()
|
self.refresh()
|
||||||
@@ -90,6 +94,9 @@ class ScrollView(Widget):
|
|||||||
self._container_size = size - self.styles.gutter.totals
|
self._container_size = size - self.styles.gutter.totals
|
||||||
self._scroll_update(virtual_size)
|
self._scroll_update(virtual_size)
|
||||||
self.scroll_to(self.scroll_x, self.scroll_y, animate=False)
|
self.scroll_to(self.scroll_x, self.scroll_y, animate=False)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
def render(self) -> RenderableType:
|
def render(self) -> RenderableType:
|
||||||
"""Render the scrollable region (if `render_lines` is not implemented).
|
"""Render the scrollable region (if `render_lines` is not implemented).
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from . import errors, events, messages
|
|||||||
from ._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction
|
from ._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction
|
||||||
from ._arrange import DockArrangeResult, arrange
|
from ._arrange import DockArrangeResult, arrange
|
||||||
from ._asyncio import create_task
|
from ._asyncio import create_task
|
||||||
|
from ._cache import FIFOCache
|
||||||
from ._context import active_app
|
from ._context import active_app
|
||||||
from ._easing import DEFAULT_SCROLL_EASING
|
from ._easing import DEFAULT_SCROLL_EASING
|
||||||
from ._layout import Layout
|
from ._layout import Layout
|
||||||
@@ -243,6 +244,7 @@ class Widget(DOMNode):
|
|||||||
self._container_size = Size(0, 0)
|
self._container_size = Size(0, 0)
|
||||||
self._layout_required = False
|
self._layout_required = False
|
||||||
self._repaint_required = False
|
self._repaint_required = False
|
||||||
|
self._scroll_required = False
|
||||||
self._default_layout = VerticalLayout()
|
self._default_layout = VerticalLayout()
|
||||||
self._animate: BoundAnimator | None = None
|
self._animate: BoundAnimator | None = None
|
||||||
self.highlight_style: Style | None = None
|
self.highlight_style: Style | None = None
|
||||||
@@ -262,8 +264,9 @@ class Widget(DOMNode):
|
|||||||
self._content_width_cache: tuple[object, int] = (None, 0)
|
self._content_width_cache: tuple[object, int] = (None, 0)
|
||||||
self._content_height_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._arrangement_cache: FIFOCache[
|
||||||
self._cached_arrangement: DockArrangeResult | None = None
|
tuple[Size, int], DockArrangeResult
|
||||||
|
] = FIFOCache(4)
|
||||||
|
|
||||||
self._styles_cache = StylesCache()
|
self._styles_cache = StylesCache()
|
||||||
self._rich_style_cache: dict[str, tuple[Style, Style]] = {}
|
self._rich_style_cache: dict[str, tuple[Style, Style]] = {}
|
||||||
@@ -477,14 +480,11 @@ class Widget(DOMNode):
|
|||||||
assert self.is_container
|
assert self.is_container
|
||||||
|
|
||||||
cache_key = (size, self._nodes._updates)
|
cache_key = (size, self._nodes._updates)
|
||||||
if (
|
cached_result = self._arrangement_cache.get(cache_key)
|
||||||
self._arrangement_cache_key == cache_key
|
if cached_result is not None:
|
||||||
and self._cached_arrangement is not None
|
return cached_result
|
||||||
):
|
|
||||||
return self._cached_arrangement
|
|
||||||
|
|
||||||
self._arrangement_cache_key = cache_key
|
arrangement = self._arrangement_cache[cache_key] = arrange(
|
||||||
arrangement = self._cached_arrangement = arrange(
|
|
||||||
self, self._nodes, size, self.screen.size
|
self, self._nodes, size, self.screen.size
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -492,7 +492,7 @@ class Widget(DOMNode):
|
|||||||
|
|
||||||
def _clear_arrangement_cache(self) -> None:
|
def _clear_arrangement_cache(self) -> None:
|
||||||
"""Clear arrangement cache, forcing a new arrange operation."""
|
"""Clear arrangement cache, forcing a new arrange operation."""
|
||||||
self._cached_arrangement = None
|
self._arrangement_cache.clear()
|
||||||
|
|
||||||
def _get_virtual_dom(self) -> Iterable[Widget]:
|
def _get_virtual_dom(self) -> Iterable[Widget]:
|
||||||
"""Get widgets not part of the DOM.
|
"""Get widgets not part of the DOM.
|
||||||
@@ -1728,7 +1728,7 @@ class Widget(DOMNode):
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
return self.scroll_to(
|
return self.scroll_to(
|
||||||
y=self.scroll_target_y - self.container_size.height,
|
y=self.scroll_y - self.container_size.height,
|
||||||
animate=animate,
|
animate=animate,
|
||||||
speed=speed,
|
speed=speed,
|
||||||
duration=duration,
|
duration=duration,
|
||||||
@@ -1760,7 +1760,7 @@ class Widget(DOMNode):
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
return self.scroll_to(
|
return self.scroll_to(
|
||||||
y=self.scroll_target_y + self.container_size.height,
|
y=self.scroll_y + self.container_size.height,
|
||||||
animate=animate,
|
animate=animate,
|
||||||
speed=speed,
|
speed=speed,
|
||||||
duration=duration,
|
duration=duration,
|
||||||
@@ -1794,7 +1794,7 @@ class Widget(DOMNode):
|
|||||||
if speed is None and duration is None:
|
if speed is None and duration is None:
|
||||||
duration = 0.3
|
duration = 0.3
|
||||||
return self.scroll_to(
|
return self.scroll_to(
|
||||||
x=self.scroll_target_x - self.container_size.width,
|
x=self.scroll_x - self.container_size.width,
|
||||||
animate=animate,
|
animate=animate,
|
||||||
speed=speed,
|
speed=speed,
|
||||||
duration=duration,
|
duration=duration,
|
||||||
@@ -1828,7 +1828,7 @@ class Widget(DOMNode):
|
|||||||
if speed is None and duration is None:
|
if speed is None and duration is None:
|
||||||
duration = 0.3
|
duration = 0.3
|
||||||
return self.scroll_to(
|
return self.scroll_to(
|
||||||
x=self.scroll_target_x + self.container_size.width,
|
x=self.scroll_x + self.container_size.width,
|
||||||
animate=animate,
|
animate=animate,
|
||||||
speed=speed,
|
speed=speed,
|
||||||
duration=duration,
|
duration=duration,
|
||||||
@@ -2164,14 +2164,18 @@ class Widget(DOMNode):
|
|||||||
self._update_styles()
|
self._update_styles()
|
||||||
|
|
||||||
def _size_updated(
|
def _size_updated(
|
||||||
self, size: Size, virtual_size: Size, container_size: Size
|
self, size: Size, virtual_size: Size, container_size: Size, layout: bool = True
|
||||||
) -> None:
|
) -> bool:
|
||||||
"""Called when the widget's size is updated.
|
"""Called when the widget's size is updated.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
size: Screen size.
|
size: Screen size.
|
||||||
virtual_size: Virtual (scrollable) size.
|
virtual_size: Virtual (scrollable) size.
|
||||||
container_size: Container size (size of parent).
|
container_size: Container size (size of parent).
|
||||||
|
layout: Perform layout if required.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if anything changed, or False if nothing changed.
|
||||||
"""
|
"""
|
||||||
if (
|
if (
|
||||||
self._size != size
|
self._size != size
|
||||||
@@ -2179,11 +2183,16 @@ class Widget(DOMNode):
|
|||||||
or self._container_size != container_size
|
or self._container_size != container_size
|
||||||
):
|
):
|
||||||
self._size = size
|
self._size = size
|
||||||
|
if layout:
|
||||||
self.virtual_size = virtual_size
|
self.virtual_size = virtual_size
|
||||||
|
else:
|
||||||
|
self._reactive_virtual_size = virtual_size
|
||||||
self._container_size = container_size
|
self._container_size = container_size
|
||||||
if self.is_scrollable:
|
if self.is_scrollable:
|
||||||
self._scroll_update(virtual_size)
|
self._scroll_update(virtual_size)
|
||||||
self.refresh()
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
def _scroll_update(self, virtual_size: Size) -> None:
|
def _scroll_update(self, virtual_size: Size) -> None:
|
||||||
"""Update scrollbars visibility and dimensions.
|
"""Update scrollbars visibility and dimensions.
|
||||||
@@ -2294,7 +2303,7 @@ class Widget(DOMNode):
|
|||||||
|
|
||||||
def _refresh_scroll(self) -> None:
|
def _refresh_scroll(self) -> None:
|
||||||
"""Refreshes the scroll position."""
|
"""Refreshes the scroll position."""
|
||||||
self._layout_required = True
|
self._scroll_required = True
|
||||||
self.check_idle()
|
self.check_idle()
|
||||||
|
|
||||||
def refresh(
|
def refresh(
|
||||||
@@ -2321,8 +2330,7 @@ class Widget(DOMNode):
|
|||||||
repaint: Repaint the widget (will call render() again). Defaults to True.
|
repaint: Repaint the widget (will call render() again). Defaults to True.
|
||||||
layout: Also layout widgets in the view. Defaults to False.
|
layout: Also layout widgets in the view. Defaults to False.
|
||||||
"""
|
"""
|
||||||
|
if layout and not self._layout_required:
|
||||||
if layout:
|
|
||||||
self._layout_required = True
|
self._layout_required = True
|
||||||
for ancestor in self.ancestors:
|
for ancestor in self.ancestors:
|
||||||
if not isinstance(ancestor, Widget):
|
if not isinstance(ancestor, Widget):
|
||||||
@@ -2403,6 +2411,9 @@ class Widget(DOMNode):
|
|||||||
except NoScreen:
|
except NoScreen:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
|
if self._scroll_required:
|
||||||
|
self._scroll_required = False
|
||||||
|
screen.post_message_no_wait(messages.UpdateScroll(self))
|
||||||
if self._repaint_required:
|
if self._repaint_required:
|
||||||
self._repaint_required = False
|
self._repaint_required = False
|
||||||
screen.post_message_no_wait(messages.Update(self, self))
|
screen.post_message_no_wait(messages.Update(self, self))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from itertools import cycle
|
from itertools import cycle
|
||||||
|
|
||||||
|
from rich.console import RenderableType
|
||||||
from typing_extensions import Literal
|
from typing_extensions import Literal
|
||||||
|
|
||||||
from .. import events
|
from .. import events
|
||||||
@@ -61,10 +62,10 @@ class Placeholder(Widget):
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: $text;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
Placeholder.-text {
|
Placeholder.-text {
|
||||||
padding: 1;
|
padding: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Consecutive placeholders get assigned consecutive colors.
|
# Consecutive placeholders get assigned consecutive colors.
|
||||||
@@ -73,7 +74,7 @@ class Placeholder(Widget):
|
|||||||
|
|
||||||
variant: Reactive[PlaceholderVariant] = reactive("default")
|
variant: Reactive[PlaceholderVariant] = reactive("default")
|
||||||
|
|
||||||
_renderables: dict[PlaceholderVariant, RenderResult]
|
_renderables: dict[PlaceholderVariant, str]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def reset_color_cycle(cls) -> None:
|
def reset_color_cycle(cls) -> None:
|
||||||
@@ -119,7 +120,7 @@ class Placeholder(Widget):
|
|||||||
while next(self._variants_cycle) != self.variant:
|
while next(self._variants_cycle) != self.variant:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def render(self) -> RenderResult:
|
def render(self) -> RenderableType:
|
||||||
return self._renderables[self.variant]
|
return self._renderables[self.variant]
|
||||||
|
|
||||||
def cycle_variant(self) -> None:
|
def cycle_variant(self) -> None:
|
||||||
@@ -147,6 +148,6 @@ class Placeholder(Widget):
|
|||||||
|
|
||||||
def on_resize(self, event: events.Resize) -> None:
|
def on_resize(self, event: events.Resize) -> None:
|
||||||
"""Update the placeholder "size" variant with the new placeholder size."""
|
"""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":
|
if self.variant == "size":
|
||||||
self.refresh(layout=True)
|
self.refresh(layout=False)
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ from textual.widget import Widget
|
|||||||
def test_arrange_empty():
|
def test_arrange_empty():
|
||||||
container = Widget(id="container")
|
container = Widget(id="container")
|
||||||
|
|
||||||
placements, widgets, spacing = arrange(container, [], Size(80, 24), Size(80, 24))
|
result = arrange(container, [], Size(80, 24), Size(80, 24))
|
||||||
assert placements == []
|
assert result.placements == []
|
||||||
assert widgets == set()
|
assert result.widgets == set()
|
||||||
assert spacing == Spacing(0, 0, 0, 0)
|
assert result.spacing == Spacing(0, 0, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
def test_arrange_dock_top():
|
def test_arrange_dock_top():
|
||||||
@@ -22,17 +22,16 @@ def test_arrange_dock_top():
|
|||||||
header.styles.dock = "top"
|
header.styles.dock = "top"
|
||||||
header.styles.height = "1"
|
header.styles.height = "1"
|
||||||
|
|
||||||
placements, widgets, spacing = arrange(
|
result = arrange(container, [child, header], Size(80, 24), Size(80, 24))
|
||||||
container, [child, header], Size(80, 24), Size(80, 24)
|
|
||||||
)
|
assert result.placements == [
|
||||||
assert placements == [
|
|
||||||
WidgetPlacement(
|
WidgetPlacement(
|
||||||
Region(0, 0, 80, 1), Spacing(), header, order=TOP_Z, fixed=True
|
Region(0, 0, 80, 1), Spacing(), header, order=TOP_Z, fixed=True
|
||||||
),
|
),
|
||||||
WidgetPlacement(Region(0, 1, 80, 23), Spacing(), child, order=0, fixed=False),
|
WidgetPlacement(Region(0, 1, 80, 23), Spacing(), child, order=0, fixed=False),
|
||||||
]
|
]
|
||||||
assert widgets == {child, header}
|
assert result.widgets == {child, header}
|
||||||
assert spacing == Spacing(1, 0, 0, 0)
|
assert result.spacing == Spacing(1, 0, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
def test_arrange_dock_left():
|
def test_arrange_dock_left():
|
||||||
@@ -42,17 +41,15 @@ def test_arrange_dock_left():
|
|||||||
header.styles.dock = "left"
|
header.styles.dock = "left"
|
||||||
header.styles.width = "10"
|
header.styles.width = "10"
|
||||||
|
|
||||||
placements, widgets, spacing = arrange(
|
result = arrange(container, [child, header], Size(80, 24), Size(80, 24))
|
||||||
container, [child, header], Size(80, 24), Size(80, 24)
|
assert result.placements == [
|
||||||
)
|
|
||||||
assert placements == [
|
|
||||||
WidgetPlacement(
|
WidgetPlacement(
|
||||||
Region(0, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True
|
Region(0, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True
|
||||||
),
|
),
|
||||||
WidgetPlacement(Region(10, 0, 70, 24), Spacing(), child, order=0, fixed=False),
|
WidgetPlacement(Region(10, 0, 70, 24), Spacing(), child, order=0, fixed=False),
|
||||||
]
|
]
|
||||||
assert widgets == {child, header}
|
assert result.widgets == {child, header}
|
||||||
assert spacing == Spacing(0, 0, 0, 10)
|
assert result.spacing == Spacing(0, 0, 0, 10)
|
||||||
|
|
||||||
|
|
||||||
def test_arrange_dock_right():
|
def test_arrange_dock_right():
|
||||||
@@ -62,17 +59,15 @@ def test_arrange_dock_right():
|
|||||||
header.styles.dock = "right"
|
header.styles.dock = "right"
|
||||||
header.styles.width = "10"
|
header.styles.width = "10"
|
||||||
|
|
||||||
placements, widgets, spacing = arrange(
|
result = arrange(container, [child, header], Size(80, 24), Size(80, 24))
|
||||||
container, [child, header], Size(80, 24), Size(80, 24)
|
assert result.placements == [
|
||||||
)
|
|
||||||
assert placements == [
|
|
||||||
WidgetPlacement(
|
WidgetPlacement(
|
||||||
Region(70, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True
|
Region(70, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True
|
||||||
),
|
),
|
||||||
WidgetPlacement(Region(0, 0, 70, 24), Spacing(), child, order=0, fixed=False),
|
WidgetPlacement(Region(0, 0, 70, 24), Spacing(), child, order=0, fixed=False),
|
||||||
]
|
]
|
||||||
assert widgets == {child, header}
|
assert result.widgets == {child, header}
|
||||||
assert spacing == Spacing(0, 10, 0, 0)
|
assert result.spacing == Spacing(0, 10, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
def test_arrange_dock_bottom():
|
def test_arrange_dock_bottom():
|
||||||
@@ -82,17 +77,15 @@ def test_arrange_dock_bottom():
|
|||||||
header.styles.dock = "bottom"
|
header.styles.dock = "bottom"
|
||||||
header.styles.height = "1"
|
header.styles.height = "1"
|
||||||
|
|
||||||
placements, widgets, spacing = arrange(
|
result = arrange(container, [child, header], Size(80, 24), Size(80, 24))
|
||||||
container, [child, header], Size(80, 24), Size(80, 24)
|
assert result.placements == [
|
||||||
)
|
|
||||||
assert placements == [
|
|
||||||
WidgetPlacement(
|
WidgetPlacement(
|
||||||
Region(0, 23, 80, 1), Spacing(), header, order=TOP_Z, fixed=True
|
Region(0, 23, 80, 1), Spacing(), header, order=TOP_Z, fixed=True
|
||||||
),
|
),
|
||||||
WidgetPlacement(Region(0, 0, 80, 23), Spacing(), child, order=0, fixed=False),
|
WidgetPlacement(Region(0, 0, 80, 23), Spacing(), child, order=0, fixed=False),
|
||||||
]
|
]
|
||||||
assert widgets == {child, header}
|
assert result.widgets == {child, header}
|
||||||
assert spacing == Spacing(0, 0, 1, 0)
|
assert result.spacing == Spacing(0, 0, 1, 0)
|
||||||
|
|
||||||
|
|
||||||
def test_arrange_dock_badly():
|
def test_arrange_dock_badly():
|
||||||
|
|||||||
64
tests/test_spatial_map.py
Normal file
64
tests/test_spatial_map.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
@@ -26,21 +26,18 @@ class VisibleTester(App[None]):
|
|||||||
async def test_visibility_changes() -> None:
|
async def test_visibility_changes() -> None:
|
||||||
"""Test changing visibility via code and CSS."""
|
"""Test changing visibility via code and CSS."""
|
||||||
async with VisibleTester().run_test() as pilot:
|
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("#keep").visible is True
|
||||||
assert pilot.app.query_one("#hide-via-code").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
|
assert pilot.app.query_one("#hide-via-css").visible is True
|
||||||
|
|
||||||
pilot.app.query_one("#hide-via-code").styles.visibility = "hidden"
|
pilot.app.query_one("#hide-via-code").styles.visibility = "hidden"
|
||||||
await pilot.pause(0)
|
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("#keep").visible is True
|
||||||
assert pilot.app.query_one("#hide-via-code").visible is False
|
assert pilot.app.query_one("#hide-via-code").visible is False
|
||||||
assert pilot.app.query_one("#hide-via-css").visible is True
|
assert pilot.app.query_one("#hide-via-css").visible is True
|
||||||
|
|
||||||
pilot.app.query_one("#hide-via-css").set_class(True, "hidden")
|
pilot.app.query_one("#hide-via-css").set_class(True, "hidden")
|
||||||
await pilot.pause(0)
|
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("#keep").visible is True
|
||||||
assert pilot.app.query_one("#hide-via-code").visible is False
|
assert pilot.app.query_one("#hide-via-code").visible is False
|
||||||
assert pilot.app.query_one("#hide-via-css").visible is False
|
assert pilot.app.query_one("#hide-via-css").visible is False
|
||||||
|
|||||||
Reference in New Issue
Block a user