mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
layout_map refactor
This commit is contained in:
@@ -26,11 +26,12 @@ class SmoothApp(App):
|
||||
"""Build layout here."""
|
||||
footer = Footer()
|
||||
self.bar = Placeholder(name="left")
|
||||
self.bar.layout_offset_x = -40
|
||||
|
||||
await self.view.dock(footer, edge="bottom")
|
||||
await self.view.dock(Placeholder(), Placeholder(), edge="top")
|
||||
await self.view.dock(self.bar, edge="left", size=40, z=1)
|
||||
|
||||
self.bar.layout_offset_x = -40
|
||||
|
||||
SmoothApp.run()
|
||||
|
||||
SmoothApp.run(log="textual.log")
|
||||
|
||||
@@ -42,6 +42,7 @@ class MyApp(App):
|
||||
)
|
||||
self.app.sub_title = os.path.basename(message.path)
|
||||
await self.body.update(syntax)
|
||||
# self.body.layout_offset_y = -5
|
||||
self.body.home()
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ class GridTest(App):
|
||||
|
||||
async def on_mount(self, event: events.Mount) -> None:
|
||||
|
||||
grid = await self.view.dock_grid()
|
||||
grid = await self.view.dock_grid(edge="left", size=70, name="left")
|
||||
left = self.view["left"]
|
||||
|
||||
grid.add_column(fraction=1, name="left", min_size=20)
|
||||
grid.add_column(size=30, name="center")
|
||||
@@ -26,11 +27,17 @@ class GridTest(App):
|
||||
area4="right,top-start|middle-end",
|
||||
)
|
||||
|
||||
def make_placeholder(name: str) -> Placeholder:
|
||||
p = Placeholder(name=name)
|
||||
p.layout_offset_x = 10
|
||||
p.layout_offset_y = 0
|
||||
return p
|
||||
|
||||
grid.place(
|
||||
area1=Placeholder(name="area1"),
|
||||
area2=Placeholder(name="area2"),
|
||||
area3=Placeholder(name="area3"),
|
||||
area4=Placeholder(name="area4"),
|
||||
area1=make_placeholder(name="area1"),
|
||||
area2=make_placeholder(name="area2"),
|
||||
area3=make_placeholder(name="area3"),
|
||||
area4=make_placeholder(name="area4"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ class App(MessagePump):
|
||||
driver_class (Type[Driver], optional): Driver class, or None to use default. Defaults to None.
|
||||
title (str, optional): Title of the application. Defaults to "Textual Application".
|
||||
"""
|
||||
self.console = console or get_console()
|
||||
self.console = console or Console()
|
||||
self.error_console = Console(stderr=True)
|
||||
self._screen = screen
|
||||
self.driver_class = driver_class or LinuxDriver
|
||||
|
||||
@@ -76,8 +76,19 @@ class Dimensions(NamedTuple):
|
||||
|
||||
@property
|
||||
def area(self) -> int:
|
||||
"""Get the area of the dimensions.
|
||||
|
||||
Returns:
|
||||
int: Area in cells.
|
||||
"""
|
||||
return self.width * self.height
|
||||
|
||||
@property
|
||||
def region(self) -> Region:
|
||||
"""Get a region of the same size."""
|
||||
width, height = self
|
||||
return Region(0, 0, width, height)
|
||||
|
||||
def contains(self, x: int, y: int) -> bool:
|
||||
"""Check if a point is in the region.
|
||||
|
||||
@@ -138,8 +149,23 @@ class Region(NamedTuple):
|
||||
"""
|
||||
return cls(x1, y1, x2 - x1, y2 - y1)
|
||||
|
||||
@classmethod
|
||||
def from_origin(cls, origin: tuple[int, int], size: tuple[int, int]) -> Region:
|
||||
"""Create a region from origin and size.
|
||||
|
||||
Args:
|
||||
origin (Point): [description]
|
||||
size (tuple[int, int]): [description]
|
||||
|
||||
Returns:
|
||||
Region: [description]
|
||||
"""
|
||||
x, y = origin
|
||||
width, height = size
|
||||
return Region(x, y, width, height)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return self.width != 0 and self.height != 0
|
||||
return bool(self.width and self.height)
|
||||
|
||||
@property
|
||||
def area(self) -> int:
|
||||
@@ -151,17 +177,6 @@ class Region(NamedTuple):
|
||||
"""Get the start point of the region."""
|
||||
return Point(self.x, self.y)
|
||||
|
||||
@property
|
||||
def limit(self) -> Point:
|
||||
x, y, width, height = self
|
||||
return Point(x + width, y + height)
|
||||
|
||||
@property
|
||||
def limit_inclusive(self) -> Point:
|
||||
"""Get the end point of the region."""
|
||||
x, y, width, height = self
|
||||
return Point(x + width - 1, y + height - 1)
|
||||
|
||||
@property
|
||||
def size(self) -> Dimensions:
|
||||
"""Get the size of the region."""
|
||||
@@ -251,7 +266,7 @@ class Region(NamedTuple):
|
||||
x2 >= ox2 >= x1 and y2 >= oy2 >= y1
|
||||
)
|
||||
|
||||
def translate(self, translate_x: int, translate_y: int) -> Region:
|
||||
def translate(self, x: int = 0, y: int = 0) -> Region:
|
||||
"""Move the origin of the Region.
|
||||
|
||||
Args:
|
||||
@@ -262,8 +277,8 @@ class Region(NamedTuple):
|
||||
Region: A new region shifted by x, y
|
||||
"""
|
||||
|
||||
x, y, width, height = self
|
||||
return Region(x + translate_x, y + translate_y, width, height)
|
||||
self_x, self_y, width, height = self
|
||||
return Region(self_x + x, self_y + y, width, height)
|
||||
|
||||
def __contains__(self, other: Any) -> bool:
|
||||
"""Check if a point is in this region."""
|
||||
@@ -287,16 +302,17 @@ class Region(NamedTuple):
|
||||
"""
|
||||
x1, y1, x2, y2 = self.corners
|
||||
|
||||
_clamp = clamp
|
||||
new_region = Region.from_corners(
|
||||
clamp(x1, 0, width),
|
||||
clamp(y1, 0, height),
|
||||
clamp(x2, 0, width),
|
||||
clamp(y2, 0, height),
|
||||
_clamp(x1, 0, width),
|
||||
_clamp(y1, 0, height),
|
||||
_clamp(x2, 0, width),
|
||||
_clamp(y2, 0, height),
|
||||
)
|
||||
return new_region
|
||||
|
||||
def clip_region(self, region: Region) -> Region:
|
||||
"""Clip this region to fit within another region.
|
||||
def intersection(self, region: Region) -> Region:
|
||||
"""Get that covers both regions.
|
||||
|
||||
Args:
|
||||
region ([type]): A region that overlaps this region.
|
||||
@@ -307,10 +323,11 @@ class Region(NamedTuple):
|
||||
x1, y1, x2, y2 = self.corners
|
||||
cx1, cy1, cx2, cy2 = region.corners
|
||||
|
||||
_clamp = clamp
|
||||
new_region = Region.from_corners(
|
||||
clamp(x1, cx1, cx2),
|
||||
clamp(y1, cy1, cy2),
|
||||
clamp(x2, cx2, cx2),
|
||||
clamp(y2, cy2, cy2),
|
||||
_clamp(x1, cx1, cx2),
|
||||
_clamp(y1, cy1, cy2),
|
||||
_clamp(x2, cx1, cx2),
|
||||
_clamp(y2, cy1, cy2),
|
||||
)
|
||||
return new_region
|
||||
|
||||
@@ -17,6 +17,7 @@ from rich.style import Style
|
||||
|
||||
from . import log
|
||||
from ._loop import loop_last
|
||||
from .layout_map import LayoutMap
|
||||
from ._types import Lines
|
||||
|
||||
from .geometry import clamp, Region, Point, Dimensions
|
||||
@@ -34,27 +35,6 @@ class NoWidget(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class RenderRegion(NamedTuple):
|
||||
region: Region
|
||||
order: tuple[int, int]
|
||||
offset: Point
|
||||
|
||||
def translate(self, offset: Point) -> RenderRegion:
|
||||
region, order, self_offset = self
|
||||
return RenderRegion(region, order, self_offset + offset)
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.RichReprResult:
|
||||
yield "region", self.region
|
||||
yield "order", self.order
|
||||
|
||||
|
||||
@dataclass
|
||||
class WidgetMap:
|
||||
virtual_size: Dimensions
|
||||
widgets: dict[Widget, RenderRegion]
|
||||
|
||||
|
||||
class OrderedRegion(NamedTuple):
|
||||
region: Region
|
||||
order: tuple[int, int]
|
||||
@@ -92,10 +72,10 @@ class Layout(ABC):
|
||||
"""Responsible for arranging Widgets in a view."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._layout_map: dict[Widget, RenderRegion] = {}
|
||||
self._layout_map: LayoutMap | None = None
|
||||
self.width = 0
|
||||
self.height = 0
|
||||
self.renders: dict[Widget, tuple[Region, Lines]] = {}
|
||||
self.renders: dict[Widget, tuple[Region, Region, Lines]] = {}
|
||||
self._cuts: list[list[int]] | None = None
|
||||
self._require_update: bool = True
|
||||
self.background = ""
|
||||
@@ -113,31 +93,33 @@ class Layout(ABC):
|
||||
self._cuts = None
|
||||
if self._require_update:
|
||||
self.renders.clear()
|
||||
self._layout_map.clear()
|
||||
self._layout_map = None
|
||||
|
||||
def reflow(
|
||||
self, console: Console, width: int, height: int, viewport: Region
|
||||
) -> ReflowResult:
|
||||
self.reset()
|
||||
|
||||
map = self.generate_map(console, Dimensions(width, height), Point(0, 0))
|
||||
map = self.generate_map(
|
||||
console, Dimensions(width, height), Region(0, 0, width, height)
|
||||
)
|
||||
self._require_update = False
|
||||
|
||||
map = {
|
||||
widget: OrderedRegion(region + offset, order)
|
||||
for widget, (region, order, offset) in map.items()
|
||||
}
|
||||
# log(map.widgets)
|
||||
# map = {
|
||||
# widget: OrderedRegion(region + offset, order)
|
||||
# for widget, (region, order, offset) in map.items()
|
||||
# }
|
||||
|
||||
# Filter out widgets that are off screen or zero area
|
||||
log("VIEWPORT", viewport)
|
||||
log(map)
|
||||
map = {
|
||||
widget: map_region
|
||||
for widget, map_region in map.items()
|
||||
if map_region.region and viewport.overlaps(map_region.region)
|
||||
}
|
||||
|
||||
old_widgets = set(self._layout_map.keys())
|
||||
# map = {
|
||||
# widget: map_region
|
||||
# for widget, map_region in map.items()
|
||||
# if map_region.region and viewport.overlaps(map_region.region)
|
||||
# }
|
||||
|
||||
old_widgets = set() if self.map is None else set(self.map.keys())
|
||||
new_widgets = set(map.keys())
|
||||
# Newly visible widgets
|
||||
shown_widgets = new_widgets - old_widgets
|
||||
@@ -150,8 +132,8 @@ class Layout(ABC):
|
||||
|
||||
# Copy renders if the size hasn't changed
|
||||
new_renders = {
|
||||
widget: (region, self.renders[widget][1])
|
||||
for widget, (region, _order) in map.items()
|
||||
widget: (region, clip, self.renders[widget][2])
|
||||
for widget, (region, _order, clip) in map.items()
|
||||
if (
|
||||
widget in self.renders
|
||||
and self.renders[widget][0].size == region.size
|
||||
@@ -163,7 +145,7 @@ class Layout(ABC):
|
||||
# Widgets with changed size
|
||||
resized_widgets = {
|
||||
widget
|
||||
for widget, (region, _order) in map.items()
|
||||
for widget, (region, *_) in map.items()
|
||||
if widget in old_widgets and widget.size != region.size
|
||||
}
|
||||
|
||||
@@ -177,35 +159,34 @@ class Layout(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def generate_map(
|
||||
self,
|
||||
console: Console,
|
||||
size: Dimensions,
|
||||
offset: Point,
|
||||
) -> dict[Widget, RenderRegion]:
|
||||
self, console: Console, size: Dimensions, viewport: Region
|
||||
) -> LayoutMap:
|
||||
...
|
||||
|
||||
async def mount_all(self, view: "View") -> None:
|
||||
await view.mount(*self.get_widgets())
|
||||
|
||||
@property
|
||||
def map(self) -> dict[Widget, RenderRegion]:
|
||||
def map(self) -> LayoutMap | None:
|
||||
return self._layout_map
|
||||
|
||||
def __iter__(self) -> Iterator[tuple[Widget, Region]]:
|
||||
layers = sorted(
|
||||
self._layout_map.items(), key=lambda item: item[1].order, reverse=True
|
||||
)
|
||||
for widget, (region, _) in layers:
|
||||
yield widget, region
|
||||
if self.map is not None:
|
||||
layers = sorted(
|
||||
self.map.widgets.items(), key=lambda item: item[1].order, reverse=True
|
||||
)
|
||||
for widget, (region, order, clip) in layers:
|
||||
yield widget, region.intersection(clip)
|
||||
|
||||
def __reversed__(self) -> Iterable[tuple[Widget, Region]]:
|
||||
layers = sorted(self._layout_map.items(), key=lambda item: item[1].order)
|
||||
for widget, (region, _) in layers:
|
||||
yield widget, region
|
||||
if self.map is not None:
|
||||
layers = sorted(self.map.items(), key=lambda item: item[1].order)
|
||||
for widget, (region, _order, clip) in layers:
|
||||
yield widget, region.intersection(clip)
|
||||
|
||||
def get_offset(self, widget: Widget) -> Point:
|
||||
try:
|
||||
return self._layout_map[widget].region.origin
|
||||
return self.map[widget].region.origin
|
||||
except KeyError:
|
||||
raise NoWidget("Widget is not in layout")
|
||||
|
||||
@@ -221,7 +202,9 @@ class Layout(ABC):
|
||||
widget, region = self.get_widget_at(x, y)
|
||||
except NoWidget:
|
||||
return Style.null()
|
||||
_region, lines = self.renders[widget]
|
||||
if widget not in self.renders:
|
||||
return Style.null()
|
||||
_region, clip, lines = self.renders[widget]
|
||||
x -= region.x
|
||||
y -= region.y
|
||||
line = lines[y]
|
||||
@@ -234,7 +217,7 @@ class Layout(ABC):
|
||||
|
||||
def get_widget_region(self, widget: Widget) -> Region:
|
||||
try:
|
||||
region, _ = self._layout_map[widget]
|
||||
region, *_ = self.map[widget]
|
||||
except KeyError:
|
||||
raise NoWidget("Widget is not in layout")
|
||||
else:
|
||||
@@ -256,28 +239,35 @@ class Layout(ABC):
|
||||
screen_region = Region(0, 0, width, height)
|
||||
cuts_sets = [{0, width} for _ in range(height)]
|
||||
|
||||
for region, order in self._layout_map.values():
|
||||
region = region.clip(width, height)
|
||||
if region and (region in screen_region): # type: ignore
|
||||
for y in range(region.y, region.y + region.height):
|
||||
cuts_sets[y].update({region.x, region.x + region.width})
|
||||
if self.map is not None:
|
||||
for region, order, clip in self.map.values():
|
||||
region = region.intersection(clip)
|
||||
if region and (region in screen_region): # type: ignore
|
||||
for y in range(region.y, region.y + region.height):
|
||||
cuts_sets[y].update({region.x, region.x + region.width})
|
||||
|
||||
# Sort the cuts for each line
|
||||
self._cuts = [sorted(cut_set) for cut_set in cuts_sets]
|
||||
return self._cuts
|
||||
|
||||
def _get_renders(self, console: Console) -> Iterable[tuple[Region, Lines]]:
|
||||
def _get_renders(self, console: Console) -> Iterable[tuple[Region, Region, Lines]]:
|
||||
_rich_traceback_guard = True
|
||||
width = self.width
|
||||
height = self.height
|
||||
screen_region = Region(0, 0, width, height)
|
||||
layout_map = self._layout_map
|
||||
layout_map = self.map
|
||||
|
||||
widget_regions = sorted(
|
||||
((widget, region, order) for widget, (region, order) in layout_map.items()),
|
||||
key=itemgetter(2),
|
||||
reverse=True,
|
||||
)
|
||||
if layout_map:
|
||||
widget_regions = sorted(
|
||||
(
|
||||
(widget, region, order, clip)
|
||||
for widget, (region, order, clip) in layout_map.items()
|
||||
),
|
||||
key=itemgetter(2),
|
||||
reverse=True,
|
||||
)
|
||||
else:
|
||||
widget_regions = []
|
||||
|
||||
def render(widget: Widget, width: int, height: int) -> Lines:
|
||||
lines = console.render_lines(
|
||||
@@ -285,7 +275,7 @@ class Layout(ABC):
|
||||
)
|
||||
return lines
|
||||
|
||||
for widget, region, _order in widget_regions:
|
||||
for widget, region, _order, clip in widget_regions:
|
||||
|
||||
if not widget.is_visual:
|
||||
continue
|
||||
@@ -295,23 +285,22 @@ class Layout(ABC):
|
||||
continue
|
||||
|
||||
lines = render(widget, region.width, region.height)
|
||||
if region in screen_region:
|
||||
self.renders[widget] = (region, lines)
|
||||
yield region, lines
|
||||
elif screen_region.overlaps(region):
|
||||
new_region = region.clip(width, height)
|
||||
if region in clip:
|
||||
self.renders[widget] = (region, clip, lines)
|
||||
yield region, clip, lines
|
||||
elif clip.overlaps(region):
|
||||
new_region = region.intersection(clip)
|
||||
delta_x = new_region.x - region.x
|
||||
delta_y = new_region.y - region.y
|
||||
region = new_region
|
||||
self.renders[widget] = (region, clip, lines)
|
||||
splits = [delta_x, delta_x + new_region.width]
|
||||
|
||||
splits = [delta_x, delta_x + region.width]
|
||||
divide = Segment.divide
|
||||
lines = [
|
||||
list(divide(line, splits))[1]
|
||||
for line in lines[delta_y : delta_y + region.height]
|
||||
for line in lines[delta_y : delta_y + new_region.height]
|
||||
]
|
||||
self.renders[widget] = (region, lines)
|
||||
yield region, lines
|
||||
yield region, clip, lines
|
||||
|
||||
@classmethod
|
||||
def _assemble_chops(
|
||||
@@ -361,7 +350,10 @@ class Layout(ABC):
|
||||
]
|
||||
# Go through all the renders in reverse order and fill buckets with no render
|
||||
renders = self._get_renders(console)
|
||||
for region, lines in chain(renders, [(screen, background_render)]):
|
||||
for region, clip, lines in chain(
|
||||
renders, [(screen, screen, background_render)]
|
||||
):
|
||||
# region = region.intersection(clip)
|
||||
for y, line in enumerate(lines, region.y):
|
||||
if clip_y > y > clip_y2:
|
||||
continue
|
||||
@@ -391,12 +383,12 @@ class Layout(ABC):
|
||||
if widget not in self.renders:
|
||||
return None
|
||||
|
||||
region, lines = self.renders[widget]
|
||||
region, clip, lines = self.renders[widget]
|
||||
new_lines = console.render_lines(
|
||||
widget, console.options.update_dimensions(region.width, region.height)
|
||||
)
|
||||
|
||||
self.renders[widget] = (region, new_lines)
|
||||
self.renders[widget] = (region, clip, new_lines)
|
||||
|
||||
update_lines = self.render(console, region).lines
|
||||
return LayoutUpdate(update_lines, region.x, region.y)
|
||||
|
||||
61
src/textual/layout_map.py
Normal file
61
src/textual/layout_map.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from typing import ItemsView, KeysView, ValuesView, NamedTuple
|
||||
|
||||
from .geometry import Region, Dimensions
|
||||
|
||||
from .widget import Widget
|
||||
|
||||
|
||||
class Order(NamedTuple):
|
||||
layer: int
|
||||
z: int
|
||||
|
||||
|
||||
class RenderRegion(NamedTuple):
|
||||
region: Region
|
||||
order: tuple[int, ...]
|
||||
clip: Region
|
||||
|
||||
|
||||
class LayoutMap:
|
||||
def __init__(self, size: Dimensions) -> None:
|
||||
self.size = size
|
||||
self.widgets: dict[Widget, RenderRegion] = {}
|
||||
|
||||
def __getitem__(self, widget: Widget) -> RenderRegion:
|
||||
return self.widgets[widget]
|
||||
|
||||
def items(self) -> ItemsView:
|
||||
return self.widgets.items()
|
||||
|
||||
def keys(self) -> KeysView:
|
||||
return self.widgets.keys()
|
||||
|
||||
def values(self) -> ValuesView:
|
||||
return self.widgets.values()
|
||||
|
||||
def clear(self) -> None:
|
||||
self.widgets.clear()
|
||||
|
||||
def add_widget(
|
||||
self,
|
||||
console: Console,
|
||||
widget: Widget,
|
||||
region: Region,
|
||||
order: tuple[int, ...],
|
||||
clip: Region,
|
||||
) -> None:
|
||||
from .view import View
|
||||
|
||||
region += widget.layout_offset
|
||||
self.widgets[widget] = RenderRegion(region, order, clip)
|
||||
|
||||
if isinstance(widget, View):
|
||||
sub_map = widget.layout.generate_map(console, region.size, region)
|
||||
for widget, (sub_region, sub_order, sub_clip) in sub_map.items():
|
||||
sub_region += region.origin
|
||||
sub_clip = sub_clip.intersection(clip)
|
||||
self.add_widget(console, widget, sub_region, sub_order, sub_clip)
|
||||
@@ -9,7 +9,8 @@ from rich.console import Console
|
||||
|
||||
from .._layout_resolve import layout_resolve
|
||||
from ..geometry import Region, Point, Dimensions
|
||||
from ..layout import Layout, RenderRegion
|
||||
from ..layout import Layout
|
||||
from ..layout_map import LayoutMap, Order
|
||||
|
||||
if sys.version_info >= (3, 8):
|
||||
from typing import Literal
|
||||
@@ -48,25 +49,16 @@ class DockLayout(Layout):
|
||||
yield from dock.widgets
|
||||
|
||||
def generate_map(
|
||||
self, console: Console, size: Dimensions, offset: Point
|
||||
) -> dict[Widget, RenderRegion]:
|
||||
from ..view import View
|
||||
self, console: Console, size: Dimensions, viewport: Region
|
||||
) -> LayoutMap:
|
||||
|
||||
map: dict[Widget, RenderRegion] = {}
|
||||
map: LayoutMap = LayoutMap(size)
|
||||
width, height = size
|
||||
layout_region = Region(0, 0, width, height)
|
||||
layers: dict[int, Region] = defaultdict(lambda: layout_region)
|
||||
|
||||
def add_widget(widget: Widget, region: Region, order: tuple[int, int]):
|
||||
region = region + widget.layout_offset
|
||||
map[widget] = RenderRegion(region, order, offset)
|
||||
if isinstance(widget, View):
|
||||
sub_map = widget.layout.generate_map(
|
||||
console,
|
||||
Dimensions(region.width, region.height),
|
||||
region.origin + offset,
|
||||
)
|
||||
map.update(sub_map)
|
||||
def add_widget(widget: Widget, region: Region, order: tuple[int, ...]):
|
||||
map.add_widget(console, widget, region, order, viewport)
|
||||
|
||||
for index, dock in enumerate(self.docks):
|
||||
dock_options = [
|
||||
@@ -88,16 +80,16 @@ class DockLayout(Layout):
|
||||
render_y = y
|
||||
remaining = region.height
|
||||
total = 0
|
||||
for widget, size in zip(dock.widgets, sizes):
|
||||
for widget, layout_size in zip(dock.widgets, sizes):
|
||||
if not widget.visible:
|
||||
continue
|
||||
size = min(remaining, size)
|
||||
if not size:
|
||||
layout_size = min(remaining, layout_size)
|
||||
if not layout_size:
|
||||
break
|
||||
total += size
|
||||
add_widget(widget, Region(x, render_y, width, size), order)
|
||||
render_y += size
|
||||
remaining = max(0, remaining - size)
|
||||
total += layout_size
|
||||
add_widget(widget, Region(x, render_y, width, layout_size), order)
|
||||
render_y += layout_size
|
||||
remaining = max(0, remaining - layout_size)
|
||||
region = Region(x, y + total, width, height - total)
|
||||
|
||||
elif dock.edge == "bottom":
|
||||
@@ -105,16 +97,20 @@ class DockLayout(Layout):
|
||||
render_y = y + height
|
||||
remaining = region.height
|
||||
total = 0
|
||||
for widget, size in zip(dock.widgets, sizes):
|
||||
for widget, layout_size in zip(dock.widgets, sizes):
|
||||
if not widget.visible:
|
||||
continue
|
||||
size = min(remaining, size)
|
||||
if not size:
|
||||
layout_size = min(remaining, layout_size)
|
||||
if not layout_size:
|
||||
break
|
||||
total += size
|
||||
add_widget(widget, Region(x, render_y - size, width, size), order)
|
||||
render_y -= size
|
||||
remaining = max(0, remaining - size)
|
||||
total += layout_size
|
||||
add_widget(
|
||||
widget,
|
||||
Region(x, render_y - layout_size, width, layout_size),
|
||||
order,
|
||||
)
|
||||
render_y -= layout_size
|
||||
remaining = max(0, remaining - layout_size)
|
||||
region = Region(x, y, width, height - total)
|
||||
|
||||
elif dock.edge == "left":
|
||||
@@ -122,16 +118,16 @@ class DockLayout(Layout):
|
||||
render_x = x
|
||||
remaining = region.width
|
||||
total = 0
|
||||
for widget, size in zip(dock.widgets, sizes):
|
||||
for widget, layout_size in zip(dock.widgets, sizes):
|
||||
if not widget.visible:
|
||||
continue
|
||||
size = min(remaining, size)
|
||||
if not size:
|
||||
layout_size = min(remaining, layout_size)
|
||||
if not layout_size:
|
||||
break
|
||||
total += size
|
||||
add_widget(widget, Region(render_x, y, size, height), order)
|
||||
render_x += size
|
||||
remaining = max(0, remaining - size)
|
||||
total += layout_size
|
||||
add_widget(widget, Region(render_x, y, layout_size, height), order)
|
||||
render_x += layout_size
|
||||
remaining = max(0, remaining - layout_size)
|
||||
region = Region(x + total, y, width - total, height)
|
||||
|
||||
elif dock.edge == "right":
|
||||
@@ -139,16 +135,20 @@ class DockLayout(Layout):
|
||||
render_x = x + width
|
||||
remaining = region.width
|
||||
total = 0
|
||||
for widget, size in zip(dock.widgets, sizes):
|
||||
for widget, layout_size in zip(dock.widgets, sizes):
|
||||
if not widget.visible:
|
||||
continue
|
||||
size = min(remaining, size)
|
||||
if not size:
|
||||
layout_size = min(remaining, layout_size)
|
||||
if not layout_size:
|
||||
break
|
||||
total += size
|
||||
add_widget(widget, Region(render_x - size, y, size, height), order)
|
||||
render_x -= size
|
||||
remaining = max(0, remaining - size)
|
||||
total += layout_size
|
||||
add_widget(
|
||||
widget,
|
||||
Region(render_x - layout_size, y, layout_size, height),
|
||||
order,
|
||||
)
|
||||
render_x -= layout_size
|
||||
remaining = max(0, remaining - layout_size)
|
||||
region = Region(x, y, width - total, height)
|
||||
|
||||
layers[dock.z] = region
|
||||
|
||||
@@ -12,8 +12,8 @@ from rich.console import Console
|
||||
|
||||
from .._layout_resolve import layout_resolve
|
||||
from ..geometry import Dimensions, Point, Region
|
||||
from ..layout import Layout, RenderRegion
|
||||
from ..view import View
|
||||
from ..layout import Layout
|
||||
from ..layout_map import LayoutMap
|
||||
from ..widget import Widget
|
||||
|
||||
if sys.version_info >= (3, 8):
|
||||
@@ -264,8 +264,8 @@ class GridLayout(Layout):
|
||||
return self.widgets.keys()
|
||||
|
||||
def generate_map(
|
||||
self, console: Console, size: Dimensions, offset: Point
|
||||
) -> dict[Widget, RenderRegion]:
|
||||
self, console: Console, size: Dimensions, viewport: Region
|
||||
) -> LayoutMap:
|
||||
"""Generate a map that associates widgets with their location on screen.
|
||||
|
||||
Args:
|
||||
@@ -276,6 +276,7 @@ class GridLayout(Layout):
|
||||
Returns:
|
||||
dict[Widget, OrderedRegion]: [description]
|
||||
"""
|
||||
map: LayoutMap = LayoutMap(size)
|
||||
width, height = size
|
||||
|
||||
def resolve(
|
||||
@@ -327,13 +328,14 @@ class GridLayout(Layout):
|
||||
return names, tracks, len(spans), max_size
|
||||
|
||||
def add_widget(widget: Widget, region: Region, order: tuple[int, int]):
|
||||
region = region + widget.layout_offset
|
||||
map[widget] = RenderRegion(region, order, offset)
|
||||
if isinstance(widget, View):
|
||||
sub_map = widget.layout.generate_map(
|
||||
region.width, region.height, region.origin + offset
|
||||
)
|
||||
map.update(sub_map)
|
||||
map.add_widget(console, widget, region, order, viewport)
|
||||
# region = region + widget.layout_offset
|
||||
# map[widget] = RenderRegion(region, order, offset)
|
||||
# if isinstance(widget, View):
|
||||
# sub_map = widget.layout.generate_map(
|
||||
# region.width, region.height, region.origin + offset
|
||||
# )
|
||||
# map.update(sub_map)
|
||||
|
||||
container = Dimensions(
|
||||
width - self.column_gutter * 2, height - self.row_gutter * 2
|
||||
@@ -365,8 +367,6 @@ class GridLayout(Layout):
|
||||
free_slots = {
|
||||
(col, row) for col, row in product(range(column_count), range(row_count))
|
||||
}
|
||||
|
||||
map: dict[Widget, RenderRegion] = {}
|
||||
order = 1
|
||||
from_corners = Region.from_corners
|
||||
gutter = Point(self.column_gutter, self.row_gutter)
|
||||
|
||||
@@ -4,7 +4,7 @@ from rich.console import Console
|
||||
|
||||
|
||||
from ..geometry import Point, Region, Dimensions
|
||||
from ..layout import Layout, RenderRegion, WidgetMap
|
||||
from ..layout import Layout
|
||||
from ..widget import Widget
|
||||
from ..view import View
|
||||
|
||||
@@ -19,8 +19,9 @@ class VerticalLayout(Layout):
|
||||
self._widgets.append(widget)
|
||||
|
||||
def generate_map(
|
||||
self, console: Console, size: Dimensions, offset: Point
|
||||
self, console: Console, size: Dimensions, viewport: Region
|
||||
) -> WidgetMap:
|
||||
offset = viewport.origin
|
||||
width, height = size
|
||||
gutter_width, gutter_height = self.gutter
|
||||
render_width = width - gutter_width * 2
|
||||
|
||||
@@ -62,6 +62,12 @@ class View(Widget):
|
||||
(virtual_height if virtual_height is not None else self.size.height),
|
||||
)
|
||||
|
||||
@virtual_size.setter
|
||||
def virtual_size(self, size: tuple[int, int]) -> None:
|
||||
width, height = size
|
||||
self.virtual_width = width
|
||||
self.virtual_height = height
|
||||
|
||||
@property
|
||||
def offset(self) -> Point:
|
||||
return Point(self.offset_x, self.offset_y)
|
||||
@@ -133,7 +139,7 @@ class View(Widget):
|
||||
|
||||
async def refresh_layout(self) -> None:
|
||||
await self.layout.mount_all(self)
|
||||
if not self.size or not self.is_root_view:
|
||||
if not self.size:
|
||||
return
|
||||
|
||||
width, height = self.console.size
|
||||
|
||||
@@ -61,8 +61,8 @@ class Widget(MessagePump):
|
||||
layout_size: Reactive[int | None] = Reactive(None, layout=True)
|
||||
layout_fraction: Reactive[int] = Reactive(1, layout=True)
|
||||
layout_min_size: Reactive[int] = Reactive(1, layout=True)
|
||||
layout_offset_x: Reactive[int] = Reactive(0, layout=True)
|
||||
layout_offset_y: Reactive[int] = Reactive(0, layout=True)
|
||||
layout_offset_x: Reactive[float] = Reactive(0.0, layout=True)
|
||||
layout_offset_y: Reactive[float] = Reactive(0.0, layout=True)
|
||||
|
||||
def validate_layout_offset_x(self, value) -> int:
|
||||
return int(value)
|
||||
|
||||
@@ -3,6 +3,57 @@ import pytest
|
||||
from textual.geometry import clamp, Point, Dimensions, Region
|
||||
|
||||
|
||||
def test_dimensions_region():
|
||||
assert Dimensions(30, 40).region == Region(0, 0, 30, 40)
|
||||
|
||||
|
||||
def test_dimensions_contains():
|
||||
assert Dimensions(10, 10).contains(5, 5)
|
||||
assert Dimensions(10, 10).contains(9, 9)
|
||||
assert Dimensions(10, 10).contains(0, 0)
|
||||
assert not Dimensions(10, 10).contains(10, 9)
|
||||
assert not Dimensions(10, 10).contains(9, 10)
|
||||
assert not Dimensions(10, 10).contains(-1, 0)
|
||||
assert not Dimensions(10, 10).contains(0, -1)
|
||||
|
||||
|
||||
def test_dimensions_contains_point():
|
||||
assert Dimensions(10, 10).contains_point(Point(5, 5))
|
||||
assert Dimensions(10, 10).contains_point(Point(9, 9))
|
||||
assert Dimensions(10, 10).contains_point(Point(0, 0))
|
||||
assert not Dimensions(10, 10).contains_point(Point(10, 9))
|
||||
assert not Dimensions(10, 10).contains_point(Point(9, 10))
|
||||
assert not Dimensions(10, 10).contains_point(Point(-1, 0))
|
||||
assert not Dimensions(10, 10).contains_point(Point(0, -1))
|
||||
|
||||
|
||||
def test_dimensions_contains_special():
|
||||
with pytest.raises(TypeError):
|
||||
(1, 2, 3) in Dimensions(10, 10)
|
||||
|
||||
assert (5, 5) in Dimensions(10, 10)
|
||||
assert (9, 9) in Dimensions(10, 10)
|
||||
assert (0, 0) in Dimensions(10, 10)
|
||||
assert (10, 9) not in Dimensions(10, 10)
|
||||
assert (9, 10) not in Dimensions(10, 10)
|
||||
assert (-1, 0) not in Dimensions(10, 10)
|
||||
assert (0, -1) not in Dimensions(10, 10)
|
||||
|
||||
|
||||
def test_dimensions_bool():
|
||||
assert Dimensions(1, 1)
|
||||
assert Dimensions(3, 4)
|
||||
assert not Dimensions(0, 1)
|
||||
assert not Dimensions(1, 0)
|
||||
|
||||
|
||||
def test_dimensions_area():
|
||||
assert Dimensions(0, 0).area == 0
|
||||
assert Dimensions(1, 0).area == 0
|
||||
assert Dimensions(1, 1).area == 1
|
||||
assert Dimensions(4, 5).area == 20
|
||||
|
||||
|
||||
def test_clamp():
|
||||
assert clamp(5, 0, 10) == 5
|
||||
assert clamp(-1, 0, 10) == 0
|
||||
@@ -34,3 +85,94 @@ def test_point_blend():
|
||||
assert Point(1, 2).blend(Point(3, 4), 0) == Point(1, 2)
|
||||
assert Point(1, 2).blend(Point(3, 4), 1) == Point(3, 4)
|
||||
assert Point(1, 2).blend(Point(3, 4), 0.5) == Point(2, 3)
|
||||
|
||||
|
||||
def test_region_from_origin():
|
||||
assert Region.from_origin(Point(3, 4), (5, 6)) == Region(3, 4, 5, 6)
|
||||
|
||||
|
||||
def test_region_area():
|
||||
assert Region(3, 4, 0, 0).area == 0
|
||||
assert Region(3, 4, 5, 6).area == 30
|
||||
|
||||
|
||||
def test_region_size():
|
||||
assert isinstance(Region(3, 4, 5, 6).size, Dimensions)
|
||||
assert Region(3, 4, 5, 6).size == Dimensions(5, 6)
|
||||
|
||||
|
||||
def test_region_origin():
|
||||
assert Region(1, 2, 3, 4).origin == Point(1, 2)
|
||||
|
||||
|
||||
def test_region_add():
|
||||
assert Region(1, 2, 3, 4) + (10, 20) == Region(11, 22, 3, 4)
|
||||
with pytest.raises(TypeError):
|
||||
Region(1, 2, 3, 4) + "foo"
|
||||
|
||||
|
||||
def test_region_sub():
|
||||
assert Region(11, 22, 3, 4) - (10, 20) == Region(1, 2, 3, 4)
|
||||
with pytest.raises(TypeError):
|
||||
Region(1, 2, 3, 4) - "foo"
|
||||
|
||||
|
||||
def test_region_overlaps():
|
||||
assert Region(10, 10, 30, 20).overlaps(Region(0, 0, 20, 20))
|
||||
assert not Region(10, 10, 5, 5).overlaps(Region(15, 15, 20, 20))
|
||||
|
||||
assert not Region(10, 10, 5, 5).overlaps(Region(0, 0, 50, 10))
|
||||
assert Region(10, 10, 5, 5).overlaps(Region(0, 0, 50, 11))
|
||||
assert not Region(10, 10, 5, 5).overlaps(Region(0, 15, 50, 10))
|
||||
assert Region(10, 10, 5, 5).overlaps(Region(0, 14, 50, 10))
|
||||
|
||||
|
||||
def test_region_contains():
|
||||
assert Region(10, 10, 20, 30).contains(10, 10)
|
||||
assert Region(10, 10, 20, 30).contains(29, 39)
|
||||
assert not Region(10, 10, 20, 30).contains(30, 40)
|
||||
|
||||
|
||||
def test_region_contains_point():
|
||||
assert Region(10, 10, 20, 30).contains_point((10, 10))
|
||||
assert Region(10, 10, 20, 30).contains_point((29, 39))
|
||||
assert not Region(10, 10, 20, 30).contains_point((30, 40))
|
||||
with pytest.raises(TypeError):
|
||||
Region(10, 10, 20, 30).contains_point((1, 2, 3))
|
||||
|
||||
|
||||
def test_region_contains_region():
|
||||
assert Region(10, 10, 20, 30).contains_region(Region(10, 10, 5, 5))
|
||||
assert not Region(10, 10, 20, 30).contains_region(Region(10, 9, 5, 5))
|
||||
assert not Region(10, 10, 20, 30).contains_region(Region(9, 10, 5, 5))
|
||||
assert Region(10, 10, 20, 30).contains_region(Region(10, 10, 20, 30))
|
||||
assert not Region(10, 10, 20, 30).contains_region(Region(10, 10, 21, 30))
|
||||
assert not Region(10, 10, 20, 30).contains_region(Region(10, 10, 20, 31))
|
||||
|
||||
|
||||
def test_region_translate():
|
||||
assert Region(1, 2, 3, 4).translate(10, 20) == Region(11, 22, 3, 4)
|
||||
assert Region(1, 2, 3, 4).translate(y=20) == Region(1, 22, 3, 4)
|
||||
|
||||
|
||||
def test_region_contains_special():
|
||||
assert (10, 10) in Region(10, 10, 20, 30)
|
||||
assert (9, 10) not in Region(10, 10, 20, 30)
|
||||
assert Region(10, 10, 5, 5) in Region(10, 10, 20, 30)
|
||||
assert Region(5, 5, 5, 5) not in Region(10, 10, 20, 30)
|
||||
assert "foo" not in Region(0, 0, 10, 10)
|
||||
|
||||
|
||||
def test_clip():
|
||||
assert Region(10, 10, 20, 30).clip(20, 25) == Region(10, 10, 10, 15)
|
||||
|
||||
|
||||
def test_region_intersection():
|
||||
assert Region(0, 0, 100, 50).intersection(Region(10, 10, 10, 10)) == Region(
|
||||
10, 10, 10, 10
|
||||
)
|
||||
assert Region(10, 10, 30, 20).intersection(Region(20, 15, 60, 40)) == Region(
|
||||
20, 15, 20, 15
|
||||
)
|
||||
|
||||
assert not Region(10, 10, 20, 30).intersection(Region(50, 50, 100, 200))
|
||||
|
||||
Reference in New Issue
Block a user