From c98e1b96049369f6af013a133f204ae0a286f2c7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 27 Jul 2022 12:22:25 +0100 Subject: [PATCH 01/16] layers and docks --- sandbox/will/basic.css | 6 +- sandbox/will/dock.py | 51 +++++++++++ sandbox/will/pred.py | 50 +++++++++++ src/textual/_arrange.py | 126 +++++++++++++++++++++++++++ src/textual/_compositor.py | 49 +++++++---- src/textual/_layout.py | 15 +++- src/textual/_partition.py | 33 +++++++ src/textual/box_model.py | 2 +- src/textual/color.py | 15 ++-- src/textual/css/_help_text.py | 24 ++--- src/textual/css/_style_properties.py | 37 +++++--- src/textual/css/_styles_builder.py | 10 ++- src/textual/css/styles.py | 10 ++- src/textual/css/types.py | 1 + src/textual/dom.py | 24 ++--- src/textual/geometry.py | 18 ++++ src/textual/layouts/dock.py | 16 ++-- src/textual/layouts/horizontal.py | 7 +- src/textual/layouts/vertical.py | 12 +-- src/textual/widget.py | 31 +++++-- src/textual/widgets/_static.py | 6 ++ 21 files changed, 447 insertions(+), 96 deletions(-) create mode 100644 sandbox/will/dock.py create mode 100644 sandbox/will/pred.py create mode 100644 src/textual/_arrange.py create mode 100644 src/textual/_partition.py diff --git a/sandbox/will/basic.css b/sandbox/will/basic.css index 76d2f689d..dedc7d161 100644 --- a/sandbox/will/basic.css +++ b/sandbox/will/basic.css @@ -15,7 +15,7 @@ App > Screen { layout: dock; docks: side=left/1; - background: $surfaceX; + background: $surface; color: $text-surface; } @@ -59,7 +59,7 @@ DataTable { } #sidebar .content { - background: $surface; + background: $panel-darken-2; color: $text-surface; border-right: wide $background; content-align: center middle; @@ -224,7 +224,7 @@ Success { height:auto; box-sizing: border-box; background: $success; - color: $text-success; + color: $text-success-fade-1; border-top: hkey $success-darken-2; border-bottom: hkey $success-darken-2; diff --git a/sandbox/will/dock.py b/sandbox/will/dock.py new file mode 100644 index 000000000..f9eaac67c --- /dev/null +++ b/sandbox/will/dock.py @@ -0,0 +1,51 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class DockApp(App): + def compose(self) -> ComposeResult: + + self.screen.styles.layers = "base sidebar" + + header = Static("Header", id="header") + header.styles.dock = "top" + header.styles.height = "3" + + header.styles.background = "blue" + header.styles.color = "white" + header.styles.margin = 0 + header.styles.align_horizontal = "center" + + # header.styles.layer = "base" + + header.styles.box_sizing = "border-box" + + yield header + + footer = Static("Footer") + footer.styles.dock = "bottom" + footer.styles.height = 1 + footer.styles.background = "green" + footer.styles.color = "white" + + yield footer + + sidebar = Static("Sidebar", id="sidebar") + sidebar.styles.dock = "right" + sidebar.styles.width = 20 + sidebar.styles.height = "100%" + sidebar.styles.background = "magenta" + # sidebar.styles.layer = "sidebar" + + yield sidebar + + for n, color in zip(range(5), ["red", "green", "blue", "yellow", "magenta"]): + thing = Static(f"Thing {n}", id=f"#thing{n}") + thing.styles.background = f"{color} 20%" + thing.styles.height = 5 + yield thing + + +app = DockApp() +if __name__ == "__main__": + app.run() diff --git a/sandbox/will/pred.py b/sandbox/will/pred.py new file mode 100644 index 000000000..95f8cd50f --- /dev/null +++ b/sandbox/will/pred.py @@ -0,0 +1,50 @@ +def partition_will(pred, values): + if not values: + return [], [] + if len(values) == 1: + return ([], values) if pred(values[0]) else (values, []) + values = sorted(values, key=pred) + lower = 0 + upper = len(values) - 1 + index = (lower + upper) // 2 + while True: + value = pred(values[index]) + if value and not pred(values[index - 1]): + return values[:index], values[index:] + if value: + upper = index + else: + lower = index + + index = (lower + upper) // 2 + + +def partition_more_iter(pred, iterable): + """ + Returns a 2-tuple of iterables derived from the input iterable. + The first yields the items that have ``pred(item) == False``. + The second yields the items that have ``pred(item) == True``. + + >>> is_odd = lambda x: x % 2 != 0 + >>> iterable = range(10) + >>> even_items, odd_items = partition(is_odd, iterable) + >>> list(even_items), list(odd_items) + ([0, 2, 4, 6, 8], [1, 3, 5, 7, 9]) + + If *pred* is None, :func:`bool` is used. + + >>> iterable = [0, 1, False, True, '', ' '] + >>> false_items, true_items = partition(None, iterable) + >>> list(false_items), list(true_items) + ([0, False, ''], [1, True, ' ']) + + """ + if pred is None: + pred = bool + + evaluations = ((pred(x), x) for x in iterable) + t1, t2 = tee(evaluations) + return ( + (x for (cond, x) in t1 if not cond), + (x for (cond, x) in t2 if cond), + ) diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py new file mode 100644 index 000000000..537371912 --- /dev/null +++ b/src/textual/_arrange.py @@ -0,0 +1,126 @@ +from __future__ import annotations + + +from fractions import Fraction +from typing import TYPE_CHECKING + +from .geometry import Region, Size, Spacing +from ._layout import ArrangeResult, WidgetPlacement +from ._partition import partition + + +if TYPE_CHECKING: + from ._layout import ArrangeResult + from .widget import Widget + + +def arrange(widget: Widget, size: Size, viewport: Size) -> ArrangeResult: + display_children = [child for child in widget.children if child.display] + + arrange_widgets: set[Widget] = set() + + dock_layers: dict[str, list[Widget]] = {} + for child in display_children: + dock_layers.setdefault(child.styles.layer or "default", []).append(child) + + width, height = size + + placements: list[WidgetPlacement] = [] + add_placement = placements.append + region = size.region + + _WidgetPlacement = WidgetPlacement + + top_z = 2**32 - 1 + + scroll_spacing = Spacing() + + for widgets in dock_layers.values(): + + dock_widgets, layout_widgets = partition( + (lambda widget: not widget.styles.dock), widgets + ) + + arrange_widgets.update(dock_widgets) + top = right = bottom = left = 0 + + for dock_widget in dock_widgets: + edge = dock_widget.styles.dock + + ( + widget_width_fraction, + widget_height_fraction, + margin, + ) = dock_widget.get_box_model( + size, + viewport, + Fraction(size.height if edge in ("top", "bottom") else size.width), + ) + + widget_width = int(widget_width_fraction) + margin.width + widget_height = int(widget_height_fraction) + margin.height + + align_offset = dock_widget.styles.align_size( + (widget_width, widget_height), size + ) + + if edge == "bottom": + dock_region = Region( + 0, height - widget_height, widget_width, widget_height + ) + bottom = max(bottom, dock_region.height) + elif edge == "top": + dock_region = Region(0, 0, widget_width, widget_height) + top = max(top, dock_region.height) + elif edge == "left": + dock_region = Region(0, 0, widget_width, widget_height) + left = max(left, dock_region.width) + elif edge == "right": + dock_region = Region( + width - widget_width, 0, widget_width, widget_height + ) + right = max(right, dock_region.width) + + dock_region = dock_region.shrink(margin).translate(align_offset) + add_placement(_WidgetPlacement(dock_region, dock_widget, top_z, True)) + + dock_spacing = Spacing(top, right, bottom, left) + region = size.region.shrink(dock_spacing) + layout_placements, _layout_widgets, spacing = widget.layout.arrange( + widget, layout_widgets, region.size + ) + if _layout_widgets: + scroll_spacing = scroll_spacing.grow_maximum(dock_spacing) + arrange_widgets.update(_layout_widgets) + placement_offset = region.offset + if placement_offset: + layout_placements = [ + _WidgetPlacement(_region + placement_offset, widget, order, fixed) + for _region, widget, order, fixed in layout_placements + ] + + placements.extend(layout_placements) + + result = ArrangeResult(placements, arrange_widgets, scroll_spacing) + return result + + # dock_spacing = Spacing(top, right, bottom, left) + # region = region.shrink(dock_spacing) + + # placements, placement_widgets, spacing = widget.layout.arrange( + # widget, layout_widgets, region.size + # ) + # dock_spacing += spacing + + # placement_offset = region.offset + # if placement_offset: + # placements = [ + # _WidgetPlacement(_region + placement_offset, widget, order, fixed) + # for _region, widget, order, fixed in placements + # ] + + # return ArrangeResult( + # (dock_placements + placements), + # placement_widgets.union(layout_widgets), + # dock_spacing, + # ) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index b44d83190..72b309d89 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -26,7 +26,7 @@ from rich.segment import Segment from rich.style import Style from . import errors -from .geometry import Region, Offset, Size +from .geometry import Region, Offset, Size, Spacing from ._cells import cell_len from ._profile import timer @@ -314,8 +314,7 @@ class Compositor: root (Widget): Top level widget. Returns: - map[dict[Widget, RenderRegion], Size]: A mapping of widget on to render region - and the "virtual size" (scrollable region) + tuple[CompositorMap, set[Widget]]: Compositor map and set of widgets. """ ORIGIN = Offset(0, 0) @@ -364,27 +363,43 @@ class Compositor: if widget.is_container: # Arrange the layout - placements, arranged_widgets = widget._arrange(child_region.size) + placements, arranged_widgets, spacing = widget._arrange( + child_region.size + ) widgets.update(arranged_widgets) placements = sorted(placements, key=get_order) # An offset added to all placements - placement_offset = ( - container_region.offset + layout_offset - widget.scroll_offset - ) + placement_offset = container_region.offset + layout_offset + placement_scroll_offset = placement_offset - widget.scroll_offset + + _layers = widget.layers + layers_to_index = { + layer_name: index for index, layer_name in enumerate(_layers) + } + get_layer_index = layers_to_index.get # Add all the widgets - for sub_region, sub_widget, z in placements: + for sub_region, sub_widget, z, fixed in placements: # Combine regions with children to calculate the "virtual size" - total_region = total_region.union(sub_region) - if sub_widget is not None: - add_widget( - sub_widget, - sub_region, - sub_region + placement_offset, - order + (z,), - sub_clip, - ) + if fixed: + widget_region = sub_region + placement_offset + else: + total_region = total_region.union(sub_region.grow(spacing)) + widget_region = sub_region + placement_scroll_offset + + if sub_widget is None: + continue + + widget_order = order + (get_layer_index(sub_widget.layer, 0), z) + + add_widget( + sub_widget, + sub_region, + widget_region, + widget_order, + sub_clip, + ) # Add any scrollbars for chrome_widget, chrome_region in widget._arrange_scrollbars( diff --git a/src/textual/_layout.py b/src/textual/_layout.py index b4c98f0b3..b8e13d663 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -5,7 +5,7 @@ import sys from typing import ClassVar, NamedTuple, TYPE_CHECKING -from .geometry import Region, Size +from .geometry import Region, Size, Spacing if sys.version_info >= (3, 10): from typing import TypeAlias @@ -16,7 +16,13 @@ else: # pragma: no cover if TYPE_CHECKING: from .widget import Widget -ArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget]]" + +class ArrangeResult(NamedTuple): + """The result of an arrange operation.""" + + placements: list[WidgetPlacement] + widgets: set[Widget] + spacing: Spacing = Spacing() class WidgetPlacement(NamedTuple): @@ -25,6 +31,7 @@ class WidgetPlacement(NamedTuple): region: Region widget: Widget | None = None # A widget of None means empty space order: int = 0 + fixed: bool = False class Layout(ABC): @@ -36,7 +43,9 @@ class Layout(ABC): return f"<{self.name}>" @abstractmethod - def arrange(self, parent: Widget, size: Size) -> ArrangeResult: + def arrange( + self, parent: Widget, children: list[Widget], size: Size + ) -> ArrangeResult: """Generate a layout map that defines where on the screen the widgets will be drawn. Args: diff --git a/src/textual/_partition.py b/src/textual/_partition.py new file mode 100644 index 000000000..3469a3240 --- /dev/null +++ b/src/textual/_partition.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from typing import Callable, Iterable, TypeVar + + +T = TypeVar("T") + + +def partition( + pred: Callable[[T], bool], iterable: Iterable[T] +) -> tuple[list[T], list[T]]: + """Partition a sequence in to two list from a given predicate. The first list will contain + the values where the predicate is False, the second list will contain the remaining values. + + Args: + pred (Callable[[T], bool]): A callable that returns True or False for a given value. + iterable (Iterable[T]): In Iterable of values. + + Returns: + tuple[list[T], list[T]]: A list of values where the predicate is False, and a list + where the predicate is True. + """ + + result: tuple[list[T], list[T]] = ([], []) + appends = (result[0].append, result[1].append) + + for value in iterable: + appends[pred(value)](value) + return result + + +if __name__ == "__main__": + print(partition((lambda n: bool(n % 2)), list(range(20)))) diff --git a/src/textual/box_model.py b/src/textual/box_model.py index 0acbfe134..3d7a9a6cd 100644 --- a/src/textual/box_model.py +++ b/src/textual/box_model.py @@ -94,7 +94,7 @@ def get_box_model( else: # Explicit height set content_height = styles.height.resolve_dimension( - sizing_container, viewport, fraction_unit + sizing_container - styles.margin.totals, viewport, fraction_unit ) if is_border_box: content_height -= gutter.height diff --git a/src/textual/color.py b/src/textual/color.py index 2647ba80b..813d6bcc6 100644 --- a/src/textual/color.py +++ b/src/textual/color.py @@ -20,6 +20,8 @@ from typing import Callable, NamedTuple import rich.repr from rich.color import Color as RichColor +from rich.color import ColorType +from rich.color_triplet import ColorTriplet from rich.style import Style from rich.text import Text @@ -30,6 +32,9 @@ from ._color_constants import COLOR_NAME_TO_RGB from .geometry import clamp +_TRUECOLOR = ColorType.TRUECOLOR + + class HLS(NamedTuple): """A color in HLS format.""" @@ -132,11 +137,10 @@ class Color(NamedTuple): def __rich__(self) -> Text: """A Rich method to show the color.""" - r, g, b, _ = self return Text( f" {self!r} ", style=Style.from_color( - self.get_contrast_text().rich_color, RichColor.from_rgb(r, g, b) + self.get_contrast_text().rich_color, self.rich_color ), ) @@ -161,9 +165,10 @@ class Color(NamedTuple): @property def rich_color(self) -> RichColor: """This color encoded in Rich's Color class.""" - # TODO: This isn't cheap as I'd like - cache in a LRUCache ? r, g, b, _a = self - return RichColor.from_rgb(r, g, b) + return RichColor( + f"#{r:02X}{g:02X}{b:02X}", _TRUECOLOR, None, ColorTriplet(r, g, b) + ) @property def normalized(self) -> tuple[float, float, float]: @@ -374,7 +379,7 @@ class Color(NamedTuple): Returns: Color: New color. """ - return self.darken(-amount).clamped + return self.darken(-amount) @lru_cache(maxsize=1024) def get_contrast_text(self, alpha=0.95) -> Color: diff --git a/src/textual/css/_help_text.py b/src/textual/css/_help_text.py index 553b03231..220add7dc 100644 --- a/src/textual/css/_help_text.py +++ b/src/textual/css/_help_text.py @@ -503,31 +503,19 @@ def dock_property_help_text(property_name: str, context: StylingContext) -> Help return HelpText( summary=f"Invalid value for [i]{property_name}[/] property", bullets=[ - Bullet("The value must be one of the defined docks"), + Bullet("The value must be one of 'top', 'right', 'bottom' or 'left'"), *ContextSpecificBullets( inline=[ Bullet( - "Attach a widget to a dock declared on the parent", - examples=[ - Example( - f'widget.styles.dock = "left" [dim] # assumes parent widget has declared left dock[/]' - ) - ], + "The 'dock' rule aligns a widget relative to the screen.", + examples=[Example(f'header.styles.dock = "top"')], ) ], css=[ Bullet( - "Define a dock using the [i]docks[/] property", - examples=[ - Example("docks: [u]lhs[/]=left/2;"), - ], - ), - Bullet( - "Then attach a widget to a defined dock using the [i]dock[/] property", - examples=[ - Example("dock: [scope.key][u]lhs[/][/];"), - ], - ), + "The 'dock' rule aligns a widget relative to the screen.", + examples=[Example(f"dock: top")], + ) ], ).get_by_context(context), ], diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 101125bf8..2f285d52f 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -39,6 +39,7 @@ from .scalar import ( Scalar, ScalarOffset, ScalarParseError, + percentage_string_to_float, ) from .transition import Transition from ..geometry import Spacing, SpacingDimensions, clamp @@ -47,7 +48,7 @@ if TYPE_CHECKING: from .._layout import Layout from .styles import DockGroup, Styles, StylesBase -from .types import EdgeType, AlignHorizontal, AlignVertical +from .types import DockEdge, EdgeType, AlignHorizontal, AlignVertical BorderDefinition = ( "Sequence[tuple[EdgeType, str | Color] | None] | tuple[EdgeType, str | Color]" @@ -533,7 +534,9 @@ class DockProperty: the docks themselves, and where they are located on screen. """ - def __get__(self, obj: StylesBase, objtype: type[StylesBase] | None = None) -> str: + def __get__( + self, obj: StylesBase, objtype: type[StylesBase] | None = None + ) -> DockEdge: """Get the Dock property Args: @@ -543,7 +546,7 @@ class DockProperty: Returns: str: The dock name as a string, or "" if the rule is not set. """ - return obj.get_rule("dock", "_default") + return cast(DockEdge, obj.get_rule("dock", "")) def __set__(self, obj: Styles, dock_name: str | None): """Set the Dock property @@ -839,15 +842,25 @@ class ColorProperty: if obj.set_rule(self.name, color): obj.refresh(children=True) elif isinstance(color, str): - try: - parsed_color = Color.parse(color) - except ColorParseError as error: - raise StyleValueError( - f"Invalid color value '{color}'", - help_text=color_property_help_text( - self.name, context="inline", error=error - ), - ) + alpha = 1.0 + parsed_color = Color(255, 255, 255) + for token in color.split(): + if token.endswith("%"): + try: + alpha = percentage_string_to_float(token) + except ValueError: + raise StyleValueError(f"invalid percentage value '{token}'") + continue + try: + parsed_color = Color.parse(token) + except ColorParseError as error: + raise StyleValueError( + f"Invalid color value '{token}'", + help_text=color_property_help_text( + self.name, context="inline", error=error + ), + ) + parsed_color = parsed_color.with_alpha(alpha) if obj.set_rule(self.name, parsed_color): obj.refresh(children=True) else: diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index b0d390573..ba4bb1e67 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -619,14 +619,18 @@ class StylesBuilder: self.styles.text_style = style_definition def process_dock(self, name: str, tokens: list[Token]) -> None: + if not tokens: + return - if len(tokens) > 1: + if len(tokens) > 1 or tokens[0].value not in VALID_EDGE: self.error( name, - tokens[1], + tokens[0], dock_property_help_text(name, context="css"), ) - self.styles._rules["dock"] = tokens[0].value if tokens else "" + + dock = tokens[0].value + self.styles._rules["dock"] = dock def process_docks(self, name: str, tokens: list[Token]) -> None: def docks_error(name, token): diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 333f62d3c..7e21786bd 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -12,7 +12,7 @@ from rich.style import Style from .._animator import Animation, EasingFunction from ..color import Color -from ..geometry import Spacing +from ..geometry import Size, Offset, Spacing from ._style_properties import ( AlignProperty, BorderProperty, @@ -436,6 +436,14 @@ class StylesBase(ABC): offset_y = parent_height - height return offset_y + def align_size(self, child: tuple[int, int], parent: tuple[int, int]) -> Offset: + width, height = child + parent_width, parent_height = parent + return Offset( + self.align_width(width, parent_width), + self.align_height(height, parent_height), + ) + @rich.repr.auto @dataclass diff --git a/src/textual/css/types.py b/src/textual/css/types.py index 206705276..d21fb0cb9 100644 --- a/src/textual/css/types.py +++ b/src/textual/css/types.py @@ -12,6 +12,7 @@ else: Edge = Literal["top", "right", "bottom", "left"] +DockEdge = Literal["top", "right", "bottom", "left", ""] EdgeType = Literal[ "", "ascii", diff --git a/src/textual/dom.py b/src/textual/dom.py index 8c8fdadee..3bda0e690 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -347,9 +347,8 @@ class DOMNode(MessagePump): def text_style(self) -> Style: """Get the text style object. - A widget's style is influenced by its parent. For instance if a widgets background has an alpha, - then its parent's background color will show through. Additionally, widgets will inherit their - parent's text style (i.e. bold, italic etc). + A widget's style is influenced by its parent. for instance if a parent is bold, then + the child will also be bold. Returns: Style: Rich Style object. @@ -357,17 +356,18 @@ class DOMNode(MessagePump): # TODO: Feels like there may be opportunity for caching here. - style = Style() - for node in reversed(self.ancestors): - style += node.styles.text_style + style = sum( + [node.styles.text_style for node in reversed(self.ancestors)], start=Style() + ) return style @property def rich_style(self) -> Style: """Get a Rich Style object for this DOMNode.""" - (_, _), (background, color) = self.colors - style = Style.from_color(color.rich_color, background.rich_color) - style += self.text_style + _, (background, color) = self.colors + style = ( + Style.from_color(color.rich_color, background.rich_color) + self.text_style + ) return style @property @@ -386,7 +386,7 @@ class DOMNode(MessagePump): background += styles.background if styles.has_rule("color"): base_color = color - color += styles.color + color = styles.color return (base_background, base_color), (background, color) @property @@ -394,9 +394,9 @@ class DOMNode(MessagePump): """Get a list of Nodes by tracing ancestors all the way back to App.""" nodes: list[DOMNode] = [self] add_node = nodes.append - node = self + node: DOMNode = self while True: - node = node.parent + node = node._parent if node is None: break add_node(node) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 73498d8ee..620d2419b 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -883,5 +883,23 @@ class Spacing(NamedTuple): ) return NotImplemented + def grow_maximum(self, other: Spacing) -> Spacing: + """Grow spacing with a maximum. + + Args: + other (Spacing): Spacing object. + + Returns: + Spacing: New spacing were the values are maximum of the two values. + """ + top, right, bottom, left = self + other_top, other_right, other_bottom, other_left = other + return Spacing( + max(top, other_top), + max(right, other_right), + max(bottom, other_bottom), + max(left, other_left), + ) + NULL_OFFSET = Offset(0, 0) diff --git a/src/textual/layouts/dock.py b/src/textual/layouts/dock.py index e1ce2b7c2..5b39d477b 100644 --- a/src/textual/layouts/dock.py +++ b/src/textual/layouts/dock.py @@ -4,11 +4,11 @@ import sys from collections import defaultdict from dataclasses import dataclass from operator import attrgetter -from typing import Iterable, TYPE_CHECKING, NamedTuple, Sequence +from typing import TYPE_CHECKING, NamedTuple, Sequence from .._layout_resolve import layout_resolve from ..css.types import Edge -from ..geometry import Offset, Region, Size +from ..geometry import Region, Size from .._layout import ArrangeResult, Layout, WidgetPlacement from ..widget import Widget @@ -52,9 +52,9 @@ class DockLayout(Layout): def __repr__(self): return "" - def get_docks(self, parent: Widget) -> list[Dock]: + def get_docks(self, parent: Widget, children: list[Widget]) -> list[Dock]: groups: dict[str, list[Widget]] = defaultdict(list) - for child in parent.displayed_children: + for child in children: assert isinstance(child, Widget) groups[child.styles.dock].append(child) docks: list[Dock] = [] @@ -63,13 +63,15 @@ class DockLayout(Layout): append_dock(Dock(edge, groups[name], z)) return docks - def arrange(self, parent: Widget, size: Size) -> ArrangeResult: + def arrange( + self, parent: Widget, children: list[Widget], size: Size + ) -> ArrangeResult: width, height = size layout_region = Region(0, 0, width, height) layers: dict[int, Region] = defaultdict(lambda: layout_region) - docks = self.get_docks(parent) + docks = self.get_docks(parent, children) def make_dock_options(widget: Widget, edge: Edge) -> DockOptions: styles = widget.styles @@ -181,4 +183,4 @@ class DockLayout(Layout): layers[z] = region - return placements, arranged_widgets + return ArrangeResult(placements, arranged_widgets) diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index 251e93838..c2749b9ac 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -16,7 +16,9 @@ class HorizontalLayout(Layout): name = "horizontal" - def arrange(self, parent: Widget, size: Size) -> ArrangeResult: + def arrange( + self, parent: Widget, children: list[Widget], size: Size + ) -> ArrangeResult: placements: list[WidgetPlacement] = [] add_placement = placements.append @@ -24,7 +26,6 @@ class HorizontalLayout(Layout): x = max_width = max_height = Fraction(0) parent_size = parent.outer_size - children = list(parent.children) styles = [child.styles for child in children if child.styles.width is not None] total_fraction = sum( [int(style.width.value) for style in styles if style.width.is_fraction] @@ -33,7 +34,7 @@ class HorizontalLayout(Layout): box_models = [ widget.get_box_model(size, parent_size, fraction_unit) - for widget in cast("list[Widget]", parent.children) + for widget in cast("list[Widget]", children) ] margins = [ diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index f8ed4f152..93f14ff24 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -15,14 +15,15 @@ class VerticalLayout(Layout): name = "vertical" - def arrange(self, parent: Widget, size: Size) -> ArrangeResult: + def arrange( + self, parent: Widget, children: list[Widget], size: Size + ) -> ArrangeResult: placements: list[WidgetPlacement] = [] add_placement = placements.append parent_size = parent.outer_size - children = list(parent.children) styles = [child.styles for child in children if child.styles.height is not None] total_fraction = sum( [int(style.height.value) for style in styles if style.height.is_fraction] @@ -31,7 +32,7 @@ class VerticalLayout(Layout): box_models = [ widget.get_box_model(size, parent_size, fraction_unit) - for widget in parent.children + for widget in children ] margins = [ @@ -43,8 +44,7 @@ class VerticalLayout(Layout): y = Fraction(box_models[0].margin.top if box_models else 0) - displayed_children = cast("list[Widget]", parent.displayed_children) - for widget, box_model, margin in zip(displayed_children, box_models, margins): + for widget, box_model, margin in zip(children, box_models, margins): content_width, content_height, box_margin = box_model offset_x = ( widget.styles.align_width( @@ -60,4 +60,4 @@ class VerticalLayout(Layout): total_region = Region(0, 0, size.width, int(y)) add_placement(WidgetPlacement(total_region, None, 0)) - return placements, set(displayed_children) + return ArrangeResult(placements, set(children)) diff --git a/src/textual/widget.py b/src/textual/widget.py index 74ffa4884..e947b1171 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -10,6 +10,7 @@ from typing import ( Collection, Iterable, NamedTuple, + Tuple, ) import rich.repr @@ -24,7 +25,7 @@ from rich.text import Text from . import errors, events, messages from ._animator import BoundAnimator -from ._border import Border +from ._arrange import arrange from ._context import active_app from ._layout import ArrangeResult, Layout from ._segment_tools import line_crop @@ -36,8 +37,6 @@ from .geometry import Offset, Region, Size, Spacing, clamp from .layouts.vertical import VerticalLayout from .message import Message from .reactive import Reactive, watch -from .renderables.opacity import Opacity -from .renderables.tint import Tint if TYPE_CHECKING: from .app import App, ComposeResult @@ -116,7 +115,7 @@ class Widget(DOMNode): self._content_width_cache: tuple[object, int] = (None, 0) self._content_height_cache: tuple[object, int] = (None, 0) - self._arrangement: ArrangeResult | None = None + self._arrangement: Tuple[ArrangeResult, Spacing] | None = None self._arrangement_cache_key: tuple[int, Size] = (-1, Size()) self._styles_cache = StylesCache() @@ -146,14 +145,17 @@ class Widget(DOMNode): Returns: ArrangeResult: Widget locations. """ + arrange_cache_key = (self.children._updates, size) if ( self._arrangement is not None and arrange_cache_key == self._arrangement_cache_key ): return self._arrangement - self._arrangement = self.layout.arrange(self, size) self._arrangement_cache_key = (self.children._updates, size) + + self._arrangement = arrange(self, size, self.screen.size) + return self._arrangement def _clear_arrangement_cache(self) -> None: @@ -542,6 +544,25 @@ class Widget(DOMNode): """ return self.is_container + @property + def layer(self) -> str: + """Get the name of this widgets layer.""" + return self.styles.layer or "default" + + @property + def layers(self) -> tuple[str, ...]: + """Layers of from parent. + + Returns: + tuple[str, ...]: Tuple of layer names. + """ + for node in self.ancestors: + if not isinstance(node, Widget): + break + if node.styles.has_rule("layers"): + return node.styles.layers + return ("default",) + def _set_dirty(self, *regions: Region) -> None: """Set the Widget as 'dirty' (requiring re-paint). diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index 0e4c9b76a..7d617e81f 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -7,6 +7,12 @@ from ..widget import Widget class Static(Widget): + CSS = """ + Static { + height: auto; + } + """ + def __init__( self, renderable: RenderableType = "", From 06bc566fec924610f311a6c4924ee40c19808714 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 27 Jul 2022 15:16:48 +0100 Subject: [PATCH 02/16] fix basic --- sandbox/will/basic.css | 24 ++++----- sandbox/will/basic.py | 74 ++++++++++++++-------------- sandbox/will/dock.py | 3 +- src/textual/_arrange.py | 65 ++++++++++-------------- src/textual/_layout.py | 10 ++-- src/textual/_styles_cache.py | 12 +++-- src/textual/css/_style_properties.py | 9 ++-- src/textual/css/styles.py | 11 ++++- src/textual/css/stylesheet.py | 7 +++ src/textual/layouts/vertical.py | 2 +- src/textual/screen.py | 6 ++- 11 files changed, 114 insertions(+), 109 deletions(-) diff --git a/sandbox/will/basic.css b/sandbox/will/basic.css index dedc7d161..3c9043b6b 100644 --- a/sandbox/will/basic.css +++ b/sandbox/will/basic.css @@ -13,10 +13,15 @@ } App > Screen { - layout: dock; - docks: side=left/1; + background: $surface; color: $text-surface; + layers: sidebar; + + color: $text-background; + background: $background; + layout: vertical; + } DataTable { @@ -31,11 +36,12 @@ DataTable { #sidebar { color: $text-panel; background: $panel; - dock: side; + dock: left; width: 30; offset-x: -100%; - layout: dock; + transition: offset 500ms in_out_cubic; + layer: sidebar; } #sidebar.-active { @@ -71,14 +77,7 @@ DataTable { height: 1; content-align: center middle; - -} - -#content { - color: $text-background; - background: $background; - layout: vertical; - overflow-y: scroll; + dock: top; } @@ -168,6 +167,7 @@ Tweet.scroll-horizontal TweetBody { height: 1; content-align: center middle; + dock:bottom; } diff --git a/sandbox/will/basic.py b/sandbox/will/basic.py index c376d5c12..dee825f0e 100644 --- a/sandbox/will/basic.py +++ b/sandbox/will/basic.py @@ -1,9 +1,9 @@ from rich.console import RenderableType -from rich.style import Style + from rich.syntax import Syntax from rich.text import Text -from textual.app import App +from textual.app import App, ComposeResult from textual.reactive import Reactive from textual.widget import Widget from textual.widgets import Static, DataTable @@ -98,45 +98,45 @@ class BasicApp(App, css_path="basic.css"): """Bind keys here.""" self.bind("s", "toggle_class('#sidebar', '-active')") - def on_mount(self): - """Build layout here.""" - + def compose(self) -> ComposeResult: table = DataTable() self.scroll_to_target = Tweet(TweetBody()) - self.mount( - header=Static( - Text.from_markup( - "[b]This is a [u]Textual[/u] app, running in the terminal" - ), - ), - content=Widget( - Tweet(TweetBody()), - Widget( - Static(Syntax(CODE, "python"), classes="code"), - classes="scrollable", - ), - table, - Error(), - Tweet(TweetBody(), classes="scrollbar-size-custom"), - Warning(), - Tweet(TweetBody(), classes="scroll-horizontal"), - Success(), - Tweet(TweetBody(), classes="scroll-horizontal"), - Tweet(TweetBody(), classes="scroll-horizontal"), - Tweet(TweetBody(), classes="scroll-horizontal"), - Tweet(TweetBody(), classes="scroll-horizontal"), - Tweet(TweetBody(), classes="scroll-horizontal"), - ), - footer=Widget(), - sidebar=Widget( - Widget(classes="title"), - Widget(classes="user"), - OptionItem(), - OptionItem(), - OptionItem(), - Widget(classes="content"), + + yield Static( + Text.from_markup( + "[b]This is a [u]Textual[/u] app, running in the terminal" ), + id="header", ) + yield from ( + Tweet(TweetBody()), + Widget( + Static(Syntax(CODE, "python"), classes="code"), + classes="scrollable", + ), + table, + Error(), + Tweet(TweetBody(), classes="scrollbar-size-custom"), + Warning(), + Tweet(TweetBody(), classes="scroll-horizontal"), + Success(), + Tweet(TweetBody(), classes="scroll-horizontal"), + Tweet(TweetBody(), classes="scroll-horizontal"), + Tweet(TweetBody(), classes="scroll-horizontal"), + Tweet(TweetBody(), classes="scroll-horizontal"), + Tweet(TweetBody(), classes="scroll-horizontal"), + ) + yield Widget(id="footer") + yield Widget( + Widget(classes="title"), + Widget(classes="user"), + OptionItem(), + OptionItem(), + OptionItem(), + Widget(classes="content"), + id="sidebar", + ) + table.add_column("Foo", width=20) table.add_column("Bar", width=20) table.add_column("Baz", width=20) diff --git a/sandbox/will/dock.py b/sandbox/will/dock.py index f9eaac67c..7f358aa0a 100644 --- a/sandbox/will/dock.py +++ b/sandbox/will/dock.py @@ -41,8 +41,9 @@ class DockApp(App): for n, color in zip(range(5), ["red", "green", "blue", "yellow", "magenta"]): thing = Static(f"Thing {n}", id=f"#thing{n}") + thing.styles.border = ("heavy", "rgba(0,0,0,0.2)") thing.styles.background = f"{color} 20%" - thing.styles.height = 5 + thing.styles.height = 15 yield thing diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index 537371912..666b96e3e 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -5,16 +5,25 @@ from fractions import Fraction from typing import TYPE_CHECKING from .geometry import Region, Size, Spacing -from ._layout import ArrangeResult, WidgetPlacement +from ._layout import DockArrangeResult, WidgetPlacement from ._partition import partition if TYPE_CHECKING: - from ._layout import ArrangeResult from .widget import Widget -def arrange(widget: Widget, size: Size, viewport: Size) -> ArrangeResult: +def arrange(widget: Widget, size: Size, viewport: Size) -> DockArrangeResult: + """Arrange widgets by applying docks and calling layouts + + Args: + widget (Widget): The parent (container) widget. + size (Size): The size of the available area. + viewport (Size): The size of the viewport (terminal). + + Returns: + tuple[list[WidgetPlacement], set[Widget], Spacing]: Widget arrangement information. + """ display_children = [child for child in widget.children if child.display] arrange_widgets: set[Widget] = set() @@ -31,7 +40,8 @@ def arrange(widget: Widget, size: Size, viewport: Size) -> ArrangeResult: _WidgetPlacement = WidgetPlacement - top_z = 2**32 - 1 + # TODO: This is a bit of a fudge, need to ensure it is impossible for layouts to generate this value + top_z = 2**31 - 1 scroll_spacing = Spacing() @@ -47,23 +57,15 @@ def arrange(widget: Widget, size: Size, viewport: Size) -> ArrangeResult: for dock_widget in dock_widgets: edge = dock_widget.styles.dock - ( - widget_width_fraction, - widget_height_fraction, - margin, - ) = dock_widget.get_box_model( - size, - viewport, - Fraction(size.height if edge in ("top", "bottom") else size.width), + fraction_unit = Fraction( + size.height if edge in ("top", "bottom") else size.width ) + box_model = dock_widget.get_box_model(size, viewport, fraction_unit) + widget_width_fraction, widget_height_fraction, margin = box_model widget_width = int(widget_width_fraction) + margin.width widget_height = int(widget_height_fraction) + margin.height - align_offset = dock_widget.styles.align_size( - (widget_width, widget_height), size - ) - if edge == "bottom": dock_region = Region( 0, height - widget_height, widget_width, widget_height @@ -80,13 +82,18 @@ def arrange(widget: Widget, size: Size, viewport: Size) -> ArrangeResult: width - widget_width, 0, widget_width, widget_height ) right = max(right, dock_region.width) + else: + raise AssertionError("invalid value for edge") + align_offset = dock_widget.styles.align_size( + (widget_width, widget_height), size + ) dock_region = dock_region.shrink(margin).translate(align_offset) add_placement(_WidgetPlacement(dock_region, dock_widget, top_z, True)) dock_spacing = Spacing(top, right, bottom, left) region = size.region.shrink(dock_spacing) - layout_placements, _layout_widgets, spacing = widget.layout.arrange( + layout_placements, _layout_widgets = widget.layout.arrange( widget, layout_widgets, region.size ) if _layout_widgets: @@ -101,26 +108,4 @@ def arrange(widget: Widget, size: Size, viewport: Size) -> ArrangeResult: placements.extend(layout_placements) - result = ArrangeResult(placements, arrange_widgets, scroll_spacing) - return result - - # dock_spacing = Spacing(top, right, bottom, left) - # region = region.shrink(dock_spacing) - - # placements, placement_widgets, spacing = widget.layout.arrange( - # widget, layout_widgets, region.size - # ) - # dock_spacing += spacing - - # placement_offset = region.offset - # if placement_offset: - # placements = [ - # _WidgetPlacement(_region + placement_offset, widget, order, fixed) - # for _region, widget, order, fixed in placements - # ] - - # return ArrangeResult( - # (dock_placements + placements), - # placement_widgets.union(layout_widgets), - # dock_spacing, - # ) + return placements, arrange_widgets, scroll_spacing diff --git a/src/textual/_layout.py b/src/textual/_layout.py index b8e13d663..04c56e00f 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -17,12 +17,8 @@ if TYPE_CHECKING: from .widget import Widget -class ArrangeResult(NamedTuple): - """The result of an arrange operation.""" - - placements: list[WidgetPlacement] - widgets: set[Widget] - spacing: Spacing = Spacing() +ArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget]]" +DockArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget], Spacing]" class WidgetPlacement(NamedTuple): @@ -93,6 +89,6 @@ class Layout(ABC): if not widget.displayed_children: height = container.height else: - placements, widgets = widget._arrange(Size(width, container.height)) + placements, *_ = widget._arrange(Size(width, container.height)) height = max(placement.region.bottom for placement in placements) return height diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py index e9b467166..2d414f352 100644 --- a/src/textual/_styles_cache.py +++ b/src/textual/_styles_cache.py @@ -246,7 +246,9 @@ class StylesCache: line: Iterable[Segment] # Draw top or bottom borders (A) if (border_top and y == 0) or (border_bottom and y == height - 1): - border_color = border_top_color if y == 0 else border_bottom_color + border_color = background + ( + border_top_color if y == 0 else border_bottom_color + ) box_segments = get_box( border_top if y == 0 else border_bottom, inner, @@ -290,9 +292,9 @@ class StylesCache: if border_left or border_right: # Add left / right border - left_style = from_color(border_left_color.rich_color) + left_style = from_color((background + border_left_color).rich_color) left = get_box(border_left, inner, outer, left_style)[1][0] - right_style = from_color(border_right_color.rich_color) + right_style = from_color((background + border_right_color).rich_color) right = get_box(border_right, inner, outer, right_style)[1][2] if border_left and border_right: @@ -321,9 +323,9 @@ class StylesCache: elif outline_left or outline_right: # Lines in side outline - left_style = from_color(outline_left_color.rich_color) + left_style = from_color((background + outline_left_color).rich_color) left = get_box(outline_left, inner, outer, left_style)[1][0] - right_style = from_color(outline_right_color.rich_color) + right_style = from_color((background + outline_right_color).rich_color) right = get_box(outline_right, inner, outer, right_style)[1][2] line = line_trim(list(line), outline_left != "", outline_right != "") if outline_left and outline_right: diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 2f285d52f..0f38ae03e 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -802,8 +802,9 @@ class NameListProperty: class ColorProperty: """Descriptor for getting and setting color properties.""" - def __init__(self, default_color: Color | str) -> None: + def __init__(self, default_color: Color | str, background: bool = False) -> None: self._default_color = Color.parse(default_color) + self._is_background = background def __set_name__(self, owner: StylesBase, name: str) -> None: self.name = name @@ -837,10 +838,10 @@ class ColorProperty: _rich_traceback_omit = True if color is None: if obj.clear_rule(self.name): - obj.refresh(children=True) + obj.refresh(children=self._is_background) elif isinstance(color, Color): if obj.set_rule(self.name, color): - obj.refresh(children=True) + obj.refresh(children=self._is_background) elif isinstance(color, str): alpha = 1.0 parsed_color = Color(255, 255, 255) @@ -862,7 +863,7 @@ class ColorProperty: ) parsed_color = parsed_color.with_alpha(alpha) if obj.set_rule(self.name, parsed_color): - obj.refresh(children=True) + obj.refresh(children=self._is_background) else: raise StyleValueError(f"Invalid color value {color}") diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 7e21786bd..311903a0a 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -185,7 +185,7 @@ class StylesBase(ABC): layout = LayoutProperty() color = ColorProperty(Color(255, 255, 255)) - background = ColorProperty(Color(0, 0, 0, 0)) + background = ColorProperty(Color(0, 0, 0, 0), background=True) text_style = StyleFlagsProperty() opacity = FractionalProperty() @@ -437,6 +437,15 @@ class StylesBase(ABC): return offset_y def align_size(self, child: tuple[int, int], parent: tuple[int, int]) -> Offset: + """Align a size according to alignment rules. + + Args: + child (tuple[int, int]): The size of the child (width, height) + parent (tuple[int, int]): The size of the parent (width, height) + + Returns: + Offset: Offset required to align the child. + """ width, height = child parent_width, parent_height = parent return Offset( diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index c7bb8b543..784b16cdd 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -304,6 +304,8 @@ class Stylesheet: animate (bool, optional): Animate changed rules. Defaults to ``False``. """ + print(node) + # 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 @@ -405,7 +407,12 @@ class Stylesheet: else: # Not animated, so we apply the rules directly get_rule = rules.get + from ..screen import Screen + for key in modified_rule_keys: + if isinstance(node, Screen): + + print(node, key, get_rule(key)) setattr(base_styles, key, get_rule(key)) node.post_message_no_wait(messages.StylesUpdated(sender=node)) diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 93f14ff24..e896319a6 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -60,4 +60,4 @@ class VerticalLayout(Layout): total_region = Region(0, 0, size.width, int(y)) add_placement(WidgetPlacement(total_region, None, 0)) - return ArrangeResult(placements, set(children)) + return placements, set(children) diff --git a/src/textual/screen.py b/src/textual/screen.py index 7ad6d3e02..07d189bb1 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -117,7 +117,11 @@ class Screen(Widget): self._refresh_layout() self._layout_required = False self._dirty_widgets.clear() - elif self._dirty_widgets: + if self._repaint_required: + self._dirty_widgets.add(self) + self._repaint_required = False + + if self._dirty_widgets: self.update_timer.resume() def _on_update(self) -> None: From 4f69516d8daff2f4439802c582b69c055e3a7a49 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 27 Jul 2022 15:18:19 +0100 Subject: [PATCH 03/16] fix basic --- src/textual/css/_styles_builder.py | 24 ---- src/textual/css/constants.py | 2 +- src/textual/css/stylesheet.py | 2 - src/textual/layouts/dock.py | 186 ----------------------------- tests/layouts/test_factory.py | 6 +- 5 files changed, 4 insertions(+), 216 deletions(-) delete mode 100644 src/textual/layouts/dock.py diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index ba4bb1e67..8eee77d6a 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -632,30 +632,6 @@ class StylesBuilder: dock = tokens[0].value self.styles._rules["dock"] = dock - def process_docks(self, name: str, tokens: list[Token]) -> None: - def docks_error(name, token): - self.error(name, token, docks_property_help_text(name, context="css")) - - docks: list[DockGroup] = [] - for token in tokens: - if token.name == "key_value": - key, edge_name = token.value.split("=") - edge_name = edge_name.strip().lower() - edge_name, _, number = edge_name.partition("/") - z = 0 - if number: - if not number.isdigit(): - docks_error(name, token) - z = int(number) - if edge_name not in VALID_EDGE: - docks_error(name, token) - docks.append(DockGroup(key.strip(), cast(Edge, edge_name), z)) - elif token.name == "bar": - pass - else: - docks_error(name, token) - self.styles._rules["docks"] = tuple(docks + [DockGroup("_default", "top", 0)]) - def process_layer(self, name: str, tokens: list[Token]) -> None: if len(tokens) > 1: self.error(name, tokens[1], f"unexpected tokens in dock-edge declaration") diff --git a/src/textual/css/constants.py b/src/textual/css/constants.py index d8784c519..57cfc3201 100644 --- a/src/textual/css/constants.py +++ b/src/textual/css/constants.py @@ -32,7 +32,7 @@ VALID_BORDER: Final[set[EdgeType]] = { "wide", } VALID_EDGE: Final = {"top", "right", "bottom", "left"} -VALID_LAYOUT: Final = {"dock", "vertical", "horizontal"} +VALID_LAYOUT: Final = {"vertical", "horizontal"} VALID_BOX_SIZING: Final = {"border-box", "content-box"} VALID_OVERFLOW: Final = {"scroll", "hidden", "auto"} diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 784b16cdd..156409c3d 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -304,8 +304,6 @@ class Stylesheet: animate (bool, optional): Animate changed rules. Defaults to ``False``. """ - print(node) - # 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 diff --git a/src/textual/layouts/dock.py b/src/textual/layouts/dock.py deleted file mode 100644 index 5b39d477b..000000000 --- a/src/textual/layouts/dock.py +++ /dev/null @@ -1,186 +0,0 @@ -from __future__ import annotations - -import sys -from collections import defaultdict -from dataclasses import dataclass -from operator import attrgetter -from typing import TYPE_CHECKING, NamedTuple, Sequence - -from .._layout_resolve import layout_resolve -from ..css.types import Edge -from ..geometry import Region, Size -from .._layout import ArrangeResult, Layout, WidgetPlacement -from ..widget import Widget - -if sys.version_info >= (3, 8): - from typing import Literal -else: - from typing_extensions import Literal - -if TYPE_CHECKING: - from ..screen import Screen - -DockEdge = Literal["top", "right", "bottom", "left"] - - -@dataclass -class DockOptions: - size: int | None = None - fraction: int | None = 1 - min_size: int = 1 - - def __post_init__(self) -> None: - if self.size is None and self.fraction is None: - self.fraction = 1 - - -class Dock(NamedTuple): - edge: Edge - widgets: Sequence[Widget] - z: int = 0 - - -class DockLayout(Layout): - """Dock Widgets to edge of screen.""" - - name = "dock" - - def __init__(self) -> None: - super().__init__() - self._docks: list[Dock] | None = None - - def __repr__(self): - return "" - - def get_docks(self, parent: Widget, children: list[Widget]) -> list[Dock]: - groups: dict[str, list[Widget]] = defaultdict(list) - for child in children: - assert isinstance(child, Widget) - groups[child.styles.dock].append(child) - docks: list[Dock] = [] - append_dock = docks.append - for name, edge, z in parent.styles.docks: - append_dock(Dock(edge, groups[name], z)) - return docks - - def arrange( - self, parent: Widget, children: list[Widget], size: Size - ) -> ArrangeResult: - - width, height = size - layout_region = Region(0, 0, width, height) - layers: dict[int, Region] = defaultdict(lambda: layout_region) - - docks = self.get_docks(parent, children) - - def make_dock_options(widget: Widget, edge: Edge) -> DockOptions: - styles = widget.styles - has_rule = styles.has_rule - - # TODO: This was written pre resolve_dimension, we should update this to use available units - return ( - DockOptions( - styles.width.cells if has_rule("width") else None, - styles.width.fraction if has_rule("width") else 1, - styles.min_width.cells if has_rule("min_width") else 1, - ) - if edge in ("left", "right") - else DockOptions( - styles.height.cells if has_rule("height") else None, - styles.height.fraction if has_rule("height") else 1, - styles.min_height.cells if has_rule("min_height") else 1, - ) - ) - - placements: list[WidgetPlacement] = [] - add_placement = placements.append - arranged_widgets: set[Widget] = set() - - for z, (edge, widgets, _z) in enumerate(sorted(docks, key=attrgetter("z"))): - - arranged_widgets.update(widgets) - dock_options = [make_dock_options(widget, edge) for widget in widgets] - region = layers[z] - if not region.area: - # No space left - continue - - x, y, width, height = region - - if edge == "top": - sizes = layout_resolve(height, dock_options) - render_y = y - remaining = region.height - total = 0 - for widget, new_size in zip(widgets, sizes): - new_size = min(remaining, new_size) - if not new_size: - break - total += new_size - add_placement( - WidgetPlacement(Region(x, render_y, width, new_size), widget, z) - ) - render_y += new_size - remaining = max(0, remaining - new_size) - region = Region(x, y + total, width, height - total) - - elif edge == "bottom": - sizes = layout_resolve(height, dock_options) - render_y = y + height - remaining = region.height - total = 0 - for widget, new_size in zip(widgets, sizes): - new_size = min(remaining, new_size) - if not new_size: - break - total += new_size - add_placement( - WidgetPlacement( - Region(x, render_y - new_size, width, new_size), widget, z - ) - ) - render_y -= new_size - remaining = max(0, remaining - new_size) - region = Region(x, y, width, height - total) - - elif edge == "left": - sizes = layout_resolve(width, dock_options) - render_x = x - remaining = region.width - total = 0 - for widget, new_size in zip(widgets, sizes): - new_size = min(remaining, new_size) - if not new_size: - break - total += new_size - add_placement( - WidgetPlacement( - Region(render_x, y, new_size, height), widget, z - ) - ) - render_x += new_size - remaining = max(0, remaining - new_size) - region = Region(x + total, y, width - total, height) - - elif edge == "right": - sizes = layout_resolve(width, dock_options) - render_x = x + width - remaining = region.width - total = 0 - for widget, new_size in zip(widgets, sizes): - new_size = min(remaining, new_size) - if not new_size: - break - total += new_size - add_placement( - WidgetPlacement( - Region(render_x - new_size, y, new_size, height), widget, z - ) - ) - render_x -= new_size - remaining = max(0, remaining - new_size) - region = Region(x, y, width - total, height) - - layers[z] = region - - return ArrangeResult(placements, arranged_widgets) diff --git a/tests/layouts/test_factory.py b/tests/layouts/test_factory.py index 8935556a5..97feb1f5c 100644 --- a/tests/layouts/test_factory.py +++ b/tests/layouts/test_factory.py @@ -1,12 +1,12 @@ import pytest -from textual.layouts.dock import DockLayout +from textual.layouts.vertical import VerticalLayout from textual.layouts.factory import get_layout, MissingLayout def test_get_layout_valid_layout(): - layout = get_layout("dock") - assert type(layout) is DockLayout + layout = get_layout("vertical") + assert type(layout) is VerticalLayout def test_get_layout_invalid_layout(): From 90de092fd5a0e182b8a4b6704a31382d8d955594 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 27 Jul 2022 15:20:49 +0100 Subject: [PATCH 04/16] removed old dock --- src/textual/app.py | 1 - src/textual/layouts/factory.py | 2 -- src/textual/screen.py | 1 + 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 3f90f3c27..18f229e9d 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -56,7 +56,6 @@ from .driver import Driver from .features import parse_features, FeatureFlag from .file_monitor import FileMonitor from .geometry import Offset, Region, Size -from .layouts.dock import Dock from .message_pump import MessagePump from .reactive import Reactive from .renderables.blank import Blank diff --git a/src/textual/layouts/factory.py b/src/textual/layouts/factory.py index 0ea00d641..cc0de2e00 100644 --- a/src/textual/layouts/factory.py +++ b/src/textual/layouts/factory.py @@ -1,11 +1,9 @@ from .horizontal import HorizontalLayout from .._layout import Layout -from ..layouts.dock import DockLayout from ..layouts.vertical import VerticalLayout LAYOUT_MAP = { - "dock": DockLayout, "vertical": VerticalLayout, "horizontal": HorizontalLayout, } diff --git a/src/textual/screen.py b/src/textual/screen.py index 07d189bb1..b00068e42 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -118,6 +118,7 @@ class Screen(Widget): self._layout_required = False self._dirty_widgets.clear() if self._repaint_required: + self._dirty_widgets.clear() self._dirty_widgets.add(self) self._repaint_required = False From 1abe8f933db7e90fd0ea572a1d65b9d882ccca3a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 27 Jul 2022 15:37:33 +0100 Subject: [PATCH 05/16] test fixes, tests for partition --- sandbox/basic.css | 231 ------------------- sandbox/basic.py | 185 --------------- sandbox/will/pred.py | 50 ---- src/textual/color.py | 2 +- tests/css/test_help_text.py | 6 - tests/css/test_parse.py | 6 +- tests/layouts/test_common_layout_features.py | 2 - tests/test_box_model.py | 2 +- tests/test_integration_layout.py | 2 +- tests/test_partition.py | 15 ++ tests/test_view.py | 1 - 11 files changed, 21 insertions(+), 481 deletions(-) delete mode 100644 sandbox/basic.css delete mode 100644 sandbox/basic.py delete mode 100644 sandbox/will/pred.py create mode 100644 tests/test_partition.py diff --git a/sandbox/basic.css b/sandbox/basic.css deleted file mode 100644 index eed5e0b5e..000000000 --- a/sandbox/basic.css +++ /dev/null @@ -1,231 +0,0 @@ -/* CSS file for basic.py */ - - - -* { - transition: color 300ms linear, background 300ms linear; -} - -* { - scrollbar-background: $panel-darken-2; - scrollbar-background-hover: $panel-darken-3; - scrollbar-color: $system; - scrollbar-color-active: $accent-darken-1; - scrollbar-size-horizontal: 1; - scrollbar-size-vertical: 2; -} - -App > Screen { - layout: dock; - docks: side=left/1; - background: $surface; - color: $text-surface; -} - - -#sidebar { - color: $text-primary; - background: $primary-background; - dock: side; - width: 30; - offset-x: -100%; - layout: dock; - transition: offset 500ms in_out_cubic; -} - -#sidebar.-active { - offset-x: 0; -} - -#sidebar .title { - height: 3; - background: $primary-background-darken-2; - color: $text-primary-darken-2 ; - border-right: outer $primary-darken-3; - content-align: center middle; -} - -#sidebar .user { - height: 8; - background: $primary-background-darken-1; - color: $text-primary-darken-1; - border-right: outer $primary-background-darken-3; - content-align: center middle; -} - -#sidebar .content { - background: $primary-background; - color: $text-primary-background; - border-right: outer $primary-background-darken-3; - content-align: center middle; -} - -#header { - color: $text-primary-darken-1; - background: $primary-darken-1; - height: 3; - content-align: center middle; -} - -#content { - color: $text-background; - background: $background; - layout: vertical; - overflow-y: scroll; -} - - -Tweet { - height:12; - width: 100%; - - margin: 1 3; - background: $panel; - color: $text-panel; - layout: vertical; - /* border: outer $primary; */ - padding: 1; - border: wide $panel-darken-2; - overflow: auto; - /* scrollbar-gutter: stable; */ - align-horizontal: center; - box-sizing: border-box; -} - - -.scrollable { - - overflow-y: scroll; - margin: 1 2; - height: 20; - align-horizontal: center; - layout: vertical; -} - -.code { - - height: auto; - -} - - -TweetHeader { - height:1; - background: $accent; - color: $text-accent -} - -TweetBody { - width: 100%; - background: $panel; - color: $text-panel; - height: auto; - padding: 0 1 0 0; -} - -Tweet.scroll-horizontal TweetBody { - width: 350; -} - -.button { - background: $accent; - color: $text-accent; - width:20; - height: 3; - /* border-top: hidden $accent-darken-3; */ - border: tall $accent-darken-2; - /* border-left: tall $accent-darken-1; */ - - - /* padding: 1 0 0 0 ; */ - - transition: background 400ms in_out_cubic, color 400ms in_out_cubic; - -} - -.button:hover { - background: $accent-lighten-1; - color: $text-accent-lighten-1; - width: 20; - height: 3; - border: tall $accent-darken-1; - /* border-left: tall $accent-darken-3; */ - - - - -} - -#footer { - color: $text-accent; - background: $accent; - height: 1; - border-top: hkey $accent-darken-2; - content-align: center middle; -} - - -#sidebar .content { - layout: vertical -} - -OptionItem { - height: 3; - background: $primary-background; - border-right: outer $primary-background-darken-2; - border-left: blank; - content-align: center middle; -} - -OptionItem:hover { - height: 3; - color: $accent; - background: $primary-background-darken-1; - /* border-top: hkey $accent2-darken-3; - border-bottom: hkey $accent2-darken-3; */ - text-style: bold; - border-left: outer $accent-darken-2; -} - -Error { - width: 100%; - height:3; - background: $error; - color: $text-error; - border-top: hkey $error-darken-2; - border-bottom: hkey $error-darken-2; - margin: 1 3; - - text-style: bold; - align-horizontal: center; -} - -Warning { - width: 100%; - height:3; - background: $warning; - color: $text-warning-fade-1; - border-top: hkey $warning-darken-2; - border-bottom: hkey $warning-darken-2; - margin: 1 2; - text-style: bold; - align-horizontal: center; -} - -Success { - width: 100%; - height:3; - box-sizing: border-box; - background: $success-lighten-3; - color: $text-success-lighten-3-fade-1; - border-top: hkey $success; - border-bottom: hkey $success; - margin: 1 2; - text-style: bold; - align-horizontal: center; -} - - -.horizontal { - layout: horizontal -} diff --git a/sandbox/basic.py b/sandbox/basic.py deleted file mode 100644 index acae838d7..000000000 --- a/sandbox/basic.py +++ /dev/null @@ -1,185 +0,0 @@ -from rich.console import RenderableType -from rich.style import Style -from rich.syntax import Syntax -from rich.text import Text - -from textual.app import App -from textual.reactive import Reactive -from textual.widget import Widget -from textual.widgets import Static - -CODE = ''' -class Offset(NamedTuple): - """A point defined by x and y coordinates.""" - - x: int = 0 - y: int = 0 - - @property - def is_origin(self) -> bool: - """Check if the point is at the origin (0, 0)""" - return self == (0, 0) - - def __bool__(self) -> bool: - return self != (0, 0) - - def __add__(self, other: object) -> Offset: - if isinstance(other, tuple): - _x, _y = self - x, y = other - return Offset(_x + x, _y + y) - return NotImplemented - - def __sub__(self, other: object) -> Offset: - if isinstance(other, tuple): - _x, _y = self - x, y = other - return Offset(_x - x, _y - y) - return NotImplemented - - def __mul__(self, other: object) -> Offset: - if isinstance(other, (float, int)): - x, y = self - return Offset(int(x * other), int(y * other)) - return NotImplemented -''' - - -lorem_short = """Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit liber a a a, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum.""" -lorem = ( - lorem_short - + """ In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. """ -) - -lorem_short_text = Text.from_markup(lorem_short) -lorem_long_text = Text.from_markup(lorem * 2) - - -class TweetHeader(Widget): - def render(self) -> RenderableType: - return Text("Lorem Impsum", justify="center") - - -class TweetBody(Widget): - short_lorem = Reactive(False) - - def render(self) -> Text: - return lorem_short_text if self.short_lorem else lorem_long_text - - -class Tweet(Widget): - pass - - -class OptionItem(Widget): - def render(self) -> Text: - return Text("Option") - - -class Error(Widget): - def render(self) -> Text: - return Text("This is an error message", justify="center") - - -class Warning(Widget): - def render(self) -> Text: - return Text("This is a warning message", justify="center") - - -class Success(Widget): - def render(self) -> Text: - return Text("This is a success message", justify="center") - - -class BasicApp(App, css_path="basic.css"): - """A basic app demonstrating CSS""" - - def on_load(self): - """Bind keys here.""" - self.bind("s", "toggle_class('#sidebar', '-active')") - - def on_mount(self): - """Build layout here.""" - - self.scroll_to_target = Tweet(TweetBody()) - self.mount( - header=Static( - Text.from_markup( - "[b]This is a [u]Textual[/u] app, running in the terminal" - ), - ), - content=Widget( - Tweet(TweetBody()), - Widget( - Static(Syntax(CODE, "python"), classes="code"), - classes="scrollable", - ), - Error(), - Tweet(TweetBody(), classes="scrollbar-size-custom"), - Warning(), - Tweet(TweetBody(), classes="scroll-horizontal"), - Success(), - Tweet(TweetBody(), classes="scroll-horizontal"), - Tweet(TweetBody(), classes="scroll-horizontal"), - Tweet(TweetBody(), classes="scroll-horizontal"), - Tweet(TweetBody(), classes="scroll-horizontal"), - Tweet(TweetBody(), classes="scroll-horizontal"), - ), - footer=Widget(), - sidebar=Widget( - Widget(classes="title"), - Widget(classes="user"), - OptionItem(), - OptionItem(), - OptionItem(), - Widget(classes="content"), - ), - ) - - async def on_key(self, event) -> None: - await self.dispatch_key(event) - - def key_d(self): - self.dark = not self.dark - - async def key_q(self): - await self.shutdown() - - def key_x(self): - self.panic(self.tree) - - def key_escape(self): - self.app.bell() - - def key_t(self): - # Pressing "t" toggles the content of the TweetBody widget, from a long "Lorem ipsum..." to a shorter one. - tweet_body = self.query("TweetBody").first() - tweet_body.short_lorem = not tweet_body.short_lorem - - def key_v(self): - self.get_child(id="content").scroll_to_widget(self.scroll_to_target) - - def key_space(self): - self.bell() - - -app = BasicApp() - -if __name__ == "__main__": - app.run() - - from textual.geometry import Region - from textual.color import Color - - print(Region.intersection.cache_info()) - print(Region.overlaps.cache_info()) - print(Region.union.cache_info()) - print(Region.split_vertical.cache_info()) - print(Region.__contains__.cache_info()) - from textual.css.scalar import Scalar - - print(Scalar.resolve_dimension.cache_info()) - - from rich.style import Style - - print(Style._add.cache_info()) diff --git a/sandbox/will/pred.py b/sandbox/will/pred.py deleted file mode 100644 index 95f8cd50f..000000000 --- a/sandbox/will/pred.py +++ /dev/null @@ -1,50 +0,0 @@ -def partition_will(pred, values): - if not values: - return [], [] - if len(values) == 1: - return ([], values) if pred(values[0]) else (values, []) - values = sorted(values, key=pred) - lower = 0 - upper = len(values) - 1 - index = (lower + upper) // 2 - while True: - value = pred(values[index]) - if value and not pred(values[index - 1]): - return values[:index], values[index:] - if value: - upper = index - else: - lower = index - - index = (lower + upper) // 2 - - -def partition_more_iter(pred, iterable): - """ - Returns a 2-tuple of iterables derived from the input iterable. - The first yields the items that have ``pred(item) == False``. - The second yields the items that have ``pred(item) == True``. - - >>> is_odd = lambda x: x % 2 != 0 - >>> iterable = range(10) - >>> even_items, odd_items = partition(is_odd, iterable) - >>> list(even_items), list(odd_items) - ([0, 2, 4, 6, 8], [1, 3, 5, 7, 9]) - - If *pred* is None, :func:`bool` is used. - - >>> iterable = [0, 1, False, True, '', ' '] - >>> false_items, true_items = partition(None, iterable) - >>> list(false_items), list(true_items) - ([0, False, ''], [1, True, ' ']) - - """ - if pred is None: - pred = bool - - evaluations = ((pred(x), x) for x in iterable) - t1, t2 = tee(evaluations) - return ( - (x for (cond, x) in t1 if not cond), - (x for (cond, x) in t2 if cond), - ) diff --git a/src/textual/color.py b/src/textual/color.py index 813d6bcc6..3d7458451 100644 --- a/src/textual/color.py +++ b/src/textual/color.py @@ -167,7 +167,7 @@ class Color(NamedTuple): """This color encoded in Rich's Color class.""" r, g, b, _a = self return RichColor( - f"#{r:02X}{g:02X}{b:02X}", _TRUECOLOR, None, ColorTriplet(r, g, b) + f"#{r:02x}{g:02x}{b:02x}", _TRUECOLOR, None, ColorTriplet(r, g, b) ) @property diff --git a/tests/css/test_help_text.py b/tests/css/test_help_text.py index f5818a11d..1114961d8 100644 --- a/tests/css/test_help_text.py +++ b/tests/css/test_help_text.py @@ -98,12 +98,6 @@ def test_docks_property_help_text(styling_context): assert "docks" in rendered -def test_dock_property_help_text(styling_context): - rendered = render(dock_property_help_text("dock", styling_context)) - assert "Invalid value for" in rendered - assert "dock" in rendered - - def test_fractional_property_help_text(styling_context): rendered = render(fractional_property_help_text("opacity", styling_context)) assert "Invalid value for" in rendered diff --git a/tests/css/test_parse.py b/tests/css/test_parse.py index bf5cc4505..5b11472cf 100644 --- a/tests/css/test_parse.py +++ b/tests/css/test_parse.py @@ -12,7 +12,7 @@ from textual.css.tokenize import tokenize from textual.css.tokenizer import Token, ReferencedBy from textual.css.transition import Transition from textual.geometry import Spacing -from textual.layouts.dock import DockLayout +from textual.layouts.vertical import VerticalLayout class TestVariableReferenceSubstitution: @@ -861,13 +861,13 @@ class TestVariableReferenceSubstitution: class TestParseLayout: def test_valid_layout_name(self): - css = "#some-widget { layout: dock; }" + css = "#some-widget { layout: vertical; }" stylesheet = Stylesheet() stylesheet.add_source(css) styles = stylesheet.rules[0].styles - assert isinstance(styles.layout, DockLayout) + assert isinstance(styles.layout, VerticalLayout) def test_invalid_layout_name(self): css = "#some-widget { layout: invalidlayout; }" diff --git a/tests/layouts/test_common_layout_features.py b/tests/layouts/test_common_layout_features.py index e8cc5ee19..7fba1efb7 100644 --- a/tests/layouts/test_common_layout_features.py +++ b/tests/layouts/test_common_layout_features.py @@ -7,10 +7,8 @@ from textual.widget import Widget @pytest.mark.parametrize( "layout,display,expected_in_displayed_children", [ - ("dock", "block", True), ("horizontal", "block", True), ("vertical", "block", True), - ("dock", "none", False), ("horizontal", "none", False), ("vertical", "none", False), ], diff --git a/tests/test_box_model.py b/tests/test_box_model.py index 31bfc3212..1c82620ff 100644 --- a/tests/test_box_model.py +++ b/tests/test_box_model.py @@ -137,7 +137,7 @@ def test_height(): box_model = get_box_model( styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height ) - assert box_model == BoxModel(Fraction(54), Fraction(20), Spacing(1, 2, 3, 4)) + assert box_model == BoxModel(Fraction(54), Fraction(16), Spacing(1, 2, 3, 4)) styles.height = "auto" styles.margin = 2 diff --git a/tests/test_integration_layout.py b/tests/test_integration_layout.py index 85990497f..0c5994ca1 100644 --- a/tests/test_integration_layout.py +++ b/tests/test_integration_layout.py @@ -228,7 +228,7 @@ async def test_border_edge_types_impact_on_widget_size( top_left_edge_style = app.screen.get_style_at(0, 0) top_left_edge_color = top_left_edge_style.color.name - assert top_left_edge_color == expected_top_left_edge_color + assert top_left_edge_color.upper() == expected_top_left_edge_color.upper() top_left_edge_char = app.get_char_at(0, 0) top_left_edge_char_is_a_visible_one = top_left_edge_char != " " diff --git a/tests/test_partition.py b/tests/test_partition.py new file mode 100644 index 000000000..cbd98333b --- /dev/null +++ b/tests/test_partition.py @@ -0,0 +1,15 @@ +from textual._partition import partition + + +def test_partition(): + def is_odd(value: int) -> bool: + return bool(value % 2) + + assert partition(is_odd, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) == ( + [2, 4, 6, 8, 10], + [1, 3, 5, 7, 9], + ) + + assert partition(is_odd, [1, 2]) == ([2], [1]) + assert partition(is_odd, [1]) == ([], [1]) + assert partition(is_odd, []) == ([], []) diff --git a/tests/test_view.py b/tests/test_view.py index fad4484bf..7aa51d33a 100644 --- a/tests/test_view.py +++ b/tests/test_view.py @@ -1,6 +1,5 @@ import pytest -from textual.layouts.dock import DockLayout from textual.layouts.grid import GridLayout from textual.layouts.horizontal import HorizontalLayout from textual.layouts.vertical import VerticalLayout From 38f4dcf8be56dae5d2b57665d112c168494db83f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 27 Jul 2022 16:24:31 +0100 Subject: [PATCH 06/16] tests fixes --- src/textual/_arrange.py | 4 ++-- src/textual/_partition.py | 6 +++--- tests/test_partition.py | 18 ++++++++++++++++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index 666b96e3e..89a45912e 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -47,8 +47,8 @@ def arrange(widget: Widget, size: Size, viewport: Size) -> DockArrangeResult: for widgets in dock_layers.values(): - dock_widgets, layout_widgets = partition( - (lambda widget: not widget.styles.dock), widgets + layout_widgets, dock_widgets = partition( + (lambda widget: widget.styles.dock), widgets ) arrange_widgets.update(dock_widgets) diff --git a/src/textual/_partition.py b/src/textual/_partition.py index 3469a3240..3a2d5ba86 100644 --- a/src/textual/_partition.py +++ b/src/textual/_partition.py @@ -7,13 +7,13 @@ T = TypeVar("T") def partition( - pred: Callable[[T], bool], iterable: Iterable[T] + pred: Callable[[T], object], iterable: Iterable[T] ) -> tuple[list[T], list[T]]: """Partition a sequence in to two list from a given predicate. The first list will contain the values where the predicate is False, the second list will contain the remaining values. Args: - pred (Callable[[T], bool]): A callable that returns True or False for a given value. + pred (Callable[[T], object]): A callable that returns True or False for a given value. iterable (Iterable[T]): In Iterable of values. Returns: @@ -25,7 +25,7 @@ def partition( appends = (result[0].append, result[1].append) for value in iterable: - appends[pred(value)](value) + appends[1 if pred(value) else 0](value) return result diff --git a/tests/test_partition.py b/tests/test_partition.py index cbd98333b..988f43111 100644 --- a/tests/test_partition.py +++ b/tests/test_partition.py @@ -5,6 +5,9 @@ def test_partition(): def is_odd(value: int) -> bool: return bool(value % 2) + def is_greater_than_five(value: int) -> bool: + return value > 5 + assert partition(is_odd, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) == ( [2, 4, 6, 8, 10], [1, 3, 5, 7, 9], @@ -13,3 +16,18 @@ def test_partition(): assert partition(is_odd, [1, 2]) == ([2], [1]) assert partition(is_odd, [1]) == ([], [1]) assert partition(is_odd, []) == ([], []) + + assert partition(is_greater_than_five, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) == ( + [1, 2, 3, 4, 5], + [6, 7, 8, 9, 10], + ) + + assert partition(is_greater_than_five, [6, 7, 8, 9, 10]) == ( + [0], + [6, 7, 8, 9, 10], + ) + + assert partition(is_greater_than_five, [1, 2, 3]) == ( + [1, 2, 3], + [], + ) From 4dfdfc8157578740d1aa2d6aaa46830a87af0213 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 29 Jul 2022 10:10:17 +0100 Subject: [PATCH 07/16] renamed --- src/textual/_arrange.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index 89a45912e..7922f0843 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -83,6 +83,7 @@ def arrange(widget: Widget, size: Size, viewport: Size) -> DockArrangeResult: ) right = max(right, dock_region.width) else: + # Should not occur, mainly to keep Mypy happy raise AssertionError("invalid value for edge") align_offset = dock_widget.styles.align_size( @@ -93,12 +94,12 @@ def arrange(widget: Widget, size: Size, viewport: Size) -> DockArrangeResult: dock_spacing = Spacing(top, right, bottom, left) region = size.region.shrink(dock_spacing) - layout_placements, _layout_widgets = widget.layout.arrange( + layout_placements, arranged_layout_widgets = widget.layout.arrange( widget, layout_widgets, region.size ) - if _layout_widgets: + if arranged_layout_widgets: scroll_spacing = scroll_spacing.grow_maximum(dock_spacing) - arrange_widgets.update(_layout_widgets) + arrange_widgets.update(arranged_layout_widgets) placement_offset = region.offset if placement_offset: layout_placements = [ From b514e9544b2c2a09caa4eb6654ea46ffbf576431 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 29 Jul 2022 10:28:50 +0100 Subject: [PATCH 08/16] test fix --- tests/test_partition.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/tests/test_partition.py b/tests/test_partition.py index 988f43111..de5147058 100644 --- a/tests/test_partition.py +++ b/tests/test_partition.py @@ -22,12 +22,6 @@ def test_partition(): [6, 7, 8, 9, 10], ) - assert partition(is_greater_than_five, [6, 7, 8, 9, 10]) == ( - [0], - [6, 7, 8, 9, 10], - ) + assert partition(is_greater_than_five, [6, 7, 8, 9, 10]) == ([], [6, 7, 8, 9, 10]) - assert partition(is_greater_than_five, [1, 2, 3]) == ( - [1, 2, 3], - [], - ) + assert partition(is_greater_than_five, [1, 2, 3]) == ([1, 2, 3], []) From 547b8531a15942fbf9d234be8b2163fb83e53727 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 29 Jul 2022 10:51:37 +0100 Subject: [PATCH 09/16] optimization --- src/textual/_arrange.py | 19 +++++++++++-------- src/textual/css/stylesheet.py | 4 ---- src/textual/widget.py | 12 +++++------- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index 7922f0843..8b8b72476 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -2,7 +2,8 @@ from __future__ import annotations from fractions import Fraction -from typing import TYPE_CHECKING +from operator import attrgetter +from typing import Sequence, TYPE_CHECKING from .geometry import Region, Size, Spacing from ._layout import DockArrangeResult, WidgetPlacement @@ -13,7 +14,9 @@ if TYPE_CHECKING: from .widget import Widget -def arrange(widget: Widget, size: Size, viewport: Size) -> DockArrangeResult: +def arrange( + widget: Widget, children: Sequence[Widget], size: Size, viewport: Size +) -> DockArrangeResult: """Arrange widgets by applying docks and calling layouts Args: @@ -24,13 +27,14 @@ def arrange(widget: Widget, size: Size, viewport: Size) -> DockArrangeResult: Returns: tuple[list[WidgetPlacement], set[Widget], Spacing]: Widget arrangement information. """ - display_children = [child for child in widget.children if child.display] + display_children = [child for child in children if child.display] arrange_widgets: set[Widget] = set() dock_layers: dict[str, list[Widget]] = {} + dock_layers_setdefault = dock_layers.setdefault for child in display_children: - dock_layers.setdefault(child.styles.layer or "default", []).append(child) + dock_layers_setdefault(child.styles.layer or "default", []).append(child) width, height = size @@ -45,12 +49,11 @@ def arrange(widget: Widget, size: Size, viewport: Size) -> DockArrangeResult: scroll_spacing = Spacing() + get_dock = attrgetter("styles.dock") + for widgets in dock_layers.values(): - layout_widgets, dock_widgets = partition( - (lambda widget: widget.styles.dock), widgets - ) - + layout_widgets, dock_widgets = partition(get_dock, widgets) arrange_widgets.update(dock_widgets) top = right = bottom = left = 0 diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 156409c3d..ffc77557c 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -405,12 +405,8 @@ class Stylesheet: else: # Not animated, so we apply the rules directly get_rule = rules.get - from ..screen import Screen for key in modified_rule_keys: - if isinstance(node, Screen): - - print(node, key, get_rule(key)) setattr(base_styles, key, get_rule(key)) node.post_message_no_wait(messages.StylesUpdated(sender=node)) diff --git a/src/textual/widget.py b/src/textual/widget.py index e947b1171..2555f1121 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -10,7 +10,6 @@ from typing import ( Collection, Iterable, NamedTuple, - Tuple, ) import rich.repr @@ -25,7 +24,7 @@ from rich.text import Text from . import errors, events, messages from ._animator import BoundAnimator -from ._arrange import arrange +from ._arrange import arrange, DockArrangeResult from ._context import active_app from ._layout import ArrangeResult, Layout from ._segment_tools import line_crop @@ -115,7 +114,7 @@ class Widget(DOMNode): self._content_width_cache: tuple[object, int] = (None, 0) self._content_height_cache: tuple[object, int] = (None, 0) - self._arrangement: Tuple[ArrangeResult, Spacing] | None = None + self._arrangement: DockArrangeResult | None = None self._arrangement_cache_key: tuple[int, Size] = (-1, Size()) self._styles_cache = StylesCache() @@ -136,7 +135,7 @@ class Widget(DOMNode): show_vertical_scrollbar = Reactive(False, layout=True) show_horizontal_scrollbar = Reactive(False, layout=True) - def _arrange(self, size: Size) -> ArrangeResult: + def _arrange(self, size: Size) -> DockArrangeResult: """Arrange children. Args: @@ -152,10 +151,9 @@ class Widget(DOMNode): and arrange_cache_key == self._arrangement_cache_key ): return self._arrangement - self._arrangement_cache_key = (self.children._updates, size) - - self._arrangement = arrange(self, size, self.screen.size) + self._arrangement_cache_key = arrange_cache_key + self._arrangement = arrange(self, self.children, size, self.screen.size) return self._arrangement def _clear_arrangement_cache(self) -> None: From 67091ef2df320e8ee0412a602482f2bc75a1f4ab Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 29 Jul 2022 11:01:58 +0100 Subject: [PATCH 10/16] fix e2etest --- e2e_tests/sandbox_basic_test.py | 5 +- e2e_tests/test_apps/basic.css | 164 ++++++++++++++++++-------------- e2e_tests/test_apps/basic.py | 154 +++++++++++++++++++----------- src/textual/_partition.py | 4 - 4 files changed, 195 insertions(+), 132 deletions(-) diff --git a/e2e_tests/sandbox_basic_test.py b/e2e_tests/sandbox_basic_test.py index 3cf1ced9d..ca3b9f4ce 100644 --- a/e2e_tests/sandbox_basic_test.py +++ b/e2e_tests/sandbox_basic_test.py @@ -14,7 +14,8 @@ if len(sys.argv) > 1: if len(sys.argv) > 2: script_time_to_live = float(sys.argv[2]) -e2e_root = Path(__file__).parent +e2e_root = Path(__file__).parent / "test_apps" +print(e2e_root) completed_process = None @@ -22,7 +23,7 @@ completed_process = None def launch_sandbox_script(python_file_name: str) -> None: global completed_process - command = f"{sys.executable} ./test_apps/{shlex.quote(python_file_name)}.py" + command = f"{sys.executable} {shlex.quote(python_file_name)}.py" print(f"Launching command '{command}'...") try: completed_process = subprocess.run( diff --git a/e2e_tests/test_apps/basic.css b/e2e_tests/test_apps/basic.css index 44560d3a2..3c9043b6b 100644 --- a/e2e_tests/test_apps/basic.css +++ b/e2e_tests/test_apps/basic.css @@ -2,27 +2,46 @@ -* { + * { transition: color 300ms linear, background 300ms linear; -} +} +*:hover { + /* tint: 30% red; + /* outline: heavy red; */ +} + App > Screen { - layout: dock; - docks: side=left/1; + background: $surface; - color: $text-surface; + color: $text-surface; + layers: sidebar; + + color: $text-background; + background: $background; + layout: vertical; + } +DataTable { + /*border:heavy red;*/ + /* tint: 10% green; */ + /* opacity: 50%; */ + padding: 1; + margin: 1 2; + height: 12; +} #sidebar { - color: $text-primary; - background: $primary; - dock: side; + color: $text-panel; + background: $panel; + dock: left; width: 30; offset-x: -100%; - layout: dock; + transition: offset 500ms in_out_cubic; + layer: sidebar; } #sidebar.-active { @@ -30,72 +49,67 @@ App > Screen { } #sidebar .title { - height: 3; - background: $primary-darken-2; - color: $text-primary-darken-2 ; - border-right: outer $primary-darken-3; + height: 1; + background: $primary-background-darken-1; + color: $text-primary-background-darken-1; + border-right: wide $background; content-align: center middle; } #sidebar .user { height: 8; - background: $primary-darken-1; - color: $text-primary-darken-1; - border-right: outer $primary-darken-3; + background: $panel-darken-1; + color: $text-panel-darken-1; + border-right: wide $background; content-align: center middle; } #sidebar .content { - background: $primary; - color: $text-primary; - border-right: outer $primary-darken-3; + background: $panel-darken-2; + color: $text-surface; + border-right: wide $background; content-align: center middle; } #header { - color: $text-primary-darken-1; - background: $primary-darken-1; - height: 3; + color: $text-secondary-background; + background: $secondary-background; + height: 1; content-align: center middle; -} - -#content { - color: $text-background; - background: $background; - layout: vertical; - overflow-y: scroll; + + dock: top; } Tweet { - height: 12; - width: 80; + height:12; + width: 100%; - margin: 1 3; + background: $panel; color: $text-panel; layout: vertical; /* border: outer $primary; */ padding: 1; - border: wide $panel-darken-2; - overflow-y: scroll; + border: wide $panel; + overflow: auto; + /* scrollbar-gutter: stable; */ align-horizontal: center; - + box-sizing: border-box; } + .scrollable { - width: 80; + overflow-y: scroll; - max-width:80; + margin: 1 2; height: 20; align-horizontal: center; layout: vertical; } -.code { - - height: 34; - width: 100%; +.code { + height: auto; } @@ -110,9 +124,12 @@ TweetBody { width: 100%; background: $panel; color: $text-panel; - height:20; - padding: 0 1 0 0; - + height: auto; + padding: 0 1 0 0; +} + +Tweet.scroll-horizontal TweetBody { + width: 350; } .button { @@ -123,11 +140,11 @@ TweetBody { /* border-top: hidden $accent-darken-3; */ border: tall $accent-darken-2; /* border-left: tall $accent-darken-1; */ - + /* padding: 1 0 0 0 ; */ - transition: background 200ms in_out_cubic, color 300ms in_out_cubic; + transition: background 400ms in_out_cubic, color 400ms in_out_cubic; } @@ -138,18 +155,19 @@ TweetBody { height: 3; border: tall $accent-darken-1; /* border-left: tall $accent-darken-3; */ - - + + } #footer { color: $text-accent; background: $accent; height: 1; - border-top: hkey $accent-darken-2; + content-align: center middle; + dock:bottom; } @@ -159,58 +177,60 @@ TweetBody { OptionItem { height: 3; - background: $primary; - transition: background 100ms linear; - border-right: outer $primary-darken-2; - border-left: hidden; + background: $panel; + border-right: wide $background; + border-left: blank; content-align: center middle; } OptionItem:hover { height: 3; - color: $accent; + color: $text-primary; background: $primary-darken-1; /* border-top: hkey $accent2-darken-3; border-bottom: hkey $accent2-darken-3; */ text-style: bold; - border-left: outer $accent-darken-2; + border-left: outer $secondary-darken-2; } Error { - width: 80; + width: 100%; height:3; background: $error; color: $text-error; - border-top: hkey $error-darken-2; - border-bottom: hkey $error-darken-2; - margin: 1 3; + border-top: tall $error-darken-2; + border-bottom: tall $error-darken-2; + padding: 0; text-style: bold; - align-horizontal: center; + align-horizontal: center; } Warning { - width: 80; + width: 100%; height:3; background: $warning; color: $text-warning-fade-1; - border-top: hkey $warning-darken-2; - border-bottom: hkey $warning-darken-2; - margin: 1 2; + border-top: tall $warning-darken-2; + border-bottom: tall $warning-darken-2; + text-style: bold; align-horizontal: center; } Success { - width: 80; - height:3; + width: 100%; + + height:auto; box-sizing: border-box; - background: $success-lighten-3; - color: $text-success-lighten-3-fade-1; - border-top: hkey $success; - border-bottom: hkey $success; - margin: 1 2; - text-style: bold; + background: $success; + color: $text-success-fade-1; + + border-top: hkey $success-darken-2; + border-bottom: hkey $success-darken-2; + + text-style: bold ; + align-horizontal: center; } diff --git a/e2e_tests/test_apps/basic.py b/e2e_tests/test_apps/basic.py index e29bbe17d..dee825f0e 100644 --- a/e2e_tests/test_apps/basic.py +++ b/e2e_tests/test_apps/basic.py @@ -1,13 +1,12 @@ -from pathlib import Path - from rich.console import RenderableType -from rich.style import Style + from rich.syntax import Syntax from rich.text import Text -from textual.app import App +from textual.app import App, ComposeResult +from textual.reactive import Reactive from textual.widget import Widget -from textual.widgets import Static +from textual.widgets import Static, DataTable CODE = ''' class Offset(NamedTuple): @@ -46,11 +45,15 @@ class Offset(NamedTuple): ''' -lorem = Text.from_markup( - """Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. """ - """Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. """ +lorem_short = """Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit liber a a a, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum.""" +lorem = ( + lorem_short + + """ In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. """ ) +lorem_short_text = Text.from_markup(lorem_short) +lorem_long_text = Text.from_markup(lorem * 2) + class TweetHeader(Widget): def render(self) -> RenderableType: @@ -58,8 +61,10 @@ class TweetHeader(Widget): class TweetBody(Widget): + short_lorem = Reactive(False) + def render(self) -> Text: - return lorem + return lorem_short_text if self.short_lorem else lorem_long_text class Tweet(Widget): @@ -83,53 +88,64 @@ class Warning(Widget): class Success(Widget): def render(self) -> Text: - return Text("This is a success message", justify="center") + return Text("This is a success message", justify="center") -class BasicApp(App): +class BasicApp(App, css_path="basic.css"): """A basic app demonstrating CSS""" def on_load(self): """Bind keys here.""" - self.bind("tab", "toggle_class('#sidebar', '-active')") + self.bind("s", "toggle_class('#sidebar', '-active')") - def on_mount(self): - """Build layout here.""" - self.mount( - header=Static( - Text.from_markup( - "[b]This is a [u]Textual[/u] app, running in the terminal" - ), - ), - content=Widget( - Tweet( - TweetBody(), - # Widget( - # Widget(classes={"button"}), - # Widget(classes={"button"}), - # classes={"horizontal"}, - # ), - ), - Widget( - Static(Syntax(CODE, "python"), classes="code"), - classes="scrollable", - ), - Error(), - Tweet(TweetBody()), - Warning(), - Tweet(TweetBody()), - Success(), - ), - footer=Widget(), - sidebar=Widget( - Widget(classes="title"), - Widget(classes="user"), - OptionItem(), - OptionItem(), - OptionItem(), - Widget(classes="content"), + def compose(self) -> ComposeResult: + table = DataTable() + self.scroll_to_target = Tweet(TweetBody()) + + yield Static( + Text.from_markup( + "[b]This is a [u]Textual[/u] app, running in the terminal" ), + id="header", ) + yield from ( + Tweet(TweetBody()), + Widget( + Static(Syntax(CODE, "python"), classes="code"), + classes="scrollable", + ), + table, + Error(), + Tweet(TweetBody(), classes="scrollbar-size-custom"), + Warning(), + Tweet(TweetBody(), classes="scroll-horizontal"), + Success(), + Tweet(TweetBody(), classes="scroll-horizontal"), + Tweet(TweetBody(), classes="scroll-horizontal"), + Tweet(TweetBody(), classes="scroll-horizontal"), + Tweet(TweetBody(), classes="scroll-horizontal"), + Tweet(TweetBody(), classes="scroll-horizontal"), + ) + yield Widget(id="footer") + yield Widget( + Widget(classes="title"), + Widget(classes="user"), + OptionItem(), + OptionItem(), + OptionItem(), + Widget(classes="content"), + id="sidebar", + ) + + table.add_column("Foo", width=20) + table.add_column("Bar", width=20) + table.add_column("Baz", width=20) + table.add_column("Foo", width=20) + table.add_column("Bar", width=20) + table.add_column("Baz", width=20) + table.zebra_stripes = True + for n in range(100): + table.add_row(*[f"Cell ([b]{n}[/b], {col})" for col in range(6)]) async def on_key(self, event) -> None: await self.dispatch_key(event) @@ -137,17 +153,47 @@ class BasicApp(App): def key_d(self): self.dark = not self.dark + async def key_q(self): + await self.shutdown() + def key_x(self): self.panic(self.tree) + def key_escape(self): + self.app.bell() -sandbox_folder = Path(__file__).parent -app = BasicApp( - css_path=sandbox_folder / "basic.css", - watch_css=True, - log_path=sandbox_folder / "basic.log", - log_verbosity=0, -) + def key_t(self): + # Pressing "t" toggles the content of the TweetBody widget, from a long "Lorem ipsum..." to a shorter one. + tweet_body = self.query("TweetBody").first() + tweet_body.short_lorem = not tweet_body.short_lorem + + def key_v(self): + self.get_child(id="content").scroll_to_widget(self.scroll_to_target) + + def key_space(self): + self.bell() + + +app = BasicApp() if __name__ == "__main__": app.run() + + # from textual.geometry import Region + # from textual.color import Color + + # print(Region.intersection.cache_info()) + # print(Region.overlaps.cache_info()) + # print(Region.union.cache_info()) + # print(Region.split_vertical.cache_info()) + # print(Region.__contains__.cache_info()) + # from textual.css.scalar import Scalar + + # print(Scalar.resolve_dimension.cache_info()) + + # from rich.style import Style + # from rich.cells import cached_cell_len + + # print(Style._add.cache_info()) + + # print(cached_cell_len.cache_info()) diff --git a/src/textual/_partition.py b/src/textual/_partition.py index 3a2d5ba86..734cdad61 100644 --- a/src/textual/_partition.py +++ b/src/textual/_partition.py @@ -27,7 +27,3 @@ def partition( for value in iterable: appends[1 if pred(value) else 0](value) return result - - -if __name__ == "__main__": - print(partition((lambda n: bool(n % 2)), list(range(20)))) From 001139b18d9c02d62acaa8bc61c9902e8e3f8ec0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 29 Jul 2022 11:07:48 +0100 Subject: [PATCH 11/16] fix for 3.7 --- src/textual/dom.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 3bda0e690..f9a77a2e1 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -356,9 +356,9 @@ class DOMNode(MessagePump): # TODO: Feels like there may be opportunity for caching here. - style = sum( - [node.styles.text_style for node in reversed(self.ancestors)], start=Style() - ) + style = Style() + for node in reversed(self.ancestors): + style += node.styles.text_style return style @property From 631685e37320a793a9c00c7853591f91b5fc96e8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 29 Jul 2022 11:11:39 +0100 Subject: [PATCH 12/16] simplify colors property --- src/textual/_styles_cache.py | 2 +- src/textual/dom.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py index 2d414f352..7628a5ec4 100644 --- a/src/textual/_styles_cache.py +++ b/src/textual/_styles_cache.py @@ -93,7 +93,7 @@ class StylesCache: Returns: Lines: Rendered lines. """ - (base_background, base_color), (background, color) = widget.colors + base_background, _base_color, background, _color = widget.colors padding = widget.styles.padding + widget.scrollbar_gutter lines = self.render( widget.styles, diff --git a/src/textual/dom.py b/src/textual/dom.py index f9a77a2e1..7ec78060b 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -364,18 +364,18 @@ class DOMNode(MessagePump): @property def rich_style(self) -> Style: """Get a Rich Style object for this DOMNode.""" - _, (background, color) = self.colors + _, _, background, color = self.colors style = ( Style.from_color(color.rich_color, background.rich_color) + self.text_style ) return style @property - def colors(self) -> tuple[tuple[Color, Color], tuple[Color, Color]]: - """Gets the Widgets foreground and background colors, and its parent's colors. + def colors(self) -> tuple[Color, Color, Color, Color]: + """Gets the Widgets foreground and background colors, and its parent's (base) colors. Returns: - tuple[tuple[Color, Color], tuple[Color, Color]]: Base colors and widget colors + tuple[Color, Color, Color, Color]: Tuple of (base background, base color, background, color) """ base_background = background = Color(0, 0, 0, 0) base_color = color = Color(255, 255, 255, 0) @@ -387,7 +387,7 @@ class DOMNode(MessagePump): if styles.has_rule("color"): base_color = color color = styles.color - return (base_background, base_color), (background, color) + return (base_background, base_color, background, color) @property def ancestors(self) -> list[DOMNode]: From 89fab350ded6e6bc167b9ccdc6a9e7a232dda65d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 29 Jul 2022 11:14:40 +0100 Subject: [PATCH 13/16] added reversed iterator --- src/textual/_node_list.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/textual/_node_list.py b/src/textual/_node_list.py index 9987c9268..cd61cb217 100644 --- a/src/textual/_node_list.py +++ b/src/textual/_node_list.py @@ -54,6 +54,9 @@ class NodeList: def __iter__(self) -> Iterator[Widget]: return iter(self._nodes) + def __reversed__(self) -> Iterator[Widget]: + return reversed(self._nodes) + @overload def __getitem__(self, index: int) -> Widget: ... From 9458ad73e1f68a14a33e0d7ff370a4779270d573 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 29 Jul 2022 11:28:01 +0100 Subject: [PATCH 14/16] Remove print --- e2e_tests/sandbox_basic_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/e2e_tests/sandbox_basic_test.py b/e2e_tests/sandbox_basic_test.py index ca3b9f4ce..888e8fa10 100644 --- a/e2e_tests/sandbox_basic_test.py +++ b/e2e_tests/sandbox_basic_test.py @@ -15,7 +15,6 @@ if len(sys.argv) > 2: script_time_to_live = float(sys.argv[2]) e2e_root = Path(__file__).parent / "test_apps" -print(e2e_root) completed_process = None From 9aecc8615270570408bb2a94a28b87c8a00d96f1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 29 Jul 2022 15:45:24 +0100 Subject: [PATCH 15/16] optimized property --- src/textual/_styles_cache.py | 2 +- src/textual/dom.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py index 7628a5ec4..fcbe23b1a 100644 --- a/src/textual/_styles_cache.py +++ b/src/textual/_styles_cache.py @@ -93,7 +93,7 @@ class StylesCache: Returns: Lines: Rendered lines. """ - base_background, _base_color, background, _color = widget.colors + base_background, background = widget.background_colors padding = widget.styles.padding + widget.scrollbar_gutter lines = self.render( widget.styles, diff --git a/src/textual/dom.py b/src/textual/dom.py index 7ec78060b..920cde011 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -370,6 +370,24 @@ class DOMNode(MessagePump): ) return style + @property + def background_colors(self) -> tuple[Color, Color]: + """Get the background color and the color of the parent's background. + + Returns: + tuple[Color, Color]: Tuple of (base background, background) + + """ + + base_background = background = Color(0, 0, 0, 0) + + for node in reversed(self.ancestors): + styles = node.styles + if styles.has_rule("background"): + base_background = background + background += styles.background + return (base_background, background) + @property def colors(self) -> tuple[Color, Color, Color, Color]: """Gets the Widgets foreground and background colors, and its parent's (base) colors. From 52199ab13469ec65658ef692ea600897d213b250 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 29 Jul 2022 17:10:43 +0100 Subject: [PATCH 16/16] add tests for arrange --- src/textual/_arrange.py | 10 ++-- src/textual/dom.py | 8 +-- tests/test_arrange.py | 109 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 tests/test_arrange.py diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index 8b8b72476..6e133b1d3 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -13,6 +13,9 @@ from ._partition import partition if TYPE_CHECKING: from .widget import Widget +# TODO: This is a bit of a fudge, need to ensure it is impossible for layouts to generate this value +TOP_Z = 2**31 - 1 + def arrange( widget: Widget, children: Sequence[Widget], size: Size, viewport: Size @@ -43,10 +46,7 @@ def arrange( region = size.region _WidgetPlacement = WidgetPlacement - - # TODO: This is a bit of a fudge, need to ensure it is impossible for layouts to generate this value - top_z = 2**31 - 1 - + top_z = TOP_Z scroll_spacing = Spacing() get_dock = attrgetter("styles.dock") @@ -87,7 +87,7 @@ def arrange( right = max(right, dock_region.width) else: # Should not occur, mainly to keep Mypy happy - raise AssertionError("invalid value for edge") + raise AssertionError("invalid value for edge") # pragma: no-cover align_offset = dock_widget.styles.align_size( (widget_width, widget_height), size diff --git a/src/textual/dom.py b/src/textual/dom.py index 920cde011..ccccd19e2 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -12,7 +12,7 @@ from rich.tree import Tree from ._context import NoActiveAppError from ._node_list import NodeList -from .color import Color +from .color import Color, WHITE, BLACK from .css._error_tools import friendly_list from .css.constants import VALID_DISPLAY, VALID_VISIBILITY from .css.errors import StyleValueError @@ -379,7 +379,7 @@ class DOMNode(MessagePump): """ - base_background = background = Color(0, 0, 0, 0) + base_background = background = BLACK for node in reversed(self.ancestors): styles = node.styles @@ -395,8 +395,8 @@ class DOMNode(MessagePump): Returns: tuple[Color, Color, Color, Color]: Tuple of (base background, base color, background, color) """ - base_background = background = Color(0, 0, 0, 0) - base_color = color = Color(255, 255, 255, 0) + base_background = background = WHITE + base_color = color = BLACK for node in reversed(self.ancestors): styles = node.styles if styles.has_rule("background"): diff --git a/tests/test_arrange.py b/tests/test_arrange.py new file mode 100644 index 000000000..16680cc68 --- /dev/null +++ b/tests/test_arrange.py @@ -0,0 +1,109 @@ +from textual._arrange import arrange, TOP_Z +from textual._layout import WidgetPlacement +from textual.geometry import Region, Size, Spacing +from textual.widget import Widget + + +def test_arrange_empty(): + container = Widget(id="container") + + placements, widgets, spacing = arrange(container, [], Size(80, 24), Size(80, 24)) + assert placements == [] + assert widgets == set() + assert spacing == Spacing(0, 0, 0, 0) + + +def test_arrange_dock_top(): + container = Widget(id="container") + child = Widget(id="child") + header = Widget(id="header") + header.styles.dock = "top" + header.styles.height = "1" + + placements, widgets, spacing = arrange( + container, [child, header], Size(80, 24), Size(80, 24) + ) + assert placements == [ + WidgetPlacement(Region(0, 0, 80, 1), header, order=TOP_Z, fixed=True), + WidgetPlacement(Region(0, 1, 80, 23), child, order=0, fixed=False), + WidgetPlacement( + region=Region(x=0, y=1, width=80, height=23), + widget=None, + order=0, + fixed=False, + ), + ] + assert widgets == {child, header} + assert spacing == Spacing(1, 0, 0, 0) + + +def test_arrange_dock_left(): + container = Widget(id="container") + child = Widget(id="child") + header = Widget(id="header") + header.styles.dock = "left" + header.styles.width = "10" + + placements, widgets, spacing = arrange( + container, [child, header], Size(80, 24), Size(80, 24) + ) + assert placements == [ + WidgetPlacement(Region(0, 0, 10, 24), header, order=TOP_Z, fixed=True), + WidgetPlacement(Region(10, 0, 70, 24), child, order=0, fixed=False), + WidgetPlacement( + region=Region(x=10, y=0, width=70, height=24), + widget=None, + order=0, + fixed=False, + ), + ] + assert widgets == {child, header} + assert spacing == Spacing(0, 0, 0, 10) + + +def test_arrange_dock_right(): + container = Widget(id="container") + child = Widget(id="child") + header = Widget(id="header") + header.styles.dock = "right" + header.styles.width = "10" + + placements, widgets, spacing = arrange( + container, [child, header], Size(80, 24), Size(80, 24) + ) + assert placements == [ + WidgetPlacement(Region(70, 0, 10, 24), header, order=TOP_Z, fixed=True), + WidgetPlacement(Region(0, 0, 70, 24), child, order=0, fixed=False), + WidgetPlacement( + region=Region(x=0, y=0, width=70, height=24), + widget=None, + order=0, + fixed=False, + ), + ] + assert widgets == {child, header} + assert spacing == Spacing(0, 10, 0, 0) + + +def test_arrange_dock_bottom(): + container = Widget(id="container") + child = Widget(id="child") + header = Widget(id="header") + header.styles.dock = "bottom" + header.styles.height = "1" + + placements, widgets, spacing = arrange( + container, [child, header], Size(80, 24), Size(80, 24) + ) + assert placements == [ + WidgetPlacement(Region(0, 23, 80, 1), header, order=TOP_Z, fixed=True), + WidgetPlacement(Region(0, 0, 80, 23), child, order=0, fixed=False), + WidgetPlacement( + region=Region(x=0, y=0, width=80, height=23), + widget=None, + order=0, + fixed=False, + ), + ] + assert widgets == {child, header} + assert spacing == Spacing(0, 0, 1, 0)