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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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