Merge branch 'main' into fix-2063

This commit is contained in:
Rodrigo Girão Serrão
2023-03-28 15:49:07 +01:00
committed by GitHub
25 changed files with 559 additions and 42 deletions

View File

@@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- 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
- Issue with watching for CSS file changes https://github.com/Textualize/textual/pull/2128 - Issue with watching for CSS file changes https://github.com/Textualize/textual/pull/2128
- Fix for tabs not invalidating https://github.com/Textualize/textual/issues/2125 - Fix for tabs not invalidating https://github.com/Textualize/textual/issues/2125
- Fixed scrollbar layers issue https://github.com/Textualize/textual/issues/1358
- Fix for interaction between pseudo-classes and widget-level render caches https://github.com/Textualize/textual/pull/2155
### Changed ### Changed
@@ -30,14 +32,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- `Tree`: `clear`, `reset` - `Tree`: `clear`, `reset`
- Screens with alpha in their background color will now blend with the background. https://github.com/Textualize/textual/pull/2139 - Screens with alpha in their background color will now blend with the background. https://github.com/Textualize/textual/pull/2139
- Added "thick" border style. https://github.com/Textualize/textual/pull/2139 - Added "thick" border style. https://github.com/Textualize/textual/pull/2139
- message_pump.app will now set the active app if it is not already set.
### Added ### Added
- 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 Query.set_classes, DOMNode.set_classes, and `classes` setter for Widget https://github.com/Textualize/textual/issues/1081
## [0.16.0] - 2023-03-22 ## [0.16.0] - 2023-03-22

1
docs/api/logging.md Normal file
View File

@@ -0,0 +1 @@
::: textual.logging

View File

@@ -120,5 +120,37 @@ class LogApp(App):
if __name__ == "__main__": if __name__ == "__main__":
LogApp().run() LogApp().run()
```
### Logging handler
Textual has a [logging handler][textual.logging.TextualHandler] which will write anything logged via the builtin logging library to the devtools.
This may be useful if you have a third-party library that uses the logging module, and you want to see those logs with Textual logs.
!!! note
The logging library works with strings only, so you won't be able to log Rich renderables such as `self.tree` with the logging handler.
Here's an example of configuring logging to use the `TextualHandler`.
```python
import logging
from textual.app import App
from textual.logging import TextualHandler
logging.basicConfig(
level="NOTSET",
handlers=[TextualHandler()],
)
class LogApp(App):
"""Using logging with Textual."""
def on_mount(self) -> None:
logging.debug("Logged via TextualHandler")
if __name__ == "__main__":
LogApp().run()
``` ```

View File

@@ -169,6 +169,7 @@ nav:
- "api/list_item.md" - "api/list_item.md"
- "api/list_view.md" - "api/list_view.md"
- "api/loading_indicator.md" - "api/loading_indicator.md"
- "api/logging.md"
- "api/markdown_viewer.md" - "api/markdown_viewer.md"
- "api/markdown.md" - "api/markdown.md"
- "api/message_pump.md" - "api/message_pump.md"

View File

@@ -142,6 +142,11 @@ class Logger:
"""Logs system information.""" """Logs system information."""
return Logger(self._log, LogGroup.SYSTEM) return Logger(self._log, LogGroup.SYSTEM)
@property
def logging(self) -> Logger:
"""Logs from stdlib logging module."""
return Logger(self._log, LogGroup.LOGGING)
log = Logger(None) log = Logger(None)

View File

