diff --git a/CHANGELOG.md b/CHANGELOG.md
index 736b6507c..dae30d7d3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
- 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
+- 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
@@ -30,14 +32,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- `Tree`: `clear`, `reset`
- 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
+- message_pump.app will now set the active app if it is not already set.
### Added
- 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 `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 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
diff --git a/docs/api/logging.md b/docs/api/logging.md
new file mode 100644
index 000000000..5fbf833e1
--- /dev/null
+++ b/docs/api/logging.md
@@ -0,0 +1 @@
+::: textual.logging
diff --git a/docs/guide/devtools.md b/docs/guide/devtools.md
index fccbce004..087ebf4ce 100644
--- a/docs/guide/devtools.md
+++ b/docs/guide/devtools.md
@@ -120,5 +120,37 @@ class LogApp(App):
if __name__ == "__main__":
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()
```
diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml
index c508c3619..e785346eb 100644
--- a/mkdocs-nav.yml
+++ b/mkdocs-nav.yml
@@ -169,6 +169,7 @@ nav:
- "api/list_item.md"
- "api/list_view.md"
- "api/loading_indicator.md"
+ - "api/logging.md"
- "api/markdown_viewer.md"
- "api/markdown.md"
- "api/message_pump.md"
diff --git a/src/textual/__init__.py b/src/textual/__init__.py
index d2e59f0bf..935f4da97 100644
--- a/src/textual/__init__.py
+++ b/src/textual/__init__.py
@@ -142,6 +142,11 @@ class Logger:
"""Logs system information."""
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)
diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py
index f1035688e..378d951eb 100644
--- a/src/textual/_arrange.py
+++ b/src/textual/_arrange.py
@@ -128,4 +128,4 @@ def arrange(
placements.extend(layout_placements)
- return DockArrangeResult(placements, arrange_widgets, scroll_spacing)
+ return DockArrangeResult(placements, arrange_widgets)
diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py
index dd7816f9c..addec6ea8 100644
--- a/src/textual/_compositor.py
+++ b/src/textual/_compositor.py
@@ -484,7 +484,6 @@ class Compositor:
# Arrange the layout
arrange_result = widget._arrange(child_region.size)
arranged_widgets = arrange_result.widgets
- spacing = arrange_result.spacing
widgets.update(arranged_widgets)
if visible_only:
@@ -513,9 +512,7 @@ class Compositor:
if fixed:
widget_region = sub_region + placement_offset
else:
- total_region = total_region.union(
- sub_region.grow(spacing + margin)
- )
+ total_region = total_region.union(sub_region.grow(margin))
widget_region = sub_region + placement_scroll_offset
widget_order = order + (
diff --git a/src/textual/_layout.py b/src/textual/_layout.py
index 0cdfe5956..5c5dc2ff1 100644
--- a/src/textual/_layout.py
+++ b/src/textual/_layout.py
@@ -21,8 +21,6 @@ class DockArrangeResult:
"""A `WidgetPlacement` for every widget to describe it's location on screen."""
widgets: set[Widget]
"""A set of widgets in the arrangement."""
- spacing: Spacing
- """Shared spacing around the widgets."""
_spatial_map: SpatialMap[WidgetPlacement] | None = None
"""A Spatial map to query widget placements."""
@@ -113,7 +111,7 @@ class Layout(ABC):
else:
# Use a size of 0, 0 to ignore relative sizes, since those are flexible anyway
arrangement = widget._arrange(Size(0, 0))
- return arrangement.total_region.right + arrangement.spacing.right
+ return arrangement.total_region.right
return width
def get_content_height(
@@ -135,5 +133,5 @@ class Layout(ABC):
else:
# Use a height of zero to ignore relative heights
arrangement = widget._arrange(Size(width, 0))
- height = arrangement.total_region.bottom + arrangement.spacing.bottom
+ height = arrangement.total_region.bottom
return height
diff --git a/src/textual/_log.py b/src/textual/_log.py
index e9adcef42..26125052d 100644
--- a/src/textual/_log.py
+++ b/src/textual/_log.py
@@ -12,6 +12,7 @@ class LogGroup(Enum):
ERROR = 5
PRINT = 6
SYSTEM = 7
+ LOGGING = 8
class LogVerbosity(Enum):
diff --git a/src/textual/actions.py b/src/textual/actions.py
index a189b9a58..9365bb268 100644
--- a/src/textual/actions.py
+++ b/src/textual/actions.py
@@ -2,8 +2,9 @@ from __future__ import annotations
import ast
import re
+from typing import Any
-from typing_extensions import Any, TypeAlias
+from typing_extensions import TypeAlias
ActionParseResult: TypeAlias = "tuple[str, tuple[Any, ...]]"
"""An action is its name and the arbitrary tuple of its arguments."""
diff --git a/src/textual/css/query.py b/src/textual/css/query.py
index 73e09c61b..98e0063a0 100644
--- a/src/textual/css/query.py
+++ b/src/textual/css/query.py
@@ -16,7 +16,7 @@ a method which evaluates the query, such as first() and last().
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
@@ -330,6 +330,25 @@ class DOMQuery(Generic[QueryType]):
node.set_class(add, *class_names)
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]:
"""Add the given class name(s) to nodes."""
for node in self:
diff --git a/src/textual/dom.py b/src/textual/dom.py
index cb9881f65..a5a8c0185 100644
--- a/src/textual/dom.py
+++ b/src/textual/dom.py
@@ -83,6 +83,26 @@ class NoScreen(DOMError):
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
class DOMNode(MessagePump):
"""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")
return tokens
- @property
- def classes(self) -> frozenset[str]:
- """A frozenset of the current classes set on the widget."""
- return frozenset(self._classes)
+ classes = _ClassesDescriptor()
@property
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)
- def set_styles(self, css: str | None = None, **update_styles) -> None:
- """Set custom styles on this object."""
+ def set_styles(self, css: str | None = None, **update_styles) -> Self:
+ """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:
try:
@@ -877,16 +902,32 @@ class DOMNode(MessagePump):
"""
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.
Args:
add: Add the classes if True, otherwise remove them.
+
+ Returns:
+ Self.
"""
if add:
self.add_class(*class_names)
else:
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:
"""Request an update of this node's styles.
@@ -898,45 +939,56 @@ class DOMNode(MessagePump):
except NoActiveAppError:
pass
- def add_class(self, *class_names: str) -> None:
+ def add_class(self, *class_names: str) -> Self:
"""Add class names to this Node.
Args:
*class_names: CSS class names to add.
+ Returns:
+ Self.
"""
check_identifiers("class name", *class_names)
old_classes = self._classes.copy()
self._classes.update(class_names)
if old_classes == self._classes:
- return
+ return self
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.
Args:
*class_names: CSS class names to remove.
+
+ Returns:
+ Self.
"""
check_identifiers("class name", *class_names)
old_classes = self._classes.copy()
self._classes.difference_update(class_names)
if old_classes == self._classes:
- return
+ return self
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.
Args:
*class_names: CSS class names to toggle.
+
+ Returns:
+ Self.
"""
check_identifiers("class name", *class_names)
old_classes = self._classes.copy()
self._classes.symmetric_difference_update(class_names)
if old_classes == self._classes:
- return
+ return self
self._update_styles()
+ return self
def has_pseudo_class(self, *class_names: str) -> bool:
"""Check for pseudo classes (such as hover, focus etc)
diff --git a/src/textual/logging.py b/src/textual/logging.py
new file mode 100644
index 000000000..1ceb1bd2a
--- /dev/null
+++ b/src/textual/logging.py
@@ -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)
diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py
index 9163811d5..6599f7191 100644
--- a/src/textual/message_pump.py
+++ b/src/textual/message_pump.py
@@ -156,17 +156,26 @@ class MessagePump(metaclass=MessagePumpMeta):
try:
return active_app.get()
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
def is_parent_active(self) -> bool:
+ """Is the parent active?"""
return bool(
self._parent and not self._parent._closed and not self._parent._closing
)
@property
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
@property
diff --git a/src/textual/widget.py b/src/textual/widget.py
index 1818cac89..09eda4f54 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -179,6 +179,15 @@ class MountError(WidgetError):
"""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
class Widget(DOMNode):
"""
@@ -2357,6 +2366,27 @@ class Widget(DOMNode):
break
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:
"""Applies style attributes to the default renderable.
diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py
index e76b092b1..0ec6a23a6 100644
--- a/src/textual/widgets/_data_table.py
+++ b/src/textual/widgets/_data_table.py
@@ -29,14 +29,13 @@ from ..reactive import Reactive
from ..render import measure
from ..scroll_view import ScrollView
from ..strip import Strip
+from ..widget import PseudoClasses
-CellCacheKey: TypeAlias = "tuple[RowKey, ColumnKey, Style, bool, bool, int]"
-LineCacheKey: TypeAlias = (
- "tuple[int, int, int, int, Coordinate, Coordinate, Style, CursorType, bool, int]"
-)
-RowCacheKey: TypeAlias = (
- "tuple[RowKey, int, Style, Coordinate, Coordinate, CursorType, bool, bool, int]"
+CellCacheKey: TypeAlias = (
+ "tuple[RowKey, ColumnKey, Style, bool, bool, int, PseudoClasses]"
)
+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"]
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)
"""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
"""Set to re-calculate dimensions on idle."""
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)
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:
style += Style.from_meta({"row": row_index, "column": column_index})
@@ -1614,6 +1626,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
show_cursor,
self._show_hover_cursor,
self._update_count,
+ self._pseudo_class_state,
)
if cache_key in self._row_render_cache:
@@ -1786,6 +1799,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
self.cursor_type,
self._show_hover_cursor,
self._update_count,
+ self._pseudo_class_state,
)
if cache_key in self._line_cache:
return self._line_cache[cache_key]
@@ -1810,6 +1824,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
self._line_cache[cache_key] = 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:
width, height = self.size
scroll_x, scroll_y = self.scroll_offset
@@ -1842,6 +1860,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
except KeyError:
pass
+ def on_leave(self, event: events.Leave) -> None:
+ self._set_hover_cursor(False)
+
def _get_fixed_offset(self) -> Spacing:
"""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
diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py
index db0d77cf7..0b5034364 100644
--- a/src/textual/widgets/_tree.py
+++ b/src/textual/widgets/_tree.py
@@ -925,6 +925,10 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
self.cursor_line = -1
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:
width = self.size.width
scroll_x, scroll_y = self.scroll_offset
@@ -952,7 +956,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
is_hover,
width,
self._updates,
- self.has_focus,
+ self._pseudo_class_state,
tuple(node._updates for node in line.path),
)
if cache_key in self._line_cache:
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index fc01ad464..42c7fd18a 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -15663,6 +15663,165 @@
'''
# ---
+# name: test_layer_fix
+ '''
+
+
+ '''
+# ---
# name: test_layers
'''