geometry tests

This commit is contained in:
Will McGugan
2022-06-27 10:11:57 +01:00
parent 82358fdb7b
commit 86fdc96ab3
8 changed files with 103 additions and 59 deletions

View File

@@ -15,6 +15,10 @@
scrollbar-size-vertical: 2; scrollbar-size-vertical: 2;
} }
*:hover {
tint: red 30%;
}
App > Screen { App > Screen {
layout: dock; layout: dock;
docks: side=left/1; docks: side=left/1;
@@ -23,7 +27,9 @@ App > Screen {
} }
DataTable { DataTable {
margin: 2; border: solid red;
margin: 1 1;
height: 12; height: 12;
} }

View File

@@ -137,17 +137,15 @@ class BasicApp(App, css_path="basic.css"):
Widget(classes="content"), Widget(classes="content"),
), ),
) )
table.add_column("Foo", width=80) table.add_column("Foo", width=20)
table.add_column("Bar", width=50) table.add_column("Bar", width=20)
table.add_column("Baz", width=40) table.add_column("Baz", width=20)
table.add_column("Foo", width=20)
table.add_column("Bar", width=20)
table.add_column("Baz", width=20)
table.zebra_stripes = True table.zebra_stripes = True
for n in range(100): for n in range(100):
table.add_row( table.add_row(*[f"Cell ([b]{n}[/b], {col})" for col in range(6)])
f"{n} This is an example of a [b]DataTable widget[/b] within a larger [bold magenta]Textual UI",
"Cells may contain just about any kind of data",
"Where there is a Will there is a Way",
height=1,
)
async def on_key(self, event) -> None: async def on_key(self, event) -> None:
await self.dispatch_key(event) await self.dispatch_key(event)

View File

@@ -578,9 +578,8 @@ class Compositor:
if not region: if not region:
continue continue
if region in clip: if region in clip:
yield region, clip, widget.render_lines( lines = widget.render_lines(Region(0, 0, region.width, region.height))
Region(0, 0, region.width, region.height) yield region, clip, lines
)
elif overlaps(clip, region): elif overlaps(clip, region):
clipped_region = intersection(region, clip) clipped_region = intersection(region, clip)
if not clipped_region: if not clipped_region:
@@ -706,12 +705,12 @@ class Compositor:
regions: list[Region] = [] regions: list[Region] = []
add_region = regions.append add_region = regions.append
for widget in self.regions.keys() & widgets: for widget in self.regions.keys() & widgets:
(x, y, _, _), clip = self.regions[widget] region, clip = self.regions[widget]
offset = region.offset
intersection = clip.intersection intersection = clip.intersection
for dirty_region in widget.get_dirty_regions(): for dirty_region in widget.get_dirty_regions():
update_region = intersection(dirty_region.translate(x, y)) update_region = intersection(dirty_region.translate(offset))
if update_region: if update_region:
add_region(update_region) add_region(update_region)
self._dirty_regions.update(regions) self._dirty_regions.update(regions)
# self.add_dirty_regions(regions)

View File

