gemoetry split

This commit is contained in:
Will McGugan
2022-03-14 17:23:03 +00:00
parent 6440bac01e
commit 924683b427
12 changed files with 271 additions and 35 deletions

View File

@@ -20,17 +20,20 @@ class BasicApp(App):
Widget(id="uber2-child2"), 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( self.mount(
uber1=Widget( uber1=uber1
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"}),
),
# uber2=uber2, # uber2=uber2,
) )

View File

@@ -13,6 +13,7 @@ from rich.style import Style
from . import errors, log from . import errors, log
from .geometry import Region, Offset, Size from .geometry import Region, Offset, Size
from ._arrange import arrange
from ._loop import loop_last from ._loop import loop_last
from ._types import Lines from ._types import Lines
from .widget import Widget from .widget import Widget
@@ -88,15 +89,20 @@ class Compositor:
# All widgets considered in the arrangement # 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 # Not this may be a supperset of self.map.keys() as some widgets may be invisible for various reasons
self.widgets: set[Widget] = set() self.widgets: set[Widget] = set()
# The top level widget
self.root: Widget | None = None self.root: Widget | None = None
# Dimensions of the arrangement # Dimensions of the arrangement
self.size = Size(0, 0) 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]] = {} 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._cuts: list[list[int]] | None = None
self._require_update: bool = True self._require_update: bool = True
self.background = ""
def __rich_repr__(self) -> rich.repr.Result: def __rich_repr__(self) -> rich.repr.Result:
yield "size", self.size yield "size", self.size
@@ -202,9 +208,15 @@ class Compositor:
total_region = region.size.region total_region = region.size.region
sub_clip = clip.intersection(region) sub_clip = clip.intersection(region)
placements, arranged_widgets = widget.layout.arrange( # for chrome_widget, chrome_region in widget.arrange_chrome(region.size):
widget, region.size, scroll # 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) widgets.update(arranged_widgets)
placements = sorted(placements, key=attrgetter("order")) placements = sorted(placements, key=attrgetter("order"))
@@ -219,6 +231,16 @@ class Compositor:
sub_clip, 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( map[widget] = RenderRegion(
region + layout_offset, order, clip, total_region.size 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]: def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]:
"""Get the widget under the given point or None.""" """Get the widget under the given point or None."""
contains = Region.contains
for widget, cropped_region, region, _ in self: for widget, cropped_region, region, _ in self:
if cropped_region.contains(x, y): if contains(cropped_region, x, y):
return widget, region return widget, region
raise errors.NoWidget(f"No widget under screen coordinate ({x}, {y})") raise errors.NoWidget(f"No widget under screen coordinate ({x}, {y})")
@@ -345,7 +368,7 @@ class Compositor:
[ [
(widget, region, order, clip) (widget, region, order, clip)
for widget, (region, order, clip, _) in self.map.items() for widget, (region, order, clip, _) in self.map.items()
if widget.visible if widget.visible and not widget.is_transparent
], ],
key=itemgetter(2), key=itemgetter(2),
reverse=True, reverse=True,
@@ -355,14 +378,12 @@ class Compositor:
divide = Segment.divide divide = Segment.divide
intersection = Region.intersection intersection = Region.intersection
overlaps = Region.overlaps
for widget, region, _order, clip in widget_regions: for widget, region, _order, clip in widget_regions:
if widget.is_transparent:
continue
if region in clip: if region in clip:
lines = widget._get_lines() yield region, clip, widget._get_lines()
yield region, clip, lines elif overlaps(clip, region):
elif clip.overlaps(region):
lines = widget._get_lines() lines = widget._get_lines()
new_x, new_y, new_width, new_height = intersection(region, clip) new_x, new_y, new_width, new_height = intersection(region, clip)
delta_x = new_x - region.x delta_x = new_x - region.x
@@ -377,7 +398,7 @@ class Compositor:
cls, chops: list[dict[int, list[Segment] | None]] cls, chops: list[dict[int, list[Segment] | None]]
) -> list[list[Segment]]: ) -> 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 = [ segment_lines = [
sum( sum(
[line for line in bucket.values() if line is not None], [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 first_cut, last_cut = render_region.x_extents
final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)] 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: if len(final_cuts) == 2:
# Two cuts, which means the entire line # Two cuts, which means the entire line
cut_segments = [line] cut_segments = [line]
@@ -440,8 +462,8 @@ class Compositor:
render_x = render_region.x render_x = render_region.x
relative_cuts = [cut - render_x for cut in final_cuts] relative_cuts = [cut - render_x for cut in final_cuts]
_, *cut_segments = divide(line, relative_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] chops_line = chops[y]
for cut, segments in zip(final_cuts, cut_segments): for cut, segments in zip(final_cuts, cut_segments):
if chops_line[cut] is None: if chops_line[cut] is None:

View File

@@ -488,6 +488,95 @@ class Region(NamedTuple):
) )
return union_region 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): class Spacing(NamedTuple):
"""The spacing around a renderable.""" """The spacing around a renderable."""

View File

