Merge pull request #493 from Textualize/compositor-granularity

Optimize Compositor by combining updates
This commit is contained in:
Will McGugan
2022-05-12 11:41:02 +01:00
committed by GitHub
16 changed files with 219 additions and 149 deletions

View File

@@ -167,7 +167,6 @@ TweetBody {
OptionItem {
height: 3;
background: $primary;
transition: background 100ms linear;
border-right: outer $primary-darken-2;
border-left: hidden;
content-align: center middle;

View File

@@ -15,12 +15,12 @@ from __future__ import annotations
from operator import attrgetter, itemgetter
import sys
from typing import cast, Iterator, Iterable, NamedTuple, TYPE_CHECKING
from typing import Callable, cast, Iterator, Iterable, NamedTuple, TYPE_CHECKING
import rich.repr
from rich.console import Console, ConsoleOptions, RenderResult
from rich.console import Console, ConsoleOptions, RenderResult, RenderableType
from rich.control import Control
from rich.segment import Segment, SegmentLines
from rich.segment import Segment
from rich.style import Style
from . import errors
@@ -78,6 +78,7 @@ class LayoutUpdate:
new_line = Segment.line()
move_to = Control.move_to
for last, (y, line) in loop_last(enumerate(self.lines, self.region.y)):
yield Control.home()
yield move_to(x, y)
yield from line
if not last:
@@ -91,6 +92,33 @@ class LayoutUpdate:
yield "height", height
@rich.repr.auto
class SpansUpdate:
"""A renderable that applies updated spans to the screen."""
def __init__(self, spans: list[tuple[int, int, list[Segment]]]) -> None:
"""Apply spans, which consist of a tuple of (LINE, OFFSET, SEGMENTS)
Args:
spans (list[tuple[int, int, list[Segment]]]): A list of spans.
"""
self.spans = spans
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
move_to = Control.move_to
new_line = Segment.line()
for last, (y, x, segments) in loop_last(self.spans):
yield move_to(x, y)
yield from segments
if not last:
yield new_line
def __rich_repr__(self) -> rich.repr.Result:
yield [(y, x, "...") for y, x, _segments in self.spans]
@rich.repr.auto(angular=True)
class Compositor:
"""Responsible for storing information regarding the relative positions of Widgets and rendering them."""
@@ -116,6 +144,42 @@ class Compositor:
# The points in each line where the line bisects the left and right edges of the widget
self._cuts: list[list[int]] | None = None
@classmethod
def _regions_to_spans(
cls, regions: Iterable[Region]
) -> Iterable[tuple[int, int, int]]:
"""Converts the regions to horizontal spans. Spans will be combined if they overlap
or are contiguous to produce optimal non-overlapping spans.
Args:
regions (Iterable[Region]): An iterable of Regions.
Returns:
Iterable[tuple[int, int, int]]: Yields tuples of (Y, X1, X2)
"""
inline_ranges: dict[int, list[tuple[int, int]]] = {}
for region_x, region_y, width, height in regions:
span = (region_x, region_x + width)
for y in range(region_y, region_y + height):
inline_ranges.setdefault(y, []).append(span)
for y, ranges in sorted(inline_ranges.items()):
if len(ranges) == 1:
# Special case of 1 span
yield (y, *ranges[0])
else:
ranges.sort()
x1, x2 = ranges[0]
for next_x1, next_x2 in ranges[1:]:
if next_x1 <= x2:
if next_x2 > x2:
x2 = next_x2
else:
yield (y, x1, x2)
x1 = next_x1
x2 = next_x2
yield (y, x1, x2)
def __rich_repr__(self) -> rich.repr.Result:
yield "size", self.size
yield "widgets", self.widgets
@@ -452,11 +516,7 @@ class Compositor:
]
return segment_lines
def render(
self,
*,
crop: Region | None = None,
) -> SegmentLines:
def render(self, regions: list[Region] | None = None) -> RenderableType:
"""Render a layout.
Args:
@@ -467,8 +527,15 @@ class Compositor:
"""
width, height = self.size
screen_region = Region(0, 0, width, height)
crop_region = crop.intersection(screen_region) if crop else screen_region
if regions:
# Create a crop regions that surrounds all updates
crop = Region.from_union(regions).intersection(screen_region)
spans = list(self._regions_to_spans(regions))
is_rendered_line = {y for y, _, _ in spans}.__contains__
else:
crop = screen_region
spans = []
is_rendered_line = lambda y: True
_Segment = Segment
divide = _Segment.divide
@@ -480,9 +547,8 @@ class Compositor:
"Callable[[list[int]], dict[int, list[Segment] | None]]", dict.fromkeys
)
# A mapping of cut index to a list of segments for each line
chops: list[dict[int, list[Segment] | None]] = [
fromkeys(cut_set) for cut_set in cuts
]
chops: list[dict[int, list[Segment] | None]]
chops = [fromkeys(cut_set) for cut_set in cuts]
# Go through all the renders in reverse order and fill buckets with no render
renders = self._get_renders(crop)
@@ -492,6 +558,8 @@ class Compositor:
render_region = intersection(region, clip)
for y, line in zip(render_region.y_range, lines):
if not is_rendered_line(y):
continue
first_cut, last_cut = render_region.x_extents
final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)]
@@ -501,6 +569,7 @@ class Compositor:
else:
render_x = render_region.x
relative_cuts = [cut - render_x for cut in final_cuts]
# print(relative_cuts)
_, *cut_segments = divide(line, relative_cuts)
# Since we are painting front to back, the first segments for a cut "wins"
@@ -509,24 +578,25 @@ class Compositor:
if chops_line[cut] is None:
chops_line[cut] = segments
# Assemble the cut renders in to lists of segments
crop_x, crop_y, crop_x2, crop_y2 = crop_region.corners
render_lines = self._assemble_chops(chops[crop_y:crop_y2])
if crop is not None and (crop_x, crop_x2) != (0, width):
render_lines = [
line_crop(line, crop_x, crop_x2) if line else line
for line in render_lines
if regions:
crop_y, crop_y2 = crop.y_extents
render_lines = self._assemble_chops(chops[crop_y:crop_y2])
render_spans = [
(y, x1, line_crop(render_lines[y - crop_y], x1, x2))
for y, x1, x2 in spans
]
return SpansUpdate(render_spans)
return SegmentLines(render_lines, new_lines=True)
else:
render_lines = self._assemble_chops(chops)
return LayoutUpdate(render_lines, screen_region)
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
yield self.render()
def update_widget(self, console: Console, widget: Widget) -> LayoutUpdate | None:
def update_widgets(self, widgets: set[Widget]) -> RenderableType | None:
"""Update a given widget in the composition.
Args:
@@ -536,14 +606,12 @@ class Compositor:
Returns:
LayoutUpdate | None: A renderable or None if nothing to render.
"""
if widget not in self.regions:
return None
region, clip = self.regions[widget]
if not region:
return None
update_region = region.intersection(clip)
if not update_region:
return None
update_lines = self.render(crop=update_region).lines
update = LayoutUpdate(update_lines, update_region)
regions: list[Region] = []
add_region = regions.append
for widget in self.regions.keys() & widgets:
region, clip = self.regions[widget]
update_region = region.intersection(clip)
if update_region:
add_region(update_region)
update = self.render(regions or None)
return update

View File

@@ -24,6 +24,9 @@ class Layout(ABC):
name: ClassVar[str] = ""
def __repr__(self) -> str:
return f"<{self.name}>"
@abstractmethod
def arrange(
self, parent: Widget, size: Size, scroll: Offset

View File

@@ -1,48 +0,0 @@
from __future__ import annotations
from collections import defaultdict
from operator import attrgetter
from typing import NamedTuple, Iterable
from .geometry import Region
class InlineRange(NamedTuple):
"""Represents a region on a single line."""
line_index: int
start: int
end: int
def regions_to_ranges(regions: Iterable[Region]) -> Iterable[InlineRange]:
"""Converts the regions to non-overlapping horizontal strips, where each strip
represents the region on a single line. Combining the resulting strips therefore
results in a shape identical to the combined original regions.
Args:
regions (Iterable[Region]): An iterable of Regions.
Returns:
Iterable[InlineRange]: Yields InlineRange objects representing the content on
a single line, with overlaps removed.
"""
inline_ranges: dict[int, list[InlineRange]] = defaultdict(list)
for region_x, region_y, width, height in regions:
for y in range(region_y, region_y + height):
inline_ranges[y].append(
InlineRange(line_index=y, start=region_x, end=region_x + width - 1)
)
get_start = attrgetter("start")
for line_index, ranges in inline_ranges.items():
sorted_ranges = iter(sorted(ranges, key=get_start))
_, start, end = next(sorted_ranges)
for next_line_index, next_start, next_end in sorted_ranges:
if next_start <= end + 1:
end = max(end, next_end)
else:
yield InlineRange(line_index, start, end)
start = next_start
end = next_end
yield InlineRange(line_index, start, end)

View File

@@ -643,6 +643,7 @@ class App(Generic[ReturnType], DOMNode):
def fatal_error(self) -> None:
"""Exits the app after an unhandled exception."""
self.console.bell()
traceback = Traceback(
show_locals=True, width=None, locals_max_length=5, suppress=[rich]
)
@@ -945,14 +946,14 @@ class App(Generic[ReturnType], DOMNode):
action_target = default_namespace or self
action_name = target
log("action", action)
log("<action>", action)
await self.dispatch_action(action_target, action_name, params)
async def dispatch_action(
self, namespace: object, action_name: str, params: Any
) -> None:
log(
"dispatch_action",
"<action>",
namespace=namespace,
action_name=action_name,
params=params,

View File

@@ -7,7 +7,7 @@ from pathlib import Path, PurePath
from typing import cast, Iterable
import rich.repr
from rich.console import RenderableType, Console, ConsoleOptions
from rich.console import RenderableType, RenderResult, Console, ConsoleOptions
from rich.highlighter import ReprHighlighter
from rich.markup import render
from rich.padding import Padding
@@ -68,10 +68,10 @@ class StylesheetErrors:
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderableType:
) -> RenderResult:
error_count = 0
for rule in self.rules:
for is_last, (token, message) in loop_last(rule.errors):
for token, message in rule.errors:
error_count += 1
if token.path:
@@ -297,7 +297,6 @@ class Stylesheet:
for name, specificity_rules in rule_attributes.items()
},
)
self.replace_rules(node, node_rules, animate=animate)
@classmethod
@@ -363,8 +362,9 @@ class Stylesheet:
setattr(base_styles, key, new_value)
else:
# Not animated, so we apply the rules directly
get_rule = rules.get
for key in modified_rule_keys:
setattr(base_styles, key, rules.get(key))
setattr(base_styles, key, get_rule(key))
def update(self, root: DOMNode, animate: bool = False) -> None:
"""Update a node and its children."""

