mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Fix for interaction between pseudoclasses and widget-level render caches (#2155)
* Using pseudoclass state in DataTable cache keys * Use full pseudo-class state on tree cache key * Adding tests for Widget.get_pseudo_class_state * Test hiding hover cursor when mouse cursor leaves DataTable * Update CHANGELOG.md
This commit is contained in:
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
- Issue with parsing action strings whose arguments contained quoted closing parenthesis https://github.com/Textualize/textual/pull/2112
|
- Issue with parsing action strings whose arguments contained quoted closing parenthesis https://github.com/Textualize/textual/pull/2112
|
||||||
- Issues with parsing action strings with tuple arguments https://github.com/Textualize/textual/pull/2112
|
- Issues with parsing action strings with tuple arguments https://github.com/Textualize/textual/pull/2112
|
||||||
- Fix for tabs not invalidating https://github.com/Textualize/textual/issues/2125
|
- Fix for tabs not invalidating https://github.com/Textualize/textual/issues/2125
|
||||||
|
- Fix for interaction between pseudo-classes and widget-level render caches https://github.com/Textualize/textual/pull/2155
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
|
|
||||||
- Added auto_scroll attribute to TextLog https://github.com/Textualize/textual/pull/2127
|
- Added auto_scroll attribute to TextLog https://github.com/Textualize/textual/pull/2127
|
||||||
- Added scroll_end switch to TextLog.write https://github.com/Textualize/textual/pull/2127
|
- Added scroll_end switch to TextLog.write https://github.com/Textualize/textual/pull/2127
|
||||||
|
- Added `Widget.get_pseudo_class_state` https://github.com/Textualize/textual/pull/2155
|
||||||
- Added Screen.ModalScreen which prevents App from handling bindings. https://github.com/Textualize/textual/pull/2139
|
- Added Screen.ModalScreen which prevents App from handling bindings. https://github.com/Textualize/textual/pull/2139
|
||||||
- Added TEXTUAL_LOG env var which should be a path that Textual will write verbose logs to (textual devtools is generally preferred) https://github.com/Textualize/textual/pull/2148
|
- Added TEXTUAL_LOG env var which should be a path that Textual will write verbose logs to (textual devtools is generally preferred) https://github.com/Textualize/textual/pull/2148
|
||||||
- Added textual.logging.TextualHandler logging handler
|
- Added textual.logging.TextualHandler logging handler
|
||||||
|
|||||||
@@ -179,6 +179,15 @@ class MountError(WidgetError):
|
|||||||
"""Error raised when there was a problem with the mount request."""
|
"""Error raised when there was a problem with the mount request."""
|
||||||
|
|
||||||
|
|
||||||
|
class PseudoClasses(NamedTuple):
|
||||||
|
"""Used for render/render_line based widgets that use caching. This structure can be used as a
|
||||||
|
cache-key."""
|
||||||
|
|
||||||
|
enabled: bool
|
||||||
|
focus: bool
|
||||||
|
hover: bool
|
||||||
|
|
||||||
|
|
||||||
@rich.repr.auto
|
@rich.repr.auto
|
||||||
class Widget(DOMNode):
|
class Widget(DOMNode):
|
||||||
"""
|
"""
|
||||||
@@ -2357,6 +2366,27 @@ class Widget(DOMNode):
|
|||||||
break
|
break
|
||||||
node = node._parent
|
node = node._parent
|
||||||
|
|
||||||
|
def get_pseudo_class_state(self) -> PseudoClasses:
|
||||||
|
"""Get an object describing whether each pseudo class is present on this object or not.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A PseudoClasses object describing the pseudo classes that are present.
|
||||||
|
"""
|
||||||
|
node: MessagePump | None = self
|
||||||
|
disabled = False
|
||||||
|
while isinstance(node, Widget):
|
||||||
|
if node.disabled:
|
||||||
|
disabled = True
|
||||||
|
break
|
||||||
|
node = node._parent
|
||||||
|
|
||||||
|
pseudo_classes = PseudoClasses(
|
||||||
|
enabled=not disabled,
|
||||||
|
hover=self.mouse_over,
|
||||||
|
focus=self.has_focus,
|
||||||
|
)
|
||||||
|
return pseudo_classes
|
||||||
|
|
||||||
def post_render(self, renderable: RenderableType) -> ConsoleRenderable:
|
def post_render(self, renderable: RenderableType) -> ConsoleRenderable:
|
||||||
"""Applies style attributes to the default renderable.
|
"""Applies style attributes to the default renderable.
|
||||||
|
|
||||||
|
|||||||
@@ -29,14 +29,13 @@ from ..reactive import Reactive
|
|||||||
from ..render import measure
|
from ..render import measure
|
||||||
from ..scroll_view import ScrollView
|
from ..scroll_view import ScrollView
|
||||||
from ..strip import Strip
|
from ..strip import Strip
|
||||||
|
from ..widget import PseudoClasses
|
||||||
|
|
||||||
CellCacheKey: TypeAlias = "tuple[RowKey, ColumnKey, Style, bool, bool, int]"
|
CellCacheKey: TypeAlias = (
|
||||||
LineCacheKey: TypeAlias = (
|
"tuple[RowKey, ColumnKey, Style, bool, bool, int, PseudoClasses]"
|
||||||
"tuple[int, int, int, int, Coordinate, Coordinate, Style, CursorType, bool, int]"
|
|
||||||
)
|
|
||||||
RowCacheKey: TypeAlias = (
|
|
||||||
"tuple[RowKey, int, Style, Coordinate, Coordinate, CursorType, bool, bool, int]"
|
|
||||||
)
|
)
|
||||||
|
LineCacheKey: TypeAlias = "tuple[int, int, int, int, Coordinate, Coordinate, Style, CursorType, bool, int, PseudoClasses]"
|
||||||
|
RowCacheKey: TypeAlias = "tuple[RowKey, int, Style, Coordinate, Coordinate, CursorType, bool, bool, int, PseudoClasses]"
|
||||||
CursorType = Literal["cell", "row", "column", "none"]
|
CursorType = Literal["cell", "row", "column", "none"]
|
||||||
CellType = TypeVar("CellType")
|
CellType = TypeVar("CellType")
|
||||||
|
|
||||||
@@ -609,6 +608,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
|||||||
self._ordered_row_cache: LRUCache[tuple[int, int], list[Row]] = LRUCache(1)
|
self._ordered_row_cache: LRUCache[tuple[int, int], list[Row]] = LRUCache(1)
|
||||||
"""Caches row ordering - key is (num_rows, update_count)."""
|
"""Caches row ordering - key is (num_rows, update_count)."""
|
||||||
|
|
||||||
|
self._pseudo_class_state = PseudoClasses(False, False, False)
|
||||||
|
"""The pseudo-class state is used as part of cache keys to ensure that, for example,
|
||||||
|
when we lose focus on the DataTable, rules which apply to :focus are invalidated
|
||||||
|
and we prevent lingering styles."""
|
||||||
|
|
||||||
self._require_update_dimensions: bool = False
|
self._require_update_dimensions: bool = False
|
||||||
"""Set to re-calculate dimensions on idle."""
|
"""Set to re-calculate dimensions on idle."""
|
||||||
self._new_rows: set[RowKey] = set()
|
self._new_rows: set[RowKey] = set()
|
||||||
@@ -1558,7 +1562,15 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
|||||||
row_key = self._row_locations.get_key(row_index)
|
row_key = self._row_locations.get_key(row_index)
|
||||||
|
|
||||||
column_key = self._column_locations.get_key(column_index)
|
column_key = self._column_locations.get_key(column_index)
|
||||||
cell_cache_key = (row_key, column_key, style, cursor, hover, self._update_count)
|
cell_cache_key = (
|
||||||
|
row_key,
|
||||||
|
column_key,
|
||||||
|
style,
|
||||||
|
cursor,
|
||||||
|
hover,
|
||||||
|
self._update_count,
|
||||||
|
self._pseudo_class_state,
|
||||||
|
)
|
||||||
|
|
||||||
if cell_cache_key not in self._cell_render_cache:
|
if cell_cache_key not in self._cell_render_cache:
|
||||||
style += Style.from_meta({"row": row_index, "column": column_index})
|
style += Style.from_meta({"row": row_index, "column": column_index})
|
||||||
@@ -1614,6 +1626,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
|||||||
show_cursor,
|
show_cursor,
|
||||||
self._show_hover_cursor,
|
self._show_hover_cursor,
|
||||||
self._update_count,
|
self._update_count,
|
||||||
|
self._pseudo_class_state,
|
||||||
)
|
)
|
||||||
|
|
||||||
if cache_key in self._row_render_cache:
|
if cache_key in self._row_render_cache:
|
||||||
@@ -1786,6 +1799,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
|||||||
self.cursor_type,
|
self.cursor_type,
|
||||||
self._show_hover_cursor,
|
self._show_hover_cursor,
|
||||||
self._update_count,
|
self._update_count,
|
||||||
|
self._pseudo_class_state,
|
||||||
)
|
)
|
||||||
if cache_key in self._line_cache:
|
if cache_key in self._line_cache:
|
||||||
return self._line_cache[cache_key]
|
return self._line_cache[cache_key]
|
||||||
@@ -1810,6 +1824,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
|||||||
self._line_cache[cache_key] = strip
|
self._line_cache[cache_key] = strip
|
||||||
return strip
|
return strip
|
||||||
|
|
||||||
|
def render_lines(self, crop: Region) -> list[Strip]:
|
||||||
|
self._pseudo_class_state = self.get_pseudo_class_state()
|
||||||
|
return super().render_lines(crop)
|
||||||
|
|
||||||
def render_line(self, y: int) -> Strip:
|
def render_line(self, y: int) -> Strip:
|
||||||
width, height = self.size
|
width, height = self.size
|
||||||
scroll_x, scroll_y = self.scroll_offset
|
scroll_x, scroll_y = self.scroll_offset
|
||||||
@@ -1842,6 +1860,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def on_leave(self, event: events.Leave) -> None:
|
||||||
|
self._set_hover_cursor(False)
|
||||||
|
|
||||||
def _get_fixed_offset(self) -> Spacing:
|
def _get_fixed_offset(self) -> Spacing:
|
||||||
"""Calculate the "fixed offset", that is the space to the top and left
|
"""Calculate the "fixed offset", that is the space to the top and left
|
||||||
that is occupied by fixed rows and columns respectively. Fixed rows and columns
|
that is occupied by fixed rows and columns respectively. Fixed rows and columns
|
||||||
|
|||||||
@@ -925,6 +925,10 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
|||||||
self.cursor_line = -1
|
self.cursor_line = -1
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
|
def render_lines(self, crop: Region) -> list[Strip]:
|
||||||
|
self._pseudo_class_state = self.get_pseudo_class_state()
|
||||||
|
return super().render_lines(crop)
|
||||||
|
|
||||||
def render_line(self, y: int) -> Strip:
|
def render_line(self, y: int) -> Strip:
|
||||||
width = self.size.width
|
width = self.size.width
|
||||||
scroll_x, scroll_y = self.scroll_offset
|
scroll_x, scroll_y = self.scroll_offset
|
||||||
@@ -952,7 +956,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
|||||||
is_hover,
|
is_hover,
|
||||||
width,
|
width,
|
||||||
self._updates,
|
self._updates,
|
||||||
self.has_focus,
|
self._pseudo_class_state,
|
||||||
tuple(node._updates for node in line.path),
|
tuple(node._updates for node in line.path),
|
||||||
)
|
)
|
||||||
if cache_key in self._line_cache:
|
if cache_key in self._line_cache:
|
||||||
|
|||||||
@@ -652,6 +652,23 @@ async def test_hover_coordinate():
|
|||||||
assert table.hover_coordinate == Coordinate(1, 0)
|
assert table.hover_coordinate == Coordinate(1, 0)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_hover_mouse_leave():
|
||||||
|
"""When the mouse cursor leaves the DataTable, there should be no hover highlighting."""
|
||||||
|
app = DataTableApp()
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
table = app.query_one(DataTable)
|
||||||
|
table.add_column("ABC")
|
||||||
|
table.add_row("123")
|
||||||
|
await pilot.pause()
|
||||||
|
assert table.hover_coordinate == Coordinate(0, 0)
|
||||||
|
# Hover over a cell, and the hover cursor is visible
|
||||||
|
await pilot.hover(DataTable, offset=Offset(1, 1))
|
||||||
|
assert table._show_hover_cursor
|
||||||
|
# Move our cursor away from the DataTable, and the hover cursor is hidden
|
||||||
|
await pilot.hover(DataTable, offset=Offset(-1, -1))
|
||||||
|
assert not table._show_hover_cursor
|
||||||
|
|
||||||
|
|
||||||
async def test_header_selected():
|
async def test_header_selected():
|
||||||
"""Ensure that a HeaderSelected event gets posted when we click
|
"""Ensure that a HeaderSelected event gets posted when we click
|
||||||
on the header in the DataTable."""
|
on the header in the DataTable."""
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from textual.containers import Container
|
|||||||
from textual.css.errors import StyleValueError
|
from textual.css.errors import StyleValueError
|
||||||
from textual.css.query import NoMatches
|
from textual.css.query import NoMatches
|
||||||
from textual.geometry import Size
|
from textual.geometry import Size
|
||||||
from textual.widget import MountError, Widget
|
from textual.widget import MountError, PseudoClasses, Widget
|
||||||
from textual.widgets import Label
|
from textual.widgets import Label
|
||||||
|
|
||||||
|
|
||||||
@@ -178,6 +178,39 @@ def test_widget_mount_ids_must_be_unique_mounting_multiple_calls(parent):
|
|||||||
parent.mount(widget2)
|
parent.mount(widget2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_pseudo_class_state():
|
||||||
|
widget = Widget()
|
||||||
|
pseudo_classes = widget.get_pseudo_class_state()
|
||||||
|
assert pseudo_classes == PseudoClasses(enabled=True, focus=False, hover=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_pseudo_class_state_disabled():
|
||||||
|
widget = Widget(disabled=True)
|
||||||
|
pseudo_classes = widget.get_pseudo_class_state()
|
||||||
|
assert pseudo_classes == PseudoClasses(enabled=False, focus=False, hover=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_pseudo_class_state_parent_disabled():
|
||||||
|
child = Widget()
|
||||||
|
_parent = Widget(child, disabled=True)
|
||||||
|
pseudo_classes = child.get_pseudo_class_state()
|
||||||
|
assert pseudo_classes == PseudoClasses(enabled=False, focus=False, hover=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_pseudo_class_state_hover():
|
||||||
|
widget = Widget()
|
||||||
|
widget.mouse_over = True
|
||||||
|
pseudo_classes = widget.get_pseudo_class_state()
|
||||||
|
assert pseudo_classes == PseudoClasses(enabled=True, focus=False, hover=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_pseudo_class_state_focus():
|
||||||
|
widget = Widget()
|
||||||
|
widget.has_focus = True
|
||||||
|
pseudo_classes = widget.get_pseudo_class_state()
|
||||||
|
assert pseudo_classes == PseudoClasses(enabled=True, focus=True, hover=False)
|
||||||
|
|
||||||
|
|
||||||
# Regression test for https://github.com/Textualize/textual/issues/1634
|
# Regression test for https://github.com/Textualize/textual/issues/1634
|
||||||
async def test_remove():
|
async def test_remove():
|
||||||
class RemoveMeLabel(Label):
|
class RemoveMeLabel(Label):
|
||||||
|
|||||||
Reference in New Issue
Block a user