@@ -243,8 +243,8 @@ class Region(NamedTuple):
return cls(x, y, width, height) return cls(x, y, width, height)
@classmethod @classmethod
def translate_inside(cls, window_region: Region, region: Region) -> Offset: def get_scroll_to_visible(cls, window_region: Region, region: Region) -> Offset:
"""Calculate the smallest offset required to translate a region so that is is within """Calculate the smallest offset required to translate a window so that it contains
another region. another region.
This method is used to calculate the required offset to scroll something in to view. This method is used to calculate the required offset to scroll something in to view.
@@ -507,33 +507,19 @@ class Region(NamedTuple):
and (y2 >= oy2 >= y1) and (y2 >= oy2 >= y1)
) )
def translate(self, x: int = 0, y: int = 0) -> Region: def translate(self, offset: tuple[int, int]) -> Region:
"""Move the offset of the Region. """Move the offset of the Region.
Args: Args:
translate_x (int): Value to add to x coordinate. translate (tuple[int, int]): Offset to add to region.
translate_y (int): Value to add to y coordinate.
Returns: Returns:
Region: A new region shifted by x, y Region: A new region shifted by (x, y)
""" """
self_x, self_y, width, height = self self_x, self_y, width, height = self
return Region(self_x + x, self_y + y, width, height) offset_x, offset_y = offset
return Region(self_x + offset_x, self_y + offset_y, width, height)
def translate_negative(self, x: int = 0, y: int = 0) -> Region:
"""Move the offset of the Region in the opposite direction.
Args:
translate_x (int): Value to subtract to x coordinate.
translate_y (int): Value to subtract to y coordinate.
Returns:
Region: A new region shifted by x, y
"""
self_x, self_y, width, height = self
return Region(self_x - x, self_y - y, width, height)
@lru_cache(maxsize=4096) @lru_cache(maxsize=4096)
def __contains__(self, other: Any) -> bool: def __contains__(self, other: Any) -> bool:

View File

@@ -253,10 +253,12 @@ class GridLayout(Layout):
offset = (container - size) // 2 offset = (container - size) // 2
return offset return offset
offset_x = align(grid_size.width, container.width, col_align) offset = Offset(
offset_y = align(grid_size.height, container.height, row_align) align(grid_size.width, container.width, col_align),
align(grid_size.height, container.height, row_align),
)
region = region.translate(offset_x, offset_y) region = region.translate(offset)
return region return region
def get_widgets(self) -> Iterable[Widget]: def get_widgets(self) -> Iterable[Widget]:

View File

