Merge branch 'css' of github.com:willmcgugan/textual into style-property-docs

This commit is contained in:
Darren Burns
2022-01-27 12:43:16 +00:00
27 changed files with 484 additions and 225 deletions

49
examples/dev_sandbox.css Normal file
View File

@@ -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;
}

32
examples/dev_sandbox.py Normal file
View File

@@ -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")

View File

@@ -1,50 +1,44 @@
from __future__ import annotations from __future__ import annotations
import os
import os
import asyncio import asyncio
from typing import Any, Callable, ClassVar, Iterable, Type, TypeVar
import warnings import warnings
from typing import Any, Callable, Iterable, Type, TypeVar
from rich.control import Control
import rich.repr import rich.repr
from rich.screen import Screen
from rich.console import Console, RenderableType from rich.console import Console, RenderableType
from rich.control import Control
from rich.measure import Measurement from rich.measure import Measurement
from rich.screen import Screen
from rich.traceback import Traceback from rich.traceback import Traceback
from . import events
from . import actions from . import actions
from .dom import DOMNode from . import events
from ._animator import Animator
from .binding import Bindings, NoBinding
from .geometry import Offset, Region, Size
from . import log from . import log
from . import messages
from ._animator import Animator
from ._callback import invoke from ._callback import invoke
from ._context import active_app from ._context import active_app
from .css.stylesheet import Stylesheet, StylesheetParseError, StylesheetError
from ._event_broker import extract_handler_actions, NoHandler 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 .driver import Driver
from .file_monitor import FileMonitor from .file_monitor import FileMonitor
from .geometry import Offset, Region, Size
from .layouts.dock import DockLayout, Dock from .layouts.dock import DockLayout, Dock
from ._linux_driver import LinuxDriver
from ._types import MessageTarget
from . import messages
from .message_pump import MessagePump from .message_pump import MessagePump
from ._profile import timer from .reactive import Reactive
from .view import View from .view import View
from .views import DockView from .views import DockView
from .widget import Widget, Reactive from .widget import Widget
# asyncio will warn against resources not being cleared # asyncio will warn against resources not being cleared
warnings.simplefilter("always", ResourceWarning) warnings.simplefilter("always", ResourceWarning)
LayoutDefinition = "dict[str, Any]" LayoutDefinition = "dict[str, Any]"
ViewType = TypeVar("ViewType", bound=View) ViewType = TypeVar("ViewType", bound=View)
@@ -96,7 +90,6 @@ class App(DOMNode):
self._screen = screen self._screen = screen
self.driver_class = driver_class or LinuxDriver self.driver_class = driver_class or LinuxDriver
self._title = title self._title = title
self._layout = DockLayout()
self._view_stack: list[View] = [] self._view_stack: list[View] = []
self.focused: Widget | None = None self.focused: Widget | None = None
@@ -638,17 +631,11 @@ class App(DOMNode):
if __name__ == "__main__": if __name__ == "__main__":
import asyncio import asyncio
from logging import FileHandler
from rich.panel import Panel
from .widgets import Header from .widgets import Header
from .widgets import Footer from .widgets import Footer
from .widgets import Placeholder from .widgets import Placeholder
from .scrollbar import ScrollBar
from rich.markdown import Markdown
# from .widgets.scroll_view import ScrollView # from .widgets.scroll_view import ScrollView
@@ -672,7 +659,6 @@ if __name__ == "__main__":
self.show_bar = not self.show_bar self.show_bar = not self.show_bar
async def on_mount(self, event: events.Mount) -> None: async def on_mount(self, event: events.Mount) -> None:
view = await self.push_view(DockView()) view = await self.push_view(DockView())
header = Header() header = Header()

View File

