From 4090d351684342b8e28ef9d5451c7c821e18d1ae Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 28 Apr 2022 13:17:10 +0100 Subject: [PATCH] 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,