From 44c1f2373aaa61c5262882a61064fa5c084ae21e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 27 Apr 2022 14:02:28 +0100 Subject: [PATCH 01/14] 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/14] [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 72555f4a77b594c21125b170d4dae75433db8955 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Thu, 28 Apr 2022 12:23:59 +0100 Subject: [PATCH 03/14] [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 04/14] 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 05/14] 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 06/14] 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 07/14] 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 08/14] 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 09/14] 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 10/14] 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 ca62a0d533ffd3cfe37859308708111729ef92b9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 28 Apr 2022 15:07:51 +0100 Subject: [PATCH 11/14] 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 12/14] 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 13/14] 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 14/14] 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]