@@ -15,6 +15,9 @@ import rich.repr
from rich.color import Color from rich.color import Color
from rich.style import Style from rich.style import Style
from ._error_tools import friendly_list
from .constants import NULL_SPACING
from .errors import StyleTypeError, StyleValueError
from .scalar import ( from .scalar import (
get_symbols, get_symbols,
UNIT_SYMBOL, UNIT_SYMBOL,
@@ -23,16 +26,15 @@ from .scalar import (
ScalarOffset, ScalarOffset,
ScalarParseError, ScalarParseError,
) )
from ..geometry import Spacing, SpacingDimensions
from .constants import NULL_SPACING
from .errors import StyleTypeError, StyleValueError
from .transition import Transition from .transition import Transition
from ._error_tools import friendly_list from ..geometry import Spacing, SpacingDimensions
if TYPE_CHECKING: if TYPE_CHECKING:
from ..layout import Layout
from .styles import Styles from .styles import Styles
from .styles import DockGroup from .styles import DockGroup
from .._box import BoxType from .._box import BoxType
from ..layouts.factory import LayoutName
class ScalarProperty: class ScalarProperty:
@@ -367,7 +369,7 @@ class SpacingProperty:
ValueError: When the value is malformed, e.g. a ``tuple`` with a length that is ValueError: When the value is malformed, e.g. a ``tuple`` with a length that is
not 1, 2, or 4. not 1, 2, or 4.
""" """
obj.refresh(True) obj.refresh(layout=True)
spacing = Spacing.unpack(spacing) spacing = Spacing.unpack(spacing)
setattr(obj, self._internal_name, spacing) setattr(obj, self._internal_name, spacing)
@@ -398,7 +400,7 @@ class DocksProperty:
obj (Styles): The ``Styles`` object. obj (Styles): The ``Styles`` object.
docks (Iterable[DockGroup]): Iterable of DockGroups docks (Iterable[DockGroup]): Iterable of DockGroups
""" """
obj.refresh(True) obj.refresh(layout=True)
if docks is None: if docks is None:
obj._rule_docks = None obj._rule_docks = None
else: else:
@@ -431,10 +433,43 @@ class DockProperty:
obj (Styles): The ``Styles`` object obj (Styles): The ``Styles`` object
spacing (str | None): The spacing to use. spacing (str | None): The spacing to use.
""" """
obj.refresh(True) obj.refresh(layout=True)
obj._rule_dock = spacing 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: class OffsetProperty:
"""Descriptor for getting and setting the offset property. """Descriptor for getting and setting the offset property.
Offset consists of two values, x and y, that a widget's position 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 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. 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): if isinstance(offset, ScalarOffset):
setattr(obj, self._internal_name, offset) setattr(obj, self._internal_name, offset)
return offset return offset
@@ -600,7 +635,7 @@ class NameProperty:
Raises: Raises:
StyleTypeError: If the value is not a ``str``. StyleTypeError: If the value is not a ``str``.
""" """
obj.refresh(True) obj.refresh(layout=True)
if not isinstance(name, str): if not isinstance(name, str):
raise StyleTypeError(f"{self._name} must be a str") raise StyleTypeError(f"{self._name} must be a str")
setattr(obj, self._internal_name, name) setattr(obj, self._internal_name, name)
@@ -619,7 +654,7 @@ class NameListProperty:
def __set__( def __set__(
self, obj: Styles, names: str | tuple[str] | None = None self, obj: Styles, names: str | tuple[str] | None = None
) -> str | tuple[str] | None: ) -> str | tuple[str] | None:
obj.refresh(True) obj.refresh(layout=True)
names_value: tuple[str, ...] | None = None names_value: tuple[str, ...] | None = None
if isinstance(names, str): if isinstance(names, str):
names_value = tuple(name.strip().lower() for name in names.split(" ")) names_value = tuple(name.strip().lower() for name in names.split(" "))

View File

@@ -6,18 +6,19 @@ import rich.repr
from rich.color import Color from rich.color import Color
from rich.style import Style from rich.style import Style
from ._error_tools import friendly_list
from .constants import VALID_BORDER, VALID_EDGE, VALID_DISPLAY, VALID_VISIBILITY from .constants import VALID_BORDER, VALID_EDGE, VALID_DISPLAY, VALID_VISIBILITY
from .errors import DeclarationError 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 .model import Declaration
from .scalar import Scalar, ScalarOffset, Unit, ScalarError from .scalar import Scalar, ScalarOffset, Unit, ScalarError
from .styles import DockGroup, Styles from .styles import DockGroup, Styles
from .types import Edge, Display, Visibility
from .tokenize import Token from .tokenize import Token
from .transition import Transition 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: class StylesBuilder:
@@ -59,7 +60,7 @@ class StylesBuilder:
self.styles.important.add(rule_name) self.styles.important.add(rule_name)
try: try:
process_method(declaration.name, tokens, important) process_method(declaration.name, tokens, important)
except DeclarationError as error: except DeclarationError:
raise raise
except Exception as error: except Exception as error:
self.error(declaration.name, declaration.token, str(error)) self.error(declaration.name, declaration.token, str(error))
@@ -128,7 +129,7 @@ class StylesBuilder:
append = space.append append = space.append
for token in tokens: for token in tokens:
(token_name, value, _, _, location) = token (token_name, value, _, _, location) = token
if token_name == "scalar": if token_name in ("number", "scalar"):
try: try:
append(int(value)) append(int(value))
except ValueError: except ValueError:
@@ -288,7 +289,16 @@ class StylesBuilder:
if len(tokens) != 1: if len(tokens) != 1:
self.error(name, tokens[0], "unexpected tokens in declaration") self.error(name, tokens[0], "unexpected tokens in declaration")
else: 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: def process_text(self, name: str, tokens: list[Token], important: bool) -> None:
style_definition = " ".join(token.value for token in tokens) style_definition = " ".join(token.value for token in tokens)

View File

@@ -5,14 +5,15 @@ import rich.repr
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum 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 .styles import Styles
from .tokenize import Token from .tokenize import Token
from .types import Specificity3 from .types import Specificity3
if TYPE_CHECKING:
from ..dom import DOMNode
class SelectorType(Enum): class SelectorType(Enum):
UNIVERSAL = 1 UNIVERSAL = 1

View File

@@ -1,32 +1,14 @@
from __future__ import annotations from __future__ import annotations
import sys
from dataclasses import dataclass, field from dataclasses import dataclass, field
from functools import lru_cache from functools import lru_cache
import sys
from typing import Any, Iterable, NamedTuple, TYPE_CHECKING from typing import Any, Iterable, NamedTuple, TYPE_CHECKING
from rich import print
from rich.color import Color
import rich.repr import rich.repr
from rich.color import Color
from rich.style import Style 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 ( from ._style_properties import (
BorderProperty, BorderProperty,
BoxProperty, BoxProperty,
@@ -42,11 +24,26 @@ from ._style_properties import (
StyleProperty, StyleProperty,
StyleFlagsProperty, StyleFlagsProperty,
TransitionsProperty, 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 Display, Edge, Visibility
from .types import Specificity3, Specificity4
from .. import log
from .._animator import Animation, EasingFunction
from ..geometry import Spacing
if TYPE_CHECKING: if TYPE_CHECKING:
from ..layout import Layout
from ..dom import DOMNode from ..dom import DOMNode
@@ -64,7 +61,7 @@ class Styles:
_rule_display: Display | None = None _rule_display: Display | None = None
_rule_visibility: Visibility | 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_color: Color | None = None
_rule_text_background: Color | None = None _rule_text_background: Color | None = None
@@ -104,7 +101,7 @@ class Styles:
display = StringEnumProperty(VALID_DISPLAY, "block") display = StringEnumProperty(VALID_DISPLAY, "block")
visibility = StringEnumProperty(VALID_VISIBILITY, "visible") visibility = StringEnumProperty(VALID_VISIBILITY, "visible")
layout = StringEnumProperty(VALID_LAYOUT, "dock") layout = LayoutProperty()
text = StyleProperty() text = StyleProperty()
text_color = ColorProperty() text_color = ColorProperty()
@@ -273,6 +270,7 @@ class Styles:
) )
else: else:
setattr(styles, f"_rule_{key}", value) setattr(styles, f"_rule_{key}", value)
if self.node is not None: if self.node is not None:
self.node.on_style_change() self.node.on_style_change()
@@ -423,7 +421,7 @@ if __name__ == "__main__":
styles.dock = "bar" styles.dock = "bar"
styles.layers = "foo bar" styles.layers = "foo bar"
from rich import inspect, print from rich import print
print(styles.text_style) print(styles.text_style)
print(styles.text) print(styles.text)

View File

@@ -108,18 +108,37 @@ class Stylesheet:
yield selector_set.specificity yield selector_set.specificity
def apply(self, node: DOMNode) -> None: 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: dict[str, list[tuple[Specificity4, object]]]
rule_attributes = defaultdict(list) rule_attributes = defaultdict(list)
_check_rule = self._check_rule _check_rule = self._check_rule
# TODO: The line below breaks inline styles and animations
node.styles.reset() node.styles.reset()
# Get the default node CSS rules # Collect default node CSS rules
for key, default_specificity, value in node._default_rules: for key, default_specificity, value in node._default_rules:
rule_attributes[key].append((default_specificity, value)) 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 rule in self.rules:
for specificity in _check_rule(rule, node): for specificity in _check_rule(rule, node):
for key, rule_specificity, value in rule.styles.extract_rules( for key, rule_specificity, value in rule.styles.extract_rules(
@@ -127,6 +146,7 @@ class Stylesheet:
): ):
rule_attributes[key].append((rule_specificity, value)) 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) get_first_item = itemgetter(0)
node_rules = [ node_rules = [
(name, max(specificity_rules, key=get_first_item)[1]) (name, max(specificity_rules, key=get_first_item)[1])

View File

@@ -1,24 +1,22 @@
from __future__ import annotations 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 import rich.repr
from rich.highlighter import ReprHighlighter
from rich.pretty import Pretty from rich.pretty import Pretty
from rich.style import Style from rich.style import Style
from rich.tree import Tree from rich.tree import Tree
from ._node_list import NodeList
from .css._error_tools import friendly_list 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.errors import StyleValueError
from .css.styles import Styles from .css.styles import Styles
from .message_pump import MessagePump from .message_pump import MessagePump
from ._node_list import NodeList
if TYPE_CHECKING: if TYPE_CHECKING:
from .css.query import DOMQuery from .css.query import DOMQuery
from .widget import Widget
class NoParent(Exception): class NoParent(Exception):
@@ -160,6 +158,22 @@ class DOMNode(MessagePump):
f"expected {friendly_list(VALID_DISPLAY)})", 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 @property
def z(self) -> tuple[int, ...]: def z(self) -> tuple[int, ...]:
"""Get the z index tuple for this node. """Get the z index tuple for this node.

View File

@@ -415,6 +415,25 @@ class Region(NamedTuple):
) )
return new_region 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: def intersection(self, region: Region) -> Region:
"""Get that covers both regions. """Get that covers both regions.

View File

@@ -55,6 +55,17 @@ class WidgetPlacement(NamedTuple):
widget: Widget | None = None widget: Widget | None = None
order: int = 0 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 @rich.repr.auto
class LayoutUpdate: class LayoutUpdate:
@@ -199,6 +210,15 @@ class Layout(ABC):
raise NoWidget(f"No widget under screen coordinate ({x}, {y})") raise NoWidget(f"No widget under screen coordinate ({x}, {y})")
def get_style_at(self, x: int, y: int) -> Style: 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: try:
widget, region = self.get_widget_at(x, y) widget, region = self.get_widget_at(x, y)
except NoWidget: except NoWidget:
@@ -217,6 +237,18 @@ class Layout(ABC):
return Style.null() return Style.null()
def get_widget_region(self, widget: Widget) -> Region: 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: try:
region, *_ = self.map[widget] region, *_ = self.map[widget]
except KeyError: except KeyError:
@@ -270,7 +302,7 @@ class Layout(ABC):
for widget, region, _order, clip in widget_regions: for widget, region, _order, clip in widget_regions:
if not widget.is_visual: if not (widget.is_visual and widget.visible):
continue continue
lines = widget._get_lines() lines = widget._get_lines()

View File

@@ -5,14 +5,10 @@ from collections import defaultdict
from dataclasses import dataclass from dataclasses import dataclass
from typing import Iterable, TYPE_CHECKING, NamedTuple, Sequence from typing import Iterable, TYPE_CHECKING, NamedTuple, Sequence
from .. import log
from ..dom import DOMNode
from .._layout_resolve import layout_resolve from .._layout_resolve import layout_resolve
from ..css.types import Edge
from ..geometry import Offset, Region, Size from ..geometry import Offset, Region, Size
from ..layout import Layout, WidgetPlacement from ..layout import Layout, WidgetPlacement
from ..layout_map import LayoutMap
from ..css.types import Edge
from ..widget import Widget from ..widget import Widget
if sys.version_info >= (3, 8): if sys.version_info >= (3, 8):
@@ -20,12 +16,9 @@ if sys.version_info >= (3, 8):
else: else:
from typing_extensions import Literal from typing_extensions import Literal
if TYPE_CHECKING: if TYPE_CHECKING:
from ..widget import Widget
from ..view import View from ..view import View
DockEdge = Literal["top", "right", "bottom", "left"] DockEdge = Literal["top", "right", "bottom", "left"]

View File

@@ -1,19 +1,24 @@
from __future__ import annotations import sys
from ..layout import Layout from ..layout import Layout
from .dock import DockLayout from ..layouts.dock import DockLayout
from .grid import GridLayout from ..layouts.grid import GridLayout
from .vertical import VerticalLayout 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): class MissingLayout(Exception):
pass pass
LAYOUT_MAP = {"dock": DockLayout, "grid": GridLayout, "vertical": VerticalLayout} def get_layout(name: LayoutName) -> Layout:
def get_layout(name: str) -> Layout:
"""Get a named layout object. """Get a named layout object.
Args: Args:
@@ -25,7 +30,8 @@ def get_layout(name: str) -> Layout:
Returns: Returns:
Layout: A layout object. Layout: A layout object.
""" """
layout_class = LAYOUT_MAP.get(name) layout_class = LAYOUT_MAP.get(name)
if layout_class is None: 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() return layout_class()

