mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
improved compositor granularity
This commit is contained in:
@@ -18,7 +18,7 @@ import sys
|
|||||||
from typing import cast, Iterator, Iterable, NamedTuple, TYPE_CHECKING
|
from typing import cast, Iterator, Iterable, NamedTuple, TYPE_CHECKING
|
||||||
|
|
||||||
import rich.repr
|
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.control import Control
|
||||||
from rich.segment import Segment, SegmentLines
|
from rich.segment import Segment, SegmentLines
|
||||||
from rich.style import Style
|
from rich.style import Style
|
||||||
@@ -91,6 +91,23 @@ class LayoutUpdate:
|
|||||||
yield "height", height
|
yield "height", height
|
||||||
|
|
||||||
|
|
||||||
|
@rich.repr.auto
|
||||||
|
class SpansUpdate:
|
||||||
|
def __init__(self, spans: list[tuple[int, int, list[Segment]]]) -> None:
|
||||||
|
self.spans = spans
|
||||||
|
|
||||||
|
def __rich_console__(
|
||||||
|
self, console: Console, options: ConsoleOptions
|
||||||
|
) -> RenderResult:
|
||||||
|
move_to = Control.move_to
|
||||||
|
for line, offset, segments in self.spans:
|
||||||
|
yield move_to(offset, line)
|
||||||
|
yield from segments
|
||||||
|
|
||||||
|
def __rich_repr__(self) -> rich.repr.Result:
|
||||||
|
yield self.spans
|
||||||
|
|
||||||
|
|
||||||
@rich.repr.auto(angular=True)
|
@rich.repr.auto(angular=True)
|
||||||
class Compositor:
|
class Compositor:
|
||||||
"""Responsible for storing information regarding the relative positions of Widgets and rendering them."""
|
"""Responsible for storing information regarding the relative positions of Widgets and rendering them."""
|
||||||
@@ -116,6 +133,43 @@ class Compositor:
|
|||||||
# The points in each line where the line bisects the left and right edges of the widget
|
# The points in each line where the line bisects the left and right edges of the widget
|
||||||
self._cuts: list[list[int]] | None = None
|
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 non-overlapping horizontal spans, where each span
|
||||||
|
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[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 - 1)
|
||||||
|
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 + 1:
|
||||||
|
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:
|
def __rich_repr__(self) -> rich.repr.Result:
|
||||||
yield "size", self.size
|
yield "size", self.size
|
||||||
yield "widgets", self.widgets
|
yield "widgets", self.widgets
|
||||||
@@ -452,11 +506,7 @@ class Compositor:
|
|||||||
]
|
]
|
||||||
return segment_lines
|
return segment_lines
|
||||||
|
|
||||||
def render(
|
def render(self, regions: list[Region] | None = None) -> RenderableType:
|
||||||
self,
|
|
||||||
*,
|
|
||||||
crop: Region | None = None,
|
|
||||||
) -> SegmentLines:
|
|
||||||
"""Render a layout.
|
"""Render a layout.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -467,8 +517,13 @@ class Compositor:
|
|||||||
"""
|
"""
|
||||||
width, height = self.size
|
width, height = self.size
|
||||||
screen_region = Region(0, 0, width, height)
|
screen_region = Region(0, 0, width, height)
|
||||||
|
if regions:
|
||||||
crop_region = crop.intersection(screen_region) if crop else screen_region
|
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
|
||||||
|
is_rendered_line = lambda y: True
|
||||||
|
|
||||||
_Segment = Segment
|
_Segment = Segment
|
||||||
divide = _Segment.divide
|
divide = _Segment.divide
|
||||||
@@ -492,6 +547,8 @@ class Compositor:
|
|||||||
render_region = intersection(region, clip)
|
render_region = intersection(region, clip)
|
||||||
|
|
||||||
for y, line in zip(render_region.y_range, lines):
|
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
|
first_cut, last_cut = render_region.x_extents
|
||||||
final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)]
|
final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)]
|
||||||
|
|
||||||
@@ -510,9 +567,18 @@ class Compositor:
|
|||||||
chops_line[cut] = segments
|
chops_line[cut] = segments
|
||||||
|
|
||||||
# Assemble the cut renders in to lists of segments
|
# Assemble the cut renders in to lists of segments
|
||||||
crop_x, crop_y, crop_x2, crop_y2 = crop_region.corners
|
crop_x, crop_y, crop_x2, crop_y2 = crop.corners
|
||||||
render_lines = self._assemble_chops(chops[crop_y:crop_y2])
|
render_lines = self._assemble_chops(chops[crop_y:crop_y2])
|
||||||
|
|
||||||
|
if not regions:
|
||||||
|
return SegmentLines(render_lines, new_lines=True)
|
||||||
|
|
||||||
|
print("SPANS", spans)
|
||||||
|
render_spans = [
|
||||||
|
(y, x1, line_crop(render_lines[y - crop_y], x1, x2)) for y, x1, x2 in spans
|
||||||
|
]
|
||||||
|
return SpansUpdate(render_spans)
|
||||||
|
|
||||||
if crop is not None and (crop_x, crop_x2) != (0, width):
|
if crop is not None and (crop_x, crop_x2) != (0, width):
|
||||||
render_lines = [
|
render_lines = [
|
||||||
line_crop(line, crop_x, crop_x2) if line else line
|
line_crop(line, crop_x, crop_x2) if line else line
|
||||||
@@ -526,7 +592,7 @@ class Compositor:
|
|||||||
) -> RenderResult:
|
) -> RenderResult:
|
||||||
yield self.render()
|
yield self.render()
|
||||||
|
|
||||||
def update_widget(self, console: Console, widget: Widget) -> LayoutUpdate | None:
|
def update_widgets(self, *widgets: Widget) -> RenderableType | None:
|
||||||
"""Update a given widget in the composition.
|
"""Update a given widget in the composition.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -536,14 +602,24 @@ class Compositor:
|
|||||||
Returns:
|
Returns:
|
||||||
LayoutUpdate | None: A renderable or None if nothing to render.
|
LayoutUpdate | None: A renderable or None if nothing to render.
|
||||||
"""
|
"""
|
||||||
|
log(widgets)
|
||||||
|
regions: list[Region] = []
|
||||||
|
add_region = regions.append
|
||||||
|
for widget in widgets:
|
||||||
if widget not in self.regions:
|
if widget not in self.regions:
|
||||||
return None
|
continue
|
||||||
region, clip = self.regions[widget]
|
region, clip = self.regions[widget]
|
||||||
if not region:
|
if not region:
|
||||||
return None
|
continue
|
||||||
update_region = region.intersection(clip)
|
update_region = region.intersection(clip)
|
||||||
if not update_region:
|
if not update_region:
|
||||||
return None
|
continue
|
||||||
update_lines = self.render(crop=update_region).lines
|
add_region(update_region)
|
||||||
update = LayoutUpdate(update_lines, update_region)
|
|
||||||
|
print(regions)
|
||||||
|
update = self.render(regions or None)
|
||||||
|
print("UPDATE", update)
|
||||||
return update
|
return update
|
||||||
|
# update = LayoutUpdate(update_lines, total_region)
|
||||||
|
# # print(widgets, total_region)
|
||||||
|
# return update
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -615,6 +615,7 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
is_renderable(renderable) for renderable in renderables
|
is_renderable(renderable) for renderable in renderables
|
||||||
), "Can only call panic with strings or Rich renderables"
|
), "Can only call panic with strings or Rich renderables"
|
||||||
|
|
||||||
|
self.console.bell()
|
||||||
prerendered = [
|
prerendered = [
|
||||||
Segments(self.console.render(renderable, self.console.options))
|
Segments(self.console.render(renderable, self.console.options))
|
||||||
for renderable in renderables
|
for renderable in renderables
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Functions and classes to manage terminal geometry (anything involving coordinate
|
|||||||
|
|
||||||
from __future__ import annotations
|
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]]
|
SpacingDimensions = Union[int, Tuple[int], Tuple[int, int], Tuple[int, int, int, int]]
|
||||||
|
|
||||||
@@ -181,6 +181,24 @@ class Region(NamedTuple):
|
|||||||
width: int = 0
|
width: int = 0
|
||||||
height: 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
|
@classmethod
|
||||||
def from_corners(cls, x1: int, y1: int, x2: int, y2: int) -> Region:
|
def from_corners(cls, x1: int, y1: int, x2: int, y2: int) -> Region:
|
||||||
"""Construct a Region form the top left and bottom right corners.
|
"""Construct a Region form the top left and bottom right corners.
|
||||||
|
|||||||
@@ -149,8 +149,11 @@ class MessagePump:
|
|||||||
callback: TimerCallback = None,
|
callback: TimerCallback = None,
|
||||||
*,
|
*,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
|
pause: bool = False,
|
||||||
) -> Timer:
|
) -> 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())
|
self._child_tasks.add(timer.start())
|
||||||
return timer
|
return timer
|
||||||
|
|
||||||
@@ -161,9 +164,16 @@ class MessagePump:
|
|||||||
*,
|
*,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
repeat: int = 0,
|
repeat: int = 0,
|
||||||
|
pause: bool = False,
|
||||||
):
|
):
|
||||||
timer = Timer(
|
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())
|
self._child_tasks.add(timer.start())
|
||||||
return timer
|
return timer
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from . import events, messages, errors
|
|||||||
|
|
||||||
from .geometry import Offset, Region
|
from .geometry import Offset, Region
|
||||||
from ._compositor import Compositor
|
from ._compositor import Compositor
|
||||||
|
from ._timer import Timer
|
||||||
from .reactive import Reactive
|
from .reactive import Reactive
|
||||||
from .widget import Widget
|
from .widget import Widget
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ class Screen(Widget):
|
|||||||
def __init__(self, name: str | None = None, id: str | None = None) -> None:
|
def __init__(self, name: str | None = None, id: str | None = None) -> None:
|
||||||
super().__init__(name=name, id=id)
|
super().__init__(name=name, id=id)
|
||||||
self._compositor = Compositor()
|
self._compositor = Compositor()
|
||||||
self._dirty_widgets: list[Widget] = []
|
self._dirty_widgets: set[Widget] = set()
|
||||||
|
|
||||||
def watch_dark(self, dark: bool) -> None:
|
def watch_dark(self, dark: bool) -> None:
|
||||||
pass
|
pass
|
||||||
@@ -90,14 +91,19 @@ class Screen(Widget):
|
|||||||
def on_idle(self, event: events.Idle) -> None:
|
def on_idle(self, event: events.Idle) -> None:
|
||||||
# Check for any widgets marked as 'dirty' (needs a repaint)
|
# Check for any widgets marked as 'dirty' (needs a repaint)
|
||||||
if self._dirty_widgets:
|
if self._dirty_widgets:
|
||||||
for widget in self._dirty_widgets:
|
self._update_timer.resume()
|
||||||
# Repaint widgets
|
|
||||||
# TODO: Combine these in to a single update.
|
def _on_update(self) -> None:
|
||||||
display_update = self._compositor.update_widget(self.console, widget)
|
"""Called by the _update_timer."""
|
||||||
|
|
||||||
|
# Render widgets together
|
||||||
|
if self._dirty_widgets:
|
||||||
|
self.log(dirty=len(self._dirty_widgets))
|
||||||
|
display_update = self._compositor.update_widgets(*self._dirty_widgets)
|
||||||
if display_update is not None:
|
if display_update is not None:
|
||||||
self.app.display(display_update)
|
self.app.display(display_update)
|
||||||
# Reset dirty list
|
|
||||||
self._dirty_widgets.clear()
|
self._dirty_widgets.clear()
|
||||||
|
self._update_timer.pause()
|
||||||
|
|
||||||
def refresh_layout(self) -> None:
|
def refresh_layout(self) -> None:
|
||||||
"""Refresh the layout (can change size and positions of widgets)."""
|
"""Refresh the layout (can change size and positions of widgets)."""
|
||||||
@@ -139,13 +145,16 @@ class Screen(Widget):
|
|||||||
message.stop()
|
message.stop()
|
||||||
widget = message.widget
|
widget = message.widget
|
||||||
assert isinstance(widget, Widget)
|
assert isinstance(widget, Widget)
|
||||||
self._dirty_widgets.append(widget)
|
self._dirty_widgets.add(widget)
|
||||||
self.check_idle()
|
self.check_idle()
|
||||||
|
|
||||||
async def handle_layout(self, message: messages.Layout) -> None:
|
async def handle_layout(self, message: messages.Layout) -> None:
|
||||||
message.stop()
|
message.stop()
|
||||||
self.refresh_layout()
|
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:
|
async def on_resize(self, event: events.Resize) -> None:
|
||||||
self.size_updated(event.size, event.virtual_size, event.container_size)
|
self.size_updated(event.size, event.virtual_size, event.container_size)
|
||||||
self.refresh_layout()
|
self.refresh_layout()
|
||||||
|
|||||||
@@ -1,44 +1,48 @@
|
|||||||
from textual._region_group import regions_to_ranges, InlineRange
|
from textual._compositor import Compositor
|
||||||
from textual.geometry import Region
|
from textual.geometry import Region
|
||||||
|
|
||||||
|
|
||||||
def test_regions_to_ranges_no_regions():
|
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():
|
def test_regions_to_ranges_single_region():
|
||||||
regions = [Region(0, 0, 3, 2)]
|
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, 2), (1, 0, 2)]
|
||||||
|
|
||||||
|
|
||||||
def test_regions_to_ranges_partially_overlapping_regions():
|
def test_regions_to_ranges_partially_overlapping_regions():
|
||||||
regions = [Region(0, 0, 2, 2), Region(1, 1, 2, 2)]
|
regions = [Region(0, 0, 2, 2), Region(1, 1, 2, 2)]
|
||||||
assert list(regions_to_ranges(regions)) == [
|
assert list(Compositor._regions_to_spans(regions)) == [
|
||||||
InlineRange(0, 0, 1), InlineRange(1, 0, 2), InlineRange(2, 1, 2),
|
(0, 0, 1),
|
||||||
|
(1, 0, 2),
|
||||||
|
(2, 1, 2),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_regions_to_ranges_fully_overlapping_regions():
|
def test_regions_to_ranges_fully_overlapping_regions():
|
||||||
regions = [Region(1, 1, 3, 3), Region(2, 2, 1, 1), Region(0, 2, 3, 1)]
|
regions = [Region(1, 1, 3, 3), Region(2, 2, 1, 1), Region(0, 2, 3, 1)]
|
||||||
assert list(regions_to_ranges(regions)) == [
|
assert list(Compositor._regions_to_spans(regions)) == [
|
||||||
InlineRange(1, 1, 3), InlineRange(2, 0, 3), InlineRange(3, 1, 3)
|
(1, 1, 3),
|
||||||
|
(2, 0, 3),
|
||||||
|
(3, 1, 3),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_regions_to_ranges_disjoint_regions_different_lines():
|
def test_regions_to_ranges_disjoint_regions_different_lines():
|
||||||
regions = [Region(0, 0, 2, 1), Region(2, 2, 2, 1)]
|
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, 1), (2, 2, 3)]
|
||||||
|
|
||||||
|
|
||||||
def test_regions_to_ranges_disjoint_regions_same_line():
|
def test_regions_to_ranges_disjoint_regions_same_line():
|
||||||
regions = [Region(0, 0, 1, 2), Region(2, 0, 1, 1)]
|
regions = [Region(0, 0, 1, 2), Region(2, 0, 1, 1)]
|
||||||
assert list(regions_to_ranges(regions)) == [
|
assert list(Compositor._regions_to_spans(regions)) == [
|
||||||
InlineRange(0, 0, 0), InlineRange(0, 2, 2), InlineRange(1, 0, 0)
|
(0, 0, 0),
|
||||||
|
(0, 2, 2),
|
||||||
|
(1, 0, 0),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_regions_to_ranges_directly_adjacent_ranges_merged():
|
def test_regions_to_ranges_directly_adjacent_ranges_merged():
|
||||||
regions = [Region(0, 0, 1, 2), Region(1, 0, 1, 2)]
|
regions = [Region(0, 0, 1, 2), Region(1, 0, 1, 2)]
|
||||||
assert list(regions_to_ranges(regions)) == [
|
assert list(Compositor._regions_to_spans(regions)) == [(0, 0, 1), (1, 0, 1)]
|
||||||
InlineRange(0, 0, 1), InlineRange(1, 0, 1)
|
|
||||||
]
|
|
||||||
@@ -114,6 +114,17 @@ def test_region_null():
|
|||||||
assert not Region()
|
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():
|
def test_region_from_origin():
|
||||||
assert Region.from_origin(Offset(3, 4), (5, 6)) == Region(3, 4, 5, 6)
|
assert Region.from_origin(Offset(3, 4), (5, 6)) == Region(3, 4, 5, 6)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user