diff --git a/examples/basic.css b/examples/basic.css index efafe797e..7500a8f1b 100644 --- a/examples/basic.css +++ b/examples/basic.css @@ -1,15 +1,11 @@ /* CSS file for basic.py */ App > View { + layout: dock; docks: side=left/1; text: on #20639b; } -Widget:hover { - outline: heavy; - text: bold !important; -} - #sidebar { text: #09312e on #3caea3; dock: side; @@ -29,10 +25,6 @@ Widget:hover { border: hkey; } -#header.-visible { - visibility: hidden; -} - #content { text: white on #20639b; border-bottom: hkey #0f2b41; diff --git a/examples/basic.py b/examples/basic.py index d06ac0765..72743ab93 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -8,7 +8,6 @@ class BasicApp(App): def on_load(self): """Bind keys here.""" self.bind("tab", "toggle_class('#sidebar', '-active')") - self.bind("a", "toggle_class('#header', '-visible')") def on_mount(self): """Build layout here.""" diff --git a/sandbox/local_styles.py b/sandbox/local_styles.py index 6be4cd0cb..0ffa1c94b 100644 --- a/sandbox/local_styles.py +++ b/sandbox/local_styles.py @@ -16,18 +16,11 @@ class BasicApp(App): sidebar=Widget(), ) - async def on_key(self, event: events.Key) -> None: - await self.dispatch_key(event) + def key_a(self) -> None: + self.query("#footer").set_styles(text="on magenta") - def key_a(self) -> bool | None: - self.query("#footer").set_styles(text="on magenta").refresh() - - self.log(self["#footer"].styles.css) - self.bell() - self.refresh() - - def key_b(self) -> bool | None: - self["#content"].set_styles("text: on magenta") + def key_b(self) -> None: + self["#footer"].set_styles("text: on green") BasicApp.run(css_file="local_styles.css", log="textual.log") diff --git a/src/textual/_animator.py b/src/textual/_animator.py index b3c51be31..f22ab9080 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -130,7 +130,10 @@ class Animator: self._timer.start() async def stop(self) -> None: - await self._timer.stop() + try: + await self._timer.stop() + except asyncio.CancelledError: + pass def bind(self, obj: object) -> BoundAnimator: return BoundAnimator(self, obj) diff --git a/src/textual/_timer.py b/src/textual/_timer.py index cbeef27cd..25c90506a 100644 --- a/src/textual/_timer.py +++ b/src/textual/_timer.py @@ -5,6 +5,7 @@ from asyncio import ( get_event_loop, CancelledError, Event, + shield, sleep, Task, ) diff --git a/src/textual/css/query.py b/src/textual/css/query.py index e03879111..42482efad 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -135,7 +135,6 @@ class DOMQuery: return self def refresh(self, repaint: bool = True, layout: bool = False) -> DOMQuery: - """Refresh matched nodes. Args: diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 268f43530..43a854113 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass, field from functools import lru_cache +from operator import attrgetter from typing import Any, Iterable, NamedTuple, TYPE_CHECKING import rich.repr @@ -49,6 +50,22 @@ if TYPE_CHECKING: from ..dom import DOMNode +_text_getter = attrgetter( + "_rule_text_color", "_rule_text_background", "_rule_text_style" +) + +_border_getter = attrgetter( + "_rule_border_top", "_rule_border_right", "_rule_border_bottom", "_rule_border_left" +) + +_outline_getter = attrgetter( + "_rule_outline_top", + "_rule_outline_right", + "_rule_outline_bottom", + "_rule_outline_left", +) + + class DockGroup(NamedTuple): name: str edge: Edge @@ -103,7 +120,15 @@ class Styles: def has_rule(self, rule: str) -> bool: """Check if a rule has been set.""" - return getattr(self, f"_rule_{rule}") != None + if rule in RULE_NAMES and getattr(self, f"_rule_{rule}") is not None: + return True + if rule == "text": + return not all(rule is None for rule in _text_getter(self)) + if rule == "border" and any(self.border): + return not all(rule is None for rule in _border_getter(self)) + if rule == "outline" and any(self.outline): + return not all(rule is None for rule in _outline_getter(self)) + return False display = StringEnumProperty(VALID_DISPLAY, "block") visibility = StringEnumProperty(VALID_VISIBILITY, "visible") @@ -164,10 +189,11 @@ class Styles: @classmethod @lru_cache(maxsize=1024) - def parse(cls, css: str, path: str) -> Styles: + def parse(cls, css: str, path: str, *, node: DOMNode = None) -> Styles: from .parse import parse_declarations styles = parse_declarations(css, path) + styles.node = node return styles def __textual_animation__( @@ -345,11 +371,11 @@ class Styles: _type, style = self._rule_outline_left append_declaration("outline-left", f"{_type} {style}") - if self.offset: + if self._rule_offset is not None: x, y = self.offset append_declaration("offset", f"{x} {y}") if self._rule_dock: - append_declaration("dock-group", self._rule_dock) + append_declaration("dock", self._rule_dock) if self._rule_docks: append_declaration( "docks", @@ -363,6 +389,7 @@ class Styles: if self._rule_layer is not None: append_declaration("layer", self.layer) if self._rule_layout is not None: + assert self.layout is not None append_declaration("layout", self.layout.name) if self._rule_text_color or self._rule_text_background or self._rule_text_style: append_declaration("text", str(self.text)) @@ -415,10 +442,9 @@ class StyleViewProperty(Generic[GetType, SetType]): def __get__( self, obj: StylesView, objtype: type[StylesView] | None = None ) -> GetType: - styles_value = getattr(obj._inline_styles, self._internal_name, None) - if styles_value is None: - return getattr(obj._base_styles, self._name) - return styles_value + if obj._inline_styles.has_rule(self._name): + return getattr(obj._inline_styles, self._name) + return getattr(obj._base_styles, self._name) @rich.repr.auto @@ -448,6 +474,17 @@ class StylesView: """Reset the inline styles.""" self._inline_styles.reset() + def refresh(self, layout: bool = False) -> None: + self._inline_styles.refresh(layout=layout) + + def merge(self, other: Styles) -> None: + """Merge values from another Styles. + + Args: + other (Styles): A Styles object. + """ + self._inline_styles.merge(other) + def check_refresh(self) -> tuple[bool, bool]: """Check if the Styles must be refreshed. @@ -475,10 +512,11 @@ class StylesView: display: StyleViewProperty[str, str | None] = StyleViewProperty() visibility: StyleViewProperty[str, str | None] = StyleViewProperty() layout: StyleViewProperty[Layout | None, str | Layout] = StyleViewProperty() + text: StyleViewProperty[Style, Style | str | None] = StyleViewProperty() - color: StyleViewProperty[Color, Color | str | None] = StyleViewProperty() - background: StyleViewProperty[Color, Color | str | None] = StyleViewProperty() - style: StyleViewProperty[Style, str | None] = StyleViewProperty() + text_color: StyleViewProperty[Color, Color | str | None] = StyleViewProperty() + text_background: StyleViewProperty[Color, Color | str | None] = StyleViewProperty() + text_style: StyleViewProperty[Style, str | None] = StyleViewProperty() padding: StyleViewProperty[Spacing, SpacingDimensions] = StyleViewProperty() margin: StyleViewProperty[Spacing, SpacingDimensions] = StyleViewProperty() @@ -537,6 +575,8 @@ class StylesView: tuple[str, ...], str | tuple[str] | None ] = StyleViewProperty() + transitions: StyleViewProperty[dict[str, Transition], None] = StyleViewProperty() + if __name__ == "__main__": styles = Styles() diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 3ef6d697d..711761cb2 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -135,8 +135,8 @@ class Stylesheet: node._css_styles.reset() # Collect default node CSS rules - # for key, default_specificity, value in node._default_rules: - # rule_attributes[key].append((default_specificity, value)) + for key, default_specificity, value in node._default_rules: + rule_attributes[key].append((default_specificity, value)) # Collect the rules defined in the stylesheet for rule in self.rules: diff --git a/src/textual/dom.py b/src/textual/dom.py index 44209073c..c58855d66 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -32,6 +32,7 @@ class DOMNode(MessagePump): """ + DEFAULT_STYLES = "" STYLES = "" def __init__(self, name: str | None = None, id: str | None = None) -> None: @@ -40,9 +41,11 @@ class DOMNode(MessagePump): self._classes: set[str] = set() self.children = NodeList() self._css_styles: Styles = Styles(self) - self._inline_styles: Styles = Styles.parse(self.STYLES, repr(self)) + self._inline_styles: Styles = Styles.parse(self.STYLES, repr(self), node=self) self.styles = StylesView(self._css_styles, self._inline_styles) super().__init__() + self.default_styles = Styles.parse(self.DEFAULT_STYLES, repr(self)) + self._default_rules = self.default_styles.extract_rules((0, 0, 0)) def __rich_repr__(self) -> rich.repr.Result: yield "name", self._name, None @@ -301,7 +304,9 @@ class DOMNode(MessagePump): ) apply_css = f"{css or ''}\n{kwarg_css}\n" new_styles = parse_declarations(apply_css, f"") - self._inline_styles.merge(new_styles) + self.styles.merge(new_styles) + self.log(repr(self.styles)) + self.log(self._inline_styles, self._css_styles, self.styles.text) self.refresh() def has_class(self, *class_names: str) -> bool: diff --git a/src/textual/layouts/dock.py b/src/textual/layouts/dock.py index 9e05a94db..f82d07ef7 100644 --- a/src/textual/layouts/dock.py +++ b/src/textual/layouts/dock.py @@ -36,6 +36,7 @@ class Dock(NamedTuple): class DockLayout(Layout): + """Dock Widgets to edge of screen.""" name = "dock" diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 5aadd9547..166a2a31c 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -311,7 +311,7 @@ class MessagePump: else: return False - async def dispatch_key(self, event: events.Key) -> None: + async def dispatch_key(self, event: events.Key) -> bool: """Dispatch a key event to method. This method will call the method named 'key_' if it exists. This key method @@ -323,9 +323,9 @@ class MessagePump: key_method = getattr(self, f"key_{event.key}", None) if key_method is not None: - key_result = await invoke(key_method, event) - if key_result is None or key_result: - event.prevent_default() + await invoke(key_method, event) + return True + return False async def on_timer(self, event: events.Timer) -> None: event.prevent_default() diff --git a/src/textual/view.py b/src/textual/view.py index fb2a74274..ebcc93047 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -15,9 +15,8 @@ from .widget import Widget @rich.repr.auto class View(Widget): - STYLES = """ + DEFAULT_STYLES = """ layout: dock; - docks: main=top; """ def __init__(self, name: str | None = None, id: str | None = None) -> None: @@ -55,9 +54,7 @@ class View(Widget): Returns: The Layout associated with this view """ - # self.log("I", self._inline_styles) - # self.log("C", self._css_styles) - # self.log("S", self.styles) + assert self.styles.layout return self.styles.layout diff --git a/src/textual/widget.py b/src/textual/widget.py index 2784231c7..234ad2045 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -58,7 +58,7 @@ class Widget(DOMNode): _counts: ClassVar[dict[str, int]] = {} can_focus: bool = False - STYLES = """ + DEFAULT_STYLES = """ dock: _default; """ @@ -360,3 +360,7 @@ class Widget(DOMNode): async def on_leave(self, event: events.Leave) -> None: self._mouse_over = False self.app.update_styles() + + async def on_key(self, event: events.Key) -> None: + if await self.dispatch_key(event): + event.prevent_default()