mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into fix-2063
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
1
docs/api/logging.md
Normal file
1
docs/api/logging.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual.logging
|
||||
@@ -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()
|
||||
```
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -128,4 +128,4 @@ def arrange(
|
||||
|
||||
placements.extend(layout_placements)
|
||||
|
||||
return DockArrangeResult(placements, arrange_widgets, scroll_spacing)
|
||||
return DockArrangeResult(placements, arrange_widgets)
|
||||
|
||||
@@ -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 + (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,7 @@ class LogGroup(Enum):
|
||||
ERROR = 5
|
||||
PRINT = 6
|
||||
SYSTEM = 7
|
||||
LOGGING = 8
|
||||
|
||||
|
||||
class LogVerbosity(Enum):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
32
src/textual/logging.py
Normal file
32
src/textual/logging.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
File diff suppressed because one or more lines are too long
48
tests/snapshot_tests/snapshot_apps/layer_fix.py
Normal file
48
tests/snapshot_tests/snapshot_apps/layer_fix.py
Normal 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()
|
||||
@@ -354,3 +354,8 @@ def test_css_hot_reloading(snap_compare):
|
||||
assert snap_compare(
|
||||
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"])
|
||||
|
||||
@@ -12,7 +12,6 @@ def test_arrange_empty():
|
||||
result = arrange(container, [], Size(80, 24), Size(80, 24))
|
||||
assert result.placements == []
|
||||
assert result.widgets == set()
|
||||
assert result.spacing == Spacing(0, 0, 0, 0)
|
||||
|
||||
|
||||
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),
|
||||
]
|
||||
assert result.widgets == {child, header}
|
||||
assert result.spacing == Spacing(1, 0, 0, 0)
|
||||
|
||||
|
||||
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),
|
||||
]
|
||||
assert result.widgets == {child, header}
|
||||
assert result.spacing == Spacing(0, 0, 0, 10)
|
||||
|
||||
|
||||
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),
|
||||
]
|
||||
assert result.widgets == {child, header}
|
||||
assert result.spacing == Spacing(0, 10, 0, 0)
|
||||
|
||||
|
||||
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),
|
||||
]
|
||||
assert result.widgets == {child, header}
|
||||
assert result.spacing == Spacing(0, 0, 1, 0)
|
||||
|
||||
|
||||
def test_arrange_dock_badly():
|
||||
|
||||
@@ -652,6 +652,23 @@ async def test_hover_coordinate():
|
||||
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():
|
||||
"""Ensure that a HeaderSelected event gets posted when we click
|
||||
on the header in the DataTable."""
|
||||
|
||||
@@ -45,6 +45,39 @@ def test_validate():
|
||||
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():
|
||||
"""Test if binding merging is done correctly when (not) inheriting bindings."""
|
||||
|
||||
|
||||
@@ -152,6 +152,25 @@ def test_query_classes():
|
||||
# Now, let's check there are *no* children with the test class.
|
||||
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.
|
||||
app.query(ClassTest).add_class("test")
|
||||
assert len(app.query(".test")) == CHILD_COUNT
|
||||
|
||||
@@ -7,7 +7,7 @@ from textual.containers import Container
|
||||
from textual.css.errors import StyleValueError
|
||||
from textual.css.query import NoMatches
|
||||
from textual.geometry import Size
|
||||
from textual.widget import MountError, Widget
|
||||
from textual.widget import MountError, PseudoClasses, Widget
|
||||
from textual.widgets import Label
|
||||
|
||||
|
||||
@@ -178,6 +178,39 @@ def test_widget_mount_ids_must_be_unique_mounting_multiple_calls(parent):
|
||||
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
|
||||
async def test_remove():
|
||||
class RemoveMeLabel(Label):
|
||||
|
||||
Reference in New Issue
Block a user