From 39dde3c3cb1061d5cec62f2371a2bdd93e7d959e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 2 Jun 2022 17:20:03 +0100 Subject: [PATCH] CSS inheritance --- docs/examples/light_dark.py | 10 ++-- sandbox/basic.css | 32 +++++------- sandbox/tabs.py | 2 - src/textual/app.py | 52 ++++++++++++-------- src/textual/css/model.py | 4 +- src/textual/css/query.py | 2 + src/textual/css/stylesheet.py | 4 +- src/textual/design.py | 4 +- src/textual/dom.py | 90 +++++++++++++++++++++++++--------- src/textual/screen.py | 2 +- src/textual/widget.py | 28 ++++++----- src/textual/widgets/_button.py | 19 +++++-- src/textual/widgets/tabs.py | 2 - 13 files changed, 160 insertions(+), 91 deletions(-) diff --git a/docs/examples/light_dark.py b/docs/examples/light_dark.py index 36dd95296..feb542d44 100644 --- a/docs/examples/light_dark.py +++ b/docs/examples/light_dark.py @@ -3,16 +3,18 @@ from textual.widgets import Button class ButtonApp(App): + CSS = """ + Button { width: 100%; } + """ def compose(self): - yield Button("Light", id="light") - yield Button("Dark", id="dark") + yield Button("Lights off") def handle_pressed(self, event): - self.dark = event.button.id == "dark" - + self.dark = not self.dark + event.button.label = "Lights ON" if self.dark else "Lights OFF" diff --git a/sandbox/basic.css b/sandbox/basic.css index 4ef50c632..c4c632736 100644 --- a/sandbox/basic.css +++ b/sandbox/basic.css @@ -11,19 +11,21 @@ scrollbar-background-hover: $panel-darken-3; scrollbar-color: $system; scrollbar-color-active: $accent-darken-1; + scrollbar-size-horizontal: 1; + scrollbar-size-vertical: 2; } App > Screen { layout: dock; docks: side=left/1; background: $surface; - color: $text-surface; + color: $text-surface; } #sidebar { color: $text-primary; - background: $primary; + background: $primary-background; dock: side; width: 30; offset-x: -100%; @@ -37,7 +39,7 @@ App > Screen { #sidebar .title { height: 3; - background: $primary-darken-2; + background: $primary-background-darken-2; color: $text-primary-darken-2 ; border-right: outer $primary-darken-3; content-align: center middle; @@ -45,16 +47,16 @@ App > Screen { #sidebar .user { height: 8; - background: $primary-darken-1; + background: $primary-background-darken-1; color: $text-primary-darken-1; - border-right: outer $primary-darken-3; + border-right: outer $primary-background-darken-3; content-align: center middle; } #sidebar .content { - background: $primary; - color: $text-primary; - border-right: outer $primary-darken-3; + background: $primary-background; + color: $text-primary-background; + border-right: outer $primary-background-darken-3; content-align: center middle; } @@ -90,14 +92,6 @@ Tweet { box-sizing: border-box; } -Tweet.scrollbar-size-custom { - scrollbar-size-vertical: 2; -} - - -Tweet.scroll-horizontal { - scrollbar-size-horizontal: 2; -} .scrollable { width: 80; @@ -178,8 +172,8 @@ Tweet.scroll-horizontal TweetBody { OptionItem { height: 3; - background: $primary; - border-right: outer $primary-darken-2; + background: $primary-background; + border-right: outer $primary-background-darken-2; border-left: blank; content-align: center middle; } @@ -187,7 +181,7 @@ OptionItem { OptionItem:hover { height: 3; color: $accent; - background: $primary-darken-1; + background: $primary-background-darken-1; /* border-top: hkey $accent2-darken-3; border-bottom: hkey $accent2-darken-3; */ text-style: bold; diff --git a/sandbox/tabs.py b/sandbox/tabs.py index e881c2ab2..06485038a 100644 --- a/sandbox/tabs.py +++ b/sandbox/tabs.py @@ -17,8 +17,6 @@ class Hr(Widget): class Info(Widget): - DEFAULT_STYLES = "height: 2;" - def __init__(self, text: str) -> None: super().__init__() self.text = text diff --git a/src/textual/app.py b/src/textual/app.py index 31effb128..d212334c7 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -48,6 +48,7 @@ from ._context import active_app from ._event_broker import extract_handler_actions, NoHandler from .binding import Bindings, NoBinding from .css.stylesheet import Stylesheet +from .css.styles import RenderStyles from .css.query import NoMatchingNodesError from .design import ColorSystem from .devtools.client import DevtoolsClient, DevtoolsConnectionError, DevtoolsLog @@ -61,6 +62,7 @@ from .layouts.dock import Dock from .message_pump import MessagePump from .reactive import Reactive from .renderables.blank import Blank + from .screen import Screen from .widget import Widget @@ -109,9 +111,9 @@ class App(Generic[ReturnType], DOMNode): """The base class for Textual Applications""" CSS = """ - $WIDGET { + App { background: $surface; - color: $text-surface; + color: $text-surface; } """ @@ -144,6 +146,7 @@ class App(Generic[ReturnType], DOMNode): # this will create some first references to an asyncio loop. _init_uvloop() + super().__init__() self.features: frozenset[FeatureFlag] = parse_features(os.getenv("TEXTUAL", "")) self.console = Console( @@ -206,10 +209,10 @@ class App(Generic[ReturnType], DOMNode): else None ) - super().__init__() - - def __init_subclass__(cls, css_path: str | None = None) -> None: - super().__init_subclass__() + def __init_subclass__( + cls, css_path: str | None = None, inherit_css: bool = True + ) -> None: + super().__init_subclass__(inherit_css=inherit_css) cls.CSS_PATH = css_path title: Reactive[str] = Reactive("Textual") @@ -340,7 +343,15 @@ class App(Generic[ReturnType], DOMNode): def watch_dark(self, dark: bool) -> None: """Watches the dark bool.""" + self.screen.dark = dark + if dark: + self.add_class("-dark-mode") + self.remove_class("-light-mode") + else: + self.remove_class("-dark-mode") + self.add_class("-light-mode") + self.refresh_css() def get_driver_class(self) -> Type[Driver]: @@ -364,6 +375,18 @@ class App(Generic[ReturnType], DOMNode): def __rich_repr__(self) -> rich.repr.Result: yield "title", self.title + yield "id", self.id, None + if self.name: + yield "name", self.name + if self.classes: + yield "classes", set(self.classes) + pseudo_classes = self.pseudo_classes + if pseudo_classes: + yield "pseudo_classes", set(pseudo_classes) + + @property + def is_transparent(self) -> bool: + return True @property def animator(self) -> Animator: @@ -373,10 +396,6 @@ class App(Generic[ReturnType], DOMNode): def screen(self) -> Screen: return self._screen_stack[-1] - @property - def css_type(self) -> str: - return "app" - @property def size(self) -> Size: return Size(*self.console.size) @@ -680,10 +699,6 @@ class App(Generic[ReturnType], DOMNode): error (Exception): An exception instance. """ - if "tb" in self.features: - self.fatal_error() - return - if hasattr(error, "__rich__"): # Exception has a rich method, so we can defer to that for the rendering self.panic(error) @@ -725,13 +740,8 @@ class App(Generic[ReturnType], DOMNode): try: if self.css_path is not None: self.stylesheet.read(self.css_path) - if self.CSS is not None: - css_code = string.Template(self.CSS).safe_substitute( - {"WIDGET": self.css_type} - ) - self.stylesheet.add_source( - css_code, path=f"<{self.__class__.__name__}>" - ) + for path, css in self.css: + self.stylesheet.add_source(css, path=path) except Exception as error: self.on_exception(error) self._print_error_renderables() diff --git a/src/textual/css/model.py b/src/textual/css/model.py index 37367fd7e..586ca2309 100644 --- a/src/textual/css/model.py +++ b/src/textual/css/model.py @@ -86,8 +86,10 @@ class Selector: return node.has_pseudo_class(*self.pseudo_classes) def _check_type(self, node: DOMNode) -> bool: - if node.css_type != self._name_lower: + if self._name_lower not in node.css_type_names: return False + # if node.css_type != self._name_lower: + # return False if self.pseudo_classes and not node.has_pseudo_class(*self.pseudo_classes): return False return True diff --git a/src/textual/css/query.py b/src/textual/css/query.py index 43fdef576..47a7eaf71 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -49,7 +49,9 @@ class DOMQuery: if selector is not None: selector_set = parse_selectors(selector) + print(selector_set) self._nodes = [_node for _node in self._nodes if match(selector_set, _node)] + print(self._nodes) def __len__(self) -> int: return len(self._nodes) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index adca495fa..d286a6637 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -115,7 +115,7 @@ class StylesheetErrors: ) -@rich.repr.auto +@rich.repr.auto(angular=True) class Stylesheet: def __init__(self, *, variables: dict[str, str] | None = None) -> None: self._rules: list[RuleSet] = [] @@ -124,7 +124,7 @@ class Stylesheet: self._require_parse = False def __rich_repr__(self) -> rich.repr.Result: - yield self.rules + yield list(self.source.keys()) @property def rules(self) -> list[RuleSet]: diff --git a/src/textual/design.py b/src/textual/design.py index 6ce0ec71a..332f4bd3c 100644 --- a/src/textual/design.py +++ b/src/textual/design.py @@ -188,6 +188,8 @@ class ColorSystem: COLORS = [ ("primary", primary), ("secondary", secondary), + ("primary-background", primary), + ("secondary-background", secondary), ("background", background), ("panel", panel), ("surface", surface), @@ -199,7 +201,7 @@ class ColorSystem: ] # Colors names that have a dark variant - DARK_SHADES = {"primary", "secondary"} + DARK_SHADES = {"primary-background", "secondary-background"} for name, color in COLORS: is_dark_shade = dark and name in DARK_SHADES diff --git a/src/textual/dom.py b/src/textual/dom.py index 797e229c3..00d39e035 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Iterable, Iterator, TYPE_CHECKING +from inspect import getfile +from typing import ClassVar, Iterable, Iterator, Type, TYPE_CHECKING import rich.repr from rich.highlighter import ReprHighlighter @@ -38,8 +39,10 @@ class DOMNode(MessagePump): """ - DEFAULT_STYLES = "" - INLINE_STYLES = "" + CSS: ClassVar[str] = "" + + inherit_css: ClassVar[bool] = True + css_type_names: ClassVar[frozenset[str]] = frozenset() def __init__( self, @@ -53,14 +56,38 @@ class DOMNode(MessagePump): self._classes: set[str] = set() if classes is None else set(classes.split()) self.children = NodeList() self._css_styles: Styles = Styles(self) - self._inline_styles: Styles = Styles.parse( - self.INLINE_STYLES, repr(self), node=self - ) + self._inline_styles: Styles = Styles(self) self.styles = RenderStyles(self, self._css_styles, self._inline_styles) self._default_styles = Styles() self._default_rules = self._default_styles.extract_rules((0, 0, 0)) super().__init__() + def __init_subclass__(cls, inherit_css: bool = True) -> None: + super().__init_subclass__() + cls.inherit_css = inherit_css + css_type_names: set[str] = set() + for base in cls._css_bases(cls): + css_type_names.add(base.__name__.lower()) + cls.css_type_names = frozenset(css_type_names) + + @property + def _node_bases(self) -> Iterator[Type[DOMNode]]: + return self._css_bases(self.__class__) + + @classmethod + def _css_bases(cls, base: Type[DOMNode]) -> Iterator[Type[DOMNode]]: + _class = base + while True: + yield _class + if not _class.inherit_css: + break + for _base in _class.__bases__: + if issubclass(_base, DOMNode): + _class = _base + break + else: + break + def on_register(self, app: App) -> None: """Called when the widget is registered @@ -74,6 +101,25 @@ class DOMNode(MessagePump): if self._classes: yield "classes", " ".join(self._classes) + @property + def css(self) -> list[tuple[str, str]]: + """Combined CSS from base classes""" + + css_stack: list[tuple[str, str]] = [] + + def get_path(base: Type[DOMNode]) -> str: + try: + return f"{getfile(base)}:{base.__name__}" + except TypeError: + return f"{base.__name__}" + + for base in self._node_bases: + css = base.CSS.strip() + if css: + css_stack.append((get_path(base), css)) + + return css_stack + @property def parent(self) -> DOMNode | None: """Get the parent node. @@ -158,15 +204,6 @@ class DOMNode(MessagePump): pseudo_classes = frozenset({*self.get_pseudo_classes()}) return pseudo_classes - @property - def css_type(self) -> str: - """Gets the CSS type, used by the CSS. - - Returns: - str: A type used in CSS (lower cased class name). - """ - return self.__class__.__name__.lower() - @property def css_path_nodes(self) -> list[DOMNode]: """A list of nodes from the root to this node, forming a "path". @@ -238,20 +275,29 @@ class DOMNode(MessagePump): from rich.console import Group from rich.panel import Panel - highlighter = ReprHighlighter() - tree = Tree(highlighter(repr(self))) + from .widget import Widget - def add_children(tree, node): - for child in node.children: + def render_info(node: DOMNode) -> Columns: + if isinstance(node, Widget): info = Columns( [ - Pretty(child), - highlighter(f"region={child.region!r}"), + Pretty(node), + highlighter(f"region={node.region!r}"), highlighter( - f"virtual_size={child.virtual_size!r}", + f"virtual_size={node.virtual_size!r}", ), ] ) + else: + info = Columns([Pretty(node)]) + return info + + highlighter = ReprHighlighter() + tree = Tree(render_info(self)) + + def add_children(tree, node): + for child in node.children: + info = render_info(child) css = child.styles.css if css: info = Group( diff --git a/src/textual/screen.py b/src/textual/screen.py index 6d0e8c07c..a56f70261 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -30,7 +30,7 @@ class Screen(Widget): """A widget for the root of the app.""" CSS = """ - $WIDGET { + Screen { layout: vertical; overflow-y: auto; } diff --git a/src/textual/widget.py b/src/textual/widget.py index b3e685f82..3a210806f 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -67,9 +67,6 @@ class RenderCache(NamedTuple): @rich.repr.auto class Widget(DOMNode): - CSS = """ - """ - can_focus: bool = False can_focus_children: bool = True @@ -177,11 +174,9 @@ class Widget(DOMNode): Args: app (App): App instance. """ - css_code = string.Template(self.CSS).safe_substitute({"WIDGET": self.css_type}) - # Parser the Widget's CSS - self.app.stylesheet.add_source( - css_code, f"{__file__}:<{self.__class__.__name__}>" - ) + # Parse the Widget's CSS + for path, css in self.css: + self.app.stylesheet.add_source(css, path=path) def get_box_model( self, container: Size, viewport: Size, fraction_unit: Fraction @@ -536,12 +531,16 @@ class Widget(DOMNode): def scroll_page_left(self, *, animate: bool = True) -> bool: return self.scroll_to( - x=self.scroll_target_x - self.container_size.width, animate=animate + x=self.scroll_target_x - self.container_size.width, + animate=animate, + duration=0.3, ) def scroll_page_right(self, *, animate: bool = True) -> bool: return self.scroll_to( - x=self.scroll_target_x + self.container_size.width, animate=animate + x=self.scroll_target_x + self.container_size.width, + animate=animate, + duration=0.3, ) def scroll_to_widget(self, widget: Widget, *, animate: bool = True) -> bool: @@ -589,9 +588,12 @@ class Widget(DOMNode): ) def __init_subclass__( - cls, can_focus: bool = True, can_focus_children: bool = True + cls, + can_focus: bool = True, + can_focus_children: bool = True, + inherit_css: bool = True, ) -> None: - super().__init_subclass__() + super().__init_subclass__(inherit_css=inherit_css) cls.can_focus = can_focus cls.can_focus_children = can_focus_children @@ -1019,7 +1021,7 @@ class Widget(DOMNode): def handle_scroll_to(self, message: ScrollTo) -> None: if self.is_container: - self.scroll_to(message.x, message.y, animate=message.animate) + self.scroll_to(message.x, message.y, animate=message.animate, duration=0.1) message.stop() def handle_scroll_up(self, event: ScrollUp) -> None: diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 19f11a07e..d93ebd4b6 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -17,25 +17,38 @@ class Button(Widget, can_focus=True): CSS = """ - $WIDGET { + + Button { width: auto; height: 3; background: $primary; color: $text-primary; content-align: center middle; border: tall $primary-lighten-3; + margin: 1 0; align: center middle; text-style: bold; } - $WIDGET:hover { + .-dark-mode Button { + border: tall white $primary-lighten-2; + color: $primary-lighten-2; + background: $background; + } + + .-dark-mode Button:hover { + background: $surface; + } + + + Button:hover { background:$primary-darken-2; color: $text-primary-darken-2; border: tall $primary-lighten-1; } - App.-show-focus $WIDGET:focus { + App.-show-focus Button:focus { tint: $accent 20%; } diff --git a/src/textual/widgets/tabs.py b/src/textual/widgets/tabs.py index 4346816f6..12e810a01 100644 --- a/src/textual/widgets/tabs.py +++ b/src/textual/widgets/tabs.py @@ -191,8 +191,6 @@ class Tabs(Widget): that character. """ - DEFAULT_STYLES = "height: 2;" - _active_tab_name: Reactive[str | None] = Reactive("") _bar_offset: Reactive[float] = Reactive(0.0)