diff --git a/src/textual/app.py b/src/textual/app.py index 24662222f..27f49e969 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -289,6 +289,7 @@ class App(DOMNode): def _print_error_renderables(self) -> None: for renderable in self._exit_renderables: self.error_console.print(renderable) + self._exit_renderables.clear() async def process_messages(self) -> None: active_app.set(self) @@ -300,7 +301,6 @@ class App(DOMNode): self.stylesheet.read(self.css_file) if self.css is not None: self.stylesheet.parse(self.css, path=f"<{self.__class__.__name__}>") - print(self.stylesheet.css) except StylesheetParseError as error: self.panic(error) self._print_error_renderables() @@ -310,20 +310,18 @@ class App(DOMNode): self._print_error_renderables() return - load_event = events.Load(sender=self) - await self.dispatch_message(load_event) - await self.post_message(events.Mount(self)) - await self.push_view(DockView()) - - # Wait for the load event to be processed, so we don't go in to application mode beforehand - await load_event.wait() - - driver = self._driver = self.driver_class(self.console, self) try: + load_event = events.Load(sender=self) + await self.dispatch_message(load_event) + await self.post_message(events.Mount(self)) + await self.push_view(DockView()) + + # Wait for the load event to be processed, so we don't go in to application mode beforehand + await load_event.wait() + + driver = self._driver = self.driver_class(self.console, self) + driver.start_application_mode() - except Exception: - self.console.print_exception() - else: try: self.title = self._title self.refresh() @@ -332,15 +330,17 @@ class App(DOMNode): log("PROCESS END") await self.animator.stop() await self.close_all() - except Exception: self.panic() finally: driver.stop_application_mode() - if self._exit_renderables: - self._print_error_renderables() - if self.log_file is not None: - self.log_file.close() + except: + self.panic() + finally: + if self._exit_renderables: + self._print_error_renderables() + if self.log_file is not None: + self.log_file.close() def register(self, child: MessagePump, parent: MessagePump) -> bool: if child not in self.registry: diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 245d5ac5e..684d99ba5 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -17,6 +17,10 @@ if TYPE_CHECKING: class ScalarProperty: + def __init__(self, units: set[str]) -> None: + self.units = units + super().__init__() + def __set_name__(self, owner: Styles, name: str) -> None: self.internal_name = f"_rule_{name}" @@ -27,19 +31,23 @@ class ScalarProperty: def __set__( self, obj: Styles, value: float | Scalar | str | None ) -> float | Scalar | str | None: + new_value: Scalar | None = None if value is None: - setattr(obj, self.internal_name, None) + new_value = None elif isinstance(value, float): - setattr(obj, self.internal_name, Scalar(value, "cells")) + new_value = Scalar(value, "") elif isinstance(value, Scalar): - setattr(obj, self.internal_name, value) + new_value = value elif isinstance(value, str): try: - setattr(obj, self.internal_name, Scalar.parse(value)) + new_value = Scalar.parse(value) except ScalarParseError: raise StyleValueError("unable to parse scalar from {value!r}") else: raise StyleValueError("expected float, Scalar, or None") + if new_value is not None and new_value.unit not in self.units: + raise StyleValueError(f"units must be one of {friendly_list(self.units)}") + setattr(obj, self.internal_name, new_value) return value diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index 65f39ca9e..0cc211515 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -6,7 +6,7 @@ import rich.repr from rich.color import ANSI_COLOR_NAMES, Color from rich.style import Style -from .constants import VALID_BORDER, VALID_DISPLAY, VALID_VISIBILITY +from .constants import VALID_BORDER, VALID_EDGE, VALID_DISPLAY, VALID_VISIBILITY from .errors import DeclarationError, StyleValueError from ._error_tools import friendly_list from ..geometry import Offset, Spacing, SpacingDimensions @@ -75,7 +75,7 @@ class StylesBuilder: if not tokens: return if len(tokens) == 1: - setattr(self.styles, f"_rule_{name}", Scalar.parse(tokens[0].value)) + setattr(self.styles, name, Scalar.parse(tokens[0].value)) else: self.error(name, tokens[0], "a single scalar is expected") @@ -294,8 +294,15 @@ class StylesBuilder: if token.name == "token": docks.append((token.value, "")) elif token.name == "key_value": - key, value = token.value.split("=") - docks.append((key.strip(), value.strip())) + key, group_name = token.value.split("=") + group_name = group_name.strip().lower() + if group_name not in VALID_EDGE: + self.error( + name, + token, + f"edge must be one of 'top', 'right', 'bottom', or 'left'; found {group_name!r}", + ) + docks.append((key.strip(), group_name)) elif token.name == "bar": pass else: diff --git a/src/textual/css/scalar.py b/src/textual/css/scalar.py index 25ba8d25d..4caf10235 100644 --- a/src/textual/css/scalar.py +++ b/src/textual/css/scalar.py @@ -21,6 +21,31 @@ class Scalar(NamedTuple): value, unit = self return f"{int(value) if value.is_integer() else value}{unit}" + @property + def cells(self) -> int | None: + value, unit = self + if unit: + return None + else: + return int(value) + + @property + def fraction(self) -> int | None: + value, unit = self + if unit == "fr": + return int(value) + else: + return None + + def resolve_size(self, total: int, total_fraction: int) -> int: + value, unit = self + if unit == "": + return int(value) + elif unit == "%": + return int(total * value / 100.0) + else: # if unit == "fr": + return int((value / total_fraction) * total) + @classmethod def parse(cls, token: str) -> Scalar: """Parse a string in to a Scalar diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 126c24900..9372c9b63 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -103,10 +103,10 @@ class Styles: outline_bottom = BoxProperty() outline_left = BoxProperty() - width = ScalarProperty() - height = ScalarProperty() - min_width = ScalarProperty() - min_height = ScalarProperty() + width = ScalarProperty({"", "fr"}) + height = ScalarProperty({"", "fr"}) + min_width = ScalarProperty({"", "fr"}) + min_height = ScalarProperty({"", "fr"}) dock_group = DockGroupProperty() docks = DocksProperty() diff --git a/src/textual/dom.py b/src/textual/dom.py index 91930eea6..04f9fd865 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -79,6 +79,10 @@ class DOMNode(MessagePump): append(node) return result[::-1] + @property + def visible(self) -> bool: + return self.styles.display != "none" + @property def tree(self) -> Tree: highlighter = ReprHighlighter() diff --git a/src/textual/layout.py b/src/textual/layout.py index ca8d0ecd1..7af1e40f6 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -15,6 +15,7 @@ from rich.segment import Segment, SegmentLines from rich.style import Style from . import log, panic +from .dom import DOMNode from ._loop import loop_last from .layout_map import LayoutMap from ._profile import timer @@ -149,7 +150,7 @@ class Layout(ABC): ) @abstractmethod - def get_widgets(self) -> Iterable[Widget]: + def get_widgets(self, view: View) -> Iterable[DOMNode]: ... @abstractmethod @@ -168,7 +169,7 @@ class Layout(ABC): """ async def mount_all(self, view: "View") -> None: - await view.mount(*self.get_widgets()) + await view.mount(*self.get_widgets(view)) @property def map(self) -> LayoutMap | None: diff --git a/src/textual/layouts/dock.py b/src/textual/layouts/dock.py index 1a73482f4..081beb0dd 100644 --- a/src/textual/layouts/dock.py +++ b/src/textual/layouts/dock.py @@ -5,8 +5,7 @@ from collections import defaultdict from dataclasses import dataclass from typing import Iterable, TYPE_CHECKING, Sequence -from rich.console import Console - +from ..dom import DOMNode from .._layout_resolve import layout_resolve from ..geometry import Offset, Region, Size from ..layout import Layout, WidgetPlacement @@ -35,14 +34,28 @@ class DockOptions: @dataclass class Dock: - edge: DockEdge - widgets: Sequence[Widget] + edge: str + widgets: Sequence[DOMNode] z: int = 0 class DockLayout(Layout): - def get_widgets(self) -> Iterable[Widget]: - for dock in self.docks: + def __init__(self) -> None: + super().__init__() + self._docks: list[Dock] | None = None + + def get_docks(self, view: View) -> list[Dock]: + groups: dict[str, list[DOMNode]] = defaultdict(list) + for child in view.children: + groups[child.styles.dock_group].append(child) + docks: list[Dock] = [] + append_dock = docks.append + for name, edge in view.styles.docks: + append_dock(Dock(edge, groups[name], 0)) + return docks + + def get_widgets(self, view: View) -> Iterable[DOMNode]: + for dock in self.get_docks(view): yield from dock.widgets def arrange( @@ -54,10 +67,23 @@ class DockLayout(Layout): layout_region = Region(0, 0, width, height) layers: dict[int, Region] = defaultdict(lambda: layout_region) - for index, dock in enumerate(self.docks): + docks = self.get_docks(view) + + for index, dock in enumerate(docks): + dock_options = [ - DockOptions( - widget.layout_size, widget.layout_fraction, widget.layout_min_size + ( + DockOptions( + widget.styles.width.cells, + widget.styles.width.fraction or 1, + widget.styles.min_width.cells or 1, + ) + if dock.edge in ("left", "right") + else DockOptions( + widget.styles.height.cells, + widget.styles.height.fraction or 1, + widget.styles.min_height.cells or 1, + ) ) for widget in dock.widgets ]