View File

@@ -1,18 +1,16 @@
from __future__ import annotations from __future__ import annotations
import sys
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass from dataclasses import dataclass
from operator import itemgetter
from logging import getLogger
from itertools import cycle, product from itertools import cycle, product
import sys from logging import getLogger
from operator import itemgetter
from typing import Iterable, NamedTuple, TYPE_CHECKING from typing import Iterable, NamedTuple, TYPE_CHECKING
from .._layout_resolve import layout_resolve from .._layout_resolve import layout_resolve
from ..geometry import Size, Offset, Region from ..geometry import Size, Offset, Region
from ..layout import Layout, WidgetPlacement from ..layout import Layout, WidgetPlacement
from ..widget import Widget
if TYPE_CHECKING: if TYPE_CHECKING:
from ..widget import Widget from ..widget import Widget

View File

@@ -4,7 +4,6 @@ from typing import Iterable, TYPE_CHECKING
from ..geometry import Offset, Region, Size, Spacing, SpacingDimensions from ..geometry import Offset, Region, Size, Spacing, SpacingDimensions
from ..layout import Layout, WidgetPlacement from ..layout import Layout, WidgetPlacement
from ..widget import Widget
from .._loop import loop_last from .._loop import loop_last
if TYPE_CHECKING: if TYPE_CHECKING:

