diff --git a/sandbox/uber.py b/sandbox/uber.py index 1a1be59a3..374bbeec0 100644 --- a/sandbox/uber.py +++ b/sandbox/uber.py @@ -20,17 +20,20 @@ class BasicApp(App): Widget(id="uber2-child2"), ) + uber1 = Widget( + Placeholder(id="child1", classes={"list-item"}), + Placeholder(id="child2", classes={"list-item"}), + Placeholder(id="child3", classes={"list-item"}), + Placeholder(classes={"list-item"}), + Placeholder(classes={"list-item"}), + Placeholder(classes={"list-item"}), + Placeholder(classes={"list-item"}), + # Placeholder(id="child3", classes={"list-item"}), + ) + uber1.show_vertical_scrollbar = True + self.mount( - uber1=Widget( - Placeholder(id="child1", classes={"list-item"}), - Placeholder(id="child2", classes={"list-item"}), - Placeholder(id="child3", classes={"list-item"}), - Placeholder(classes={"list-item"}), - Placeholder(classes={"list-item"}), - Placeholder(classes={"list-item"}), - Placeholder(classes={"list-item"}), - # Placeholder(id="child3", classes={"list-item"}), - ), + uber1=uber1 # uber2=uber2, ) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 881d78a4a..e4ea733cc 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -13,6 +13,7 @@ from rich.style import Style from . import errors, log from .geometry import Region, Offset, Size +from ._arrange import arrange from ._loop import loop_last from ._types import Lines from .widget import Widget @@ -88,15 +89,20 @@ class Compositor: # All widgets considered in the arrangement # Not this may be a supperset of self.map.keys() as some widgets may be invisible for various reasons self.widgets: set[Widget] = set() + + # The top level widget self.root: Widget | None = None # Dimensions of the arrangement self.size = Size(0, 0) + # A mapping of Widget on to region, and clip region + # The clip region can be considered the window through which a widget is viewed self.regions: dict[Widget, tuple[Region, Region]] = {} + + # The points in each line where the line bisects the left and right edges of the widget self._cuts: list[list[int]] | None = None self._require_update: bool = True - self.background = "" def __rich_repr__(self) -> rich.repr.Result: yield "size", self.size @@ -202,9 +208,15 @@ class Compositor: total_region = region.size.region sub_clip = clip.intersection(region) - placements, arranged_widgets = widget.layout.arrange( - widget, region.size, scroll - ) + # for chrome_widget, chrome_region in widget.arrange_chrome(region.size): + # map[chrome_widget] = RenderRegion( + # chrome_region + layout_offset, + # order, + # clip, + # total_region.size, + # ) + + placements, arranged_widgets = arrange(widget, region.size, scroll) widgets.update(arranged_widgets) placements = sorted(placements, key=attrgetter("order")) @@ -219,6 +231,16 @@ class Compositor: sub_clip, ) + for chrome_widget, chrome_region in widget.arrange_chrome(region.size): + render_region = RenderRegion( + chrome_region + region.origin + layout_offset, + order, + clip, + total_region.size, + ) + log(render_region) + map[chrome_widget] = render_region + map[widget] = RenderRegion( region + layout_offset, order, clip, total_region.size ) @@ -245,8 +267,9 @@ class Compositor: def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: """Get the widget under the given point or None.""" + contains = Region.contains for widget, cropped_region, region, _ in self: - if cropped_region.contains(x, y): + if contains(cropped_region, x, y): return widget, region raise errors.NoWidget(f"No widget under screen coordinate ({x}, {y})") @@ -345,7 +368,7 @@ class Compositor: [ (widget, region, order, clip) for widget, (region, order, clip, _) in self.map.items() - if widget.visible + if widget.visible and not widget.is_transparent ], key=itemgetter(2), reverse=True, @@ -355,14 +378,12 @@ class Compositor: divide = Segment.divide intersection = Region.intersection + overlaps = Region.overlaps for widget, region, _order, clip in widget_regions: - if widget.is_transparent: - continue if region in clip: - lines = widget._get_lines() - yield region, clip, lines - elif clip.overlaps(region): + yield region, clip, widget._get_lines() + elif overlaps(clip, region): lines = widget._get_lines() new_x, new_y, new_width, new_height = intersection(region, clip) delta_x = new_x - region.x @@ -377,7 +398,7 @@ class Compositor: cls, chops: list[dict[int, list[Segment] | None]] ) -> list[list[Segment]]: - # Pretty sure we don't need to sort the buck items + # Pretty sure we don't need to sort the bucket items segment_lines = [ sum( [line for line in bucket.values() if line is not None], @@ -432,6 +453,7 @@ class Compositor: first_cut, last_cut = render_region.x_extents final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)] + # TODO: Suspect this may break for region not on cut boundaries if len(final_cuts) == 2: # Two cuts, which means the entire line cut_segments = [line] @@ -440,8 +462,8 @@ class Compositor: render_x = render_region.x relative_cuts = [cut - render_x for cut in final_cuts] _, *cut_segments = divide(line, relative_cuts) - # Since we are painting front to back, the first segments for a cut "wins" + # Since we are painting front to back, the first segments for a cut "wins" chops_line = chops[y] for cut, segments in zip(final_cuts, cut_segments): if chops_line[cut] is None: diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 5d0d65601..2ecf92820 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -488,6 +488,95 @@ class Region(NamedTuple): ) return union_region + def split(self, cut_x: int, cut_y: int) -> tuple[Region, Region, Region, Region]: + """Split a region in to 4 from given x and y offsets (cuts). + + cut_x ↓ + ┌────────┐┌───┐ + │ ││ │ + │ ││ │ + │ ││ │ + cut_y → └────────┘└───┘ + ┌────────┐┌───┐ + │ ││ │ + └────────┘└───┘ + + Args: + cut_x (int): Offset from self.x where the cut should be made. If negative, the cut + is taken from the right edge. + cut_y (int): Offset from self.y where the cut should be made. If negative, the cut + is taken from the lower edge. + + Returns: + tuple[Region, Region, Region, Region]: Four new regions which add up to the original (self). + """ + + x, y, width, height = self + if cut_x < 0: + cut_x = width + cut_x + if cut_y < 0: + cut_y = height + cut_y + + _Region = Region + return ( + _Region(x, y, cut_x, cut_y), + _Region(x + cut_x, y, width - cut_x, cut_y), + _Region(x, y + cut_y, cut_x, height - cut_y), + _Region(x + cut_x, y + cut_y, width - cut_x, height - cut_y), + ) + + def split_vertical(self, cut: int) -> tuple[Region, Region]: + """Split a region in to two, from a given x offset. + + cut ↓ + ┌────────┐┌───┐ + │ ││ │ + │ ││ │ + └────────┘└───┘ + + Args: + cut (int): An offset from self.x where the cut should be made. If cut is negative, + it is taken from the right edge. + + Returns: + tuple[Region, Region]: Two regions, which add up to the original (self). + """ + + x, y, width, height = self + if cut < 0: + cut = width + cut + + return ( + Region(x, y, cut, height), + Region(x + cut, y, width - cut, height), + ) + + def split_horizontal(self, cut: int) -> tuple[Region, Region]: + """Split a region in to two, from a given x offset. + + ┌─────────┐ + │ │ + │ │ + cut → └─────────┘ + ┌─────────┐ + └─────────┘ + + Args: + cut (int): An offset from self.x where the cut should be made. May be negative, + for the offset to start from the right edge. + + Returns: + tuple[Region, Region]: Two regions, which add up to the original (self). + """ + x, y, width, height = self + if cut < 0: + cut = height + cut + + return ( + Region(x, y, width, cut), + Region(x, y + cut, width, height - cut), + ) + class Spacing(NamedTuple): """The spacing around a renderable.""" diff --git a/src/textual/layout.py b/src/textual/layout.py index d445cf45c..2aaba919d 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -48,7 +48,7 @@ class Layout(ABC): @abstractmethod def arrange( self, parent: Widget, size: Size, scroll: Offset - ) -> tuple[Iterable[WidgetPlacement], set[Widget]]: + ) -> tuple[list[WidgetPlacement], set[Widget]]: """Generate a layout map that defines where on the screen the widgets will be drawn. Args: diff --git a/src/textual/layouts/dock.py b/src/textual/layouts/dock.py index 864026837..e040a9642 100644 --- a/src/textual/layouts/dock.py +++ b/src/textual/layouts/dock.py @@ -61,7 +61,7 @@ class DockLayout(Layout): def arrange( self, parent: Widget, size: Size, scroll: Offset - ) -> tuple[Iterable[WidgetPlacement], set[Widget]]: + ) -> tuple[list[WidgetPlacement], set[Widget]]: width, height = size layout_region = Region(0, 0, width, height) diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index e37e8facb..db9e3d7b4 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import Iterable - from textual.geometry import Size, Offset, Region from textual.layout import Layout, WidgetPlacement @@ -27,11 +25,9 @@ class HorizontalLayout(Layout): parent_size = parent.size for widget in parent.children: - (content_width, content_height), margin = widget.styles.get_box_model( size, parent_size ) - region = Region(margin.left + x, margin.top, content_width, content_height) max_height = max(max_height, content_height + margin.height) add_placement(WidgetPlacement(region, widget, 0)) diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 197359c53..fca5ed4e1 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -27,11 +27,9 @@ class VerticalLayout(Layout): parent_size = parent.size for widget in parent.children: - (content_width, content_height), margin = widget.styles.get_box_model( size, parent_size ) - region = Region(margin.left, y + margin.top, content_width, content_height) max_width = max(max_width, content_width + margin.width) add_placement(WidgetPlacement(region, widget, 0)) diff --git a/src/textual/screen.py b/src/textual/screen.py index d65e777a9..cb2eea5a2 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -95,10 +95,12 @@ class Screen(Widget): try: hidden, shown, resized = self._compositor.reflow(self, self.size) + Hide = events.Hide + Show = events.Show for widget in hidden: - widget.post_message_no_wait(events.Hide(self)) + widget.post_message_no_wait(Hide(self)) for widget in shown: - widget.post_message_no_wait(events.Show(self)) + widget.post_message_no_wait(Show(self)) send_resize = shown | resized diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index d932359d7..5c908b225 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -3,7 +3,7 @@ from __future__ import annotations import rich.repr from rich.color import Color from rich.console import ConsoleOptions, RenderResult, RenderableType -from rich.segment import Segment +from rich.segment import Segment, Segments from rich.style import Style, StyleType from textual.reactive import Reactive diff --git a/src/textual/widget.py b/src/textual/widget.py index b53b2dbfd..d8bae12eb 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -36,8 +36,10 @@ from .layout import Layout from .reactive import Reactive, watch from .renderables.opacity import Opacity + if TYPE_CHECKING: from .screen import Screen + from .scrollbar import ScrollBar class RenderCache(NamedTuple): @@ -79,6 +81,9 @@ class Widget(DOMNode): self.render_cache: RenderCache | None = None self.highlight_style: Style | None = None + self._vertical_scrollbar: ScrollBar | None = None + self._horizontal_scrollbar: ScrollBar | None = None + super().__init__(name=name, id=id, classes=classes) self.add_children(*children) @@ -87,6 +92,54 @@ class Widget(DOMNode): scroll_x = Reactive(0) scroll_y = Reactive(0) virtual_size = Reactive(Size(0, 0)) + show_vertical_scrollbar = Reactive(False) + show_horizontal_scrollbar = Reactive(False) + + @property + def vertical_scrollbar(self) -> ScrollBar: + """Get a vertical scrollbar (create if necessary) + + Returns: + ScrollBar: ScrollBar Widget. + """ + from .scrollbar import ScrollBar + + if self._vertical_scrollbar is not None: + return self._vertical_scrollbar + self._vertical_scrollbar = scroll_bar = ScrollBar( + vertical=True, name="vertical" + ) + self.app.register(self, scroll_bar) + return scroll_bar + + @property + def horizontal_scrollbar(self) -> ScrollBar: + """Get a vertical scrollbar (create if necessary) + + Returns: + ScrollBar: ScrollBar Widget. + """ + from .scrollbar import ScrollBar + + if self._horizontal_scrollbar is not None: + return self._horizontal_scrollbar + self._horizontal_scrollbar = scroll_bar = ScrollBar( + vertical=True, name="vertical" + ) + self.app.register(self, scroll_bar) + return scroll_bar + + @property + def scrollbars_enabled(self) -> tuple[bool, bool]: + """A tuple of booleans that indicate if scrollbars are enabled. + + Returns: + tuple[bool, bool]: A tuple of (, ) + + """ + if self.layout is None: + return False, False + return self.show_vertical_scrollbar, self.show_horizontal_scrollbar def __init_subclass__(cls, can_focus: bool = True) -> None: super().__init_subclass__() @@ -102,6 +155,30 @@ class Widget(DOMNode): if pseudo_classes: yield "pseudo_classes", set(pseudo_classes) + def arrange_chrome(self, size: Size) -> Iterable[tuple[Widget, Region]]: + region = size.region + show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled + + if show_horizontal_scrollbar and show_vertical_scrollbar: + ( + region, + vertical_scrollbar_region, + horizontal_scrollbar_region, + _, + ) = region.split(-1, -1) + if vertical_scrollbar_region: + yield self.vertical_scrollbar, vertical_scrollbar_region + if horizontal_scrollbar_region: + yield self.horizontal_scrollbar, horizontal_scrollbar_region + elif show_vertical_scrollbar: + region, scrollbar_region = region.split_vertical(-1) + if scrollbar_region: + yield self.vertical_scrollbar, scrollbar_region + elif show_horizontal_scrollbar: + region, scrollbar_region = region.split_horizontal(-1) + if scrollbar_region: + yield self.horizontal_scrollbar, scrollbar_region + def get_pseudo_classes(self) -> Iterable[str]: """Pseudo classes for a widget""" if self._mouse_over: diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index 148a60074..455ebe1a1 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -11,11 +11,14 @@ class Static(Widget): def __init__( self, renderable: RenderableType, + *, name: str | None = None, + id: str | None = None, + classes: set[str] | None = None, style: StyleType = "", padding: PaddingDimensions = 0, ) -> None: - super().__init__(name) + super().__init__(name=name, id=id, classes=classes) self.renderable = renderable self.style = style self.padding = padding diff --git a/tests/test_geometry.py b/tests/test_geometry.py index d0305941a..bcda65129 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -307,3 +307,49 @@ def test_spacing_add(): with pytest.raises(TypeError): Spacing(1, 2, 3, 4) + "foo" + + +def test_split(): + assert Region(10, 5, 22, 15).split(10, 5) == ( + Region(10, 5, 10, 5), + Region(20, 5, 12, 10), + Region(10, 10, 10, 10), + Region(20, 10, 10, 5), + ) + + +def test_split_negative(): + assert Region(10, 5, 22, 15).split(-1, -1) == ( + Region(10, 5, 21, 14), + Region(31, 5, 1, 14), + Region(10, 19, 21, 1), + Region(31, 19, 1, 1), + ) + + +def test_split_vertical(): + assert Region(10, 5, 22, 15).split_vertical(10) == ( + Region(10, 5, 10, 15), + Region(20, 5, 12, 15), + ) + + +def test_split_vertical_negative(): + assert Region(10, 5, 22, 15).split_vertical(-1) == ( + Region(10, 5, 21, 15), + Region(31, 5, 1, 15), + ) + + +def test_split_horizontal(): + assert Region(10, 5, 22, 15).split_horizontal(5) == ( + Region(10, 5, 22, 5), + Region(10, 10, 22, 10), + ) + + +def test_split_horizontal_negative(): + assert Region(10, 5, 22, 15).split_horizontal(-1) == ( + Region(10, 5, 22, 14), + Region(10, 19, 22, 1), + )