diff --git a/examples/dev_sandbox.css b/examples/dev_sandbox.css new file mode 100644 index 000000000..2e348e134 --- /dev/null +++ b/examples/dev_sandbox.css @@ -0,0 +1,49 @@ +/* CSS file for dev_sandbox.py */ + +App > View { + docks: side=left/1; + text: on #20639b; +} + +Widget:hover { + outline: heavy; + text: bold !important; +} + +#sidebar { + text: #09312e on #3caea3; + dock: side; + width: 30; + offset-x: -100%; + transition: offset 500ms in_out_cubic; + border-right: outer #09312e; +} + +#sidebar.-active { + offset-x: 0; +} + +#header { + text: white on #173f5f; + height: 3; + border: hkey; +} + +#header.-visible { + visibility: hidden; +} + +#content { + text: white on #20639b; + border-bottom: hkey #0f2b41; + offset-y: -3; +} + +#content.-content-visible { + visibility: hidden; +} + +#footer { + text: #3a3009 on #f6d55c; + height: 3; +} diff --git a/examples/dev_sandbox.py b/examples/dev_sandbox.py new file mode 100644 index 000000000..d53946fb3 --- /dev/null +++ b/examples/dev_sandbox.py @@ -0,0 +1,32 @@ +from rich.console import RenderableType +from rich.panel import Panel + +from textual.app import App +from textual.widget import Widget + + +class PanelWidget(Widget): + def render(self) -> RenderableType: + return Panel("hello world!", title="Title") + + +class BasicApp(App): + """Sandbox application used for testing/development by Textual developers""" + + def on_load(self): + """Bind keys here.""" + self.bind("tab", "toggle_class('#sidebar', '-active')") + self.bind("a", "toggle_class('#header', '-visible')") + self.bind("c", "toggle_class('#content', '-content-visible')") + + def on_mount(self): + """Build layout here.""" + self.mount( + header=Widget(), + content=PanelWidget(), + footer=Widget(), + sidebar=Widget(), + ) + + +BasicApp.run(css_file="test_app.css", watch_css=True, log="textual.log") diff --git a/src/textual/app.py b/src/textual/app.py index 0e6dee254..96ca521e4 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1,50 +1,44 @@ from __future__ import annotations -import os +import os import asyncio -from typing import Any, Callable, ClassVar, Iterable, Type, TypeVar import warnings - -from rich.control import Control +from typing import Any, Callable, Iterable, Type, TypeVar import rich.repr -from rich.screen import Screen from rich.console import Console, RenderableType +from rich.control import Control from rich.measure import Measurement - +from rich.screen import Screen from rich.traceback import Traceback - -from . import events from . import actions -from .dom import DOMNode -from ._animator import Animator -from .binding import Bindings, NoBinding -from .geometry import Offset, Region, Size +from . import events from . import log +from . import messages +from ._animator import Animator from ._callback import invoke from ._context import active_app -from .css.stylesheet import Stylesheet, StylesheetParseError, StylesheetError from ._event_broker import extract_handler_actions, NoHandler +from ._linux_driver import LinuxDriver +from ._profile import timer +from .binding import Bindings, NoBinding +from .css.stylesheet import Stylesheet, StylesheetParseError, StylesheetError +from .dom import DOMNode from .driver import Driver from .file_monitor import FileMonitor - +from .geometry import Offset, Region, Size from .layouts.dock import DockLayout, Dock -from ._linux_driver import LinuxDriver -from ._types import MessageTarget -from . import messages from .message_pump import MessagePump -from ._profile import timer +from .reactive import Reactive from .view import View from .views import DockView -from .widget import Widget, Reactive - +from .widget import Widget # asyncio will warn against resources not being cleared warnings.simplefilter("always", ResourceWarning) - LayoutDefinition = "dict[str, Any]" ViewType = TypeVar("ViewType", bound=View) @@ -96,7 +90,6 @@ class App(DOMNode): self._screen = screen self.driver_class = driver_class or LinuxDriver self._title = title - self._layout = DockLayout() self._view_stack: list[View] = [] self.focused: Widget | None = None @@ -638,17 +631,11 @@ class App(DOMNode): if __name__ == "__main__": import asyncio - from logging import FileHandler - - from rich.panel import Panel from .widgets import Header from .widgets import Footer from .widgets import Placeholder - from .scrollbar import ScrollBar - - from rich.markdown import Markdown # from .widgets.scroll_view import ScrollView @@ -672,7 +659,6 @@ if __name__ == "__main__": self.show_bar = not self.show_bar async def on_mount(self, event: events.Mount) -> None: - view = await self.push_view(DockView()) header = Header() diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 6e229172c..172ca7e14 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -15,6 +15,9 @@ import rich.repr from rich.color import Color from rich.style import Style +from ._error_tools import friendly_list +from .constants import NULL_SPACING +from .errors import StyleTypeError, StyleValueError from .scalar import ( get_symbols, UNIT_SYMBOL, @@ -23,16 +26,15 @@ from .scalar import ( ScalarOffset, ScalarParseError, ) -from ..geometry import Spacing, SpacingDimensions -from .constants import NULL_SPACING -from .errors import StyleTypeError, StyleValueError from .transition import Transition -from ._error_tools import friendly_list +from ..geometry import Spacing, SpacingDimensions if TYPE_CHECKING: + from ..layout import Layout from .styles import Styles from .styles import DockGroup from .._box import BoxType + from ..layouts.factory import LayoutName class ScalarProperty: @@ -367,7 +369,7 @@ class SpacingProperty: ValueError: When the value is malformed, e.g. a ``tuple`` with a length that is not 1, 2, or 4. """ - obj.refresh(True) + obj.refresh(layout=True) spacing = Spacing.unpack(spacing) setattr(obj, self._internal_name, spacing) @@ -398,7 +400,7 @@ class DocksProperty: obj (Styles): The ``Styles`` object. docks (Iterable[DockGroup]): Iterable of DockGroups """ - obj.refresh(True) + obj.refresh(layout=True) if docks is None: obj._rule_docks = None else: @@ -431,10 +433,43 @@ class DockProperty: obj (Styles): The ``Styles`` object spacing (str | None): The spacing to use. """ - obj.refresh(True) + obj.refresh(layout=True) obj._rule_dock = spacing +class LayoutProperty: + """Descriptor for getting and setting layout.""" + + def __set_name__(self, owner: Styles, name: str) -> None: + self._internal_name = f"_rule_{name}" + + def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> Layout: + """ + Args: + obj (Styles): The Styles object + objtype (type[Styles]): The Styles class + Returns: + The ``Layout`` object. + """ + return getattr(obj, self._internal_name) + + def __set__(self, obj: Styles, layout: LayoutName | Layout): + """ + Args: + obj (Styles): The Styles object. + layout (LayoutName | Layout): The layout to use. You can supply a ``LayoutName`` + (a string literal such as ``"dock"``) or a ``Layout`` object. + """ + from ..layouts.factory import get_layout, Layout # Prevents circular import + + obj.refresh(layout=True) + if isinstance(layout, Layout): + new_layout = layout + else: + new_layout = get_layout(layout) + setattr(obj, self._internal_name, new_layout) + + class OffsetProperty: """Descriptor for getting and setting the offset property. Offset consists of two values, x and y, that a widget's position @@ -473,7 +508,7 @@ class OffsetProperty: ScalarParseError: If any of the string values supplied in the 2-tuple cannot be parsed into a Scalar. For example, if you specify an non-existent unit. """ - obj.refresh(True) + obj.refresh(layout=True) if isinstance(offset, ScalarOffset): setattr(obj, self._internal_name, offset) return offset @@ -600,7 +635,7 @@ class NameProperty: Raises: StyleTypeError: If the value is not a ``str``. """ - obj.refresh(True) + obj.refresh(layout=True) if not isinstance(name, str): raise StyleTypeError(f"{self._name} must be a str") setattr(obj, self._internal_name, name) @@ -619,7 +654,7 @@ class NameListProperty: def __set__( self, obj: Styles, names: str | tuple[str] | None = None ) -> str | tuple[str] | None: - obj.refresh(True) + obj.refresh(layout=True) names_value: tuple[str, ...] | None = None if isinstance(names, str): names_value = tuple(name.strip().lower() for name in names.split(" ")) diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index 17349a04f..79e478f23 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -6,18 +6,19 @@ import rich.repr from rich.color import Color from rich.style import Style +from ._error_tools import friendly_list from .constants import VALID_BORDER, VALID_EDGE, VALID_DISPLAY, VALID_VISIBILITY from .errors import DeclarationError -from ._error_tools import friendly_list -from .._duration import _duration_as_seconds -from .._easing import EASING -from ..geometry import Spacing, SpacingDimensions from .model import Declaration from .scalar import Scalar, ScalarOffset, Unit, ScalarError from .styles import DockGroup, Styles -from .types import Edge, Display, Visibility from .tokenize import Token from .transition import Transition +from .types import Edge, Display, Visibility +from .._duration import _duration_as_seconds +from .._easing import EASING +from ..geometry import Spacing, SpacingDimensions +from ..layouts.factory import get_layout, LayoutName, MissingLayout, LAYOUT_MAP class StylesBuilder: @@ -59,7 +60,7 @@ class StylesBuilder: self.styles.important.add(rule_name) try: process_method(declaration.name, tokens, important) - except DeclarationError as error: + except DeclarationError: raise except Exception as error: self.error(declaration.name, declaration.token, str(error)) @@ -128,7 +129,7 @@ class StylesBuilder: append = space.append for token in tokens: (token_name, value, _, _, location) = token - if token_name == "scalar": + if token_name in ("number", "scalar"): try: append(int(value)) except ValueError: @@ -288,7 +289,16 @@ class StylesBuilder: if len(tokens) != 1: self.error(name, tokens[0], "unexpected tokens in declaration") else: - self.styles._rule_layout = tokens[0].value + value = tokens[0].value + layout_name = cast(LayoutName, value) + try: + self.styles._rule_layout = get_layout(layout_name) + except MissingLayout: + self.error( + name, + tokens[0], + f"invalid value for layout (received {value!r}, expected {friendly_list(LAYOUT_MAP.keys())})", + ) def process_text(self, name: str, tokens: list[Token], important: bool) -> None: style_definition = " ".join(token.value for token in tokens) diff --git a/src/textual/css/model.py b/src/textual/css/model.py index 2dd7a5cc0..64dfb4ef6 100644 --- a/src/textual/css/model.py +++ b/src/textual/css/model.py @@ -5,14 +5,15 @@ import rich.repr from dataclasses import dataclass, field from enum import Enum -from typing import Iterable +from typing import Iterable, TYPE_CHECKING -from .. import log -from ..dom import DOMNode from .styles import Styles from .tokenize import Token from .types import Specificity3 +if TYPE_CHECKING: + from ..dom import DOMNode + class SelectorType(Enum): UNIVERSAL = 1 diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 5c51dc1c3..555e710ea 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -1,32 +1,14 @@ from __future__ import annotations +import sys from dataclasses import dataclass, field from functools import lru_cache -import sys from typing import Any, Iterable, NamedTuple, TYPE_CHECKING -from rich import print -from rich.color import Color import rich.repr +from rich.color import Color from rich.style import Style -from .. import log -from .._animator import SimpleAnimation, Animation, EasingFunction -from .._types import MessageTarget -from .errors import StyleValueError -from .. import events -from ._error_tools import friendly_list -from .types import Specificity3, Specificity4 -from .constants import ( - VALID_DISPLAY, - VALID_VISIBILITY, - VALID_LAYOUT, - NULL_SPACING, -) -from .scalar_animation import ScalarAnimation -from ..geometry import NULL_OFFSET, Offset, Spacing -from .scalar import Scalar, ScalarOffset, Unit -from .transition import Transition from ._style_properties import ( BorderProperty, BoxProperty, @@ -42,11 +24,26 @@ from ._style_properties import ( StyleProperty, StyleFlagsProperty, TransitionsProperty, + LayoutProperty, ) +from .constants import ( + VALID_DISPLAY, + VALID_VISIBILITY, +) +from .scalar import Scalar, ScalarOffset, Unit +from .scalar_animation import ScalarAnimation +from .transition import Transition from .types import Display, Edge, Visibility +from .types import Specificity3, Specificity4 +from .. import log +from .._animator import Animation, EasingFunction +from ..geometry import Spacing + + if TYPE_CHECKING: + from ..layout import Layout from ..dom import DOMNode @@ -64,7 +61,7 @@ class Styles: _rule_display: Display | None = None _rule_visibility: Visibility | None = None - _rule_layout: str | None = None + _rule_layout: "Layout" | None = None _rule_text_color: Color | None = None _rule_text_background: Color | None = None @@ -104,7 +101,7 @@ class Styles: display = StringEnumProperty(VALID_DISPLAY, "block") visibility = StringEnumProperty(VALID_VISIBILITY, "visible") - layout = StringEnumProperty(VALID_LAYOUT, "dock") + layout = LayoutProperty() text = StyleProperty() text_color = ColorProperty() @@ -273,6 +270,7 @@ class Styles: ) else: setattr(styles, f"_rule_{key}", value) + if self.node is not None: self.node.on_style_change() @@ -423,7 +421,7 @@ if __name__ == "__main__": styles.dock = "bar" styles.layers = "foo bar" - from rich import inspect, print + from rich import print print(styles.text_style) print(styles.text) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 05704b782..8c6075011 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -108,18 +108,37 @@ class Stylesheet: yield selector_set.specificity def apply(self, node: DOMNode) -> None: + """Apply the stylesheet to a DOM node. + + Args: + node (DOMNode): The ``DOMNode`` to apply the stylesheet to. + Applies the styles defined in this ``Stylesheet`` to the node. + If the same rule is defined multiple times for the node (e.g. multiple + classes modifying the same CSS property), then only the most specific + rule will be applied. + + Returns: + None + """ + + # Dictionary of rule attribute names e.g. "text_background" to list of tuples. + # The tuples contain the rule specificity, and the value for that rule. + # We can use this to determine, for a given rule, whether we should apply it + # or not by examining the specificity. If we have two rules for the + # same attribute, then we can choose the most specific rule and use that. rule_attributes: dict[str, list[tuple[Specificity4, object]]] rule_attributes = defaultdict(list) _check_rule = self._check_rule + # TODO: The line below breaks inline styles and animations node.styles.reset() - # Get the default node CSS rules + # Collect default node CSS rules for key, default_specificity, value in node._default_rules: rule_attributes[key].append((default_specificity, value)) - # Apply styles on top of the default node CSS rules + # Collect the rules defined in the stylesheet for rule in self.rules: for specificity in _check_rule(rule, node): for key, rule_specificity, value in rule.styles.extract_rules( @@ -127,6 +146,7 @@ class Stylesheet: ): rule_attributes[key].append((rule_specificity, value)) + # For each rule declared for this node, keep only the most specific one get_first_item = itemgetter(0) node_rules = [ (name, max(specificity_rules, key=get_first_item)[1]) diff --git a/src/textual/dom.py b/src/textual/dom.py index 8b17700ed..02bfa3735 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1,24 +1,22 @@ from __future__ import annotations -from typing import Any, cast, Iterable, Iterator, TYPE_CHECKING +from typing import Iterable, Iterator, TYPE_CHECKING -from rich.highlighter import ReprHighlighter import rich.repr +from rich.highlighter import ReprHighlighter from rich.pretty import Pretty from rich.style import Style from rich.tree import Tree +from ._node_list import NodeList from .css._error_tools import friendly_list -from .css.constants import VALID_DISPLAY +from .css.constants import VALID_DISPLAY, VALID_VISIBILITY from .css.errors import StyleValueError from .css.styles import Styles from .message_pump import MessagePump -from ._node_list import NodeList - if TYPE_CHECKING: from .css.query import DOMQuery - from .widget import Widget class NoParent(Exception): @@ -160,6 +158,22 @@ class DOMNode(MessagePump): f"expected {friendly_list(VALID_DISPLAY)})", ) + @property + def visible(self) -> bool: + return self.styles.visibility != "hidden" + + @visible.setter + def visible(self, new_value: bool) -> None: + if isinstance(new_value, bool): + self.styles.visibility = "visible" if new_value else "hidden" + elif new_value in VALID_VISIBILITY: + self.styles.visibility = new_value + else: + raise StyleValueError( + f"invalid value for visibility (received {new_value!r}, " + f"expected {friendly_list(VALID_VISIBILITY)})" + ) + @property def z(self) -> tuple[int, ...]: """Get the z index tuple for this node. diff --git a/src/textual/geometry.py b/src/textual/geometry.py index b9fcb2f52..004964ba9 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -415,6 +415,25 @@ class Region(NamedTuple): ) return new_region + def shrink(self, margin: Spacing) -> Region: + """Shrink a region by pushing each edge inwards. + + Args: + margin (Spacing): Defines how many cells to shrink the Region by at each edge. + + Returns: + Region: The new, smaller region. + """ + _clamp = clamp + top, right, bottom, left = margin + x, y, width, height = self + return Region( + x=_clamp(x + left, 0, width), + y=_clamp(y + top, 0, height), + width=_clamp(width - left - right, 0, width), + height=_clamp(height - top - bottom, 0, height), + ) + def intersection(self, region: Region) -> Region: """Get that covers both regions. diff --git a/src/textual/layout.py b/src/textual/layout.py index 72f5e1422..8d5f88e58 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -55,6 +55,17 @@ class WidgetPlacement(NamedTuple): widget: Widget | None = None order: int = 0 + def apply_margin(self) -> "WidgetPlacement": + region, widget, order = self + styles = widget.styles + if styles.has_margin: + return WidgetPlacement( + region=region.shrink(styles.margin), + widget=widget, + order=order, + ) + return self + @rich.repr.auto class LayoutUpdate: @@ -199,6 +210,15 @@ class Layout(ABC): raise NoWidget(f"No widget under screen coordinate ({x}, {y})") def get_style_at(self, x: int, y: int) -> Style: + """Get the Style at the given cell or Style.null() + + Args: + x (int): X position within the Layout + y (int): Y position within the Layout + + Returns: + Style: The Style at the cell (x, y) within the Layout + """ try: widget, region = self.get_widget_at(x, y) except NoWidget: @@ -217,6 +237,18 @@ class Layout(ABC): return Style.null() def get_widget_region(self, widget: Widget) -> Region: + """Get the Region of a Widget contained in this Layout. + + Args: + widget (Widget): The Widget in this layout you wish to know the Region of. + + Raises: + NoWidget: If the Widget is not contained in this Layout. + + Returns: + Region: The Region of the Widget. + + """ try: region, *_ = self.map[widget] except KeyError: @@ -270,7 +302,7 @@ class Layout(ABC): for widget, region, _order, clip in widget_regions: - if not widget.is_visual: + if not (widget.is_visual and widget.visible): continue lines = widget._get_lines() diff --git a/src/textual/layouts/dock.py b/src/textual/layouts/dock.py index 16b4f152b..281c53f5f 100644 --- a/src/textual/layouts/dock.py +++ b/src/textual/layouts/dock.py @@ -5,14 +5,10 @@ from collections import defaultdict from dataclasses import dataclass from typing import Iterable, TYPE_CHECKING, NamedTuple, Sequence - -from .. import log -from ..dom import DOMNode from .._layout_resolve import layout_resolve +from ..css.types import Edge from ..geometry import Offset, Region, Size from ..layout import Layout, WidgetPlacement -from ..layout_map import LayoutMap -from ..css.types import Edge from ..widget import Widget if sys.version_info >= (3, 8): @@ -20,12 +16,9 @@ if sys.version_info >= (3, 8): else: from typing_extensions import Literal - if TYPE_CHECKING: - from ..widget import Widget from ..view import View - DockEdge = Literal["top", "right", "bottom", "left"] diff --git a/src/textual/layouts/factory.py b/src/textual/layouts/factory.py index a6718f427..4953fb50a 100644 --- a/src/textual/layouts/factory.py +++ b/src/textual/layouts/factory.py @@ -1,19 +1,24 @@ -from __future__ import annotations +import sys from ..layout import Layout -from .dock import DockLayout -from .grid import GridLayout -from .vertical import VerticalLayout +from ..layouts.dock import DockLayout +from ..layouts.grid import GridLayout +from ..layouts.vertical import VerticalLayout + +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal + +LayoutName = Literal["dock", "grid", "vertical"] +LAYOUT_MAP = {"dock": DockLayout, "grid": GridLayout, "vertical": VerticalLayout} class MissingLayout(Exception): pass -LAYOUT_MAP = {"dock": DockLayout, "grid": GridLayout, "vertical": VerticalLayout} - - -def get_layout(name: str) -> Layout: +def get_layout(name: LayoutName) -> Layout: """Get a named layout object. Args: @@ -25,7 +30,8 @@ def get_layout(name: str) -> Layout: Returns: Layout: A layout object. """ + layout_class = LAYOUT_MAP.get(name) if layout_class is None: - raise MissingLayout("no layout called {name!r}") + raise MissingLayout(f"no layout called {name!r}, valid layouts") return layout_class() diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index f53127190..276cd417f 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -1,18 +1,16 @@ from __future__ import annotations +import sys from collections import defaultdict from dataclasses import dataclass -from operator import itemgetter -from logging import getLogger from itertools import cycle, product -import sys +from logging import getLogger +from operator import itemgetter 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 ..widget import Widget - if TYPE_CHECKING: from ..widget import Widget diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 247909c67..75a54df73 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -4,7 +4,6 @@ from typing import Iterable, TYPE_CHECKING from ..geometry import Offset, Region, Size, Spacing, SpacingDimensions from ..layout import Layout, WidgetPlacement -from ..widget import Widget from .._loop import loop_last if TYPE_CHECKING: diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 3a5590b0f..d932359d7 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -1,17 +1,17 @@ from __future__ import annotations - import rich.repr from rich.color import Color -from rich.console import Console, ConsoleOptions, RenderResult, RenderableType -from rich.segment import Segment, Segments +from rich.console import ConsoleOptions, RenderResult, RenderableType +from rich.segment import Segment from rich.style import Style, StyleType +from textual.reactive import Reactive from . import events -from .geometry import Offset from ._types import MessageTarget +from .geometry import Offset from .message import Message -from .widget import Reactive, Widget +from .widget import Widget @rich.repr.auto diff --git a/src/textual/view.py b/src/textual/view.py index d6cd03784..f6078969f 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -1,55 +1,26 @@ from __future__ import annotations -from itertools import chain -from typing import Callable, Iterable, ClassVar, TYPE_CHECKING +from typing import Callable, Iterable -from rich.console import RenderableType import rich.repr +from rich.console import RenderableType from rich.style import Style -from . import events -from . import errors -from . import log -from . import messages -from .layout import Layout, NoWidget, WidgetPlacement -from .layouts.factory import get_layout +from . import errors, events, messages from .geometry import Size, Offset, Region +from .layout import Layout, NoWidget, WidgetPlacement from .reactive import Reactive, watch - -from .widget import Widget, Widget - - -if TYPE_CHECKING: - from .app import App - - -class LayoutProperty: - def __get__(self, obj: View, objtype: type[View] | None = None) -> str: - return obj._layout.name - - def __set__(self, obj: View, layout: str | Layout) -> str: - if isinstance(layout, str): - new_layout = get_layout(layout) - else: - new_layout = layout - self._layout = new_layout - return self._layout.name +from .widget import Widget @rich.repr.auto class View(Widget): - STYLES = """ + layout: dock; docks: main=top; - """ def __init__(self, name: str | None = None, id: str | None = None) -> None: - - from .layouts.dock import DockLayout - - self._layout: Layout = DockLayout() - self.mouse_over: Widget | None = None self.widgets: set[Widget] = set() self._mouse_style: Style = Style() @@ -69,17 +40,34 @@ class View(Widget): cls.layout_factory = layout super().__init_subclass__(**kwargs) - layout = LayoutProperty() - background: Reactive[str] = Reactive("") scroll_x: Reactive[int] = Reactive(0) scroll_y: Reactive[int] = Reactive(0) virtual_size = Reactive(Size(0, 0)) async def watch_background(self, value: str) -> None: - self._layout.background = value + self.layout.background = value self.app.refresh() + @property + def layout(self) -> Layout: + """Convenience property for accessing ``view.styles.layout``. + + Returns: The Layout associated with this view + """ + return self.styles.layout + + @layout.setter + def layout(self, new_value: Layout) -> None: + """Convenience property setter for setting ``view.styles.layout``. + Args: + new_value: + + Returns: + None + """ + self.styles.layout = new_value + @property def scroll(self) -> Offset: return Offset(self.scroll_x, self.scroll_y) @@ -105,18 +93,23 @@ class View(Widget): return self.app.is_mounted(widget) def render(self) -> RenderableType: - return self._layout + return self.layout def get_offset(self, widget: Widget) -> Offset: - return self._layout.get_offset(widget) + return self.layout.get_offset(widget) def get_arrangement(self, size: Size, scroll: Offset) -> Iterable[WidgetPlacement]: cached_size, cached_scroll, arrangement = self._cached_arrangement if cached_size == size and cached_scroll == scroll: return arrangement - arrangement = list(self._layout.arrange(self, size, scroll)) - self._cached_arrangement = (size, scroll, arrangement) - return arrangement + + placements = [ + placement.apply_margin() + for placement in self._layout.arrange(self, size, scroll) + ] + + self._cached_arrangement = (size, scroll, placements) + return placements async def handle_update(self, message: messages.Update) -> None: if self.is_root_view: @@ -124,7 +117,7 @@ class View(Widget): widget = message.widget assert isinstance(widget, Widget) - display_update = self._layout.update_widget(self.console, widget) + display_update = self.layout.update_widget(self.console, widget) if display_update is not None: self.app.display(display_update) @@ -141,7 +134,7 @@ class View(Widget): async def refresh_layout(self) -> None: self._cached_arrangement = (Size(), Offset(), []) try: - await self._layout.mount_all(self) + await self.layout.mount_all(self) if not self.is_root_view: await self.app.view.refresh_layout() return @@ -149,8 +142,8 @@ class View(Widget): if not self.size: return - hidden, shown, resized = self._layout.reflow(self, Size(*self.console.size)) - assert self._layout.map is not None + hidden, shown, resized = self.layout.reflow(self, Size(*self.console.size)) + assert self.layout.map is not None for widget in hidden: widget.post_message_no_wait(events.Hide(self)) @@ -160,7 +153,7 @@ class View(Widget): send_resize = shown send_resize.update(resized) - for widget, region, unclipped_region in self._layout: + for widget, region, unclipped_region in self.layout: widget._update_size(unclipped_region.size) if widget in send_resize: widget.post_message_no_wait( @@ -177,13 +170,13 @@ class View(Widget): event.stop() def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: - return self._layout.get_widget_at(x, y) + return self.layout.get_widget_at(x, y) def get_style_at(self, x: int, y: int) -> Style: - return self._layout.get_style_at(x, y) + return self.layout.get_style_at(x, y) def get_widget_region(self, widget: Widget) -> Region: - return self._layout.get_widget_region(widget) + return self.layout.get_widget_region(widget) async def on_mount(self, event: events.Mount) -> None: async def watch_background(value: str) -> None: @@ -192,8 +185,8 @@ class View(Widget): watch(self.app, "background", watch_background) async def on_idle(self, event: events.Idle) -> None: - if self._layout.check_update(): - self._layout.reset_update() + if self.layout.check_update(): + self.layout.reset_update() await self.refresh_layout() async def _on_mouse_move(self, event: events.MouseMove) -> None: diff --git a/src/textual/views/_dock_view.py b/src/textual/views/_dock_view.py index 842dae397..22d55c93b 100644 --- a/src/textual/views/_dock_view.py +++ b/src/textual/views/_dock_view.py @@ -29,8 +29,8 @@ class DockView(View): ) -> None: dock = Dock(edge, widgets, z) - assert isinstance(self._layout, DockLayout) - self._layout.docks.append(dock) + assert isinstance(self.layout, DockLayout) + self.layout.docks.append(dock) for widget in widgets: if id is not None: widget._id = id @@ -60,8 +60,8 @@ class DockView(View): grid = GridLayout(gap=gap, gutter=gutter, align=align) view = View(layout=grid, id=id, name=name) dock = Dock(edge, (view,), z) - assert isinstance(self._layout, DockLayout) - self._layout.docks.append(dock) + assert isinstance(self.layout, DockLayout) + self.layout.docks.append(dock) if size is not do_not_set: view.layout_size = cast(Optional[int], size) if name is None: diff --git a/src/textual/views/_window_view.py b/src/textual/views/_window_view.py index 92edce916..56ee65e63 100644 --- a/src/textual/views/_window_view.py +++ b/src/textual/views/_window_view.py @@ -32,7 +32,7 @@ class WindowView(View, layout=VerticalLayout): super().__init__(name=name, layout=layout) async def update(self, widget: Widget | RenderableType) -> None: - layout = self._layout + layout = self.layout assert isinstance(layout, VerticalLayout) layout.clear() self.widget = widget if isinstance(widget, Widget) else Static(widget) @@ -46,7 +46,7 @@ class WindowView(View, layout=VerticalLayout): await self.emit(WindowChange(self)) async def handle_layout(self, message: messages.Layout) -> None: - self._layout.require_update() + self.layout.require_update() message.stop() self.refresh() @@ -54,11 +54,11 @@ class WindowView(View, layout=VerticalLayout): await self.emit(WindowChange(self)) async def watch_scroll_x(self, value: int) -> None: - self._layout.require_update() + self.layout.require_update() self.refresh() async def watch_scroll_y(self, value: int) -> None: - self._layout.require_update() + self.layout.require_update() self.refresh() async def on_resize(self, event: events.Resize) -> None: diff --git a/src/textual/widget.py b/src/textual/widget.py index 51c7e97ab..7b44f5540 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1,6 +1,6 @@ from __future__ import annotations -from logging import PercentStyle, getLogger +from logging import getLogger from typing import ( Any, Awaitable, @@ -11,35 +11,29 @@ from typing import ( NamedTuple, cast, ) -import rich.repr -from rich import box -from rich.align import Align -from rich.console import Console, RenderableType, ConsoleOptions -from rich.measure import Measurement -from rich.panel import Panel -from rich.padding import Padding -from rich.pretty import Pretty -from rich.segment import Segment -from rich.style import Style, StyleType -from rich.styled import Styled -from rich.text import Text, TextType -from . import events +import rich.repr +from rich.align import Align +from rich.console import Console, RenderableType +from rich.padding import Padding +from rich.style import Style +from rich.styled import Styled +from rich.text import Text + from . import errors +from . import events from ._animator import BoundAnimator -from ._border import Border, BORDER_STYLES +from ._border import Border from ._callback import invoke -from .blank import Blank -from .dom import DOMNode from ._context import active_app -from .geometry import Size, Spacing, SpacingDimensions +from ._types import Lines +from .dom import DOMNode +from .geometry import Size, Spacing from .message import Message from .messages import Layout, Update -from .reactive import Reactive, watch -from ._types import Lines +from .reactive import watch if TYPE_CHECKING: - from .app import App from .view import View log = getLogger("rich") @@ -163,34 +157,26 @@ class Widget(DOMNode): styles = self.styles parent_text_style = self.parent.text_style - if styles.visibility == "hidden": - renderable = Blank(parent_text_style) - else: - text_style = styles.text - renderable_text_style = parent_text_style + text_style - if renderable_text_style: - renderable = Styled(renderable, renderable_text_style) + text_style = styles.text + renderable_text_style = parent_text_style + text_style + if renderable_text_style: + renderable = Styled(renderable, renderable_text_style) - if styles.has_padding: - renderable = Padding( - renderable, styles.padding, style=renderable_text_style - ) + if styles.has_padding: + renderable = Padding( + renderable, styles.padding, style=renderable_text_style + ) - if styles.has_border: - renderable = Border( - renderable, styles.border, style=renderable_text_style - ) + if styles.has_border: + renderable = Border(renderable, styles.border, style=renderable_text_style) - if styles.has_margin: - renderable = Padding(renderable, styles.margin, style=parent_text_style) - - if styles.has_outline: - renderable = Border( - renderable, - styles.outline, - outline=True, - style=renderable_text_style, - ) + if styles.has_outline: + renderable = Border( + renderable, + styles.outline, + outline=True, + style=renderable_text_style, + ) return renderable diff --git a/src/textual/widgets/_placeholder.py b/src/textual/widgets/_placeholder.py index a0946b4fc..9fa95bf0d 100644 --- a/src/textual/widgets/_placeholder.py +++ b/src/textual/widgets/_placeholder.py @@ -10,7 +10,8 @@ import rich.repr from logging import getLogger from .. import events -from ..widget import Reactive, Widget +from ..reactive import Reactive +from ..widget import Widget log = getLogger("rich") diff --git a/src/textual/widgets/_scroll_view.py b/src/textual/widgets/_scroll_view.py index 210edf740..fd53c1203 100644 --- a/src/textual/widgets/_scroll_view.py +++ b/src/textual/widgets/_scroll_view.py @@ -93,13 +93,13 @@ class ScrollView(View): await self.window.update(renderable) async def on_mount(self, event: events.Mount) -> None: - assert isinstance(self._layout, GridLayout) - self._layout.place( + assert isinstance(self.layout, GridLayout) + self.layout.place( content=self.window, vscroll=self.vscroll, hscroll=self.hscroll, ) - await self._layout.mount_all(self) + await self.layout.mount_all(self) def home(self) -> None: self.x = self.y = 0 @@ -215,10 +215,10 @@ class ScrollView(View): self.vscroll.virtual_size = virtual_height self.vscroll.window_size = height - assert isinstance(self._layout, GridLayout) + assert isinstance(self.layout, GridLayout) - vscroll_change = self._layout.show_column("vscroll", virtual_height > height) - hscroll_change = self._layout.show_row("hscroll", virtual_width > width) + vscroll_change = self.layout.show_column("vscroll", virtual_height > height) + hscroll_change = self.layout.show_row("hscroll", virtual_width > width) if hscroll_change or vscroll_change: self.refresh(layout=True) diff --git a/tests/layouts/test_factory.py b/tests/layouts/test_factory.py new file mode 100644 index 000000000..8935556a5 --- /dev/null +++ b/tests/layouts/test_factory.py @@ -0,0 +1,14 @@ +import pytest + +from textual.layouts.dock import DockLayout +from textual.layouts.factory import get_layout, MissingLayout + + +def test_get_layout_valid_layout(): + layout = get_layout("dock") + assert type(layout) is DockLayout + + +def test_get_layout_invalid_layout(): + with pytest.raises(MissingLayout): + get_layout("invalid") diff --git a/tests/test_css_parse.py b/tests/test_css_parse.py index f17a0c3e9..ad03fb34d 100644 --- a/tests/test_css_parse.py +++ b/tests/test_css_parse.py @@ -4,6 +4,27 @@ from rich.color import Color, ColorType from textual.css.scalar import Scalar, Unit from textual.css.stylesheet import Stylesheet, StylesheetParseError from textual.css.transition import Transition +from textual.layouts.dock import DockLayout + + +class TestParseLayout: + def test_valid_layout_name(self): + css = "#some-widget { layout: dock; }" + + stylesheet = Stylesheet() + stylesheet.parse(css) + + styles = stylesheet.rules[0].styles + assert isinstance(styles.layout, DockLayout) + + def test_invalid_layout_name(self): + css = "#some-widget { layout: invalidlayout; }" + + stylesheet = Stylesheet() + with pytest.raises(StylesheetParseError) as ex: + stylesheet.parse(css) + + assert ex.value.errors is not None class TestParseText: diff --git a/tests/test_geometry.py b/tests/test_geometry.py index ffaded07b..2bfbc695d 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -1,6 +1,6 @@ import pytest -from textual.geometry import clamp, Offset, Size, Region +from textual.geometry import clamp, Offset, Size, Region, Spacing def test_dimensions_region(): @@ -173,6 +173,12 @@ def test_clip(): assert Region(10, 10, 20, 30).clip(20, 25) == Region(10, 10, 10, 15) +def test_region_shrink(): + margin = Spacing(top=1, right=2, bottom=3, left=4) + region = Region(x=10, y=10, width=50, height=50) + assert region.shrink(margin) == Region(x=14, y=11, width=44, height=46) + + def test_region_intersection(): assert Region(0, 0, 100, 50).intersection(Region(10, 10, 10, 10)) == Region( 10, 10, 10, 10 diff --git a/tests/test_view.py b/tests/test_view.py new file mode 100644 index 000000000..08db68d69 --- /dev/null +++ b/tests/test_view.py @@ -0,0 +1,18 @@ +import pytest + +from textual.layouts.dock import DockLayout +from textual.layouts.grid import GridLayout +from textual.layouts.vertical import VerticalLayout +from textual.view import View + + +@pytest.mark.parametrize("layout_name, layout_type", [ + ["dock", DockLayout], + ["grid", GridLayout], + ["vertical", VerticalLayout], +]) +def test_view_layout_get_and_set(layout_name, layout_type): + view = View() + view.layout = layout_name + assert type(view.layout) is layout_type + assert view.styles.layout is view.layout diff --git a/tests/test_widget.py b/tests/test_widget.py new file mode 100644 index 000000000..7eee43a54 --- /dev/null +++ b/tests/test_widget.py @@ -0,0 +1,28 @@ +import pytest + +from textual.css.errors import StyleValueError +from textual.widget import Widget + + +@pytest.mark.parametrize( + "set_val, get_val, style_str", [ + [True, True, "visible"], + [False, False, "hidden"], + ["hidden", False, "hidden"], + ["visible", True, "visible"], + ]) +def test_widget_set_visible_true(set_val, get_val, style_str): + widget = Widget() + widget.visible = set_val + + assert widget.visible is get_val + assert widget.styles.visibility == style_str + + +def test_widget_set_visible_invalid_string(): + widget = Widget() + + with pytest.raises(StyleValueError): + widget.visible = "nope! no widget for me!" + + assert widget.visible