@@ -435,12 +435,13 @@ class Widget(DOMNode):
# self._dirty_regions.append(self.size.region) # self._dirty_regions.append(self.size.region)
if regions: if regions:
content_offset = self.content_offset
self._dirty_regions.update(regions) self._dirty_regions.update(regions)
else: else:
self._dirty_regions.clear() self._dirty_regions.clear()
# TODO: Does this need to be content region? # TODO: Does this need to be content region?
# self._dirty_regions.append(self.size.region) # self._dirty_regions.append(self.size.region)
self._dirty_regions.add(self.content_region.size.region) self._dirty_regions.add(self.size.region)
def get_dirty_regions(self) -> Collection[Region]: def get_dirty_regions(self) -> Collection[Region]:
regions = self._dirty_regions.copy() regions = self._dirty_regions.copy()
@@ -589,6 +590,7 @@ class Widget(DOMNode):
bool: True if any scrolling has occurred in any descendant, otherwise False. bool: True if any scrolling has occurred in any descendant, otherwise False.
""" """
# TODO: Update this to use scroll_to_region
scrolls = set() scrolls = set()
node = widget.parent node = widget.parent
@@ -657,7 +659,7 @@ class Widget(DOMNode):
window = self.region.at_offset(self.scroll_offset) window = self.region.at_offset(self.scroll_offset)
if spacing is not None: if spacing is not None:
window = window.shrink(spacing) window = window.shrink(spacing)
delta = Region.translate_inside(window, region) delta = Region.get_scroll_to_visible(window, region)
if delta: if delta:
self.scroll_relative( self.scroll_relative(
delta.x or None, delta.x or None,
@@ -665,7 +667,6 @@ class Widget(DOMNode):
animate=animate, animate=animate,
duration=0.2, duration=0.2,
) )
return delta return delta
def __init_subclass__( def __init_subclass__(
@@ -769,18 +770,18 @@ class Widget(DOMNode):
def watch(self, attribute_name, callback: Callable[[Any], Awaitable[None]]) -> None: def watch(self, attribute_name, callback: Callable[[Any], Awaitable[None]]) -> None:
watch(self, attribute_name, callback) watch(self, attribute_name, callback)
def render_styled(self) -> RenderableType: def _style_renderable(self, renderable: RenderableType) -> RenderableType:
"""Applies style attributes to the default renderable. """Applies CSS styles to a renderable by wrapping it in another renderable.
Args:
renderable (RenderableType): Renderable to apply styles to.
Returns: Returns:
RenderableType: A new renderable. RenderableType: An updated renderable.
""" """
(base_background, base_color), (background, color) = self.colors (base_background, base_color), (background, color) = self.colors
styles = self.styles styles = self.styles
renderable = self.render()
content_align = (styles.content_align_horizontal, styles.content_align_vertical) content_align = (styles.content_align_horizontal, styles.content_align_vertical)
if content_align != ("left", "top"): if content_align != ("left", "top"):
horizontal, vertical = content_align horizontal, vertical = content_align
@@ -816,6 +817,17 @@ class Widget(DOMNode):
return renderable return renderable
def render_styled(self) -> RenderableType:
"""Applies style attributes to the default renderable.
Returns:
RenderableType: A new renderable.
"""
renderable = self.render()
renderable = self._style_renderable(renderable)
return renderable
@property @property
def size(self) -> Size: def size(self) -> Size:
return self._size return self._size
@@ -843,6 +855,16 @@ class Widget(DOMNode):
except errors.NoWidget: except errors.NoWidget:
return Region() return Region()
@property
def window_region(self) -> Region:
"""The region within the scrollable area that is currently visible.
Returns:
Region: New region.
"""
window_region = self.region.at_offset(self.scroll_offset)
return window_region
@property @property
def scroll_offset(self) -> Offset: def scroll_offset(self) -> Offset:
return Offset(int(self.scroll_x), int(self.scroll_y)) return Offset(int(self.scroll_x), int(self.scroll_y))
@@ -937,7 +959,8 @@ class Widget(DOMNode):
def _crop_lines(self, lines: Lines, x1, x2) -> Lines: def _crop_lines(self, lines: Lines, x1, x2) -> Lines:
width = self.size.width width = self.size.width
if (x1, x2) != (0, width): if (x1, x2) != (0, width):
lines = [line_crop(line, x1, x2, width) for line in lines] _line_crop = line_crop
lines = [_line_crop(line, x1, x2, width) for line in lines]
return lines return lines
def render_lines(self, crop: Region) -> Lines: def render_lines(self, crop: Region) -> Lines:

View File

@@ -284,9 +284,10 @@ class DataTable(ScrollView, Generic[CellType]):
if row_index < 0 or column_index < 0: if row_index < 0 or column_index < 0:
return return
region = self._get_cell_region(row_index, column_index) region = self._get_cell_region(row_index, column_index)
region = region.translate_negative(*self.scroll_offset) if not self.window_region.overlaps(region):
if region: return
self.refresh(region) region = region.translate(-self.scroll_offset)
self.refresh(region)
# refresh_region = self.content_region.intersection(region) # refresh_region = self.content_region.intersection(region)
# if refresh_region: # if refresh_region:
# self.refresh(refresh_region) # self.refresh(refresh_region)
@@ -491,8 +492,8 @@ class DataTable(ScrollView, Generic[CellType]):
Returns: Returns:
Lines: A list of segments for every line within crop region. Lines: A list of segments for every line within crop region.
""" """
scroll_x, scroll_y = self.scroll_offset scroll_y = self.scroll_offset.y
x1, y1, x2, y2 = crop.translate(scroll_x, scroll_y).corners x1, y1, x2, y2 = crop.translate(self.scroll_offset).corners
base_style = self.rich_style base_style = self.rich_style
@@ -511,6 +512,7 @@ class DataTable(ScrollView, Generic[CellType]):
for line_index, y in enumerate(range(y1, y2)): for line_index, y in enumerate(range(y1, y2)):
if y - scroll_y < fixed_top_row_count: if y - scroll_y < fixed_top_row_count:
lines[line_index] = fixed_lines[line_index] lines[line_index] = fixed_lines[line_index]
return lines return lines
def on_mouse_move(self, event: events.MouseMove): def on_mouse_move(self, event: events.MouseMove):

View File

@@ -89,6 +89,11 @@ def test_offset_sub():
Offset(1, 1) - "foo" Offset(1, 1) - "foo"
def test_offset_neg():
assert Offset(0, 0) == Offset(0, 0)
assert -Offset(2, -3) == Offset(-2, 3)
def test_offset_mul(): def test_offset_mul():
assert Offset(2, 1) * 2 == Offset(4, 2) assert Offset(2, 1) * 2 == Offset(4, 2)
assert Offset(2, 1) * -2 == Offset(-4, -2) assert Offset(2, 1) * -2 == Offset(-4, -2)
@@ -125,10 +130,23 @@ def test_region_from_union():
assert Region.from_union(regions) == Region(10, 20, 40, 40) assert Region.from_union(regions) == Region(10, 20, 40, 40)
def test_region_from_origin(): def test_region_from_offset():
assert Region.from_offset(Offset(3, 4), (5, 6)) == Region(3, 4, 5, 6) assert Region.from_offset(Offset(3, 4), (5, 6)) == Region(3, 4, 5, 6)
@pytest.mark.parametrize(
"window,region,scroll",
[
(Region(0, 0, 200, 100), Region(0, 0, 200, 100), Offset(0, 0)),
(Region(0, 0, 200, 100), Region(0, -100, 10, 10), Offset(0, -100)),
(Region(10, 15, 20, 10), Region(0, 0, 50, 50), Offset(-10, -15)),
],
)
def test_get_scroll_to_visible(window, region, scroll):
assert Region.get_scroll_to_visible(window, region) == scroll
assert region.overlaps(window + scroll)
def test_region_area(): def test_region_area():
assert Region(3, 4, 0, 0).area == 0 assert Region(3, 4, 0, 0).area == 0
assert Region(3, 4, 5, 6).area == 30 assert Region(3, 4, 5, 6).area == 30
@@ -167,6 +185,16 @@ def test_region_sub():
Region(1, 2, 3, 4) - "foo" Region(1, 2, 3, 4) - "foo"
def test_region_at_offset():
assert Region(10, 10, 30, 40).at_offset((0, 0)) == Region(0, 0, 30, 40)
assert Region(10, 10, 30, 40).at_offset((-15, 30)) == Region(-15, 30, 30, 40)
def test_crop_size():
assert Region(10, 20, 100, 200).crop_size((50, 40)) == Region(10, 20, 50, 40)
assert Region(10, 20, 100, 200).crop_size((500, 40)) == Region(10, 20, 100, 40)
def test_region_overlaps(): def test_region_overlaps():
assert Region(10, 10, 30, 20).overlaps(Region(0, 0, 20, 20)) assert Region(10, 10, 30, 20).overlaps(Region(0, 0, 20, 20))
assert not Region(10, 10, 5, 5).overlaps(Region(15, 15, 20, 20)) assert not Region(10, 10, 5, 5).overlaps(Region(15, 15, 20, 20))
@@ -201,8 +229,8 @@ def test_region_contains_region():
def test_region_translate(): def test_region_translate():
assert Region(1, 2, 3, 4).translate(10, 20) == Region(11, 22, 3, 4) assert Region(1, 2, 3, 4).translate((10, 20)) == Region(11, 22, 3, 4)
assert Region(1, 2, 3, 4).translate(y=20) == Region(1, 22, 3, 4) assert Region(1, 2, 3, 4).translate((0, 20)) == Region(1, 22, 3, 4)
def test_region_contains_special(): def test_region_contains_special():
@@ -274,7 +302,7 @@ def test_region_y_range():
assert Region(5, 10, 20, 30).y_range == range(10, 40) assert Region(5, 10, 20, 30).y_range == range(10, 40)
def test_region_reset_origin(): def test_region_reset_offset():
assert Region(5, 10, 20, 30).reset_offset == Region(0, 0, 20, 30) assert Region(5, 10, 20, 30).reset_offset == Region(0, 0, 20, 30)