@@ -48,7 +48,7 @@ class Layout(ABC):
@abstractmethod @abstractmethod
def arrange( def arrange(
self, parent: Widget, size: Size, scroll: Offset 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. """Generate a layout map that defines where on the screen the widgets will be drawn.
Args: Args:

View File

@@ -61,7 +61,7 @@ class DockLayout(Layout):
def arrange( def arrange(
self, parent: Widget, size: Size, scroll: Offset self, parent: Widget, size: Size, scroll: Offset
) -> tuple[Iterable[WidgetPlacement], set[Widget]]: ) -> tuple[list[WidgetPlacement], set[Widget]]:
width, height = size width, height = size
layout_region = Region(0, 0, width, height) layout_region = Region(0, 0, width, height)

View File

@@ -1,7 +1,5 @@
from __future__ import annotations from __future__ import annotations
from typing import Iterable
from textual.geometry import Size, Offset, Region from textual.geometry import Size, Offset, Region
from textual.layout import Layout, WidgetPlacement from textual.layout import Layout, WidgetPlacement
@@ -27,11 +25,9 @@ class HorizontalLayout(Layout):
parent_size = parent.size parent_size = parent.size
for widget in parent.children: for widget in parent.children:
(content_width, content_height), margin = widget.styles.get_box_model( (content_width, content_height), margin = widget.styles.get_box_model(
size, parent_size size, parent_size
) )
region = Region(margin.left + x, margin.top, content_width, content_height) region = Region(margin.left + x, margin.top, content_width, content_height)
max_height = max(max_height, content_height + margin.height) max_height = max(max_height, content_height + margin.height)
add_placement(WidgetPlacement(region, widget, 0)) add_placement(WidgetPlacement(region, widget, 0))

View File

@@ -27,11 +27,9 @@ class VerticalLayout(Layout):
parent_size = parent.size parent_size = parent.size
for widget in parent.children: for widget in parent.children:
(content_width, content_height), margin = widget.styles.get_box_model( (content_width, content_height), margin = widget.styles.get_box_model(
size, parent_size size, parent_size
) )
region = Region(margin.left, y + margin.top, content_width, content_height) region = Region(margin.left, y + margin.top, content_width, content_height)
max_width = max(max_width, content_width + margin.width) max_width = max(max_width, content_width + margin.width)
add_placement(WidgetPlacement(region, widget, 0)) add_placement(WidgetPlacement(region, widget, 0))

View File

@@ -95,10 +95,12 @@ class Screen(Widget):
try: try:
hidden, shown, resized = self._compositor.reflow(self, self.size) hidden, shown, resized = self._compositor.reflow(self, self.size)
Hide = events.Hide
Show = events.Show
for widget in hidden: for widget in hidden:
widget.post_message_no_wait(events.Hide(self)) widget.post_message_no_wait(Hide(self))
for widget in shown: for widget in shown:
widget.post_message_no_wait(events.Show(self)) widget.post_message_no_wait(Show(self))
send_resize = shown | resized send_resize = shown | resized

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import rich.repr import rich.repr
from rich.color import Color from rich.color import Color
from rich.console import ConsoleOptions, RenderResult, RenderableType 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 rich.style import Style, StyleType
from textual.reactive import Reactive from textual.reactive import Reactive

View File

@@ -36,8 +36,10 @@ from .layout import Layout
from .reactive import Reactive, watch from .reactive import Reactive, watch
from .renderables.opacity import Opacity from .renderables.opacity import Opacity
if TYPE_CHECKING: if TYPE_CHECKING:
from .screen import Screen from .screen import Screen
from .scrollbar import ScrollBar
class RenderCache(NamedTuple): class RenderCache(NamedTuple):
@@ -79,6 +81,9 @@ class Widget(DOMNode):
self.render_cache: RenderCache | None = None self.render_cache: RenderCache | None = None
self.highlight_style: Style | 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) super().__init__(name=name, id=id, classes=classes)
self.add_children(*children) self.add_children(*children)
@@ -87,6 +92,54 @@ class Widget(DOMNode):
scroll_x = Reactive(0) scroll_x = Reactive(0)
scroll_y = Reactive(0) scroll_y = Reactive(0)
virtual_size = Reactive(Size(0, 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 (<vertical scrollbar enabled>, <horizontal scrollbar enabled>)
"""
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: def __init_subclass__(cls, can_focus: bool = True) -> None:
super().__init_subclass__() super().__init_subclass__()
@@ -102,6 +155,30 @@ class Widget(DOMNode):
if pseudo_classes: if pseudo_classes:
yield "pseudo_classes", set(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]: def get_pseudo_classes(self) -> Iterable[str]:
"""Pseudo classes for a widget""" """Pseudo classes for a widget"""
if self._mouse_over: if self._mouse_over:

View File

@@ -11,11 +11,14 @@ class Static(Widget):
def __init__( def __init__(
self, self,
renderable: RenderableType, renderable: RenderableType,
*,
name: str | None = None, name: str | None = None,
id: str | None = None,
classes: set[str] | None = None,
style: StyleType = "", style: StyleType = "",
padding: PaddingDimensions = 0, padding: PaddingDimensions = 0,
) -> None: ) -> None:
super().__init__(name) super().__init__(name=name, id=id, classes=classes)
self.renderable = renderable self.renderable = renderable
self.style = style self.style = style
self.padding = padding self.padding = padding

View File

@@ -307,3 +307,49 @@ def test_spacing_add():
with pytest.raises(TypeError): with pytest.raises(TypeError):
Spacing(1, 2, 3, 4) + "foo" 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),
)