mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
gemoetry split
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user