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: