diff --git a/sandbox/will/basic.css b/sandbox/will/basic.css index 0a7fa15ec..129b4e4a7 100644 --- a/sandbox/will/basic.css +++ b/sandbox/will/basic.css @@ -15,6 +15,10 @@ scrollbar-size-vertical: 2; } +*:hover { + tint: red 30%; +} + App > Screen { layout: dock; docks: side=left/1; @@ -23,7 +27,9 @@ App > Screen { } DataTable { - margin: 2; + border: solid red; + + margin: 1 1; height: 12; } diff --git a/sandbox/will/basic.py b/sandbox/will/basic.py index aca946a19..2b3b1ea7f 100644 --- a/sandbox/will/basic.py +++ b/sandbox/will/basic.py @@ -137,17 +137,15 @@ class BasicApp(App, css_path="basic.css"): Widget(classes="content"), ), ) - table.add_column("Foo", width=80) - table.add_column("Bar", width=50) - table.add_column("Baz", width=40) + table.add_column("Foo", width=20) + table.add_column("Bar", width=20) + 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 for n in range(100): - table.add_row( - 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, - ) + table.add_row(*[f"Cell ([b]{n}[/b], {col})" for col in range(6)]) async def on_key(self, event) -> None: await self.dispatch_key(event) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index c2b25f39b..994b122d6 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -578,9 +578,8 @@ class Compositor: if not region: continue if region in clip: - yield region, clip, widget.render_lines( - Region(0, 0, region.width, region.height) - ) + lines = widget.render_lines(Region(0, 0, region.width, region.height)) + yield region, clip, lines elif overlaps(clip, region): clipped_region = intersection(region, clip) if not clipped_region: @@ -706,12 +705,12 @@ class Compositor: regions: list[Region] = [] add_region = regions.append for widget in self.regions.keys() & widgets: - (x, y, _, _), clip = self.regions[widget] + region, clip = self.regions[widget] + offset = region.offset intersection = clip.intersection 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: add_region(update_region) self._dirty_regions.update(regions) - # self.add_dirty_regions(regions) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index f8fdf8b1c..2ff423efc 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -243,8 +243,8 @@ class Region(NamedTuple): return cls(x, y, width, height) @classmethod - def translate_inside(cls, window_region: Region, region: Region) -> Offset: - """Calculate the smallest offset required to translate a region so that is is within + def get_scroll_to_visible(cls, window_region: Region, region: Region) -> Offset: + """Calculate the smallest offset required to translate a window so that it contains another region. 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) ) - def translate(self, x: int = 0, y: int = 0) -> Region: + def translate(self, offset: tuple[int, int]) -> Region: """Move the offset of the Region. Args: - translate_x (int): Value to add to x coordinate. - translate_y (int): Value to add to y coordinate. + translate (tuple[int, int]): Offset to add to region. Returns: - Region: A new region shifted by x, y + 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) - - 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) + offset_x, offset_y = offset + return Region(self_x + offset_x, self_y + offset_y, width, height) @lru_cache(maxsize=4096) def __contains__(self, other: Any) -> bool: diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index f925d1b4c..40526f29a 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -253,10 +253,12 @@ class GridLayout(Layout): offset = (container - size) // 2 return offset - offset_x = align(grid_size.width, container.width, col_align) - offset_y = align(grid_size.height, container.height, row_align) + offset = Offset( + 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 def get_widgets(self) -> Iterable[Widget]: diff --git a/src/textual/widget.py b/src/textual/widget.py index 042598318..8eb086677 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -435,12 +435,13 @@ class Widget(DOMNode): # self._dirty_regions.append(self.size.region) if regions: + content_offset = self.content_offset self._dirty_regions.update(regions) else: self._dirty_regions.clear() # TODO: Does this need to be content 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]: regions = self._dirty_regions.copy() @@ -589,6 +590,7 @@ class Widget(DOMNode): bool: True if any scrolling has occurred in any descendant, otherwise False. """ + # TODO: Update this to use scroll_to_region scrolls = set() node = widget.parent @@ -657,7 +659,7 @@ class Widget(DOMNode): window = self.region.at_offset(self.scroll_offset) if spacing is not None: window = window.shrink(spacing) - delta = Region.translate_inside(window, region) + delta = Region.get_scroll_to_visible(window, region) if delta: self.scroll_relative( delta.x or None, @@ -665,7 +667,6 @@ class Widget(DOMNode): animate=animate, duration=0.2, ) - return delta def __init_subclass__( @@ -769,18 +770,18 @@ class Widget(DOMNode): def watch(self, attribute_name, callback: Callable[[Any], Awaitable[None]]) -> None: watch(self, attribute_name, callback) - def render_styled(self) -> RenderableType: - """Applies style attributes to the default renderable. + def _style_renderable(self, renderable: RenderableType) -> RenderableType: + """Applies CSS styles to a renderable by wrapping it in another renderable. + + Args: + renderable (RenderableType): Renderable to apply styles to. Returns: - RenderableType: A new renderable. + RenderableType: An updated renderable. """ - (base_background, base_color), (background, color) = self.colors styles = self.styles - renderable = self.render() - content_align = (styles.content_align_horizontal, styles.content_align_vertical) if content_align != ("left", "top"): horizontal, vertical = content_align @@ -816,6 +817,17 @@ class Widget(DOMNode): 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 def size(self) -> Size: return self._size @@ -843,6 +855,16 @@ class Widget(DOMNode): except errors.NoWidget: 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 def scroll_offset(self) -> Offset: 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: width = self.size.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 def render_lines(self, crop: Region) -> Lines: diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 4948a8c1e..861cc19ab 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -284,9 +284,10 @@ class DataTable(ScrollView, Generic[CellType]): if row_index < 0 or column_index < 0: return region = self._get_cell_region(row_index, column_index) - region = region.translate_negative(*self.scroll_offset) - if region: - self.refresh(region) + if not self.window_region.overlaps(region): + return + region = region.translate(-self.scroll_offset) + self.refresh(region) # refresh_region = self.content_region.intersection(region) # if refresh_region: # self.refresh(refresh_region) @@ -491,8 +492,8 @@ class DataTable(ScrollView, Generic[CellType]): Returns: Lines: A list of segments for every line within crop region. """ - scroll_x, scroll_y = self.scroll_offset - x1, y1, x2, y2 = crop.translate(scroll_x, scroll_y).corners + scroll_y = self.scroll_offset.y + x1, y1, x2, y2 = crop.translate(self.scroll_offset).corners base_style = self.rich_style @@ -511,6 +512,7 @@ class DataTable(ScrollView, Generic[CellType]): for line_index, y in enumerate(range(y1, y2)): if y - scroll_y < fixed_top_row_count: lines[line_index] = fixed_lines[line_index] + return lines def on_mouse_move(self, event: events.MouseMove): diff --git a/tests/test_geometry.py b/tests/test_geometry.py index 102228daf..241acdd34 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -89,6 +89,11 @@ def test_offset_sub(): 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(): 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) -def test_region_from_origin(): +def test_region_from_offset(): 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(): assert Region(3, 4, 0, 0).area == 0 assert Region(3, 4, 5, 6).area == 30 @@ -167,6 +185,16 @@ def test_region_sub(): 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(): 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)) @@ -201,8 +229,8 @@ def test_region_contains_region(): 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(y=20) == Region(1, 22, 3, 4) + assert Region(1, 2, 3, 4).translate((10, 20)) == Region(11, 22, 3, 4) + assert Region(1, 2, 3, 4).translate((0, 20)) == Region(1, 22, 3, 4) 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) -def test_region_reset_origin(): +def test_region_reset_offset(): assert Region(5, 10, 20, 30).reset_offset == Region(0, 0, 20, 30)