@@ -128,4 +128,4 @@ def arrange(
placements.extend(layout_placements) placements.extend(layout_placements)
return DockArrangeResult(placements, arrange_widgets, scroll_spacing) return DockArrangeResult(placements, arrange_widgets)

View File

@@ -484,7 +484,6 @@ class Compositor:
# Arrange the layout # Arrange the layout
arrange_result = widget._arrange(child_region.size) arrange_result = widget._arrange(child_region.size)
arranged_widgets = arrange_result.widgets arranged_widgets = arrange_result.widgets
spacing = arrange_result.spacing
widgets.update(arranged_widgets) widgets.update(arranged_widgets)
if visible_only: if visible_only:
@@ -513,9 +512,7 @@ class Compositor:
if fixed: if fixed:
widget_region = sub_region + placement_offset widget_region = sub_region + placement_offset
else: else:
total_region = total_region.union( total_region = total_region.union(sub_region.grow(margin))
sub_region.grow(spacing + margin)
)
widget_region = sub_region + placement_scroll_offset widget_region = sub_region + placement_scroll_offset
widget_order = order + ( widget_order = order + (

View File

@@ -21,8 +21,6 @@ class DockArrangeResult:
"""A `WidgetPlacement` for every widget to describe it's location on screen.""" """A `WidgetPlacement` for every widget to describe it's location on screen."""
widgets: set[Widget] widgets: set[Widget]
"""A set of widgets in the arrangement.""" """A set of widgets in the arrangement."""
spacing: Spacing
"""Shared spacing around the widgets."""
_spatial_map: SpatialMap[WidgetPlacement] | None = None _spatial_map: SpatialMap[WidgetPlacement] | None = None
"""A Spatial map to query widget placements.""" """A Spatial map to query widget placements."""
@@ -113,7 +111,7 @@ class Layout(ABC):
else: else:
# Use a size of 0, 0 to ignore relative sizes, since those are flexible anyway # Use a size of 0, 0 to ignore relative sizes, since those are flexible anyway
arrangement = widget._arrange(Size(0, 0)) arrangement = widget._arrange(Size(0, 0))
return arrangement.total_region.right + arrangement.spacing.right return arrangement.total_region.right
return width return width
def get_content_height( def get_content_height(
@@ -135,5 +133,5 @@ class Layout(ABC):
else: else:
# Use a height of zero to ignore relative heights # Use a height of zero to ignore relative heights
arrangement = widget._arrange(Size(width, 0)) arrangement = widget._arrange(Size(width, 0))
height = arrangement.total_region.bottom + arrangement.spacing.bottom height = arrangement.total_region.bottom
return height return height

View File

@@ -12,6 +12,7 @@ class LogGroup(Enum):
ERROR = 5 ERROR = 5
PRINT = 6 PRINT = 6
SYSTEM = 7 SYSTEM = 7
LOGGING = 8
class LogVerbosity(Enum): class LogVerbosity(Enum):

View File

@@ -2,8 +2,9 @@ from __future__ import annotations
import ast import ast
import re import re
from typing import Any
from typing_extensions import Any, TypeAlias from typing_extensions import TypeAlias
ActionParseResult: TypeAlias = "tuple[str, tuple[Any, ...]]" ActionParseResult: TypeAlias = "tuple[str, tuple[Any, ...]]"
"""An action is its name and the arbitrary tuple of its arguments.""" """An action is its name and the arbitrary tuple of its arguments."""

View File

@@ -16,7 +16,7 @@ a method which evaluates the query, such as first() and last().
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Generic, Iterator, TypeVar, cast, overload from typing import TYPE_CHECKING, Generic, Iterable, Iterator, TypeVar, cast, overload
import rich.repr import rich.repr
@@ -330,6 +330,25 @@ class DOMQuery(Generic[QueryType]):
node.set_class(add, *class_names) node.set_class(add, *class_names)
return self return self
def set_classes(self, classes: str | Iterable[str]) -> DOMQuery[QueryType]:
"""Set the classes on nodes to exactly the given set.
Args:
classes: A string of space separated classes, or an iterable of class names.
Returns:
Self.
"""
if isinstance(classes, str):
for node in self:
node.set_classes(classes)
else:
class_names = list(classes)
for node in self:
node.set_classes(class_names)
return self
def add_class(self, *class_names: str) -> DOMQuery[QueryType]: def add_class(self, *class_names: str) -> DOMQuery[QueryType]:
"""Add the given class name(s) to nodes.""" """Add the given class name(s) to nodes."""
for node in self: for node in self:

View File

@@ -83,6 +83,26 @@ class NoScreen(DOMError):
pass pass
class _ClassesDescriptor:
"""A descriptor to manage the `classes` property."""
def __get__(
self, obj: DOMNode, objtype: type[DOMNode] | None = None
) -> frozenset[str]:
"""A frozenset of the current classes on the widget."""
return frozenset(obj._classes)
def __set__(self, obj: DOMNode, classes: str | Iterable[str]) -> None:
"""Replaces classes entirely."""
if isinstance(classes, str):
class_names = set(classes.split())
else:
class_names = set(classes)
check_identifiers("class name", *class_names)
obj._classes = class_names
obj._update_styles()
@rich.repr.auto @rich.repr.auto
class DOMNode(MessagePump): class DOMNode(MessagePump):
"""The base class for object that can be in the Textual DOM (App and Widget)""" """The base class for object that can be in the Textual DOM (App and Widget)"""
@@ -428,10 +448,7 @@ class DOMNode(MessagePump):
tokens.append(f"[name={self.name}]", style="underline") tokens.append(f"[name={self.name}]", style="underline")
return tokens return tokens
@property classes = _ClassesDescriptor()
def classes(self) -> frozenset[str]:
"""A frozenset of the current classes set on the widget."""
return frozenset(self._classes)
@property @property
def pseudo_classes(self) -> frozenset[str]: def pseudo_classes(self) -> frozenset[str]:
@@ -851,8 +868,16 @@ class DOMNode(MessagePump):
return query.only_one() if expect_type is None else query.only_one(expect_type) return query.only_one() if expect_type is None else query.only_one(expect_type)
def set_styles(self, css: str | None = None, **update_styles) -> None: def set_styles(self, css: str | None = None, **update_styles) -> Self:
"""Set custom styles on this object.""" """Set custom styles on this object.
Args:
css: Styles in CSS format.
**update_styles: Keyword arguments map style names on to style.
Returns:
Self.
"""
if css is not None: if css is not None:
try: try:
@@ -877,16 +902,32 @@ class DOMNode(MessagePump):
""" """
return self._classes.issuperset(class_names) return self._classes.issuperset(class_names)
def set_class(self, add: bool, *class_names: str) -> None: def set_class(self, add: bool, *class_names: str) -> Self:
"""Add or remove class(es) based on a condition. """Add or remove class(es) based on a condition.
Args: Args:
add: Add the classes if True, otherwise remove them. add: Add the classes if True, otherwise remove them.
Returns:
Self.
""" """
if add: if add:
self.add_class(*class_names) self.add_class(*class_names)
else: else:
self.remove_class(*class_names) self.remove_class(*class_names)
return self
def set_classes(self, classes: str | Iterable[str]) -> Self:
"""Replace all classes.
Args:
A string contain space separated classes, or an iterable of class names.
Returns:
Self.
"""
self.classes = classes
return self
def _update_styles(self) -> None: def _update_styles(self) -> None:
"""Request an update of this node's styles. """Request an update of this node's styles.
@@ -898,45 +939,56 @@ class DOMNode(MessagePump):
except NoActiveAppError: except NoActiveAppError:
pass pass
def add_class(self, *class_names: str) -> None: def add_class(self, *class_names: str) -> Self:
"""Add class names to this Node. """Add class names to this Node.
Args: Args:
*class_names: CSS class names to add. *class_names: CSS class names to add.
Returns:
Self.
""" """
check_identifiers("class name", *class_names) check_identifiers("class name", *class_names)
old_classes = self._classes.copy() old_classes = self._classes.copy()
self._classes.update(class_names) self._classes.update(class_names)
if old_classes == self._classes: if old_classes == self._classes:
return return self
self._update_styles() self._update_styles()
return self
def remove_class(self, *class_names: str) -> None: def remove_class(self, *class_names: str) -> Self:
"""Remove class names from this Node. """Remove class names from this Node.
Args: Args:
*class_names: CSS class names to remove. *class_names: CSS class names to remove.
Returns:
Self.
""" """
check_identifiers("class name", *class_names) check_identifiers("class name", *class_names)
old_classes = self._classes.copy() old_classes = self._classes.copy()
self._classes.difference_update(class_names) self._classes.difference_update(class_names)
if old_classes == self._classes: if old_classes == self._classes:
return return self
self._update_styles() self._update_styles()
return self
def toggle_class(self, *class_names: str) -> None: def toggle_class(self, *class_names: str) -> Self:
"""Toggle class names on this Node. """Toggle class names on this Node.
Args: Args:
*class_names: CSS class names to toggle. *class_names: CSS class names to toggle.
Returns:
Self.
""" """
check_identifiers("class name", *class_names) check_identifiers("class name", *class_names)
old_classes = self._classes.copy() old_classes = self._classes.copy()
self._classes.symmetric_difference_update(class_names) self._classes.symmetric_difference_update(class_names)
if old_classes == self._classes: if old_classes == self._classes:
return return self
self._update_styles() self._update_styles()
return self
def has_pseudo_class(self, *class_names: str) -> bool: def has_pseudo_class(self, *class_names: str) -> bool:
"""Check for pseudo classes (such as hover, focus etc) """Check for pseudo classes (such as hover, focus etc)

32
src/textual/logging.py Normal file
View File

@@ -0,0 +1,32 @@
import sys
from logging import Handler, LogRecord
from ._context import active_app
class TextualHandler(Handler):
"""A Logging handler for Textual apps."""
def __init__(self, stderr: bool = True, stdout: bool = False) -> None:
"""Initialize a Textual logging handler.
Args:
stderr: Log to stderr when there is no active app.
stdout: Log to stdout when there is not active app.
"""
super().__init__()
self._stderr = stderr
self._stdout = stdout
def emit(self, record: LogRecord) -> None:
"""Invoked by logging."""
message = self.format(record)
try:
app = active_app.get()
except LookupError:
if self._stderr:
print(message, file=sys.stderr)
elif self._stdout:
print(message, file=sys.stdout)
else:
app.log.logging(message)

View File

@@ -156,17 +156,26 @@ class MessagePump(metaclass=MessagePumpMeta):
try: try:
return active_app.get() return active_app.get()
except LookupError: except LookupError:
raise NoActiveAppError() from .app import App
node: MessagePump | None = self
while not isinstance(node, App):
if node is None:
raise NoActiveAppError()
node = node._parent
active_app.set(node)
return node
@property @property
def is_parent_active(self) -> bool: def is_parent_active(self) -> bool:
"""Is the parent active?"""
return bool( return bool(
self._parent and not self._parent._closed and not self._parent._closing self._parent and not self._parent._closed and not self._parent._closing
) )
@property @property
def is_running(self) -> bool: def is_running(self) -> bool:
"""bool: Is the message pump running (potentially processing messages).""" """Is the message pump running (potentially processing messages)."""
return self._running return self._running
@property @property

View File

@@ -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.

View File

@@ -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

View File

@@ -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:

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,48 @@
from textual.app import App, ComposeResult
from textual.containers import Vertical
from textual.widgets import Header, Footer, Label
from textual.binding import Binding
class Dialog(Vertical):
def compose(self) -> ComposeResult:
"""Compose the child widgets."""
yield Label("This should not cause a scrollbar to appear")
class DialogIssueApp(App[None]):
CSS = """
Screen {
layers: base dialog;
}
.hidden {
display: none;
}
Dialog {
align: center middle;
border: round red;
width: 50%;
height: 50%;
layer: dialog;
offset: 50% 50%;
}
"""
BINDINGS = [
Binding("d", "dialog", "Toggle the dialog"),
]
def compose(self) -> ComposeResult:
yield Header()
yield Vertical()
yield Dialog(classes="hidden")
yield Footer()
def action_dialog(self) -> None:
self.query_one(Dialog).toggle_class("hidden")
if __name__ == "__main__":
DialogIssueApp().run()

View File

@@ -354,3 +354,8 @@ def test_css_hot_reloading(snap_compare):
assert snap_compare( assert snap_compare(
SNAPSHOT_APPS_DIR / "hot_reloading_app.py", run_before=run_before SNAPSHOT_APPS_DIR / "hot_reloading_app.py", run_before=run_before
) )
def test_layer_fix(snap_compare):
# Check https://github.com/Textualize/textual/issues/1358
assert snap_compare(SNAPSHOT_APPS_DIR / "layer_fix.py", press=["d"])

View File

@@ -12,7 +12,6 @@ def test_arrange_empty():
result = arrange(container, [], Size(80, 24), Size(80, 24)) result = arrange(container, [], Size(80, 24), Size(80, 24))
assert result.placements == [] assert result.placements == []
assert result.widgets == set() assert result.widgets == set()
assert result.spacing == Spacing(0, 0, 0, 0)
def test_arrange_dock_top(): def test_arrange_dock_top():
@@ -31,7 +30,6 @@ def test_arrange_dock_top():
WidgetPlacement(Region(0, 1, 80, 23), Spacing(), child, order=0, fixed=False), WidgetPlacement(Region(0, 1, 80, 23), Spacing(), child, order=0, fixed=False),
] ]
assert result.widgets == {child, header} assert result.widgets == {child, header}
assert result.spacing == Spacing(1, 0, 0, 0)
def test_arrange_dock_left(): def test_arrange_dock_left():
@@ -49,7 +47,6 @@ def test_arrange_dock_left():
WidgetPlacement(Region(10, 0, 70, 24), Spacing(), child, order=0, fixed=False), WidgetPlacement(Region(10, 0, 70, 24), Spacing(), child, order=0, fixed=False),
] ]
assert result.widgets == {child, header} assert result.widgets == {child, header}
assert result.spacing == Spacing(0, 0, 0, 10)
def test_arrange_dock_right(): def test_arrange_dock_right():
@@ -67,7 +64,6 @@ def test_arrange_dock_right():
WidgetPlacement(Region(0, 0, 70, 24), Spacing(), child, order=0, fixed=False), WidgetPlacement(Region(0, 0, 70, 24), Spacing(), child, order=0, fixed=False),
] ]
assert result.widgets == {child, header} assert result.widgets == {child, header}
assert result.spacing == Spacing(0, 10, 0, 0)
def test_arrange_dock_bottom(): def test_arrange_dock_bottom():
@@ -85,7 +81,6 @@ def test_arrange_dock_bottom():
WidgetPlacement(Region(0, 0, 80, 23), Spacing(), child, order=0, fixed=False), WidgetPlacement(Region(0, 0, 80, 23), Spacing(), child, order=0, fixed=False),
] ]
assert result.widgets == {child, header} assert result.widgets == {child, header}
assert result.spacing == Spacing(0, 0, 1, 0)
def test_arrange_dock_badly(): def test_arrange_dock_badly():