View File

@@ -6,7 +6,7 @@ Functions and classes to manage terminal geometry (anything involving coordinate
from __future__ import annotations
from typing import Any, cast, NamedTuple, Tuple, Union, TypeVar
from typing import Any, cast, Iterable, NamedTuple, Tuple, Union, TypeVar
SpacingDimensions = Union[int, Tuple[int], Tuple[int, int], Tuple[int, int, int, int]]
@@ -181,6 +181,24 @@ class Region(NamedTuple):
width: int = 0
height: int = 0
@classmethod
def from_union(cls, regions: list[Region]) -> Region:
"""Create a Region from the union of other regions.
Args:
regions (Iterable[Region]): One or more regions.
Returns:
Region: A Region that encloses all other regions.
"""
if not regions:
raise ValueError("At least one region expected")
min_x = min([region.x for region in regions])
max_x = max([x + width for x, _y, width, _height in regions])
min_y = min([region.y for region in regions])
max_y = max([y + height for _x, y, _width, height in regions])
return cls(min_x, min_y, max_x - min_x, max_y - min_y)
@classmethod
def from_corners(cls, x1: int, y1: int, x2: int, y2: int) -> Region:
"""Construct a Region form the top left and bottom right corners.

View File

@@ -149,8 +149,11 @@ class MessagePump:
callback: TimerCallback = None,
*,
name: str | None = None,
pause: bool = False,
) -> Timer:
timer = Timer(self, delay, self, name=name, callback=callback, repeat=0)
timer = Timer(
self, delay, self, name=name, callback=callback, repeat=0, pause=pause
)
self._child_tasks.add(timer.start())
return timer
@@ -161,9 +164,16 @@ class MessagePump:
*,
name: str | None = None,
repeat: int = 0,
pause: bool = False,
):
timer = Timer(
self, interval, self, name=name, callback=callback, repeat=repeat or None
self,
interval,
self,
name=name,
callback=callback,
repeat=repeat or None,
pause=pause,
)
self._child_tasks.add(timer.start())
return timer

View File

@@ -33,7 +33,7 @@ class Screen(Widget):
def __init__(self, name: str | None = None, id: str | None = None) -> None:
super().__init__(name=name, id=id)
self._compositor = Compositor()
self._dirty_widgets: list[Widget] = []
self._dirty_widgets: set[Widget] = set()
def watch_dark(self, dark: bool) -> None:
pass
@@ -90,19 +90,27 @@ class Screen(Widget):
def on_idle(self, event: events.Idle) -> None:
# Check for any widgets marked as 'dirty' (needs a repaint)
if self._dirty_widgets:
for widget in self._dirty_widgets:
# Repaint widgets
# TODO: Combine these in to a single update.
display_update = self._compositor.update_widget(self.console, widget)
if display_update is not None:
self.app.display(display_update)
# Reset dirty list
self._update_timer.resume()
def _on_update(self) -> None:
"""Called by the _update_timer."""
# Render widgets together
if self._dirty_widgets:
self.log(dirty=self._dirty_widgets)
display_update = self._compositor.update_widgets(self._dirty_widgets)
if display_update is not None:
self.app.display(display_update)
self._dirty_widgets.clear()
self._update_timer.pause()
def refresh_layout(self) -> None:
"""Refresh the layout (can change size and positions of widgets)."""
if not self.size:
return
# This paint the entire screen, so replaces the batched dirty widgets
self._update_timer.pause()
self._dirty_widgets.clear()
try:
hidden, shown, resized = self._compositor.reflow(self, self.size)
@@ -133,26 +141,27 @@ class Screen(Widget):
self.app.on_exception(error)
return
self.app.refresh()
self._dirty_widgets.clear()
async def handle_update(self, message: messages.Update) -> None:
message.stop()
widget = message.widget
assert isinstance(widget, Widget)
self._dirty_widgets.append(widget)
self._dirty_widgets.add(widget)
self.check_idle()
async def handle_layout(self, message: messages.Layout) -> None:
message.stop()
self.refresh_layout()
def on_mount(self, event: events.Mount) -> None:
self._update_timer = self.set_interval(1 / 20, self._on_update, pause=True)
async def on_resize(self, event: events.Resize) -> None:
self.size_updated(event.size, event.virtual_size, event.container_size)
self.refresh_layout()
event.stop()
async def _on_mouse_move(self, event: events.MouseMove) -> None:
try:
if self.app.mouse_captured:
widget = self.app.mouse_captured

View File

@@ -227,9 +227,6 @@ class ScrollBar(Widget):
style=scrollbar_style,
)
async def on_event(self, event) -> None:
await super().on_event(event)
async def on_enter(self, event: events.Enter) -> None:
self.mouse_over = True

View File

@@ -84,6 +84,7 @@ class Widget(DOMNode):
self._virtual_size = Size(0, 0)
self._container_size = Size(0, 0)
self._layout_required = False
self._repaint_required = False
self._default_layout = VerticalLayout()
self._animate: BoundAnimator | None = None
self._reactive_watches: dict[str, Callable] = {}
@@ -273,6 +274,8 @@ class Widget(DOMNode):
self.show_horizontal_scrollbar = show_horizontal
self.show_vertical_scrollbar = show_vertical
self.horizontal_scrollbar.display = show_horizontal
self.vertical_scrollbar.display = show_vertical
@property
def scrollbars_enabled(self) -> tuple[bool, bool]:
@@ -312,7 +315,6 @@ class Widget(DOMNode):
Returns:
bool: True if the scroll position changed, otherwise False.
"""
scrolled_x = scrolled_y = False
if animate:
@@ -342,13 +344,13 @@ class Widget(DOMNode):
else:
if x is not None:
if x != self.scroll_x:
self.scroll_target_x = self.scroll_x = x
scrolled_x = True
scroll_x = self.scroll_x
self.scroll_target_x = self.scroll_x = x
scrolled_x = scroll_x != self.scroll_x
if y is not None:
if y != self.scroll_y:
self.scroll_target_y = self.scroll_y = y
scrolled_y = True
scroll_y = self.scroll_y
self.scroll_target_y = self.scroll_y = y
scrolled_y = scroll_y != self.scroll_y
if scrolled_x or scrolled_y:
self.refresh(repaint=False, layout=True)
@@ -735,8 +737,10 @@ class Widget(DOMNode):
if self._dirty_regions:
self._render_lines()
if self.is_container:
self.horizontal_scrollbar.refresh()
self.vertical_scrollbar.refresh()
if self.show_horizontal_scrollbar:
self.horizontal_scrollbar.refresh()
if self.show_vertical_scrollbar:
self.vertical_scrollbar.refresh()
lines = self._render_cache.lines[start:end]
return lines
@@ -772,6 +776,7 @@ class Widget(DOMNode):
self._layout_required = True
if repaint:
self.set_dirty()
self._repaint_required = True
self.check_idle()
def render(self, style: Style) -> RenderableType:
@@ -783,13 +788,7 @@ class Widget(DOMNode):
Returns:
RenderableType: Any renderable
"""
# Default displays a pretty repr in the center of the screen
if self.is_container:
return ""
return self.css_identifier_styled
return "" if self.is_container else self.css_identifier_styled
async def action(self, action: str, *params) -> None:
await self.app.action(action, self)
@@ -811,8 +810,9 @@ class Widget(DOMNode):
if self.check_layout():
self._reset_check_layout()
self.screen.post_message_no_wait(messages.Layout(self))
elif self._dirty_regions:
elif self._repaint_required:
self.emit_no_wait(messages.Update(self, self))
self._repaint_required = False
def focus(self) -> None:
"""Give input focus to this widget."""

View File

@@ -1,9 +1,7 @@
from __future__ import annotations
from rich.console import RenderableType
from rich.padding import Padding, PaddingDimensions
from rich.style import StyleType, Style
from rich.styled import Styled
from rich.style import Style
from ..widget import Widget
@@ -16,20 +14,13 @@ class Static(Widget):
name: str | None = None,
id: str | None = None,
classes: str | None = None,
style: StyleType = "",
padding: PaddingDimensions = 0,
) -> None:
super().__init__(name=name, id=id, classes=classes)
self.renderable = renderable
self.style = style
self.padding = padding
def render(self, style: Style) -> RenderableType:
renderable = self.renderable
if self.padding:
renderable = Padding(renderable, self.padding)
return Styled(renderable, self.style)
return self.renderable
async def update(self, renderable: RenderableType) -> None:
def update(self, renderable: RenderableType) -> None:
self.renderable = renderable
self.refresh()
self.refresh(layout=True)

View File

@@ -1,44 +1,54 @@
from textual._region_group import regions_to_ranges, InlineRange
from textual._compositor import Compositor
from textual.geometry import Region
def test_regions_to_ranges_no_regions():
assert list(regions_to_ranges([])) == []
assert list(Compositor._regions_to_spans([])) == []
def test_regions_to_ranges_single_region():
regions = [Region(0, 0, 3, 2)]
assert list(regions_to_ranges(regions)) == [InlineRange(0, 0, 2), InlineRange(1, 0, 2)]
assert list(Compositor._regions_to_spans(regions)) == [
(0, 0, 3),
(1, 0, 3),
]
def test_regions_to_ranges_partially_overlapping_regions():
regions = [Region(0, 0, 2, 2), Region(1, 1, 2, 2)]
assert list(regions_to_ranges(regions)) == [
InlineRange(0, 0, 1), InlineRange(1, 0, 2), InlineRange(2, 1, 2),
assert list(Compositor._regions_to_spans(regions)) == [
(0, 0, 2),
(1, 0, 3),
(2, 1, 3),
]
def test_regions_to_ranges_fully_overlapping_regions():
regions = [Region(1, 1, 3, 3), Region(2, 2, 1, 1), Region(0, 2, 3, 1)]
assert list(regions_to_ranges(regions)) == [
InlineRange(1, 1, 3), InlineRange(2, 0, 3), InlineRange(3, 1, 3)
assert list(Compositor._regions_to_spans(regions)) == [
(1, 1, 4),
(2, 0, 4),
(3, 1, 4),
]
def test_regions_to_ranges_disjoint_regions_different_lines():
regions = [Region(0, 0, 2, 1), Region(2, 2, 2, 1)]
assert list(regions_to_ranges(regions)) == [InlineRange(0, 0, 1), InlineRange(2, 2, 3)]
assert list(Compositor._regions_to_spans(regions)) == [(0, 0, 2), (2, 2, 4)]
def test_regions_to_ranges_disjoint_regions_same_line():
regions = [Region(0, 0, 1, 2), Region(2, 0, 1, 1)]
assert list(regions_to_ranges(regions)) == [
InlineRange(0, 0, 0), InlineRange(0, 2, 2), InlineRange(1, 0, 0)
assert list(Compositor._regions_to_spans(regions)) == [
(0, 0, 1),
(0, 2, 3),
(1, 0, 1),
]
def test_regions_to_ranges_directly_adjacent_ranges_merged():
regions = [Region(0, 0, 1, 2), Region(1, 0, 1, 2)]
assert list(regions_to_ranges(regions)) == [
InlineRange(0, 0, 1), InlineRange(1, 0, 1)
assert list(Compositor._regions_to_spans(regions)) == [
(0, 0, 2),
(1, 0, 2),
]

View File

@@ -114,6 +114,17 @@ def test_region_null():
assert not Region()
def test_region_from_union():
with pytest.raises(ValueError):
Region.from_union([])
regions = [
Region(10, 20, 30, 40),
Region(15, 25, 5, 5),
Region(30, 25, 20, 10),
]
assert Region.from_union(regions) == Region(10, 20, 40, 40)
def test_region_from_origin():
assert Region.from_origin(Offset(3, 4), (5, 6)) == Region(3, 4, 5, 6)

View File

@@ -21,6 +21,7 @@ from textual.widgets import Placeholder
SCREEN_SIZE = Size(100, 30)
@pytest.mark.skip("flaky test")
@pytest.mark.asyncio
@pytest.mark.integration_test # this is a slow test, we may want to skip them in some contexts
@pytest.mark.parametrize(

View File

@@ -58,7 +58,7 @@ class AppTest(App):
def in_running_state(
self,
*,
waiting_duration_after_initialisation: float = 0.001,
waiting_duration_after_initialisation: float = 0.1,
waiting_duration_post_yield: float = 0,
) -> AsyncContextManager:
async def run_app() -> None: