From 44c1f2373aaa61c5262882a61064fa5c084ae21e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 27 Apr 2022 14:02:28 +0100 Subject: [PATCH 01/18] button widget --- CHANGELOG.md | 2 + sandbox/basic.py | 10 ++-- sandbox/uber.py | 12 ++-- src/textual/app.py | 1 + src/textual/css/_styles_builder.py | 13 +++-- src/textual/css/styles.py | 20 ++++++- src/textual/css/stylesheet.py | 2 + src/textual/dom.py | 17 ++++-- src/textual/reactive.py | 10 +++- src/textual/screen.py | 17 +++--- src/textual/widget.py | 20 ++++++- src/textual/widgets/__init__.py | 3 +- src/textual/widgets/_button.py | 91 ++++++++++++++++-------------- src/textual/widgets/_static.py | 2 +- 14 files changed, 142 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f66641495..97f76dde6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [0.2.0] - Unreleased + ## [0.1.15] - 2022-01-31 ### Added diff --git a/sandbox/basic.py b/sandbox/basic.py index e89f54034..b3dd17ecc 100644 --- a/sandbox/basic.py +++ b/sandbox/basic.py @@ -110,8 +110,8 @@ class BasicApp(App): # ), ), Widget( - Static(Syntax(CODE, "python"), classes={"code"}), - classes={"scrollable"}, + Static(Syntax(CODE, "python"), classes="code"), + classes="scrollable", ), Error(), Tweet(TweetBody()), @@ -121,12 +121,12 @@ class BasicApp(App): ), footer=Widget(), sidebar=Widget( - Widget(classes={"title"}), - Widget(classes={"user"}), + Widget(classes="title"), + Widget(classes="user"), OptionItem(), OptionItem(), OptionItem(), - Widget(classes={"content"}), + Widget(classes="content"), ), ) diff --git a/sandbox/uber.py b/sandbox/uber.py index 37fd74acb..32dec3e98 100644 --- a/sandbox/uber.py +++ b/sandbox/uber.py @@ -24,12 +24,12 @@ class BasicApp(App): Widget(id="uber2-child2"), ) uber1 = Widget( - Placeholder(id="child1", classes={"list-item"}), - Placeholder(id="child2", classes={"list-item"}), - Placeholder(id="child3", classes={"list-item"}), - Placeholder(classes={"list-item"}), - Placeholder(classes={"list-item"}), - Placeholder(classes={"list-item"}), + Placeholder(id="child1", classes="list-item"), + Placeholder(id="child2", classes="list-item"), + Placeholder(id="child3", classes="list-item"), + Placeholder(classes="list-item"), + Placeholder(classes="list-item"), + Placeholder(classes="list-item"), ) self.mount(uber1=uber1) diff --git a/src/textual/app.py b/src/textual/app.py index 6a6e80272..8bab41905 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -558,6 +558,7 @@ class App(DOMNode): parent.children._append(child) self.registry.add(child) child.set_parent(parent) + child.on_register(self) child.start_messages() return True return False diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index 1e3c07b46..e81b2a936 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -621,13 +621,18 @@ class StylesBuilder: f"invalid token {token_vertical!r}, expected {friendly_list(VALID_ALIGN_VERTICAL)}", ) - self.styles._rules["align_horizontal"] = token_horizontal.value - self.styles._rules["align_vertical"] = token_vertical.value + name = name.replace("-", "_") + self.styles._rules[f"{name}_horizontal"] = token_horizontal.value + self.styles._rules[f"{name}_vertical"] = token_vertical.value def process_align_horizontal(self, name: str, tokens: list[Token]) -> None: value = self._process_enum(name, tokens, VALID_ALIGN_HORIZONTAL) - self.styles._rules["align_horizontal"] = value + self.styles._rules[name.replace("-", "_")] = value def process_align_vertical(self, name: str, tokens: list[Token]) -> None: value = self._process_enum(name, tokens, VALID_ALIGN_VERTICAL) - self.styles._rules["align_vertical"] = value + self.styles._rules[name.replace("-", "_")] = value + + process_content_align = process_align + process_content_align_horizontal = process_align_horizontal + process_content_align_vertical = process_align_vertical diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index d1ecbdd91..c8f803c01 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -13,7 +13,7 @@ from rich.style import Style from .. import log from .._animator import Animation, EasingFunction from ..color import Color -from ..geometry import Offset, Size, Spacing +from ..geometry import Spacing from ._style_properties import ( BorderProperty, BoxProperty, @@ -130,6 +130,9 @@ class RulesMap(TypedDict, total=False): align_horizontal: AlignHorizontal align_vertical: AlignVertical + content_align_horizontal: AlignHorizontal + content_align_vertical: AlignVertical + RULE_NAMES = list(RulesMap.__annotations__.keys()) RULE_NAMES_SET = frozenset(RULE_NAMES) @@ -222,6 +225,9 @@ class StylesBase(ABC): align_horizontal = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left") align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top") + content_align_horizontal = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left") + content_align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top") + def __eq__(self, styles: object) -> bool: """Check that Styles containts the same rules.""" if not isinstance(styles, StylesBase): @@ -677,6 +683,18 @@ class Styles(StylesBase): elif has_rule("align_horizontal"): append_declaration("align-vertical", self.align_vertical) + if has_rule("content_align_horizontal") and has_rule("content_align_vertical"): + append_declaration( + "content-align", + f"{self.content_align_horizontal} {self.content_align_vertical}", + ) + elif has_rule("content_align_horizontal"): + append_declaration( + "content-align-horizontal", self.content_align_horizontal + ) + elif has_rule("content_align_horizontal"): + append_declaration("content-align-vertical", self.content_align_vertical) + lines.sort() return lines diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 8ff1382e9..f0e6c52ea 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -169,6 +169,8 @@ class Stylesheet: StylesheetError: If the CSS could not be read. StylesheetParseError: If the CSS is invalid. """ + if (css, path) in self.source: + return try: rules = list(parse(css, path, variables=self.variables)) except TokenizeError: diff --git a/src/textual/dom.py b/src/textual/dom.py index 7c0c0ba54..c2f740a43 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -19,6 +19,7 @@ from .css.query import NoMatchingNodesError from .message_pump import MessagePump if TYPE_CHECKING: + from .app import App from .css.query import DOMQuery from .screen import Screen @@ -40,28 +41,36 @@ class DOMNode(MessagePump): def __init__( self, + *, name: str | None = None, id: str | None = None, - classes: set[str] | None = None, + classes: str | None = None, ) -> None: self._name = name self._id = id - self._classes: set[str] = set() if classes is None else classes + 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.styles = RenderStyles(self, self._css_styles, self._inline_styles) - self._default_styles = Styles.parse(self.DEFAULT_STYLES, f"{self.__class__}") + self._default_styles = Styles() self._default_rules = self._default_styles.extract_rules((0, 0, 0)) super().__init__() + def on_register(self, app: App) -> None: + """Called when the widget is registered + + Args: + app (App): Parent application. + """ + def __rich_repr__(self) -> rich.repr.Result: yield "name", self._name, None yield "id", self._id, None if self._classes: - yield "classes", self._classes + yield "classes", " ".join(self._classes) @property def parent(self) -> DOMNode: diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 27bfdaedb..049ceef76 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: Reactable = Union[Widget, App] -ReactiveType = TypeVar("ReactiveType") +ReactiveType = TypeVar("ReactiveType", covariant=True) T = TypeVar("T") @@ -36,7 +36,7 @@ class Reactive(Generic[ReactiveType]): def __init__( self, - default: ReactiveType, + default: ReactiveType | Callable[[], ReactiveType], *, layout: bool = False, repaint: bool = True, @@ -58,7 +58,11 @@ class Reactive(Generic[ReactiveType]): self.name = name self.internal_name = f"_reactive_{name}" - setattr(owner, self.internal_name, self._default) + setattr( + owner, + self.internal_name, + self._default() if callable(self._default) else self._default, + ) def __get__(self, obj: Reactable, obj_type: type[object]) -> ReactiveType: return getattr(obj, self.internal_name) diff --git a/src/textual/screen.py b/src/textual/screen.py index e348416fe..e577aa4c3 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -18,11 +18,14 @@ from .renderables.gradient import VerticalGradient class Screen(Widget): """A widget for the root of the app.""" - DEFAULT_STYLES = """ - - layout: dock; - docks: _default=top; - + CSS = """ + + Screen { + layout: vertical; + docks: _default=top; + background: $surface; + } + """ dark = Reactive(False) @@ -39,8 +42,8 @@ class Screen(Widget): def is_transparent(self) -> bool: return False - def render(self) -> RenderableType: - return VerticalGradient("red", "blue") + # def render(self) -> RenderableType: + # return VerticalGradient("red", "blue") def get_offset(self, widget: Widget) -> Offset: """Get the absolute offset of a given Widget. diff --git a/src/textual/widget.py b/src/textual/widget.py index aacf448f6..ee978592d 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1,6 +1,6 @@ from __future__ import annotations -from logging import getLogger + from typing import ( Any, Awaitable, @@ -39,6 +39,7 @@ from .renderables.opacity import Opacity if TYPE_CHECKING: + from .app import App from .scrollbar import ( ScrollBar, ScrollTo, @@ -71,12 +72,15 @@ class Widget(DOMNode): """ + CSS = """ + """ + def __init__( self, *children: Widget, name: str | None = None, id: str | None = None, - classes: set[str] | None = None, + classes: str | None = None, ) -> None: self._size = Size(0, 0) @@ -107,6 +111,9 @@ class Widget(DOMNode): show_vertical_scrollbar = Reactive(False, layout=True) show_horizontal_scrollbar = Reactive(False, layout=True) + def on_register(self, app: App) -> None: + self.app.stylesheet.parse(self.CSS, path=f"<{self.__class__.name}>") + def get_box_model(self, container: Size, viewport: Size) -> BoxModel: """Process the box model for this widget. @@ -414,12 +421,18 @@ class Widget(DOMNode): """ renderable = self.render() + styles = self.styles parent_styles = self.parent.styles parent_text_style = self.parent.rich_text_style text_style = styles.rich_style + content_align = (styles.content_align_horizontal, styles.content_align_vertical) + if content_align != ("left", "top"): + horizontal, vertical = content_align + renderable = Align(renderable, horizontal, vertical=vertical) + renderable_text_style = parent_text_style + text_style if renderable_text_style: renderable = Styled(renderable, renderable_text_style) @@ -615,6 +628,9 @@ class Widget(DOMNode): # Default displays a pretty repr in the center of the screen + if self.is_container: + return "" + label = self.css_identifier_styled return Align.center(label, vertical="middle") diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index 79955a396..0646452be 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -1,6 +1,6 @@ from ._footer import Footer from ._header import Header -from ._button import Button, ButtonPressed +from ._button import Button from ._placeholder import Placeholder from ._static import Static from ._tree_control import TreeControl, TreeClick, TreeNode, NodeID @@ -8,7 +8,6 @@ from ._directory_tree import DirectoryTree, FileClick __all__ = [ "Button", - "ButtonPressed", "DirectoryTree", "FileClick", "Footer", diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 770edb197..bb558480c 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -1,8 +1,7 @@ from __future__ import annotations -from rich.align import Align -from rich.console import Console, ConsoleOptions, RenderResult, RenderableType -from rich.style import StyleType +from rich.console import RenderableType +from rich.text import Text from .. import events from ..message import Message @@ -10,58 +9,64 @@ from ..reactive import Reactive from ..widget import Widget -class ButtonPressed(Message, bubble=True): - pass +class Button(Widget, can_focus=True): + """A simple clickable button.""" + CSS = """ + + Button { + width: auto; + height: 3; + padding: 0 2; + background: $primary; + color: $text-primary; + content-align: center middle; + border: tall $primary-lighten-3; + margin: 1; + min-width:16; + text-style: bold; + } -class Expand: - def __init__(self, renderable: RenderableType) -> None: - self.renderable = renderable + Button:hover { + background:$primary-darken-2; + color: $text-primary-darken-2; + border: tall $primary-lighten-1; + + } + + """ - def __rich_console__( - self, console: Console, options: ConsoleOptions - ) -> RenderResult: - width = options.max_width - height = options.height or 1 - yield from console.render( - self.renderable, options.update_dimensions(width, height) - ) + class Pressed(Message, bubble=True): + pass - -class ButtonRenderable: - def __init__(self, label: RenderableType, style: StyleType = "") -> None: - self.label = label - self.style = style - - def __rich_console__( - self, console: Console, options: ConsoleOptions - ) -> RenderResult: - width = options.max_width - height = options.height or 1 - - yield Align.center( - self.label, vertical="middle", style=self.style, width=width, height=height - ) - - -class Button(Widget): def __init__( self, - label: RenderableType, + label: RenderableType | None = None, + disabled: bool = False, + *, name: str | None = None, - style: StyleType = "white on dark_blue", + id: str | None = None, + classes: str | None = None, ): - super().__init__(name=name) - self.name = name or str(label) - self.button_style = style + super().__init__(name=name, id=id, classes=classes) - self.label = label + self.label = self.css_identifier_styled if label is None else label + self.disabled = disabled + if disabled: + self.add_class("-disabled") label: Reactive[RenderableType] = Reactive("") + def validate_label(self, label: RenderableType) -> RenderableType: + """Parse markup for self.label""" + if isinstance(label, str): + return Text.from_markup(label) + return label + def render(self) -> RenderableType: - return ButtonRenderable(self.label, style=self.button_style) + return self.label async def on_click(self, event: events.Click) -> None: - event.prevent_default().stop() - await self.emit(ButtonPressed(self)) + event.stop() + if not self.disabled: + await self.emit(Button.Pressed(self)) diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index 455ebe1a1..733986e2f 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -14,7 +14,7 @@ class Static(Widget): *, name: str | None = None, id: str | None = None, - classes: set[str] | None = None, + classes: str | None = None, style: StyleType = "", padding: PaddingDimensions = 0, ) -> None: From f2bb9003c7e5a692ecc6340d0d96637e5cf908ca Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Wed, 27 Apr 2022 14:59:14 +0100 Subject: [PATCH 02/18] [layout][bugfix] Horizontal & Vertical layouts shouldn't display children that have `display: none` --- src/textual/_node_list.py | 4 +-- src/textual/app.py | 1 + src/textual/css/styles.py | 2 +- src/textual/dom.py | 4 +++ src/textual/layouts/dock.py | 5 ++-- src/textual/layouts/horizontal.py | 6 ++-- src/textual/layouts/vertical.py | 7 +++-- tests/layouts/test_common_layout_features.py | 30 ++++++++++++++++++++ 8 files changed, 49 insertions(+), 10 deletions(-) create mode 100644 tests/layouts/test_common_layout_features.py diff --git a/src/textual/_node_list.py b/src/textual/_node_list.py index 61b00a2c5..41282193c 100644 --- a/src/textual/_node_list.py +++ b/src/textual/_node_list.py @@ -18,8 +18,8 @@ class NodeList: """ - def __init__(self) -> None: - self._nodes: list[DOMNode] = [] + def __init__(self, nodes: list[DOMNode] = None) -> None: + self._nodes: list[DOMNode] = [] if nodes is None else nodes def __bool__(self) -> bool: return bool(self._nodes) diff --git a/src/textual/app.py b/src/textual/app.py index 6a6e80272..2acc04ffe 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -521,6 +521,7 @@ class App(DOMNode): mount_event = events.Mount(sender=self) await self.dispatch_message(mount_event) + # TODO: don't override `self.console` here self.console = Console(file=sys.__stdout__) self.title = self._title self.refresh() diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index d1ecbdd91..43c724479 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -70,7 +70,7 @@ if TYPE_CHECKING: class RulesMap(TypedDict, total=False): """A typed dict for CSS rules. - Any key may be absent, indiciating that rule has not been set. + Any key may be absent, indicating that rule has not been set. Does not define composite rules, that is a rule that is made of a combination of other rules. diff --git a/src/textual/dom.py b/src/textual/dom.py index 7c0c0ba54..b4003cec2 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -278,6 +278,10 @@ class DOMNode(MessagePump): add_children(tree, self) return tree + @property + def displayed_children(self) -> NodeList: + return NodeList([child for child in self.children if child.display]) + def get_pseudo_classes(self) -> Iterable[str]: """Get any pseudo classes applicable to this Node, e.g. hover, focus. diff --git a/src/textual/layouts/dock.py b/src/textual/layouts/dock.py index b507bbc67..f85e6de02 100644 --- a/src/textual/layouts/dock.py +++ b/src/textual/layouts/dock.py @@ -50,10 +50,9 @@ class DockLayout(Layout): def get_docks(self, parent: Widget) -> list[Dock]: groups: dict[str, list[Widget]] = defaultdict(list) - for child in parent.children: + for child in parent.displayed_children: assert isinstance(child, Widget) - if child.display: - groups[child.styles.dock].append(child) + groups[child.styles.dock].append(child) docks: list[Dock] = [] append_dock = docks.append for name, edge, z in parent.styles.docks: diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index 36fef9bd7..ba428c968 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -39,7 +39,9 @@ class HorizontalLayout(Layout): x = box_models[0].margin.left if box_models else 0 - for widget, box_model, margin in zip(parent.children, box_models, margins): + displayed_children = parent.displayed_children + + for widget, box_model, margin in zip(displayed_children, box_models, margins): content_width, content_height = box_model.size offset_y = widget.styles.align_height(content_height, parent_size.height) region = Region(x, offset_y, content_width, content_height) @@ -53,4 +55,4 @@ class HorizontalLayout(Layout): total_region = Region(0, 0, max_width, max_height) add_placement(WidgetPlacement(total_region, None, 0)) - return placements, set(parent.children) + return placements, set(displayed_children) diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 2752c4445..196f7eb52 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -40,10 +40,13 @@ class VerticalLayout(Layout): y = box_models[0].margin.top if box_models else 0 - for widget, box_model, margin in zip(parent.children, box_models, margins): + displayed_children = parent.displayed_children + + for widget, box_model, margin in zip(displayed_children, box_models, margins): content_width, content_height = box_model.size offset_x = widget.styles.align_width(content_width, parent_size.width) region = Region(offset_x, y, content_width, content_height) + # TODO: it seems that `max_height` is not used? max_height = max(max_height, content_height) add_placement(WidgetPlacement(region, widget, 0)) y += region.height + margin @@ -54,4 +57,4 @@ class VerticalLayout(Layout): total_region = Region(0, 0, max_width, max_height) add_placement(WidgetPlacement(total_region, None, 0)) - return placements, set(parent.children) + return placements, set(displayed_children) diff --git a/tests/layouts/test_common_layout_features.py b/tests/layouts/test_common_layout_features.py new file mode 100644 index 000000000..0fb6c80b7 --- /dev/null +++ b/tests/layouts/test_common_layout_features.py @@ -0,0 +1,30 @@ +import pytest +from textual.dom import NodeList +from textual.screen import Screen +from textual.widget import Widget + + +@pytest.mark.parametrize( + "layout,display,expected_in_displayed_children", + [ + ("dock", "block", True), + ("horizontal", "block", True), + ("vertical", "block", True), + ("dock", "none", False), + ("horizontal", "none", False), + ("vertical", "none", False), + ], +) +def test_nodes_take_display_property_into_account_when_they_display_their_children( + layout: str, display: str, expected_in_displayed_children: bool +): + widget = Widget(name="widget that might not be visible 🥷 ") + widget.styles.display = display + + screen = Screen() + screen.styles.layout = layout + screen.add_child(widget) + + displayed_children = screen.displayed_children + assert isinstance(displayed_children, NodeList) + assert (widget in screen.displayed_children) is expected_in_displayed_children From d0b8cacc350744dd1086aecd0bd52a85389ea779 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Thu, 28 Apr 2022 10:25:37 +0100 Subject: [PATCH 03/18] [css][bugfix] CSS colors can now have digits at the end of their names --- src/textual/css/_styles_builder.py | 19 ++++++++++-- tests/css/test_stylesheet.py | 49 ++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 tests/css/test_stylesheet.py diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index 1e3c07b46..6c849db58 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -461,10 +461,25 @@ class StylesBuilder: def process_color(self, name: str, tokens: list[Token]) -> None: """Processes a simple color declaration.""" name = name.replace("-", "_") - for token in tokens: + token_indexes_to_skip = [] + for i, token in enumerate(tokens): + if i in token_indexes_to_skip: + continue if token.name in ("color", "token"): + value = token.value + # Color names can include digits (e.g. "turquoise4"): if the next token is a number + # we should consider it part of the color name: + next_token = tokens[i + 1] if len(tokens) > (i + 1) else None + if ( + next_token + and next_token.name == "number" + and next_token.location[1] == token.location[1] + len(value) + ): + value = value + next_token.value + # skip next token, as we included it in this one's value: + token_indexes_to_skip.append(i + 1) try: - self.styles._rules[name] = Color.parse(token.value) + self.styles._rules[name] = Color.parse(value) except Exception as error: self.error( name, token, f"failed to parse color {token.value!r}; {error}" diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py new file mode 100644 index 000000000..87f27845d --- /dev/null +++ b/tests/css/test_stylesheet.py @@ -0,0 +1,49 @@ +from contextlib import nullcontext as does_not_raise +import pytest + +from textual.color import Color +from textual.css.stylesheet import Stylesheet, StylesheetParseError +from textual.css.tokenizer import TokenizeError + + +@pytest.mark.parametrize( + "css_value,expectation,expected_color", + [ + # Valid values: + ["red", does_not_raise(), Color(128, 0, 0)], + ["dark_cyan", does_not_raise(), Color(0, 175, 135)], + ["medium_turquoise", does_not_raise(), Color(95, 215, 215)], + ["turquoise4", does_not_raise(), Color(0, 135, 135)], + ["#ffcc00", does_not_raise(), Color(255, 204, 0)], + ["#ffcc0033", does_not_raise(), Color(255, 204, 0, 0.2)], + ["rgb(200,90,30)", does_not_raise(), Color(200, 90, 30)], + ["rgba(200,90,30,0.3)", does_not_raise(), Color(200, 90, 30, 0.3)], + # Some invalid ones: + ["coffee", pytest.raises(StylesheetParseError), None], # invalid color name + ["turquoise10", pytest.raises(StylesheetParseError), None], + ["turquoise 4", pytest.raises(StylesheetParseError), None], # space in it + ["1", pytest.raises(StylesheetParseError), None], # invalid value + ["()", pytest.raises(TokenizeError), None], # invalid tokens + # TODO: implement hex colors with 3 chars? @link https://devdocs.io/css/color_value + ["#09f", pytest.raises(TokenizeError), None], + # TODO: allow spaces in rgb/rgba expressions? + ["rgb(200, 90, 30)", pytest.raises(TokenizeError), None], + ["rgba(200,90,30, 0.4)", pytest.raises(TokenizeError), None], + ], +) +def test_color_property_parsing(css_value, expectation, expected_color): + stylesheet = Stylesheet() + css = """ + * { + background: ${COLOR}; + } + """.replace( + "${COLOR}", css_value + ) + + with expectation: + stylesheet.parse(css) + + if expected_color: + css_rule = stylesheet.rules[0] + assert css_rule.styles.background == expected_color From 3ed17cb406fb48d93d240f1ef7cc9af5f1b37e5e Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Thu, 28 Apr 2022 12:18:23 +0100 Subject: [PATCH 04/18] [css][bugfix] CSS tokens can now have digits in their names ...excepted for their first char of course --- src/textual/css/_styles_builder.py | 19 ++----------------- src/textual/css/tokenize.py | 2 +- 2 files changed, 3 insertions(+), 18 deletions(-) diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index 6c849db58..1e3c07b46 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -461,25 +461,10 @@ class StylesBuilder: def process_color(self, name: str, tokens: list[Token]) -> None: """Processes a simple color declaration.""" name = name.replace("-", "_") - token_indexes_to_skip = [] - for i, token in enumerate(tokens): - if i in token_indexes_to_skip: - continue + for token in tokens: if token.name in ("color", "token"): - value = token.value - # Color names can include digits (e.g. "turquoise4"): if the next token is a number - # we should consider it part of the color name: - next_token = tokens[i + 1] if len(tokens) > (i + 1) else None - if ( - next_token - and next_token.name == "number" - and next_token.location[1] == token.location[1] + len(value) - ): - value = value + next_token.value - # skip next token, as we included it in this one's value: - token_indexes_to_skip.append(i + 1) try: - self.styles._rules[name] = Color.parse(value) + self.styles._rules[name] = Color.parse(token.value) except Exception as error: self.error( name, token, f"failed to parse color {token.value!r}; {error}" diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py index 3ccc8166f..7d5dbe3a0 100644 --- a/src/textual/css/tokenize.py +++ b/src/textual/css/tokenize.py @@ -11,7 +11,7 @@ DURATION = r"\d+\.?\d*(?:ms|s)" NUMBER = r"\-?\d+\.?\d*" COLOR = r"\#[0-9a-fA-F]{8}|\#[0-9a-fA-F]{6}|rgb\(\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*\)|rgba\(\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*\)" KEY_VALUE = r"[a-zA-Z_-][a-zA-Z0-9_-]*=[0-9a-zA-Z_\-\/]+" -TOKEN = "[a-zA-Z_-]+" +TOKEN = "[a-zA-Z][a-zA-Z0-9_-]*" STRING = r"\".*?\"" VARIABLE_REF = r"\$[a-zA-Z0-9_\-]+" From 72555f4a77b594c21125b170d4dae75433db8955 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Thu, 28 Apr 2022 12:23:59 +0100 Subject: [PATCH 05/18] [dom] Simplify the implementation of the `displayed_children` property --- src/textual/_node_list.py | 4 ++-- src/textual/dom.py | 4 ++-- tests/layouts/test_common_layout_features.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/textual/_node_list.py b/src/textual/_node_list.py index 41282193c..61b00a2c5 100644 --- a/src/textual/_node_list.py +++ b/src/textual/_node_list.py @@ -18,8 +18,8 @@ class NodeList: """ - def __init__(self, nodes: list[DOMNode] = None) -> None: - self._nodes: list[DOMNode] = [] if nodes is None else nodes + def __init__(self) -> None: + self._nodes: list[DOMNode] = [] def __bool__(self) -> bool: return bool(self._nodes) diff --git a/src/textual/dom.py b/src/textual/dom.py index b4003cec2..79f2cc796 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -279,8 +279,8 @@ class DOMNode(MessagePump): return tree @property - def displayed_children(self) -> NodeList: - return NodeList([child for child in self.children if child.display]) + def displayed_children(self) -> list[DOMNode]: + return [child for child in self.children if child.display] def get_pseudo_classes(self) -> Iterable[str]: """Get any pseudo classes applicable to this Node, e.g. hover, focus. diff --git a/tests/layouts/test_common_layout_features.py b/tests/layouts/test_common_layout_features.py index 0fb6c80b7..e8cc5ee19 100644 --- a/tests/layouts/test_common_layout_features.py +++ b/tests/layouts/test_common_layout_features.py @@ -1,5 +1,5 @@ import pytest -from textual.dom import NodeList + from textual.screen import Screen from textual.widget import Widget @@ -26,5 +26,5 @@ def test_nodes_take_display_property_into_account_when_they_display_their_childr screen.add_child(widget) displayed_children = screen.displayed_children - assert isinstance(displayed_children, NodeList) + assert isinstance(displayed_children, list) assert (widget in screen.displayed_children) is expected_in_displayed_children From 4090d351684342b8e28ef9d5451c7c821e18d1ae Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 28 Apr 2022 13:17:10 +0100 Subject: [PATCH 06/18] new layout --- sandbox/basic.css | 10 ++++- sandbox/basic.py | 14 +++--- sandbox/buttons.css | 0 sandbox/buttons.py | 24 +++++++++++ sandbox/uber.py | 5 ++- src/textual/_color_constants.py | 2 +- src/textual/app.py | 52 ++++++++++++---------- src/textual/color.py | 11 ++++- src/textual/css/_style_properties.py | 2 +- src/textual/css/styles.py | 4 +- src/textual/dom.py | 64 +++++++++++++++++++--------- src/textual/layout.py | 50 ++++++---------------- src/textual/layouts/dock.py | 2 +- src/textual/layouts/factory.py | 2 +- src/textual/layouts/grid.py | 2 +- src/textual/layouts/horizontal.py | 2 +- src/textual/layouts/vertical.py | 2 +- src/textual/screen.py | 12 ++---- src/textual/widget.py | 36 +++++++++++++--- src/textual/widgets/_button.py | 9 ++-- 20 files changed, 189 insertions(+), 116 deletions(-) create mode 100644 sandbox/buttons.css create mode 100644 sandbox/buttons.py diff --git a/sandbox/basic.css b/sandbox/basic.css index 5d2446655..a2ced4868 100644 --- a/sandbox/basic.css +++ b/sandbox/basic.css @@ -41,6 +41,7 @@ App > Screen { background: $primary-darken-2; color: $text-primary-darken-2 ; border-right: outer $primary-darken-3; + content-align: center middle; } #sidebar .user { @@ -48,19 +49,21 @@ App > Screen { background: $primary-darken-1; color: $text-primary-darken-1; border-right: outer $primary-darken-3; + content-align: center middle; } #sidebar .content { background: $primary; color: $text-primary; border-right: outer $primary-darken-3; + content-align: center middle; } #header { color: $text-primary-darken-1; background: $primary-darken-1; - height: 3 - + height: 3; + content-align: center middle; } #content { @@ -84,6 +87,7 @@ Tweet { border: wide $panel-darken-2; overflow-y: scroll; align-horizontal: center; + } .scrollable { @@ -152,6 +156,7 @@ TweetBody { background: $accent; height: 1; border-top: hkey $accent-darken-2; + content-align: center middle; } @@ -165,6 +170,7 @@ OptionItem { transition: background 100ms linear; border-right: outer $primary-darken-2; border-left: hidden; + content-align: center middle; } OptionItem:hover { diff --git a/sandbox/basic.py b/sandbox/basic.py index b3dd17ecc..0778a9eed 100644 --- a/sandbox/basic.py +++ b/sandbox/basic.py @@ -66,7 +66,7 @@ class Tweet(Widget): class OptionItem(Widget): def render(self) -> Text: - return Align.center(Text("Option", justify="center"), vertical="middle") + return Text("Option") class Error(Widget): @@ -95,10 +95,9 @@ class BasicApp(App): """Build layout here.""" self.mount( header=Static( - Align.center( - "[b]This is a [u]Textual[/u] app, running in the terminal", - vertical="middle", - ) + Text.from_markup( + "[b]This is a [u]Textual[/u] app, running in the terminal" + ), ), content=Widget( Tweet( @@ -140,4 +139,7 @@ class BasicApp(App): self.panic(self.tree) -BasicApp.run(css_file="basic.css", watch_css=True, log="textual.log") +app = BasicApp(css_file="basic.css", watch_css=True, log="textual.log") + +if __name__ == "__main__": + app.run() diff --git a/sandbox/buttons.css b/sandbox/buttons.css new file mode 100644 index 000000000..e69de29bb diff --git a/sandbox/buttons.py b/sandbox/buttons.py new file mode 100644 index 000000000..8ff5a72f8 --- /dev/null +++ b/sandbox/buttons.py @@ -0,0 +1,24 @@ +from textual.app import App, ComposeResult + +from textual.widgets import Button +from textual import layout + + +class ButtonsApp(App[str]): + def compose(self) -> ComposeResult: + yield layout.Vertical( + Button("foo", id="foo"), + Button("bar", id="bar"), + Button("baz", id="baz"), + ) + + def handle_pressed(self, event: Button.Pressed) -> None: + self.app.bell() + self.exit(event.button.id) + + +app = ButtonsApp(log="textual.log") + +if __name__ == "__main__": + result = app.run() + print(repr(result)) diff --git a/sandbox/uber.py b/sandbox/uber.py index 32dec3e98..a19fe9ba4 100644 --- a/sandbox/uber.py +++ b/sandbox/uber.py @@ -56,4 +56,7 @@ class BasicApp(App): sys.stdout.write("abcdef") -BasicApp.run(css_file="uber.css", log="textual.log", log_verbosity=1) +app = BasicApp(css_file="uber.css", log="textual.log", log_verbosity=1) + +if __name__ == "__main__": + app.run() diff --git a/src/textual/_color_constants.py b/src/textual/_color_constants.py index 4b1a1e89b..fd0037ea8 100644 --- a/src/textual/_color_constants.py +++ b/src/textual/_color_constants.py @@ -1,6 +1,6 @@ from __future__ import annotations -ANSI_COLOR_TO_RGB: dict[str, tuple[int, int, int]] = { +COLOR_NAME_TO_RGB: dict[str, tuple[int, int, int]] = { "black": (0, 0, 0), "red": (128, 0, 0), "green": (0, 128, 0), diff --git a/src/textual/app.py b/src/textual/app.py index 8bab41905..c7d73963c 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -9,7 +9,7 @@ import warnings from asyncio import AbstractEventLoop from contextlib import redirect_stdout from time import perf_counter -from typing import Any, Iterable, TextIO, Type, TYPE_CHECKING +from typing import Any, Generic, Iterable, TextIO, Type, TypeVar, TYPE_CHECKING import rich import rich.repr @@ -67,6 +67,8 @@ DEFAULT_COLORS = ColorSystem( dark_surface="#292929", ) +ComposeResult = Iterable[Widget] + class AppError(Exception): pass @@ -76,8 +78,11 @@ class ActionError(Exception): pass +ReturnType = TypeVar("ReturnType") + + @rich.repr.auto -class App(DOMNode): +class App(Generic[ReturnType], DOMNode): """The base class for Textual Applications""" css = "" @@ -159,6 +164,8 @@ class App(DOMNode): self.devtools = DevtoolsClient() + self._return_value: ReturnType | None = None + super().__init__() title: Reactive[str] = Reactive("Textual") @@ -166,6 +173,14 @@ class App(DOMNode): background: Reactive[str] = Reactive("black") dark = Reactive(False) + def exit(self, result: ReturnType | None = None) -> None: + self._return_value = result + self.close_messages_no_wait() + + def compose(self) -> Iterable[Widget]: + return + yield + def get_css_variables(self) -> dict[str, str]: """Get a mapping of variables used to pre-populate CSS. @@ -284,27 +299,9 @@ class App(DOMNode): keys, action, description, show=show, key_display=key_display ) - @classmethod - def run( - cls, - console: Console | None = None, - screen: bool = True, - driver: Type[Driver] | None = None, - loop: AbstractEventLoop | None = None, - **kwargs, - ): - """Run the app. - - Args: - console (Console, optional): Console object. Defaults to None. - screen (bool, optional): Enable application mode. Defaults to True. - driver (Type[Driver], optional): Driver class or None for default. Defaults to None. - loop (AbstractEventLoop): Event loop to run the application on. If not specified, uvloop will be used. - """ - + def run(self, loop: AbstractEventLoop | None = None) -> ReturnType | None: async def run_app() -> None: - app = cls(screen=screen, driver_class=driver, **kwargs) - await app.process_messages() + await self.process_messages() if loop: asyncio.set_event_loop(loop) @@ -322,6 +319,8 @@ class App(DOMNode): finally: event_loop.close() + return self._return_value + async def _on_css_change(self) -> None: """Called when the CSS changes (if watch_css is True).""" if self.css_file is not None: @@ -341,6 +340,9 @@ class App(DOMNode): self.stylesheet.update(self) self.screen.refresh(layout=True) + def render(self) -> RenderableType: + return "" + def query(self, selector: str | None = None) -> DOMQuery: """Get a DOM query in the current screen. @@ -547,6 +549,12 @@ class App(DOMNode): if self._log_file is not None: self._log_file.close() + def on_mount(self) -> None: + widgets = list(self.compose()) + if widgets: + self.mount(*widgets) + self.screen.refresh() + async def on_idle(self) -> None: """Perform actions when there are no messages in the queue.""" if self._require_styles_update: diff --git a/src/textual/color.py b/src/textual/color.py index 22949cf8c..399b54ea6 100644 --- a/src/textual/color.py +++ b/src/textual/color.py @@ -24,7 +24,7 @@ from rich.style import Style from rich.text import Text -from ._color_constants import ANSI_COLOR_TO_RGB +from ._color_constants import COLOR_NAME_TO_RGB from .geometry import clamp @@ -123,6 +123,10 @@ class Color(NamedTuple): ), ) + @property + def is_transparent(self) -> bool: + return self.a == 0 + @property def clamped(self) -> Color: """Get a color with all components saturated to maximum and minimum values.""" @@ -253,7 +257,9 @@ class Color(NamedTuple): """ if isinstance(color_text, Color): return color_text - ansi_color = ANSI_COLOR_TO_RGB.get(color_text) + if color_text == "transparent": + return TRANSPARENT + ansi_color = COLOR_NAME_TO_RGB.get(color_text) if ansi_color is not None: return cls(*ansi_color) color_match = RE_COLOR.match(color_text) @@ -329,6 +335,7 @@ class Color(NamedTuple): # Color constants WHITE = Color(255, 255, 255) BLACK = Color(0, 0, 0) +TRANSPARENT = Color(0, 0, 0, 0) class ColorPair(NamedTuple): diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 8be05f045..104470a14 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -31,7 +31,7 @@ from .transition import Transition from ..geometry import Spacing, SpacingDimensions, clamp if TYPE_CHECKING: - from ..layout import Layout + from .._layout import Layout from .styles import DockGroup, Styles, StylesBase diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index c8f803c01..cb2ce307f 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -64,7 +64,7 @@ else: if TYPE_CHECKING: from ..dom import DOMNode - from ..layout import Layout + from .._layout import Layout class RulesMap(TypedDict, total=False): @@ -173,7 +173,7 @@ class StylesBase(ABC): layout = LayoutProperty() color = ColorProperty(Color(255, 255, 255)) - background = ColorProperty(Color(0, 0, 0)) + background = ColorProperty(Color(0, 0, 0, 0)) text_style = StyleFlagsProperty() opacity = FractionalProperty() diff --git a/src/textual/dom.py b/src/textual/dom.py index c2f740a43..b596eea87 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -10,6 +10,7 @@ from rich.text import Text from rich.tree import Tree from ._node_list import NodeList +from .color import Color from .css._error_tools import friendly_list from .css.constants import VALID_DISPLAY, VALID_VISIBILITY from .css.errors import StyleValueError @@ -73,18 +74,12 @@ class DOMNode(MessagePump): yield "classes", " ".join(self._classes) @property - def parent(self) -> DOMNode: + def parent(self) -> DOMNode | None: """Get the parent node. - Raises: - NoParent: If this is the root node. - Returns: DOMNode: The node which is the direct parent of this node. """ - if self._parent is None: - raise NoParent(f"{self} has no parent") - assert isinstance(self._parent, DOMNode) return self._parent @property @@ -231,19 +226,6 @@ class DOMNode(MessagePump): f"expected {friendly_list(VALID_VISIBILITY)})" ) - @property - def rich_text_style(self) -> Style: - """Get the text style (added to parent style). - - Returns: - Style: Rich Style object. - """ - return ( - self.parent.rich_text_style + self.styles.rich_style - if self.has_parent - else self.styles.rich_style - ) - @property def tree(self) -> Tree: """Get a Rich tree object which will recursively render the structure of the node tree. @@ -287,6 +269,48 @@ class DOMNode(MessagePump): add_children(tree, self) return tree + @property + def rich_text_style(self) -> Style: + """Get the text style object. + + A widget's style is influenced by its parent. For instance if a widgets background has an alpha, + then its parent's background color will show throw. Additionally, widgets will inherit their + parent's text style (i.e. bold, italic etc). + + Returns: + Style: Rich Style object. + """ + + # TODO: Feels like there may be opportunity for caching here. + + background = Color(0, 0, 0, 0) + color = Color(255, 255, 255, 0) + style = Style() + for node in reversed(self.ancestors): + styles = node.styles + if styles.has_rule("background"): + background += styles.background + if styles.has_rule("color"): + color = styles.color + style += styles.text_style + + style = Style(bgcolor=background.rich_color, color=color.rich_color) + style + return style + + @property + def ancestors(self) -> list[DOMNode]: + """Get a list of Nodes by tracing ancestors all the way back to App.""" + + nodes: list[DOMNode] = [self] + add_node = nodes.append + node = self + while True: + node = node.parent + if node is None: + break + add_node(node) + return nodes + def get_pseudo_classes(self) -> Iterable[str]: """Get any pseudo classes applicable to this Node, e.g. hover, focus. diff --git a/src/textual/layout.py b/src/textual/layout.py index 9914ad78c..a8e759558 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -1,41 +1,17 @@ -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import ClassVar, NamedTuple, TYPE_CHECKING +from .widget import Widget -from .geometry import Region, Offset, Size +class Vertical(Widget): + CSS = """ + Vertical { + layout: vertical; + } + """ -if TYPE_CHECKING: - from .widget import Widget - from .screen import Screen - - -class WidgetPlacement(NamedTuple): - """The position, size, and relative order of a widget within its parent.""" - - region: Region - widget: Widget | None = None # A widget of None means empty space - order: int = 0 - - -class Layout(ABC): - """Responsible for arranging Widgets in a view and rendering them.""" - - name: ClassVar[str] = "" - - @abstractmethod - def arrange( - self, parent: Widget, size: Size, scroll: Offset - ) -> tuple[list[WidgetPlacement], set[Widget]]: - """Generate a layout map that defines where on the screen the widgets will be drawn. - - Args: - parent (Widget): Parent widget. - size (Size): Size of container. - scroll (Offset): Offset to apply to the Widget placements. - - Returns: - Iterable[WidgetPlacement]: An iterable of widget location - """ +class Horizontal(Widget): + CSS = """ + Horizontal { + layout: horizontal; + } + """ diff --git a/src/textual/layouts/dock.py b/src/textual/layouts/dock.py index b507bbc67..13e8ef6b0 100644 --- a/src/textual/layouts/dock.py +++ b/src/textual/layouts/dock.py @@ -9,7 +9,7 @@ from typing import Iterable, TYPE_CHECKING, NamedTuple, Sequence from .._layout_resolve import layout_resolve from ..css.types import Edge from ..geometry import Offset, Region, Size -from ..layout import Layout, WidgetPlacement +from .._layout import Layout, WidgetPlacement from ..widget import Widget if sys.version_info >= (3, 8): diff --git a/src/textual/layouts/factory.py b/src/textual/layouts/factory.py index c16c3afa6..c94828a45 100644 --- a/src/textual/layouts/factory.py +++ b/src/textual/layouts/factory.py @@ -1,7 +1,7 @@ import sys from .horizontal import HorizontalLayout -from ..layout import Layout +from .._layout import Layout from ..layouts.dock import DockLayout from ..layouts.grid import GridLayout from ..layouts.vertical import VerticalLayout diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index 97b17c455..0f9d1ea48 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -10,7 +10,7 @@ from typing import Iterable, NamedTuple, TYPE_CHECKING from .._layout_resolve import layout_resolve from ..geometry import Size, Offset, Region -from ..layout import Layout, WidgetPlacement +from .._layout import Layout, WidgetPlacement if TYPE_CHECKING: from ..widget import Widget diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index 36fef9bd7..63df8b792 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import cast from textual.geometry import Size, Offset, Region -from textual.layout import Layout, WidgetPlacement +from textual._layout import Layout, WidgetPlacement from textual.widget import Widget diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 2752c4445..2b4cbfaf0 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -5,7 +5,7 @@ from typing import cast, TYPE_CHECKING from .. import log from ..geometry import Offset, Region, Size -from ..layout import Layout, WidgetPlacement +from .._layout import Layout, WidgetPlacement if TYPE_CHECKING: from ..widget import Widget diff --git a/src/textual/screen.py b/src/textual/screen.py index e577aa4c3..57722a25e 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -11,7 +11,6 @@ from .geometry import Offset, Region from ._compositor import Compositor from .reactive import Reactive from .widget import Widget -from .renderables.gradient import VerticalGradient @rich.repr.auto @@ -21,9 +20,10 @@ class Screen(Widget): CSS = """ Screen { - layout: vertical; + layout: dock; docks: _default=top; background: $surface; + color: $text-surface; } """ @@ -38,12 +38,8 @@ class Screen(Widget): def watch_dark(self, dark: bool) -> None: pass - @property - def is_transparent(self) -> bool: - return False - - # def render(self) -> RenderableType: - # return VerticalGradient("red", "blue") + def render(self) -> RenderableType: + return self.app.render() def get_offset(self, widget: Widget) -> Offset: """Get the absolute offset of a given Widget. diff --git a/src/textual/widget.py b/src/textual/widget.py index ee978592d..161169958 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -33,7 +33,7 @@ from .dom import DOMNode from .geometry import clamp, Offset, Region, Size, Spacing from .message import Message from . import messages -from .layout import Layout +from ._layout import Layout from .reactive import Reactive, watch from .renderables.opacity import Opacity @@ -111,8 +111,16 @@ class Widget(DOMNode): show_vertical_scrollbar = Reactive(False, layout=True) show_horizontal_scrollbar = Reactive(False, layout=True) + def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None: + self.app.register(self, *anon_widgets, **widgets) + self.screen.refresh() + + def compose(self) -> Iterable[Widget]: + return + yield + def on_register(self, app: App) -> None: - self.app.stylesheet.parse(self.CSS, path=f"<{self.__class__.name}>") + self.app.stylesheet.parse(self.CSS, path=f"<{self.__class__.__name__}>") def get_box_model(self, container: Size, viewport: Size) -> BoxModel: """Process the box model for this widget. @@ -246,6 +254,18 @@ class Widget(DOMNode): enabled = self.show_vertical_scrollbar, self.show_horizontal_scrollbar return enabled + @property + def background_color(self) -> Color: + color = self.styles.background + colors: list[Color] = [color] + add_color = colors.append + node = self + while color.a < 1 and node.parent is not None: + node = node.parent + color = node.styles.background + add_color(color) + return sum(reversed(colors), start=Color(0, 0, 0, 0)) + def set_dirty(self) -> None: """Set the Widget as 'dirty' (requiring re-render).""" self._dirty_regions.clear() @@ -491,8 +511,7 @@ class Widget(DOMNode): Returns: bool: ``True`` if there is background color, otherwise ``False``. """ - return False - return self.layout is not None + return self.is_container and self.styles.background.is_transparent @property def console(self) -> Console: @@ -631,8 +650,7 @@ class Widget(DOMNode): if self.is_container: return "" - label = self.css_identifier_styled - return Align.center(label, vertical="middle") + return self.css_identifier_styled async def action(self, action: str, *params) -> None: await self.app.action(action, self) @@ -693,6 +711,12 @@ class Widget(DOMNode): async def on_key(self, event: events.Key) -> None: await self.dispatch_key(event) + def on_mount(self, event: events.Mount) -> None: + widgets = list(self.compose()) + if widgets: + self.mount(*widgets) + self.screen.refresh() + def on_leave(self) -> None: self.mouse_over = False diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index bb558480c..4d08a2796 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import cast + from rich.console import RenderableType from rich.text import Text @@ -30,14 +32,15 @@ class Button(Widget, can_focus=True): Button:hover { background:$primary-darken-2; color: $text-primary-darken-2; - border: tall $primary-lighten-1; - + border: tall $primary-lighten-1; } """ class Pressed(Message, bubble=True): - pass + @property + def button(self) -> Button: + return cast(Button, self.sender) def __init__( self, From 8999ca5a3d67f7d7feb3a39d62c29ec3d3d1f465 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 28 Apr 2022 13:19:17 +0100 Subject: [PATCH 07/18] docstring --- src/textual/app.py | 5 +++++ src/textual/message_pump.py | 1 + 2 files changed, 6 insertions(+) diff --git a/src/textual/app.py b/src/textual/app.py index c7d73963c..c0be02030 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -174,6 +174,11 @@ class App(Generic[ReturnType], DOMNode): dark = Reactive(False) def exit(self, result: ReturnType | None = None) -> None: + """Exit the app, and return the supplied result. + + Args: + result (ReturnType | None, optional): Return value. Defaults to None. + """ self._return_value = result self.close_messages_no_wait() diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index c426cb2bd..0d9fb0dfb 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -174,6 +174,7 @@ class MessagePump: ) def close_messages_no_wait(self) -> None: + """Request the message queue to exit.""" self._message_queue.put_nowait(MessagePriority(None)) async def close_messages(self) -> None: From 4a204558382cd478629eaf8569e634f1a9417d0f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 28 Apr 2022 13:26:29 +0100 Subject: [PATCH 08/18] moved layout --- src/textual/_layout.py | 40 ++++++++++++++++++++++++++++++++++++++++ src/textual/app.py | 3 ++- src/textual/widget.py | 5 +++-- 3 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 src/textual/_layout.py diff --git a/src/textual/_layout.py b/src/textual/_layout.py new file mode 100644 index 000000000..ea459a3e0 --- /dev/null +++ b/src/textual/_layout.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import ClassVar, NamedTuple, TYPE_CHECKING + + +from .geometry import Region, Offset, Size + + +if TYPE_CHECKING: + from .widget import Widget + + +class WidgetPlacement(NamedTuple): + """The position, size, and relative order of a widget within its parent.""" + + region: Region + widget: Widget | None = None # A widget of None means empty space + order: int = 0 + + +class Layout(ABC): + """Responsible for arranging Widgets in a view and rendering them.""" + + name: ClassVar[str] = "" + + @abstractmethod + def arrange( + self, parent: Widget, size: Size, scroll: Offset + ) -> tuple[list[WidgetPlacement], set[Widget]]: + """Generate a layout map that defines where on the screen the widgets will be drawn. + + Args: + parent (Widget): Parent widget. + size (Size): Size of container. + scroll (Offset): Offset to apply to the Widget placements. + + Returns: + Iterable[WidgetPlacement]: An iterable of widget location + """ diff --git a/src/textual/app.py b/src/textual/app.py index c0be02030..2341f2c6b 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -182,7 +182,8 @@ class App(Generic[ReturnType], DOMNode): self._return_value = result self.close_messages_no_wait() - def compose(self) -> Iterable[Widget]: + def compose(self) -> ComposeResult: + """Yield child widgets for a container.""" return yield diff --git a/src/textual/widget.py b/src/textual/widget.py index 161169958..e2a4b53c7 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -39,7 +39,7 @@ from .renderables.opacity import Opacity if TYPE_CHECKING: - from .app import App + from .app import App, ComposeResult from .scrollbar import ( ScrollBar, ScrollTo, @@ -115,7 +115,8 @@ class Widget(DOMNode): self.app.register(self, *anon_widgets, **widgets) self.screen.refresh() - def compose(self) -> Iterable[Widget]: + def compose(self) -> ComposeResult: + """Yield child widgets for a container.""" return yield From 2701172e768ace102f9dbadbb71713bb611268d3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 28 Apr 2022 13:27:03 +0100 Subject: [PATCH 09/18] docstrings --- src/textual/layout.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/textual/layout.py b/src/textual/layout.py index a8e759558..4c900a593 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -2,6 +2,8 @@ from .widget import Widget class Vertical(Widget): + """A container widget to align children vertically.""" + CSS = """ Vertical { layout: vertical; @@ -10,6 +12,8 @@ class Vertical(Widget): class Horizontal(Widget): + """A container widget to align children horizontally.""" + CSS = """ Horizontal { layout: horizontal; From c6f3252890173b1499462b9ec9fe111fc4693cd8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 28 Apr 2022 13:27:45 +0100 Subject: [PATCH 10/18] docstring --- src/textual/color.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/color.py b/src/textual/color.py index 399b54ea6..56fa3bc46 100644 --- a/src/textual/color.py +++ b/src/textual/color.py @@ -125,6 +125,7 @@ class Color(NamedTuple): @property def is_transparent(self) -> bool: + """Check if the color is transparent, i.e. has 0 alpha.""" return self.a == 0 @property From a107663dfe1637bc2c412467205a75a366d40f3b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 28 Apr 2022 13:31:16 +0100 Subject: [PATCH 11/18] removed code --- src/textual/widget.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index e2a4b53c7..dc3ee6bfd 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -255,18 +255,6 @@ class Widget(DOMNode): enabled = self.show_vertical_scrollbar, self.show_horizontal_scrollbar return enabled - @property - def background_color(self) -> Color: - color = self.styles.background - colors: list[Color] = [color] - add_color = colors.append - node = self - while color.a < 1 and node.parent is not None: - node = node.parent - color = node.styles.background - add_color(color) - return sum(reversed(colors), start=Color(0, 0, 0, 0)) - def set_dirty(self) -> None: """Set the Widget as 'dirty' (requiring re-render).""" self._dirty_regions.clear() From 9f9c681c01682ce539f087bc4182b1f7b61d5198 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 28 Apr 2022 13:57:05 +0100 Subject: [PATCH 12/18] docstirng --- src/textual/widget.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/textual/widget.py b/src/textual/widget.py index dc3ee6bfd..7976b5818 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -121,6 +121,12 @@ class Widget(DOMNode): yield def on_register(self, app: App) -> None: + """Called when the instance is registered. + + Args: + app (App): App instance. + """ + # Parser the Widget's CSS self.app.stylesheet.parse(self.CSS, path=f"<{self.__class__.__name__}>") def get_box_model(self, container: Size, viewport: Size) -> BoxModel: From a3043346285f18baec1c1a8ce253ca0fadc5d924 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Thu, 28 Apr 2022 14:21:02 +0100 Subject: [PATCH 13/18] [colors] Add management of named Web colors to our CSS processing, prefix ANSI ones with `ansi_` --- src/textual/_color_constants.py | 151 ++++++++++++++++++++++++++++++++ src/textual/color.py | 13 ++- src/textual/css/styles.py | 6 +- src/textual/scrollbar.py | 6 +- tests/css/test_stylesheet.py | 18 ++-- 5 files changed, 178 insertions(+), 16 deletions(-) diff --git a/src/textual/_color_constants.py b/src/textual/_color_constants.py index 4b1a1e89b..dc12cf873 100644 --- a/src/textual/_color_constants.py +++ b/src/textual/_color_constants.py @@ -237,3 +237,154 @@ ANSI_COLOR_TO_RGB: dict[str, tuple[int, int, int]] = { "grey93": (238, 238, 238), "gray93": (238, 238, 238), } + +WEB_COLOR_TO_RGB: dict[str, tuple[int, int, int]] = { + "black": (0, 0, 0), + "silver": (192, 192, 192), + "gray": (128, 128, 128), + "white": (255, 255, 255), + "maroon": (128, 0, 0), + "red": (255, 0, 0), + "purple": (128, 0, 128), + "fuchsia": (255, 0, 255), + "green": (0, 128, 0), + "lime": (0, 255, 0), + "olive": (128, 128, 0), + "yellow": (255, 255, 0), + "navy": (0, 0, 128), + "blue": (0, 0, 255), + "teal": (0, 128, 128), + "aqua": (0, 255, 255), + "orange": (255, 165, 0), + "aliceblue": (240, 248, 255), + "antiquewhite": (250, 235, 215), + "aquamarine": (127, 255, 212), + "azure": (240, 255, 255), + "beige": (245, 245, 220), + "bisque": (255, 228, 196), + "blanchedalmond": (255, 235, 205), + "blueviolet": (138, 43, 226), + "brown": (165, 42, 42), + "burlywood": (222, 184, 135), + "cadetblue": (95, 158, 160), + "chartreuse": (127, 255, 0), + "chocolate": (210, 105, 30), + "coral": (255, 127, 80), + "cornflowerblue": (100, 149, 237), + "cornsilk": (255, 248, 220), + "crimson": (220, 20, 60), + "cyan": (0, 255, 255), + "darkblue": (0, 0, 139), + "darkcyan": (0, 139, 139), + "darkgoldenrod": (184, 134, 11), + "darkgray": (169, 169, 169), + "darkgreen": (0, 100, 0), + "darkgrey": (169, 169, 169), + "darkkhaki": (189, 183, 107), + "darkmagenta": (139, 0, 139), + "darkolivegreen": (85, 107, 47), + "darkorange": (255, 140, 0), + "darkorchid": (153, 50, 204), + "darkred": (139, 0, 0), + "darksalmon": (233, 150, 122), + "darkseagreen": (143, 188, 143), + "darkslateblue": (72, 61, 139), + "darkslategray": (47, 79, 79), + "darkslategrey": (47, 79, 79), + "darkturquoise": (0, 206, 209), + "darkviolet": (148, 0, 211), + "deeppink": (255, 20, 147), + "deepskyblue": (0, 191, 255), + "dimgray": (105, 105, 105), + "dimgrey": (105, 105, 105), + "dodgerblue": (30, 144, 255), + "firebrick": (178, 34, 34), + "floralwhite": (255, 250, 240), + "forestgreen": (34, 139, 34), + "gainsboro": (220, 220, 220), + "ghostwhite": (248, 248, 255), + "gold": (255, 215, 0), + "goldenrod": (218, 165, 32), + "greenyellow": (173, 255, 47), + "grey": (128, 128, 128), + "honeydew": (240, 255, 240), + "hotpink": (255, 105, 180), + "indianred": (205, 92, 92), + "indigo": (75, 0, 130), + "ivory": (255, 255, 240), + "khaki": (240, 230, 140), + "lavender": (230, 230, 250), + "lavenderblush": (255, 240, 245), + "lawngreen": (124, 252, 0), + "lemonchiffon": (255, 250, 205), + "lightblue": (173, 216, 230), + "lightcoral": (240, 128, 128), + "lightcyan": (224, 255, 255), + "lightgoldenrodyellow": (250, 250, 210), + "lightgray": (211, 211, 211), + "lightgreen": (144, 238, 144), + "lightgrey": (211, 211, 211), + "lightpink": (255, 182, 193), + "lightsalmon": (255, 160, 122), + "lightseagreen": (32, 178, 170), + "lightskyblue": (135, 206, 250), + "lightslategray": (119, 136, 153), + "lightslategrey": (119, 136, 153), + "lightsteelblue": (176, 196, 222), + "lightyellow": (255, 255, 224), + "limegreen": (50, 205, 50), + "linen": (250, 240, 230), + "magenta": (255, 0, 255), + "mediumaquamarine": (102, 205, 170), + "mediumblue": (0, 0, 205), + "mediumorchid": (186, 85, 211), + "mediumpurple": (147, 112, 219), + "mediumseagreen": (60, 179, 113), + "mediumslateblue": (123, 104, 238), + "mediumspringgreen": (0, 250, 154), + "mediumturquoise": (72, 209, 204), + "mediumvioletred": (199, 21, 133), + "midnightblue": (25, 25, 112), + "mintcream": (245, 255, 250), + "mistyrose": (255, 228, 225), + "moccasin": (255, 228, 181), + "navajowhite": (255, 222, 173), + "oldlace": (253, 245, 230), + "olivedrab": (107, 142, 35), + "orangered": (255, 69, 0), + "orchid": (218, 112, 214), + "palegoldenrod": (238, 232, 170), + "palegreen": (152, 251, 152), + "paleturquoise": (175, 238, 238), + "palevioletred": (219, 112, 147), + "papayawhip": (255, 239, 213), + "peachpuff": (255, 218, 185), + "peru": (205, 133, 63), + "pink": (255, 192, 203), + "plum": (221, 160, 221), + "powderblue": (176, 224, 230), + "rosybrown": (188, 143, 143), + "royalblue": (65, 105, 225), + "saddlebrown": (139, 69, 19), + "salmon": (250, 128, 114), + "sandybrown": (244, 164, 96), + "seagreen": (46, 139, 87), + "seashell": (255, 245, 238), + "sienna": (160, 82, 45), + "skyblue": (135, 206, 235), + "slateblue": (106, 90, 205), + "slategray": (112, 128, 144), + "slategrey": (112, 128, 144), + "snow": (255, 250, 250), + "springgreen": (0, 255, 127), + "steelblue": (70, 130, 180), + "tan": (210, 180, 140), + "thistle": (216, 191, 216), + "tomato": (255, 99, 71), + "turquoise": (64, 224, 208), + "violet": (238, 130, 238), + "wheat": (245, 222, 179), + "whitesmoke": (245, 245, 245), + "yellowgreen": (154, 205, 50), + "rebeccapurple": (102, 51, 153), +} diff --git a/src/textual/color.py b/src/textual/color.py index 22949cf8c..58d87a783 100644 --- a/src/textual/color.py +++ b/src/textual/color.py @@ -24,7 +24,7 @@ from rich.style import Style from rich.text import Text -from ._color_constants import ANSI_COLOR_TO_RGB +from ._color_constants import ANSI_COLOR_TO_RGB, WEB_COLOR_TO_RGB from .geometry import clamp @@ -253,9 +253,14 @@ class Color(NamedTuple): """ if isinstance(color_text, Color): return color_text - ansi_color = ANSI_COLOR_TO_RGB.get(color_text) - if ansi_color is not None: - return cls(*ansi_color) + if color_text.startswith("ansi_"): + ansi_color = ANSI_COLOR_TO_RGB.get(color_text[5:]) + if ansi_color is not None: + return cls(*ansi_color) + else: + web_color = WEB_COLOR_TO_RGB.get(color_text) + if web_color is not None: + return cls(*web_color) color_match = RE_COLOR.match(color_text) if color_match is None: raise ColorParseError(f"failed to parse {color_text!r} as a color") diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index d1ecbdd91..73da37f64 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -211,9 +211,9 @@ class StylesBase(ABC): rich_style = StyleProperty() - scrollbar_color = ColorProperty("bright_magenta") - scrollbar_color_hover = ColorProperty("yellow") - scrollbar_color_active = ColorProperty("bright_yellow") + scrollbar_color = ColorProperty("ansi_bright_magenta") + scrollbar_color_hover = ColorProperty("ansi_yellow") + scrollbar_color_active = ColorProperty("ansi_bright_yellow") scrollbar_background = ColorProperty("#555555") scrollbar_background_hover = ColorProperty("#444444") diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 5633f0d0e..62f05185c 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -69,7 +69,7 @@ class ScrollBarRender: position: float = 0, thickness: int = 1, vertical: bool = True, - style: StyleType = "bright_magenta on #555555", + style: StyleType = "ansi_bright_magenta on #555555", ) -> None: self.virtual_size = virtual_size self.window_size = window_size @@ -89,7 +89,7 @@ class ScrollBarRender: thickness: int = 1, vertical: bool = True, back_color: Color = Color.parse("#555555"), - bar_color: Color = Color.parse("bright_magenta"), + bar_color: Color = Color.parse("ansi_bright_magenta"), ) -> Segments: if vertical: @@ -181,7 +181,7 @@ class ScrollBarRender: vertical=self.vertical, thickness=thickness, back_color=_style.bgcolor or Color.parse("#555555"), - bar_color=_style.color or Color.parse("bright_magenta"), + bar_color=_style.color or Color.parse("ansi_bright_magenta"), ) yield bar diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py index 87f27845d..d1da10662 100644 --- a/tests/css/test_stylesheet.py +++ b/tests/css/test_stylesheet.py @@ -10,18 +10,24 @@ from textual.css.tokenizer import TokenizeError "css_value,expectation,expected_color", [ # Valid values: - ["red", does_not_raise(), Color(128, 0, 0)], - ["dark_cyan", does_not_raise(), Color(0, 175, 135)], - ["medium_turquoise", does_not_raise(), Color(95, 215, 215)], - ["turquoise4", does_not_raise(), Color(0, 135, 135)], + ["ansi_red", does_not_raise(), Color(128, 0, 0)], + ["ansi_dark_cyan", does_not_raise(), Color(0, 175, 135)], + ["ansi_medium_turquoise", does_not_raise(), Color(95, 215, 215)], + ["ansi_turquoise4", does_not_raise(), Color(0, 135, 135)], + ["red", does_not_raise(), Color(255, 0, 0)], + ["lime", does_not_raise(), Color(0, 255, 0)], + ["coral", does_not_raise(), Color(255, 127, 80)], + ["aqua", does_not_raise(), Color(0, 255, 255)], + ["deepskyblue", does_not_raise(), Color(0, 191, 255)], + ["rebeccapurple", does_not_raise(), Color(102, 51, 153)], ["#ffcc00", does_not_raise(), Color(255, 204, 0)], ["#ffcc0033", does_not_raise(), Color(255, 204, 0, 0.2)], ["rgb(200,90,30)", does_not_raise(), Color(200, 90, 30)], ["rgba(200,90,30,0.3)", does_not_raise(), Color(200, 90, 30, 0.3)], # Some invalid ones: ["coffee", pytest.raises(StylesheetParseError), None], # invalid color name - ["turquoise10", pytest.raises(StylesheetParseError), None], - ["turquoise 4", pytest.raises(StylesheetParseError), None], # space in it + ["ansi_turquoise10", pytest.raises(StylesheetParseError), None], + ["ansi_turquoise 4", pytest.raises(StylesheetParseError), None], # space in it ["1", pytest.raises(StylesheetParseError), None], # invalid value ["()", pytest.raises(TokenizeError), None], # invalid tokens # TODO: implement hex colors with 3 chars? @link https://devdocs.io/css/color_value From eedec6c8630af01c83ae905ac6b45d8071faa782 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Thu, 28 Apr 2022 15:00:26 +0100 Subject: [PATCH 14/18] [colors] Merge the first 16 ANSI colors with the named Web ones, add "transparent" --- src/textual/_color_constants.py | 260 +++----------------------------- src/textual/color.py | 13 +- tests/css/test_stylesheet.py | 9 +- 3 files changed, 29 insertions(+), 253 deletions(-) diff --git a/src/textual/_color_constants.py b/src/textual/_color_constants.py index dc12cf873..f9a03b8ef 100644 --- a/src/textual/_color_constants.py +++ b/src/textual/_color_constants.py @@ -1,244 +1,26 @@ from __future__ import annotations -ANSI_COLOR_TO_RGB: dict[str, tuple[int, int, int]] = { - "black": (0, 0, 0), - "red": (128, 0, 0), - "green": (0, 128, 0), - "yellow": (128, 128, 0), - "blue": (0, 0, 128), - "magenta": (128, 0, 128), - "cyan": (0, 128, 128), - "white": (192, 192, 192), - "bright_black": (128, 128, 128), - "bright_red": (255, 0, 0), - "bright_green": (0, 255, 0), - "bright_yellow": (255, 255, 0), - "bright_blue": (0, 0, 255), - "bright_magenta": (255, 0, 255), - "bright_cyan": (0, 255, 255), - "bright_white": (255, 255, 255), - "grey0": (0, 0, 0), - "gray0": (0, 0, 0), - "navy_blue": (0, 0, 95), - "dark_blue": (0, 0, 135), - "blue3": (0, 0, 215), - "blue1": (0, 0, 255), - "dark_green": (0, 95, 0), - "deep_sky_blue4": (0, 95, 175), - "dodger_blue3": (0, 95, 215), - "dodger_blue2": (0, 95, 255), - "green4": (0, 135, 0), - "spring_green4": (0, 135, 95), - "turquoise4": (0, 135, 135), - "deep_sky_blue3": (0, 135, 215), - "dodger_blue1": (0, 135, 255), - "green3": (0, 215, 0), - "spring_green3": (0, 215, 95), - "dark_cyan": (0, 175, 135), - "light_sea_green": (0, 175, 175), - "deep_sky_blue2": (0, 175, 215), - "deep_sky_blue1": (0, 175, 255), - "spring_green2": (0, 255, 95), - "cyan3": (0, 215, 175), - "dark_turquoise": (0, 215, 215), - "turquoise2": (0, 215, 255), - "green1": (0, 255, 0), - "spring_green1": (0, 255, 135), - "medium_spring_green": (0, 255, 175), - "cyan2": (0, 255, 215), - "cyan1": (0, 255, 255), - "dark_red": (135, 0, 0), - "deep_pink4": (175, 0, 95), - "purple4": (95, 0, 175), - "purple3": (95, 0, 215), - "blue_violet": (95, 0, 255), - "orange4": (135, 95, 0), - "grey37": (95, 95, 95), - "gray37": (95, 95, 95), - "medium_purple4": (95, 95, 135), - "slate_blue3": (95, 95, 215), - "royal_blue1": (95, 95, 255), - "chartreuse4": (95, 135, 0), - "dark_sea_green4": (95, 175, 95), - "pale_turquoise4": (95, 135, 135), - "steel_blue": (95, 135, 175), - "steel_blue3": (95, 135, 215), - "cornflower_blue": (95, 135, 255), - "chartreuse3": (95, 215, 0), - "cadet_blue": (95, 175, 175), - "sky_blue3": (95, 175, 215), - "steel_blue1": (95, 215, 255), - "pale_green3": (135, 215, 135), - "sea_green3": (95, 215, 135), - "aquamarine3": (95, 215, 175), - "medium_turquoise": (95, 215, 215), - "chartreuse2": (135, 215, 0), - "sea_green2": (95, 255, 95), - "sea_green1": (95, 255, 175), - "aquamarine1": (135, 255, 215), - "dark_slate_gray2": (95, 255, 255), - "dark_magenta": (135, 0, 175), - "dark_violet": (175, 0, 215), - "purple": (175, 0, 255), - "light_pink4": (135, 95, 95), - "plum4": (135, 95, 135), - "medium_purple3": (135, 95, 215), - "slate_blue1": (135, 95, 255), - "yellow4": (135, 175, 0), - "wheat4": (135, 135, 95), - "grey53": (135, 135, 135), - "gray53": (135, 135, 135), - "light_slate_grey": (135, 135, 175), - "light_slate_gray": (135, 135, 175), - "medium_purple": (135, 135, 215), - "light_slate_blue": (135, 135, 255), - "dark_olive_green3": (175, 215, 95), - "dark_sea_green": (135, 175, 135), - "light_sky_blue3": (135, 175, 215), - "sky_blue2": (135, 175, 255), - "dark_sea_green3": (175, 215, 135), - "dark_slate_gray3": (135, 215, 215), - "sky_blue1": (135, 215, 255), - "chartreuse1": (135, 255, 0), - "light_green": (135, 255, 135), - "pale_green1": (175, 255, 135), - "dark_slate_gray1": (135, 255, 255), - "red3": (215, 0, 0), - "medium_violet_red": (175, 0, 135), - "magenta3": (215, 0, 215), - "dark_orange3": (215, 95, 0), - "indian_red": (215, 95, 95), - "hot_pink3": (215, 95, 135), - "medium_orchid3": (175, 95, 175), - "medium_orchid": (175, 95, 215), - "medium_purple2": (175, 135, 215), - "dark_goldenrod": (175, 135, 0), - "light_salmon3": (215, 135, 95), - "rosy_brown": (175, 135, 135), - "grey63": (175, 135, 175), - "gray63": (175, 135, 175), - "medium_purple1": (175, 135, 255), - "gold3": (215, 175, 0), - "dark_khaki": (175, 175, 95), - "navajo_white3": (175, 175, 135), - "grey69": (175, 175, 175), - "gray69": (175, 175, 175), - "light_steel_blue3": (175, 175, 215), - "light_steel_blue": (175, 175, 255), - "yellow3": (215, 215, 0), - "dark_sea_green2": (175, 255, 175), - "light_cyan3": (175, 215, 215), - "light_sky_blue1": (175, 215, 255), - "green_yellow": (175, 255, 0), - "dark_olive_green2": (175, 255, 95), - "dark_sea_green1": (215, 255, 175), - "pale_turquoise1": (175, 255, 255), - "deep_pink3": (215, 0, 135), - "magenta2": (255, 0, 215), - "hot_pink2": (215, 95, 175), - "orchid": (215, 95, 215), - "medium_orchid1": (255, 95, 255), - "orange3": (215, 135, 0), - "light_pink3": (215, 135, 135), - "pink3": (215, 135, 175), - "plum3": (215, 135, 215), - "violet": (215, 135, 255), - "light_goldenrod3": (215, 175, 95), - "tan": (215, 175, 135), - "misty_rose3": (215, 175, 175), - "thistle3": (215, 175, 215), - "plum2": (215, 175, 255), - "khaki3": (215, 215, 95), - "light_goldenrod2": (255, 215, 135), - "light_yellow3": (215, 215, 175), - "grey84": (215, 215, 215), - "gray84": (215, 215, 215), - "light_steel_blue1": (215, 215, 255), - "yellow2": (215, 255, 0), - "dark_olive_green1": (215, 255, 135), - "honeydew2": (215, 255, 215), - "light_cyan1": (215, 255, 255), - "red1": (255, 0, 0), - "deep_pink2": (255, 0, 95), - "deep_pink1": (255, 0, 175), - "magenta1": (255, 0, 255), - "orange_red1": (255, 95, 0), - "indian_red1": (255, 95, 135), - "hot_pink": (255, 95, 215), - "dark_orange": (255, 135, 0), - "salmon1": (255, 135, 95), - "light_coral": (255, 135, 135), - "pale_violet_red1": (255, 135, 175), - "orchid2": (255, 135, 215), - "orchid1": (255, 135, 255), - "orange1": (255, 175, 0), - "sandy_brown": (255, 175, 95), - "light_salmon1": (255, 175, 135), - "light_pink1": (255, 175, 175), - "pink1": (255, 175, 215), - "plum1": (255, 175, 255), - "gold1": (255, 215, 0), - "navajo_white1": (255, 215, 175), - "misty_rose1": (255, 215, 215), - "thistle1": (255, 215, 255), - "yellow1": (255, 255, 0), - "light_goldenrod1": (255, 255, 95), - "khaki1": (255, 255, 135), - "wheat1": (255, 255, 175), - "cornsilk1": (255, 255, 215), - "grey100": (255, 255, 255), - "gray100": (255, 255, 255), - "grey3": (8, 8, 8), - "gray3": (8, 8, 8), - "grey7": (18, 18, 18), - "gray7": (18, 18, 18), - "grey11": (28, 28, 28), - "gray11": (28, 28, 28), - "grey15": (38, 38, 38), - "gray15": (38, 38, 38), - "grey19": (48, 48, 48), - "gray19": (48, 48, 48), - "grey23": (58, 58, 58), - "gray23": (58, 58, 58), - "grey27": (68, 68, 68), - "gray27": (68, 68, 68), - "grey30": (78, 78, 78), - "gray30": (78, 78, 78), - "grey35": (88, 88, 88), - "gray35": (88, 88, 88), - "grey39": (98, 98, 98), - "gray39": (98, 98, 98), - "grey42": (108, 108, 108), - "gray42": (108, 108, 108), - "grey46": (118, 118, 118), - "gray46": (118, 118, 118), - "grey50": (128, 128, 128), - "gray50": (128, 128, 128), - "grey54": (138, 138, 138), - "gray54": (138, 138, 138), - "grey58": (148, 148, 148), - "gray58": (148, 148, 148), - "grey62": (158, 158, 158), - "gray62": (158, 158, 158), - "grey66": (168, 168, 168), - "gray66": (168, 168, 168), - "grey70": (178, 178, 178), - "gray70": (178, 178, 178), - "grey74": (188, 188, 188), - "gray74": (188, 188, 188), - "grey78": (198, 198, 198), - "gray78": (198, 198, 198), - "grey82": (208, 208, 208), - "gray82": (208, 208, 208), - "grey85": (218, 218, 218), - "gray85": (218, 218, 218), - "grey89": (228, 228, 228), - "gray89": (228, 228, 228), - "grey93": (238, 238, 238), - "gray93": (238, 238, 238), -} - -WEB_COLOR_TO_RGB: dict[str, tuple[int, int, int]] = { +COLOR_NAME_TO_RGB: dict[str, tuple[int, int, int] | tuple[int, int, int, int]] = { + # Let's start with a specific pseudo-color:: + "transparent": (0, 0, 0, 0), + # Then, the 16 common ANSI colors: + "ansi_black": (0, 0, 0), + "ansi_red": (128, 0, 0), + "ansi_green": (0, 128, 0), + "ansi_yellow": (128, 128, 0), + "ansi_blue": (0, 0, 128), + "ansi_magenta": (128, 0, 128), + "ansi_cyan": (0, 128, 128), + "ansi_white": (192, 192, 192), + "ansi_bright_black": (128, 128, 128), + "ansi_bright_red": (255, 0, 0), + "ansi_bright_green": (0, 255, 0), + "ansi_bright_yellow": (255, 255, 0), + "ansi_bright_blue": (0, 0, 255), + "ansi_bright_magenta": (255, 0, 255), + "ansi_bright_cyan": (0, 255, 255), + "ansi_bright_white": (255, 255, 255), + # And then, Web color keywords: (up to CSS Color Module Level 4) "black": (0, 0, 0), "silver": (192, 192, 192), "gray": (128, 128, 128), diff --git a/src/textual/color.py b/src/textual/color.py index 58d87a783..489def730 100644 --- a/src/textual/color.py +++ b/src/textual/color.py @@ -24,7 +24,7 @@ from rich.style import Style from rich.text import Text -from ._color_constants import ANSI_COLOR_TO_RGB, WEB_COLOR_TO_RGB +from ._color_constants import COLOR_NAME_TO_RGB from .geometry import clamp @@ -253,14 +253,9 @@ class Color(NamedTuple): """ if isinstance(color_text, Color): return color_text - if color_text.startswith("ansi_"): - ansi_color = ANSI_COLOR_TO_RGB.get(color_text[5:]) - if ansi_color is not None: - return cls(*ansi_color) - else: - web_color = WEB_COLOR_TO_RGB.get(color_text) - if web_color is not None: - return cls(*web_color) + color_from_name = COLOR_NAME_TO_RGB.get(color_text) + if color_from_name is not None: + return cls(*color_from_name) color_match = RE_COLOR.match(color_text) if color_match is None: raise ColorParseError(f"failed to parse {color_text!r} as a color") diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py index d1da10662..6fac997ee 100644 --- a/tests/css/test_stylesheet.py +++ b/tests/css/test_stylesheet.py @@ -10,10 +10,9 @@ from textual.css.tokenizer import TokenizeError "css_value,expectation,expected_color", [ # Valid values: + ["transparent", does_not_raise(), Color(0, 0, 0, 0)], ["ansi_red", does_not_raise(), Color(128, 0, 0)], - ["ansi_dark_cyan", does_not_raise(), Color(0, 175, 135)], - ["ansi_medium_turquoise", does_not_raise(), Color(95, 215, 215)], - ["ansi_turquoise4", does_not_raise(), Color(0, 135, 135)], + ["ansi_bright_magenta", does_not_raise(), Color(255, 0, 255)], ["red", does_not_raise(), Color(255, 0, 0)], ["lime", does_not_raise(), Color(0, 255, 0)], ["coral", does_not_raise(), Color(255, 127, 80)], @@ -26,8 +25,8 @@ from textual.css.tokenizer import TokenizeError ["rgba(200,90,30,0.3)", does_not_raise(), Color(200, 90, 30, 0.3)], # Some invalid ones: ["coffee", pytest.raises(StylesheetParseError), None], # invalid color name - ["ansi_turquoise10", pytest.raises(StylesheetParseError), None], - ["ansi_turquoise 4", pytest.raises(StylesheetParseError), None], # space in it + ["ansi_dark_cyan", pytest.raises(StylesheetParseError), None], + ["red 4", pytest.raises(StylesheetParseError), None], # space in it ["1", pytest.raises(StylesheetParseError), None], # invalid value ["()", pytest.raises(TokenizeError), None], # invalid tokens # TODO: implement hex colors with 3 chars? @link https://devdocs.io/css/color_value From ca62a0d533ffd3cfe37859308708111729ef92b9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 28 Apr 2022 15:07:51 +0100 Subject: [PATCH 15/18] fix property --- src/textual/dom.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/dom.py b/src/textual/dom.py index 7f056dc44..a42180df7 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -311,6 +311,7 @@ class DOMNode(MessagePump): add_node(node) return nodes + @property def displayed_children(self) -> list[DOMNode]: return [child for child in self.children if child.display] From ee82f284077af15b511a7074db881d21a7ad9b3c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 28 Apr 2022 15:26:32 +0100 Subject: [PATCH 16/18] Update src/textual/dom.py Co-authored-by: Darren Burns --- src/textual/dom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index a42180df7..f24368bb8 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -274,7 +274,7 @@ class DOMNode(MessagePump): """Get the text style object. A widget's style is influenced by its parent. For instance if a widgets background has an alpha, - then its parent's background color will show throw. Additionally, widgets will inherit their + then its parent's background color will show through. Additionally, widgets will inherit their parent's text style (i.e. bold, italic etc). Returns: From 233c2c4075be37f2c1e130789fad0b9e307d971e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 29 Apr 2022 10:20:32 +0100 Subject: [PATCH 17/18] fix css reload --- sandbox/uber.css | 2 +- sandbox/uber.py | 2 +- src/textual/app.py | 9 +-- src/textual/css/parse.py | 2 +- src/textual/css/stylesheet.py | 100 +++++++++++++++++++-------------- src/textual/widget.py | 8 +-- src/textual/widgets/_button.py | 6 +- tests/css/test_parse.py | 26 ++++----- tests/css/test_stylesheet.py | 2 +- 9 files changed, 87 insertions(+), 70 deletions(-) diff --git a/sandbox/uber.css b/sandbox/uber.css index fcc11b51d..e41b1ef1b 100644 --- a/sandbox/uber.css +++ b/sandbox/uber.css @@ -1,7 +1,7 @@ #uber1 { layout: vertical; - background: dark_green; + background: green; overflow: hidden auto; border: heavy white; } diff --git a/sandbox/uber.py b/sandbox/uber.py index a19fe9ba4..d71d56ae7 100644 --- a/sandbox/uber.py +++ b/sandbox/uber.py @@ -37,7 +37,7 @@ class BasicApp(App): await self.dispatch_key(event) def action_quit(self): - self.panic(self.screen.tree) + self.panic(self.app.tree) def action_dump(self): self.panic(str(self.app.registry)) diff --git a/src/textual/app.py b/src/textual/app.py index 38c969a9d..6979f628a 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -330,10 +330,10 @@ class App(Generic[ReturnType], DOMNode): async def _on_css_change(self) -> None: """Called when the CSS changes (if watch_css is True).""" if self.css_file is not None: - stylesheet = Stylesheet(variables=self.get_css_variables()) + try: time = perf_counter() - stylesheet.read(self.css_file) + self.stylesheet.read(self.css_file) elapsed = (perf_counter() - time) * 1000 self.log(f"loaded {self.css_file} in {elapsed:.0f}ms") except Exception as error: @@ -342,7 +342,6 @@ class App(Generic[ReturnType], DOMNode): self.log(error) else: self.reset_styles() - self.stylesheet = stylesheet self.stylesheet.update(self) self.screen.refresh(layout=True) @@ -506,7 +505,9 @@ class App(Generic[ReturnType], DOMNode): if self.css_file is not None: self.stylesheet.read(self.css_file) if self.css is not None: - self.stylesheet.parse(self.css, path=f"<{self.__class__.__name__}>") + self.stylesheet.add_source( + self.css, path=f"<{self.__class__.__name__}>" + ) except Exception as error: self.on_exception(error) self._print_error_renderables() diff --git a/src/textual/css/parse.py b/src/textual/css/parse.py index d934a2109..6cb47076a 100644 --- a/src/textual/css/parse.py +++ b/src/textual/css/parse.py @@ -340,7 +340,7 @@ if __name__ == "__main__": console = Console() stylesheet = Stylesheet() try: - stylesheet.parse(css) + stylesheet.add_source(css) except StylesheetParseError as e: console.print(e.errors) print(stylesheet) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index f0e6c52ea..7b8606cc1 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -101,21 +101,23 @@ class StylesheetErrors: @rich.repr.auto class Stylesheet: def __init__(self, *, variables: dict[str, str] | None = None) -> None: - self.rules: list[RuleSet] = [] + self._rules: list[RuleSet] | None = None self.variables = variables or {} - self.source: list[tuple[str, str]] = [] + self.source: dict[str, str] = {} def __rich_repr__(self) -> rich.repr.Result: yield self.rules @property - def css(self) -> str: - return "\n\n".join(rule_set.css for rule_set in self.rules) + def rules(self) -> list[RuleSet]: + if self._rules is None: + self.parse() + assert self._rules is not None + return self._rules @property - def any_errors(self) -> bool: - """Check if there are any errors.""" - return any(rule.errors for rule in self.rules) + def css(self) -> str: + return "\n\n".join(rule_set.css for rule_set in self.rules) @property def error_renderable(self) -> StylesheetErrors: @@ -129,6 +131,28 @@ class Stylesheet: """ self.variables = variables + def _parse_rules(self, css: str, path: str) -> list[RuleSet]: + """Parse CSS and return rules. + + + Args: + css (str): String containing Textual CSS. + path (str): Path to CSS or unique identifier + + Raises: + StylesheetError: If the CSS is invalid. + + Returns: + list[RuleSet]: List of RuleSets. + """ + try: + rules = list(parse(css, path, variables=self.variables)) + except TokenizeError: + raise + except Exception as error: + raise StylesheetError(f"failed to parse css; {error}") + return rules + def read(self, filename: str) -> None: """Read Textual CSS file. @@ -146,19 +170,10 @@ class Stylesheet: path = os.path.abspath(filename) except Exception as error: raise StylesheetError(f"unable to read {filename!r}; {error}") - try: - rules = list(parse(css, path, variables=self.variables)) - except TokenizeError: - raise - except Exception as error: - raise StylesheetError(f"failed to parse {filename!r}; {error!r}") - else: - self.source.append((css, path)) - self.rules.extend(rules) - if self.any_errors: - raise StylesheetParseError(self.error_renderable) + self.source[path] = css + self._rules = None - def parse(self, css: str, *, path: str = "") -> None: + def add_source(self, css: str, path: str | None = None) -> None: """Parse CSS from a string. Args: @@ -169,28 +184,30 @@ class Stylesheet: StylesheetError: If the CSS could not be read. StylesheetParseError: If the CSS is invalid. """ - if (css, path) in self.source: + + if path is None: + path = str(hash(css)) + if path in self.source and self.source[path] == css: + # Path already in source, and CSS is identical return - try: - rules = list(parse(css, path, variables=self.variables)) - except TokenizeError: - raise - except Exception as error: - raise StylesheetError(f"failed to parse css; {error}") - else: - self.source.append((css, path)) - self.rules.extend(rules) - if self.any_errors: - raise StylesheetParseError(self.error_renderable) - def _clone(self, stylesheet: Stylesheet) -> None: - """Replace this stylesheet contents with another. + self.source[path] = css + self._rules = None - Args: - stylesheet (Stylesheet): A Stylesheet. + def parse(self) -> None: + """Parse the source in the stylesheet. + + Raises: + StylesheetParseError: If there are any CSS related errors. """ - self.rules = stylesheet.rules.copy() - self.source = stylesheet.source.copy() + rules: list[RuleSet] = [] + add_rules = rules.extend + for path, css in self.source.items(): + css_rules = self._parse_rules(css, path) + if any(rule.errors for rule in css_rules): + raise StylesheetParseError(self.error_renderable) + add_rules(css_rules) + self._rules = rules def reparse(self) -> None: """Re-parse source, applying new variables. @@ -202,9 +219,10 @@ class Stylesheet: """ # Do this in a fresh Stylesheet so if there are errors we don't break self. stylesheet = Stylesheet(variables=self.variables) - for css, path in self.source: - stylesheet.parse(css, path=path) - self._clone(stylesheet) + for path, css in self.source.items(): + stylesheet.add_source(css, path) + self._rules = None + self.source = stylesheet.source @classmethod def _check_rule(cls, rule: RuleSet, node: DOMNode) -> Iterable[Specificity3]: @@ -403,7 +421,7 @@ if __name__ == "__main__": """ stylesheet = Stylesheet() - stylesheet.parse(CSS) + stylesheet.add_source(CSS) print(stylesheet.css) diff --git a/src/textual/widget.py b/src/textual/widget.py index 7976b5818..4f47e620e 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -68,10 +68,6 @@ class Widget(DOMNode): can_focus: bool = False - DEFAULT_STYLES = """ - - """ - CSS = """ """ @@ -127,7 +123,9 @@ class Widget(DOMNode): app (App): App instance. """ # Parser the Widget's CSS - self.app.stylesheet.parse(self.CSS, path=f"<{self.__class__.__name__}>") + self.app.stylesheet.add_source( + self.CSS, f"{__file__}:<{self.__class__.__name__}>" + ) def get_box_model(self, container: Size, viewport: Size) -> BoxModel: """Process the box model for this widget. diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 4d08a2796..331b6bdde 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -23,9 +23,9 @@ class Button(Widget, can_focus=True): background: $primary; color: $text-primary; content-align: center middle; - border: tall $primary-lighten-3; - margin: 1; - min-width:16; + border: tall $primary-lighten-3; + + margin: 1 0; text-style: bold; } diff --git a/tests/css/test_parse.py b/tests/css/test_parse.py index b5b3179fd..f0c05dc2d 100644 --- a/tests/css/test_parse.py +++ b/tests/css/test_parse.py @@ -864,7 +864,7 @@ class TestParseLayout: css = "#some-widget { layout: dock; }" stylesheet = Stylesheet() - stylesheet.parse(css) + stylesheet.add_source(css) styles = stylesheet.rules[0].styles assert isinstance(styles.layout, DockLayout) @@ -874,7 +874,7 @@ class TestParseLayout: stylesheet = Stylesheet() with pytest.raises(StylesheetParseError) as ex: - stylesheet.parse(css) + stylesheet.add_source(css) assert ex.value.errors is not None @@ -886,7 +886,7 @@ class TestParseText: } """ stylesheet = Stylesheet() - stylesheet.parse(css) + stylesheet.add_source(css) styles = stylesheet.rules[0].styles assert styles.color == Color.parse("green") @@ -897,7 +897,7 @@ class TestParseText: } """ stylesheet = Stylesheet() - stylesheet.parse(css) + stylesheet.add_source(css) styles = stylesheet.rules[0].styles assert styles.background == Color.parse("red") @@ -933,7 +933,7 @@ class TestParseOffset: }} """ stylesheet = Stylesheet() - stylesheet.parse(css) + stylesheet.add_source(css) styles = stylesheet.rules[0].styles @@ -972,7 +972,7 @@ class TestParseOffset: }} """ stylesheet = Stylesheet() - stylesheet.parse(css) + stylesheet.add_source(css) styles = stylesheet.rules[0].styles @@ -1002,7 +1002,7 @@ class TestParseTransition: }} """ stylesheet = Stylesheet() - stylesheet.parse(css) + stylesheet.add_source(css) styles = stylesheet.rules[0].styles @@ -1017,7 +1017,7 @@ class TestParseTransition: def test_no_delay_specified(self): css = f"#some-widget {{ transition: offset-x 1 in_out_cubic; }}" stylesheet = Stylesheet() - stylesheet.parse(css) + stylesheet.add_source(css) styles = stylesheet.rules[0].styles @@ -1032,7 +1032,7 @@ class TestParseTransition: stylesheet = Stylesheet() with pytest.raises(StylesheetParseError) as ex: - stylesheet.parse(css) + stylesheet.add_source(css) stylesheet_errors = stylesheet.rules[0].errors @@ -1056,7 +1056,7 @@ class TestParseOpacity: def test_opacity_to_styles(self, css_value, styles_value): css = f"#some-widget {{ opacity: {css_value} }}" stylesheet = Stylesheet() - stylesheet.parse(css) + stylesheet.add_source(css) assert stylesheet.rules[0].styles.opacity == styles_value assert not stylesheet.rules[0].errors @@ -1066,7 +1066,7 @@ class TestParseOpacity: stylesheet = Stylesheet() with pytest.raises(StylesheetParseError): - stylesheet.parse(css) + stylesheet.add_source(css) assert stylesheet.rules[0].errors @@ -1074,7 +1074,7 @@ class TestParseMargin: def test_margin_partial(self): css = "#foo {margin: 1; margin-top: 2; margin-right: 3; margin-bottom: -1;}" stylesheet = Stylesheet() - stylesheet.parse(css) + stylesheet.add_source(css) assert stylesheet.rules[0].styles.margin == Spacing(2, 3, -1, 1) @@ -1082,5 +1082,5 @@ class TestParsePadding: def test_padding_partial(self): css = "#foo {padding: 1; padding-top: 2; padding-right: 3; padding-bottom: -1;}" stylesheet = Stylesheet() - stylesheet.parse(css) + stylesheet.add_source(css) assert stylesheet.rules[0].styles.padding == Spacing(2, 3, -1, 1) diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py index 87f27845d..b31633504 100644 --- a/tests/css/test_stylesheet.py +++ b/tests/css/test_stylesheet.py @@ -42,7 +42,7 @@ def test_color_property_parsing(css_value, expectation, expected_color): ) with expectation: - stylesheet.parse(css) + stylesheet.add_source(css) if expected_color: css_rule = stylesheet.rules[0] From 8e821d41131c1324a64eff4d446ac26de917fac3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 29 Apr 2022 10:34:35 +0100 Subject: [PATCH 18/18] lazy parse_rules --- src/textual/css/stylesheet.py | 14 +++++++++----- tests/css/test_parse.py | 9 +++++++-- tests/css/test_stylesheet.py | 1 + 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 7b8606cc1..52993f02c 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -101,17 +101,19 @@ class StylesheetErrors: @rich.repr.auto class Stylesheet: def __init__(self, *, variables: dict[str, str] | None = None) -> None: - self._rules: list[RuleSet] | None = None + self._rules: list[RuleSet] = [] self.variables = variables or {} self.source: dict[str, str] = {} + self._require_parse = False def __rich_repr__(self) -> rich.repr.Result: yield self.rules @property def rules(self) -> list[RuleSet]: - if self._rules is None: + if self._require_parse: self.parse() + self._require_parse = False assert self._rules is not None return self._rules @@ -171,7 +173,7 @@ class Stylesheet: except Exception as error: raise StylesheetError(f"unable to read {filename!r}; {error}") self.source[path] = css - self._rules = None + self._require_parse = True def add_source(self, css: str, path: str | None = None) -> None: """Parse CSS from a string. @@ -192,7 +194,7 @@ class Stylesheet: return self.source[path] = css - self._rules = None + self._require_parse = True def parse(self) -> None: """Parse the source in the stylesheet. @@ -208,6 +210,7 @@ class Stylesheet: raise StylesheetParseError(self.error_renderable) add_rules(css_rules) self._rules = rules + self._require_parse = False def reparse(self) -> None: """Re-parse source, applying new variables. @@ -221,7 +224,8 @@ class Stylesheet: stylesheet = Stylesheet(variables=self.variables) for path, css in self.source.items(): stylesheet.add_source(css, path) - self._rules = None + stylesheet.parse() + self.rules = stylesheet.rules self.source = stylesheet.source @classmethod diff --git a/tests/css/test_parse.py b/tests/css/test_parse.py index f0c05dc2d..ccf7a75f4 100644 --- a/tests/css/test_parse.py +++ b/tests/css/test_parse.py @@ -875,6 +875,7 @@ class TestParseLayout: stylesheet = Stylesheet() with pytest.raises(StylesheetParseError) as ex: stylesheet.add_source(css) + stylesheet.parse() assert ex.value.errors is not None @@ -1033,8 +1034,10 @@ class TestParseTransition: stylesheet = Stylesheet() with pytest.raises(StylesheetParseError) as ex: stylesheet.add_source(css) + stylesheet.parse() - stylesheet_errors = stylesheet.rules[0].errors + rules = stylesheet._parse_rules(css, "foo") + stylesheet_errors = rules[0].errors assert len(stylesheet_errors) == 1 assert stylesheet_errors[0][0].value == invalid_func_name @@ -1067,7 +1070,9 @@ class TestParseOpacity: with pytest.raises(StylesheetParseError): stylesheet.add_source(css) - assert stylesheet.rules[0].errors + stylesheet.parse() + rules = stylesheet._parse_rules(css, "foo") + assert rules[0].errors class TestParseMargin: diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py index b31633504..718446a43 100644 --- a/tests/css/test_stylesheet.py +++ b/tests/css/test_stylesheet.py @@ -43,6 +43,7 @@ def test_color_property_parsing(css_value, expectation, expected_color): with expectation: stylesheet.add_source(css) + stylesheet.parse() if expected_color: css_rule = stylesheet.rules[0]