Merge pull request #855 from Textualize/regions-optimize

Regions optimize
This commit is contained in:
Will McGugan
2022-10-08 15:10:13 +01:00
committed by GitHub
9 changed files with 109 additions and 111 deletions

View File

@@ -1,14 +0,0 @@
from textual.app import App, ComposeResult
from textual.widgets import Button
class ButtonsApp(App):
def compose(self) -> ComposeResult:
yield Button("Paul")
yield Button("Duncan")
yield Button("Chani")
if __name__ == "__main__":
app = ButtonsApp()
app.run()

View File

@@ -16,7 +16,7 @@ from __future__ import annotations
import sys
from itertools import chain
from operator import itemgetter
from typing import TYPE_CHECKING, Callable, Iterable, Iterator, NamedTuple, cast
from typing import TYPE_CHECKING, Callable, Iterable, NamedTuple, cast
import rich.repr
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
@@ -182,8 +182,8 @@ class Compositor:
# Note this may be a superset of self.map.keys() as some widgets may be invisible for various reasons
self.widgets: set[Widget] = set()
# A lazy cache of visible (on screen) widgets
self._visible_widgets: set[Widget] | None = set()
# Mapping of visible widgets on to their region, and clip region
self._visible_widgets: dict[Widget, tuple[Region, Region]] | None = None
# The top level widget
self.root: Widget | None = None
@@ -191,10 +191,6 @@ class Compositor:
# Dimensions of the arrangement
self.size = Size(0, 0)
# A mapping of Widget on to region, and clip region
# The clip region can be considered the window through which a widget is viewed
self.regions: dict[Widget, tuple[Region, Region]] = {}
# The points in each line where the line bisects the left and right edges of the widget
self._cuts: list[list[int]] | None = None
@@ -259,6 +255,7 @@ class Compositor:
self._cuts = None
self._layers = None
self._layers_visible = None
self._visible_widgets = None
self.root = parent
self.size = size
@@ -277,12 +274,8 @@ class Compositor:
# Replace map and widgets
self.map = map
self.widgets = widgets
self._visible_widgets = None
# Get a map of regions
self.regions = {
widget: (region, clip) for widget, (region, _order, clip, *_) in map.items()
}
screen = size.region
# Widgets with changed size
resized_widgets = {
@@ -293,7 +286,6 @@ class Compositor:
# Gets pairs of tuples of (Widget, MapGeometry) which have changed
# i.e. if something is moved / deleted / added
screen = size.region
if screen not in self._dirty_regions:
crop_screen = screen.intersection
@@ -315,18 +307,26 @@ class Compositor:
)
@property
def visible_widgets(self) -> set[Widget]:
"""Get a set of visible widgets.
def visible_widgets(self) -> dict[Widget, tuple[Region, Region]]:
"""Get a mapping of widgets on to region and clip.
Returns:
set[Widget]: Widgets in the screen.
dict[Widget, tuple[Region, Region]]: visible widget mapping.
"""
if self._visible_widgets is None:
in_screen = self.size.region.__contains__
screen = self.size.region
in_screen = screen.overlaps
overlaps = Region.overlaps
# Widgets and regions in render order
visible_widgets = [
(order, widget, region, clip)
for widget, (region, order, clip, _, _, _) in self.map.items()
if in_screen(region) and overlaps(clip, region)
]
visible_widgets.sort(key=itemgetter(0), reverse=True)
self._visible_widgets = {
widget
for widget, (region, clip) in self.regions.items()
if in_screen(region)
widget: (region, clip) for _, widget, region, clip in visible_widgets
}
return self._visible_widgets
@@ -480,41 +480,23 @@ class Compositor:
@property
def layers_visible(self) -> list[list[tuple[Widget, Region, Region]]]:
"""Visible widgets and regions in layers order."""
screen_height = self.size.height
if self._layers_visible is None:
layers_visible: list[list[tuple[Widget, Region, Region]]]
layers_visible = [[] for y in range(self.size.height)]
layers_visible_appends = [layer.append for layer in layers_visible]
intersection = Region.intersection
_range = range
for widget, (region, _, clip, _, _, _) in self.layers:
_x, y, _width, height = region
if -height <= y < screen_height:
cropped_region = intersection(region, clip)
_x, region_y, _width, region_height = cropped_region
if region_height:
widget_location = (widget, cropped_region, region)
for y in _range(region_y, region_y + region_height):
layers_visible_appends[y](widget_location)
for widget, (region, clip) in self.visible_widgets.items():
cropped_region = intersection(region, clip)
_x, region_y, _width, region_height = cropped_region
if region_height:
widget_location = (widget, cropped_region, region)
for y in _range(region_y, region_y + region_height):
layers_visible_appends[y](widget_location)
self._layers_visible = layers_visible
return self._layers_visible
def __iter__(self) -> Iterator[tuple[Widget, Region, Size, Size]]:
"""Iterate map with information regarding each widget and is position
Yields:
Iterator[tuple[Widget, Region, Region, Size, Size]]: Iterates a tuple of
Widget, region, virtual size, and container size.
"""
layers = self.layers
for widget, (region, _order, clip, virtual_size, container_size, _) in layers:
yield (
widget,
region,
virtual_size,
container_size,
)
def get_offset(self, widget: Widget) -> Offset:
"""Get the offset of a widget."""
try:
@@ -537,9 +519,10 @@ class Compositor:
"""
contains = Region.contains
for widget, cropped_region, region in self.layers_visible[y]:
if contains(cropped_region, x, y) and widget.visible:
return widget, region
if len(self.layers_visible) > y >= 0:
for widget, cropped_region, region in self.layers_visible[y]:
if contains(cropped_region, x, y) and widget.visible:
return widget, region
raise errors.NoWidget(f"No widget under screen coordinate ({x}, {y})")
def get_widgets_at(self, x: int, y: int) -> Iterable[tuple[Widget, Region]]:
@@ -571,7 +554,7 @@ class Compositor:
widget, region = self.get_widget_at(x, y)
except errors.NoWidget:
return Style.null()
if widget not in self.regions:
if widget not in self.visible_widgets:
return Style.null()
x -= region.x
@@ -627,7 +610,7 @@ class Compositor:
intersection = Region.intersection
extend = list.extend
for region, order, clip, *_ in self.map.values():
for region, clip in self.visible_widgets.values():
region = intersection(region, clip)
if region and (region in screen_region):
x, y, region_width, region_height = region
@@ -652,6 +635,9 @@ class Compositor:
# up to this point.
_rich_traceback_guard = True
if not self.map:
return
def is_visible(widget: Widget) -> bool:
"""Return True if the widget is (literally) visible by examining various
properties which affect whether it can be seen or not."""
@@ -661,45 +647,42 @@ class Compositor:
and widget.styles.opacity > 0
)
if self.map:
if crop:
overlaps = crop.overlaps
mapped_regions = [
(widget, region, order, clip)
for widget, (region, order, clip, *_) in self.map.items()
if is_visible(widget) and overlaps(crop)
]
else:
mapped_regions = [
(widget, region, order, clip)
for widget, (region, order, clip, *_) in self.map.items()
if is_visible(widget)
]
_Region = Region
widget_regions = sorted(mapped_regions, key=itemgetter(2), reverse=True)
visible_widgets = self.visible_widgets
if crop:
crop_overlaps = crop.overlaps
widget_regions = [
(widget, region, clip)
for widget, (region, clip) in visible_widgets.items()
if crop_overlaps(clip) and is_visible(widget)
]
else:
widget_regions = []
widget_regions = [
(widget, region, clip)
for widget, (region, clip) in visible_widgets.items()
if is_visible(widget)
]
intersection = Region.intersection
overlaps = Region.overlaps
intersection = _Region.intersection
contains_region = _Region.contains_region
for widget, region, _order, clip in widget_regions:
if not region:
continue
if region in clip:
lines = widget.render_lines(Region(0, 0, region.width, region.height))
yield region, clip, lines
elif overlaps(clip, region):
for widget, region, clip in widget_regions:
if contains_region(clip, region):
yield region, clip, widget.render_lines(
_Region(0, 0, region.width, region.height)
)
else:
clipped_region = intersection(region, clip)
if not clipped_region:
continue
new_x, new_y, new_width, new_height = clipped_region
delta_x = new_x - region.x
delta_y = new_y - region.y
lines = widget.render_lines(
Region(delta_x, delta_y, new_width, new_height)
yield region, clip, widget.render_lines(
_Region(delta_x, delta_y, new_width, new_height)
)
yield region, clip, lines
@classmethod
def _assemble_chops(
@@ -813,8 +796,9 @@ class Compositor:
"""
regions: list[Region] = []
add_region = regions.append
for widget in self.regions.keys() & widgets:
region, clip = self.regions[widget]
get_widget = self.visible_widgets.__getitem__
for widget in self.visible_widgets.keys() & widgets:
region, clip = get_widget(widget)
offset = region.offset
intersection = clip.intersection
for dirty_region in widget._exchange_repaint_regions():

View File

@@ -46,7 +46,7 @@ from ..geometry import Spacing, SpacingDimensions, clamp
if TYPE_CHECKING:
from .._layout import Layout
from .styles import DockGroup, Styles, StylesBase
from .styles import Styles, StylesBase
from .types import DockEdge, EdgeType, AlignHorizontal, AlignVertical
@@ -409,12 +409,37 @@ class BorderProperty:
"""
_rich_traceback_omit = True
top, right, bottom, left = self._properties
border_spacing = Edges(
getattr(obj, top),
getattr(obj, right),
getattr(obj, bottom),
getattr(obj, left),
).spacing
def check_refresh() -> None:
"""Check if an update requires a layout"""
if not self._layout:
obj.refresh()
else:
layout = (
Edges(
getattr(obj, top),
getattr(obj, right),
getattr(obj, bottom),
getattr(obj, left),
).spacing
!= border_spacing
)
obj.refresh(layout=layout)
if border is None:
clear_rule = obj.clear_rule
clear_rule(top)
clear_rule(right)
clear_rule(bottom)
clear_rule(left)
check_refresh()
return
if isinstance(border, tuple) and len(border) == 2:
_border = normalize_border_value(border)
@@ -422,6 +447,7 @@ class BorderProperty:
setattr(obj, right, _border)
setattr(obj, bottom, _border)
setattr(obj, left, _border)
check_refresh()
return
count = len(border)
@@ -456,7 +482,7 @@ class BorderProperty:
"expected 1, 2, or 4 values",
help_text=border_property_help_text(self.name, context="inline"),
)
obj.refresh(layout=self._layout)
check_refresh()
class SpacingProperty:

View File

@@ -643,7 +643,7 @@ class Styles(StylesBase):
speed: float | None,
easing: EasingFunction,
on_complete: CallbackType | None = None,
) -> Animation | None:
) -> ScalarAnimation | None:
# from ..widget import Widget
# node = self.node
# assert isinstance(self.node, Widget)

