mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
geometry tests
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user