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
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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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.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:

View File

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

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