View File

@@ -1,17 +1,17 @@
from __future__ import annotations from __future__ import annotations
import rich.repr import rich.repr
from rich.color import Color from rich.color import Color
from rich.console import Console, ConsoleOptions, RenderResult, RenderableType from rich.console import ConsoleOptions, RenderResult, RenderableType
from rich.segment import Segment, Segments from rich.segment import Segment
from rich.style import Style, StyleType from rich.style import Style, StyleType
from textual.reactive import Reactive
from . import events from . import events
from .geometry import Offset
from ._types import MessageTarget from ._types import MessageTarget
from .geometry import Offset
from .message import Message from .message import Message
from .widget import Reactive, Widget from .widget import Widget
@rich.repr.auto @rich.repr.auto

View File

@@ -1,55 +1,26 @@
from __future__ import annotations from __future__ import annotations
from itertools import chain from typing import Callable, Iterable
from typing import Callable, Iterable, ClassVar, TYPE_CHECKING
from rich.console import RenderableType
import rich.repr import rich.repr
from rich.console import RenderableType
from rich.style import Style from rich.style import Style
from . import events from . import errors, events, messages
from . import errors
from . import log
from . import messages
from .layout import Layout, NoWidget, WidgetPlacement
from .layouts.factory import get_layout
from .geometry import Size, Offset, Region from .geometry import Size, Offset, Region
from .layout import Layout, NoWidget, WidgetPlacement
from .reactive import Reactive, watch from .reactive import Reactive, watch
from .widget import Widget
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
@rich.repr.auto @rich.repr.auto
class View(Widget): class View(Widget):
STYLES = """ STYLES = """
layout: dock;
docks: main=top; docks: main=top;
""" """
def __init__(self, name: str | None = None, id: str | None = None) -> None: 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.mouse_over: Widget | None = None
self.widgets: set[Widget] = set() self.widgets: set[Widget] = set()
self._mouse_style: Style = Style() self._mouse_style: Style = Style()
@@ -69,17 +40,34 @@ class View(Widget):
cls.layout_factory = layout cls.layout_factory = layout
super().__init_subclass__(**kwargs) super().__init_subclass__(**kwargs)
layout = LayoutProperty()
background: Reactive[str] = Reactive("") background: Reactive[str] = Reactive("")
scroll_x: Reactive[int] = Reactive(0) scroll_x: Reactive[int] = Reactive(0)
scroll_y: Reactive[int] = Reactive(0) scroll_y: Reactive[int] = Reactive(0)
virtual_size = Reactive(Size(0, 0)) virtual_size = Reactive(Size(0, 0))
async def watch_background(self, value: str) -> None: async def watch_background(self, value: str) -> None:
self._layout.background = value self.layout.background = value
self.app.refresh() 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 @property
def scroll(self) -> Offset: def scroll(self) -> Offset:
return Offset(self.scroll_x, self.scroll_y) return Offset(self.scroll_x, self.scroll_y)
@@ -105,18 +93,23 @@ class View(Widget):
return self.app.is_mounted(widget) return self.app.is_mounted(widget)
def render(self) -> RenderableType: def render(self) -> RenderableType:
return self._layout return self.layout
def get_offset(self, widget: Widget) -> Offset: 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]: def get_arrangement(self, size: Size, scroll: Offset) -> Iterable[WidgetPlacement]:
cached_size, cached_scroll, arrangement = self._cached_arrangement cached_size, cached_scroll, arrangement = self._cached_arrangement
if cached_size == size and cached_scroll == scroll: if cached_size == size and cached_scroll == scroll:
return arrangement return arrangement
arrangement = list(self._layout.arrange(self, size, scroll))
self._cached_arrangement = (size, scroll, arrangement) placements = [
return arrangement 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: async def handle_update(self, message: messages.Update) -> None:
if self.is_root_view: if self.is_root_view:
@@ -124,7 +117,7 @@ class View(Widget):
widget = message.widget widget = message.widget
assert isinstance(widget, 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: if display_update is not None:
self.app.display(display_update) self.app.display(display_update)
@@ -141,7 +134,7 @@ class View(Widget):
async def refresh_layout(self) -> None: async def refresh_layout(self) -> None:
self._cached_arrangement = (Size(), Offset(), []) self._cached_arrangement = (Size(), Offset(), [])
try: try:
await self._layout.mount_all(self) await self.layout.mount_all(self)
if not self.is_root_view: if not self.is_root_view:
await self.app.view.refresh_layout() await self.app.view.refresh_layout()
return return
@@ -149,8 +142,8 @@ class View(Widget):
if not self.size: if not self.size:
return return
hidden, shown, resized = self._layout.reflow(self, Size(*self.console.size)) hidden, shown, resized = self.layout.reflow(self, Size(*self.console.size))
assert self._layout.map is not None assert self.layout.map is not None
for widget in hidden: for widget in hidden:
widget.post_message_no_wait(events.Hide(self)) widget.post_message_no_wait(events.Hide(self))
@@ -160,7 +153,7 @@ class View(Widget):
send_resize = shown send_resize = shown
send_resize.update(resized) 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) widget._update_size(unclipped_region.size)
if widget in send_resize: if widget in send_resize:
widget.post_message_no_wait( widget.post_message_no_wait(
@@ -177,13 +170,13 @@ class View(Widget):
event.stop() event.stop()
def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: 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: 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: 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 on_mount(self, event: events.Mount) -> None:
async def watch_background(value: str) -> None: async def watch_background(value: str) -> None:
@@ -192,8 +185,8 @@ class View(Widget):
watch(self.app, "background", watch_background) watch(self.app, "background", watch_background)
async def on_idle(self, event: events.Idle) -> None: async def on_idle(self, event: events.Idle) -> None:
if self._layout.check_update(): if self.layout.check_update():
self._layout.reset_update() self.layout.reset_update()
await self.refresh_layout() await self.refresh_layout()
async def _on_mouse_move(self, event: events.MouseMove) -> None: async def _on_mouse_move(self, event: events.MouseMove) -> None:

View File

@@ -29,8 +29,8 @@ class DockView(View):
) -> None: ) -> None:
dock = Dock(edge, widgets, z) dock = Dock(edge, widgets, z)
assert isinstance(self._layout, DockLayout) assert isinstance(self.layout, DockLayout)
self._layout.docks.append(dock) self.layout.docks.append(dock)
for widget in widgets: for widget in widgets:
if id is not None: if id is not None:
widget._id = id widget._id = id
@@ -60,8 +60,8 @@ class DockView(View):
grid = GridLayout(gap=gap, gutter=gutter, align=align) grid = GridLayout(gap=gap, gutter=gutter, align=align)
view = View(layout=grid, id=id, name=name) view = View(layout=grid, id=id, name=name)
dock = Dock(edge, (view,), z) dock = Dock(edge, (view,), z)
assert isinstance(self._layout, DockLayout) assert isinstance(self.layout, DockLayout)
self._layout.docks.append(dock) self.layout.docks.append(dock)
if size is not do_not_set: if size is not do_not_set:
view.layout_size = cast(Optional[int], size) view.layout_size = cast(Optional[int], size)
if name is None: if name is None:

View File

@@ -32,7 +32,7 @@ class WindowView(View, layout=VerticalLayout):
super().__init__(name=name, layout=layout) super().__init__(name=name, layout=layout)
async def update(self, widget: Widget | RenderableType) -> None: async def update(self, widget: Widget | RenderableType) -> None:
layout = self._layout layout = self.layout
assert isinstance(layout, VerticalLayout) assert isinstance(layout, VerticalLayout)
layout.clear() layout.clear()
self.widget = widget if isinstance(widget, Widget) else Static(widget) self.widget = widget if isinstance(widget, Widget) else Static(widget)
@@ -46,7 +46,7 @@ class WindowView(View, layout=VerticalLayout):
await self.emit(WindowChange(self)) await self.emit(WindowChange(self))
async def handle_layout(self, message: messages.Layout) -> None: async def handle_layout(self, message: messages.Layout) -> None:
self._layout.require_update() self.layout.require_update()
message.stop() message.stop()
self.refresh() self.refresh()
@@ -54,11 +54,11 @@ class WindowView(View, layout=VerticalLayout):
await self.emit(WindowChange(self)) await self.emit(WindowChange(self))
async def watch_scroll_x(self, value: int) -> None: async def watch_scroll_x(self, value: int) -> None:
self._layout.require_update() self.layout.require_update()
self.refresh() self.refresh()
async def watch_scroll_y(self, value: int) -> None: async def watch_scroll_y(self, value: int) -> None:
self._layout.require_update() self.layout.require_update()
self.refresh() self.refresh()
async def on_resize(self, event: events.Resize) -> None: async def on_resize(self, event: events.Resize) -> None:

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from logging import PercentStyle, getLogger from logging import getLogger
from typing import ( from typing import (
Any, Any,
Awaitable, Awaitable,
@@ -11,35 +11,29 @@ from typing import (
NamedTuple, NamedTuple,
cast, 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 errors
from . import events
from ._animator import BoundAnimator from ._animator import BoundAnimator
from ._border import Border, BORDER_STYLES from ._border import Border
from ._callback import invoke from ._callback import invoke
from .blank import Blank
from .dom import DOMNode
from ._context import active_app 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 .message import Message
from .messages import Layout, Update from .messages import Layout, Update
from .reactive import Reactive, watch from .reactive import watch
from ._types import Lines
if TYPE_CHECKING: if TYPE_CHECKING:
from .app import App
from .view import View from .view import View
log = getLogger("rich") log = getLogger("rich")
@@ -163,9 +157,6 @@ class Widget(DOMNode):
styles = self.styles styles = self.styles
parent_text_style = self.parent.text_style parent_text_style = self.parent.text_style
if styles.visibility == "hidden":
renderable = Blank(parent_text_style)
else:
text_style = styles.text text_style = styles.text
renderable_text_style = parent_text_style + text_style renderable_text_style = parent_text_style + text_style
if renderable_text_style: if renderable_text_style:
@@ -177,12 +168,7 @@ class Widget(DOMNode):
) )
if styles.has_border: if styles.has_border:
renderable = Border( renderable = Border(renderable, styles.border, style=renderable_text_style)
renderable, styles.border, style=renderable_text_style
)
if styles.has_margin:
renderable = Padding(renderable, styles.margin, style=parent_text_style)
if styles.has_outline: if styles.has_outline:
renderable = Border( renderable = Border(

View File

@@ -10,7 +10,8 @@ import rich.repr
from logging import getLogger from logging import getLogger
from .. import events from .. import events
from ..widget import Reactive, Widget from ..reactive import Reactive
from ..widget import Widget
log = getLogger("rich") log = getLogger("rich")

View File

@@ -93,13 +93,13 @@ class ScrollView(View):
await self.window.update(renderable) await self.window.update(renderable)
async def on_mount(self, event: events.Mount) -> None: async def on_mount(self, event: events.Mount) -> None:
assert isinstance(self._layout, GridLayout) assert isinstance(self.layout, GridLayout)
self._layout.place( self.layout.place(
content=self.window, content=self.window,
vscroll=self.vscroll, vscroll=self.vscroll,
hscroll=self.hscroll, hscroll=self.hscroll,
) )
await self._layout.mount_all(self) await self.layout.mount_all(self)
def home(self) -> None: def home(self) -> None:
self.x = self.y = 0 self.x = self.y = 0
@@ -215,10 +215,10 @@ class ScrollView(View):
self.vscroll.virtual_size = virtual_height self.vscroll.virtual_size = virtual_height
self.vscroll.window_size = 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) vscroll_change = self.layout.show_column("vscroll", virtual_height > height)
hscroll_change = self._layout.show_row("hscroll", virtual_width > width) hscroll_change = self.layout.show_row("hscroll", virtual_width > width)
if hscroll_change or vscroll_change: if hscroll_change or vscroll_change:
self.refresh(layout=True) self.refresh(layout=True)

View File

@@ -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")

View File

@@ -4,6 +4,27 @@ from rich.color import Color, ColorType
from textual.css.scalar import Scalar, Unit from textual.css.scalar import Scalar, Unit
from textual.css.stylesheet import Stylesheet, StylesheetParseError from textual.css.stylesheet import Stylesheet, StylesheetParseError
from textual.css.transition import Transition 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: class TestParseText:

View File

@@ -1,6 +1,6 @@
import pytest import pytest
from textual.geometry import clamp, Offset, Size, Region from textual.geometry import clamp, Offset, Size, Region, Spacing
def test_dimensions_region(): 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) 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(): def test_region_intersection():
assert Region(0, 0, 100, 50).intersection(Region(10, 10, 10, 10)) == Region( assert Region(0, 0, 100, 50).intersection(Region(10, 10, 10, 10)) == Region(
10, 10, 10, 10 10, 10, 10, 10

18
tests/test_view.py Normal file
View File

@@ -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

28
tests/test_widget.py Normal file
View File

@@ -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