View File

@@ -346,7 +346,8 @@ class Region(NamedTuple):
def __bool__(self) -> bool:
"""A Region is considered False when it has no area."""
return bool(self.width and self.height)
_, _, width, height = self
return width * height > 0
@property
def column_span(self) -> tuple[int, int]:
@@ -603,6 +604,7 @@ class Region(NamedTuple):
raise TypeError(f"a tuple of two integers is required, not {point!r}")
return (x2 > ox >= x1) and (y2 > oy >= y1)
@lru_cache(maxsize=1024)
def contains_region(self, other: Region) -> bool:
"""Check if a region is entirely contained within this region.

View File

@@ -76,7 +76,7 @@ class ScrollView(Widget):
self._size = size
virtual_size = self.virtual_size
self._scroll_update(virtual_size)
self._container_size = size - self.gutter.totals
self._container_size = size - self.styles.gutter.totals
self.scroll_to(self.scroll_x, self.scroll_y, animate=False)
self.refresh()

View File

@@ -1680,21 +1680,22 @@ class Widget(DOMNode):
return lines
def get_style_at(self, x: int, y: int) -> Style:
"""Get the Rich style at a given screen offset.
"""Get the Rich style in a widget at a given relative offset.
Args:
x (int): X coordinate relative to the screen.
y (int): Y coordinate relative to the screen.
x (int): X coordinate relative to the widget.
y (int): Y coordinate relative to the widget.
Returns:
Style: A rich Style object.
"""
widget, region = self.screen.get_widget_at(x, y)
offset = Offset(x, y)
screen_offset = offset + self.region.offset
widget, _ = self.screen.get_widget_at(*screen_offset)
if widget is not self:
return Style()
offset_x, offset_y = region.offset
# offset_x, offset_y = self.screen.get_offset(self)
return self.screen.get_style_at(x + offset_x, y + offset_y)
return self.screen.get_style_at(*screen_offset)
async def _forward_event(self, event: events.Event) -> None:
event._set_forwarded()
@@ -1733,8 +1734,6 @@ class Widget(DOMNode):
self._content_height_cache = (None, 0)
self._rich_style_cache.clear()
self._repaint_required = True
if isinstance(self.parent, Widget) and self.styles.auto_dimensions:
self.parent.refresh(layout=True)
self.check_idle()

View File

@@ -269,9 +269,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
column.content_width = max(column.content_width, content_width)
total_width = sum(column.render_width for column in self.columns)
header_height = self.header_height if self.show_header else 0
self.virtual_size = Size(
total_width,
max(len(self._y_offsets), (self.header_height if self.show_header else 0)),
len(self._y_offsets) + header_height,
)
def _get_cell_region(self, row_index: int, column_index: int) -> Region:

View File

@@ -126,7 +126,7 @@ class AppTest(App):
widget, region = self.get_widget_at(x, y)
except errors.NoWidget:
return ""
if widget not in self.screen._compositor.regions:
if widget not in self.screen._compositor.visible_widgets:
return ""
x -= region.x