View File

@@ -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."""

View File

@@ -45,6 +45,39 @@ def test_validate():
node.toggle_class("1") node.toggle_class("1")
def test_classes_setter():
node = DOMNode(classes="foo bar")
assert node.classes == frozenset({"foo", "bar"})
node.classes = "baz egg"
assert node.classes == frozenset({"baz", "egg"})
node.classes = ""
assert node.classes == frozenset({})
def test_classes_setter_iterable():
node = DOMNode(classes="foo bar")
assert node.classes == frozenset({"foo", "bar"})
node.classes = "baz", "egg"
assert node.classes == frozenset({"baz", "egg"})
node.classes = []
assert node.classes == frozenset({})
def test_classes_set_classes():
node = DOMNode(classes="foo bar")
assert node.classes == frozenset({"foo", "bar"})
node.set_classes("baz egg")
assert node.classes == frozenset({"baz", "egg"})
node.set_classes([])
assert node.classes == frozenset({})
node.set_classes(["paul"])
assert node.classes == frozenset({"paul"})
with pytest.raises(BadIdentifier):
node.classes = "foo 25"
with pytest.raises(BadIdentifier):
node.classes = ["foo", "25"]
def test_inherited_bindings(): def test_inherited_bindings():
"""Test if binding merging is done correctly when (not) inheriting bindings.""" """Test if binding merging is done correctly when (not) inheriting bindings."""

View File

@@ -152,6 +152,25 @@ def test_query_classes():
# Now, let's check there are *no* children with the test class. # Now, let's check there are *no* children with the test class.
assert len(app.query(".test")) == 0 assert len(app.query(".test")) == 0
# Add classes via set_classes
app.query(ClassTest).set_classes("foo bar")
assert (len(app.query(".foo"))) == CHILD_COUNT
assert (len(app.query(".bar"))) == CHILD_COUNT
# Reset classes
app.query(ClassTest).set_classes("")
assert (len(app.query(".foo"))) == 0
assert (len(app.query(".bar"))) == 0
# Repeat, to check setting empty iterable
app.query(ClassTest).set_classes("foo bar")
assert (len(app.query(".foo"))) == CHILD_COUNT
assert (len(app.query(".bar"))) == CHILD_COUNT
app.query(ClassTest).set_classes([])
assert (len(app.query(".foo"))) == 0
assert (len(app.query(".bar"))) == 0
# Add the test class to everything and then check again. # Add the test class to everything and then check again.
app.query(ClassTest).add_class("test") app.query(ClassTest).add_class("test")
assert len(app.query(".test")) == CHILD_COUNT assert len(app.query(".test")) == CHILD_COUNT

View File

@@ -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):