layout_map refactor

This commit is contained in:
Will McGugan
2021-07-27 14:23:28 +01:00
parent a5faf4a07e
commit 534f7b4dc1
13 changed files with 405 additions and 177 deletions

View File

@@ -26,11 +26,12 @@ class SmoothApp(App):
"""Build layout here.""" """Build layout here."""
footer = Footer() footer = Footer()
self.bar = Placeholder(name="left") self.bar = Placeholder(name="left")
self.bar.layout_offset_x = -40
await self.view.dock(footer, edge="bottom") await self.view.dock(footer, edge="bottom")
await self.view.dock(Placeholder(), Placeholder(), edge="top") await self.view.dock(Placeholder(), Placeholder(), edge="top")
await self.view.dock(self.bar, edge="left", size=40, z=1) 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")

View File

@@ -42,6 +42,7 @@ class MyApp(App):
) )
self.app.sub_title = os.path.basename(message.path) self.app.sub_title = os.path.basename(message.path)
await self.body.update(syntax) await self.body.update(syntax)
# self.body.layout_offset_y = -5
self.body.home() self.body.home()

View File

@@ -9,7 +9,8 @@ class GridTest(App):
async def on_mount(self, event: events.Mount) -> None: 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(fraction=1, name="left", min_size=20)
grid.add_column(size=30, name="center") grid.add_column(size=30, name="center")
@@ -26,11 +27,17 @@ class GridTest(App):
area4="right,top-start|middle-end", 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( grid.place(
area1=Placeholder(name="area1"), area1=make_placeholder(name="area1"),
area2=Placeholder(name="area2"), area2=make_placeholder(name="area2"),
area3=Placeholder(name="area3"), area3=make_placeholder(name="area3"),
area4=Placeholder(name="area4"), area4=make_placeholder(name="area4"),
) )

View File

@@ -84,7 +84,7 @@ class App(MessagePump):
driver_class (Type[Driver], optional): Driver class, or None to use default. Defaults to None. 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". 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.error_console = Console(stderr=True)
self._screen = screen self._screen = screen
self.driver_class = driver_class or LinuxDriver self.driver_class = driver_class or LinuxDriver

View File

@@ -76,8 +76,19 @@ class Dimensions(NamedTuple):
@property @property
def area(self) -> int: def area(self) -> int:
"""Get the area of the dimensions.
Returns:
int: Area in cells.
"""
return self.width * self.height 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: def contains(self, x: int, y: int) -> bool:
"""Check if a point is in the region. """Check if a point is in the region.
@@ -138,8 +149,23 @@ class Region(NamedTuple):
""" """
return cls(x1, y1, x2 - x1, y2 - y1) 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: def __bool__(self) -> bool:
return self.width != 0 and self.height != 0 return bool(self.width and self.height)
@property @property
def area(self) -> int: def area(self) -> int:
@@ -151,17 +177,6 @@ class Region(NamedTuple):
"""Get the start point of the region.""" """Get the start point of the region."""
return Point(self.x, self.y) 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 @property
def size(self) -> Dimensions: def size(self) -> Dimensions:
"""Get the size of the region.""" """Get the size of the region."""
@@ -251,7 +266,7 @@ class Region(NamedTuple):
x2 >= ox2 >= x1 and y2 >= oy2 >= y1 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. """Move the origin of the Region.
Args: Args:
@@ -262,8 +277,8 @@ class Region(NamedTuple):
Region: A new region shifted by x, y Region: A new region shifted by x, y
""" """
x, y, width, height = self self_x, self_y, width, height = self
return Region(x + translate_x, y + translate_y, width, height) return Region(self_x + x, self_y + y, width, height)
def __contains__(self, other: Any) -> bool: def __contains__(self, other: Any) -> bool:
"""Check if a point is in this region.""" """Check if a point is in this region."""
@@ -287,16 +302,17 @@ class Region(NamedTuple):
""" """
x1, y1, x2, y2 = self.corners x1, y1, x2, y2 = self.corners
_clamp = clamp
new_region = Region.from_corners( new_region = Region.from_corners(
clamp(x1, 0, width), _clamp(x1, 0, width),
clamp(y1, 0, height), _clamp(y1, 0, height),
clamp(x2, 0, width), _clamp(x2, 0, width),
clamp(y2, 0, height), _clamp(y2, 0, height),
) )
return new_region return new_region
def clip_region(self, region: Region) -> Region: def intersection(self, region: Region) -> Region:
"""Clip this region to fit within another region. """Get that covers both regions.
Args: Args:
region ([type]): A region that overlaps this region. region ([type]): A region that overlaps this region.
@@ -307,10 +323,11 @@ class Region(NamedTuple):
x1, y1, x2, y2 = self.corners x1, y1, x2, y2 = self.corners
cx1, cy1, cx2, cy2 = region.corners cx1, cy1, cx2, cy2 = region.corners
_clamp = clamp
new_region = Region.from_corners( new_region = Region.from_corners(
clamp(x1, cx1, cx2), _clamp(x1, cx1, cx2),
clamp(y1, cy1, cy2), _clamp(y1, cy1, cy2),
clamp(x2, cx2, cx2), _clamp(x2, cx1, cx2),
clamp(y2, cy2, cy2), _clamp(y2, cy1, cy2),
) )
return new_region return new_region

View File

@@ -17,6 +17,7 @@ from rich.style import Style
from . import log from . import log
from ._loop import loop_last from ._loop import loop_last
from .layout_map import LayoutMap
from ._types import Lines from ._types import Lines
from .geometry import clamp, Region, Point, Dimensions from .geometry import clamp, Region, Point, Dimensions
@@ -34,27 +35,6 @@ class NoWidget(Exception):
pass 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): class OrderedRegion(NamedTuple):
region: Region region: Region
order: tuple[int, int] order: tuple[int, int]
@@ -92,10 +72,10 @@ class Layout(ABC):
"""Responsible for arranging Widgets in a view.""" """Responsible for arranging Widgets in a view."""
def __init__(self) -> None: def __init__(self) -> None:
self._layout_map: dict[Widget, RenderRegion] = {} self._layout_map: LayoutMap | None = None
self.width = 0 self.width = 0
self.height = 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._cuts: list[list[int]] | None = None
self._require_update: bool = True self._require_update: bool = True
self.background = "" self.background = ""
@@ -113,31 +93,33 @@ class Layout(ABC):
self._cuts = None self._cuts = None
if self._require_update: if self._require_update:
self.renders.clear() self.renders.clear()
self._layout_map.clear() self._layout_map = None
def reflow( def reflow(
self, console: Console, width: int, height: int, viewport: Region self, console: Console, width: int, height: int, viewport: Region
) -> ReflowResult: ) -> ReflowResult:
self.reset() 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 self._require_update = False
map = { # log(map.widgets)
widget: OrderedRegion(region + offset, order) # map = {
for widget, (region, order, offset) in map.items() # widget: OrderedRegion(region + offset, order)
} # for widget, (region, order, offset) in map.items()
# }
# Filter out widgets that are off screen or zero area # 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()) new_widgets = set(map.keys())
# Newly visible widgets # Newly visible widgets
shown_widgets = new_widgets - old_widgets shown_widgets = new_widgets - old_widgets
@@ -150,8 +132,8 @@ class Layout(ABC):
# Copy renders if the size hasn't changed # Copy renders if the size hasn't changed
new_renders = { new_renders = {
widget: (region, self.renders[widget][1]) widget: (region, clip, self.renders[widget][2])
for widget, (region, _order) in map.items() for widget, (region, _order, clip) in map.items()
if ( if (
widget in self.renders widget in self.renders
and self.renders[widget][0].size == region.size and self.renders[widget][0].size == region.size
@@ -163,7 +145,7 @@ class Layout(ABC):
# Widgets with changed size # Widgets with changed size
resized_widgets = { resized_widgets = {
widget widget
for widget, (region, _order) in map.items() for widget, (region, *_) in map.items()
if widget in old_widgets and widget.size != region.size if widget in old_widgets and widget.size != region.size
} }
@@ -177,35 +159,34 @@ class Layout(ABC):
@abstractmethod @abstractmethod
def generate_map( def generate_map(
self, self, console: Console, size: Dimensions, viewport: Region
console: Console, ) -> LayoutMap:
size: Dimensions,
offset: Point,
) -> dict[Widget, RenderRegion]:
... ...
async def mount_all(self, view: "View") -> None: async def mount_all(self, view: "View") -> None:
await view.mount(*self.get_widgets()) await view.mount(*self.get_widgets())
@property @property
def map(self) -> dict[Widget, RenderRegion]: def map(self) -> LayoutMap | None:
return self._layout_map return self._layout_map
def __iter__(self) -> Iterator[tuple[Widget, Region]]: def __iter__(self) -> Iterator[tuple[Widget, Region]]:
if self.map is not None:
layers = sorted( layers = sorted(
self._layout_map.items(), key=lambda item: item[1].order, reverse=True self.map.widgets.items(), key=lambda item: item[1].order, reverse=True
) )
for widget, (region, _) in layers: for widget, (region, order, clip) in layers:
yield widget, region yield widget, region.intersection(clip)
def __reversed__(self) -> Iterable[tuple[Widget, Region]]: def __reversed__(self) -> Iterable[tuple[Widget, Region]]:
layers = sorted(self._layout_map.items(), key=lambda item: item[1].order) if self.map is not None:
for widget, (region, _) in layers: layers = sorted(self.map.items(), key=lambda item: item[1].order)
yield widget, region for widget, (region, _order, clip) in layers:
yield widget, region.intersection(clip)
def get_offset(self, widget: Widget) -> Point: def get_offset(self, widget: Widget) -> Point:
try: try:
return self._layout_map[widget].region.origin return self.map[widget].region.origin
except KeyError: except KeyError:
raise NoWidget("Widget is not in layout") raise NoWidget("Widget is not in layout")
@@ -221,7 +202,9 @@ class Layout(ABC):
widget, region = self.get_widget_at(x, y) widget, region = self.get_widget_at(x, y)
except NoWidget: except NoWidget:
return Style.null() 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 x -= region.x
y -= region.y y -= region.y
line = lines[y] line = lines[y]
@@ -234,7 +217,7 @@ class Layout(ABC):
def get_widget_region(self, widget: Widget) -> Region: def get_widget_region(self, widget: Widget) -> Region:
try: try:
region, _ = self._layout_map[widget] region, *_ = self.map[widget]
except KeyError: except KeyError:
raise NoWidget("Widget is not in layout") raise NoWidget("Widget is not in layout")
else: else:
@@ -256,8 +239,9 @@ class Layout(ABC):
screen_region = Region(0, 0, width, height) screen_region = Region(0, 0, width, height)
cuts_sets = [{0, width} for _ in range(height)] cuts_sets = [{0, width} for _ in range(height)]
for region, order in self._layout_map.values(): if self.map is not None:
region = region.clip(width, height) for region, order, clip in self.map.values():
region = region.intersection(clip)
if region and (region in screen_region): # type: ignore if region and (region in screen_region): # type: ignore
for y in range(region.y, region.y + region.height): for y in range(region.y, region.y + region.height):
cuts_sets[y].update({region.x, region.x + region.width}) cuts_sets[y].update({region.x, region.x + region.width})
@@ -266,18 +250,24 @@ class Layout(ABC):
self._cuts = [sorted(cut_set) for cut_set in cuts_sets] self._cuts = [sorted(cut_set) for cut_set in cuts_sets]
return self._cuts 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 _rich_traceback_guard = True
width = self.width width = self.width
height = self.height height = self.height
screen_region = Region(0, 0, width, height) screen_region = Region(0, 0, width, height)
layout_map = self._layout_map layout_map = self.map
if layout_map:
widget_regions = sorted( widget_regions = sorted(
((widget, region, order) for widget, (region, order) in layout_map.items()), (
(widget, region, order, clip)
for widget, (region, order, clip) in layout_map.items()
),
key=itemgetter(2), key=itemgetter(2),
reverse=True, reverse=True,
) )
else:
widget_regions = []
def render(widget: Widget, width: int, height: int) -> Lines: def render(widget: Widget, width: int, height: int) -> Lines:
lines = console.render_lines( lines = console.render_lines(
@@ -285,7 +275,7 @@ class Layout(ABC):
) )
return lines return lines
for widget, region, _order in widget_regions: for widget, region, _order, clip in widget_regions:
if not widget.is_visual: if not widget.is_visual:
continue continue
@@ -295,23 +285,22 @@ class Layout(ABC):
continue continue
lines = render(widget, region.width, region.height) lines = render(widget, region.width, region.height)
if region in screen_region: if region in clip:
self.renders[widget] = (region, lines) self.renders[widget] = (region, clip, lines)
yield region, lines yield region, clip, lines
elif screen_region.overlaps(region): elif clip.overlaps(region):
new_region = region.clip(width, height) new_region = region.intersection(clip)
delta_x = new_region.x - region.x delta_x = new_region.x - region.x
delta_y = new_region.y - region.y 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 divide = Segment.divide
lines = [ lines = [
list(divide(line, splits))[1] 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, clip, lines
yield region, lines
@classmethod @classmethod
def _assemble_chops( def _assemble_chops(
@@ -361,7 +350,10 @@ class Layout(ABC):
] ]
# Go through all the renders in reverse order and fill buckets with no render # Go through all the renders in reverse order and fill buckets with no render
renders = self._get_renders(console) 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): for y, line in enumerate(lines, region.y):
if clip_y > y > clip_y2: if clip_y > y > clip_y2:
continue continue
@@ -391,12 +383,12 @@ class Layout(ABC):
if widget not in self.renders: if widget not in self.renders:
return None return None
region, lines = self.renders[widget] region, clip, lines = self.renders[widget]
new_lines = console.render_lines( new_lines = console.render_lines(
widget, console.options.update_dimensions(region.width, region.height) 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 update_lines = self.render(console, region).lines
return LayoutUpdate(update_lines, region.x, region.y) return LayoutUpdate(update_lines, region.x, region.y)

61
src/textual/layout_map.py Normal file
View 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)

View File

@@ -9,7 +9,8 @@ from rich.console import Console
from .._layout_resolve import layout_resolve from .._layout_resolve import layout_resolve
from ..geometry import Region, Point, Dimensions 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): if sys.version_info >= (3, 8):
from typing import Literal from typing import Literal
@@ -48,25 +49,16 @@ class DockLayout(Layout):
yield from dock.widgets yield from dock.widgets
def generate_map( def generate_map(
self, console: Console, size: Dimensions, offset: Point self, console: Console, size: Dimensions, viewport: Region
) -> dict[Widget, RenderRegion]: ) -> LayoutMap:
from ..view import View
map: dict[Widget, RenderRegion] = {} map: LayoutMap = LayoutMap(size)
width, height = size width, height = size
layout_region = Region(0, 0, width, height) layout_region = Region(0, 0, width, height)
layers: dict[int, Region] = defaultdict(lambda: layout_region) layers: dict[int, Region] = defaultdict(lambda: layout_region)
def add_widget(widget: Widget, region: Region, order: tuple[int, int]): def add_widget(widget: Widget, region: Region, order: tuple[int, ...]):
region = region + widget.layout_offset map.add_widget(console, widget, region, order, viewport)
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)
for index, dock in enumerate(self.docks): for index, dock in enumerate(self.docks):
dock_options = [ dock_options = [
@@ -88,16 +80,16 @@ class DockLayout(Layout):
render_y = y render_y = y
remaining = region.height remaining = region.height
total = 0 total = 0
for widget, size in zip(dock.widgets, sizes): for widget, layout_size in zip(dock.widgets, sizes):
if not widget.visible: if not widget.visible:
continue continue
size = min(remaining, size) layout_size = min(remaining, layout_size)
if not size: if not layout_size:
break break
total += size total += layout_size
add_widget(widget, Region(x, render_y, width, size), order) add_widget(widget, Region(x, render_y, width, layout_size), order)
render_y += size render_y += layout_size
remaining = max(0, remaining - size) remaining = max(0, remaining - layout_size)
region = Region(x, y + total, width, height - total) region = Region(x, y + total, width, height - total)
elif dock.edge == "bottom": elif dock.edge == "bottom":
@@ -105,16 +97,20 @@ class DockLayout(Layout):
render_y = y + height render_y = y + height
remaining = region.height remaining = region.height
total = 0 total = 0
for widget, size in zip(dock.widgets, sizes): for widget, layout_size in zip(dock.widgets, sizes):
if not widget.visible: if not widget.visible:
continue continue
size = min(remaining, size) layout_size = min(remaining, layout_size)
if not size: if not layout_size:
break break
total += size total += layout_size
add_widget(widget, Region(x, render_y - size, width, size), order) add_widget(
render_y -= size widget,
remaining = max(0, remaining - size) 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) region = Region(x, y, width, height - total)
elif dock.edge == "left": elif dock.edge == "left":
@@ -122,16 +118,16 @@ class DockLayout(Layout):
render_x = x render_x = x
remaining = region.width remaining = region.width
total = 0 total = 0
for widget, size in zip(dock.widgets, sizes): for widget, layout_size in zip(dock.widgets, sizes):
if not widget.visible: if not widget.visible:
continue continue
size = min(remaining, size) layout_size = min(remaining, layout_size)
if not size: if not layout_size:
break break
total += size total += layout_size
add_widget(widget, Region(render_x, y, size, height), order) add_widget(widget, Region(render_x, y, layout_size, height), order)
render_x += size render_x += layout_size
remaining = max(0, remaining - size) remaining = max(0, remaining - layout_size)
region = Region(x + total, y, width - total, height) region = Region(x + total, y, width - total, height)
elif dock.edge == "right": elif dock.edge == "right":
@@ -139,16 +135,20 @@ class DockLayout(Layout):
render_x = x + width render_x = x + width
remaining = region.width remaining = region.width
total = 0 total = 0
for widget, size in zip(dock.widgets, sizes): for widget, layout_size in zip(dock.widgets, sizes):
if not widget.visible: if not widget.visible:
continue continue
size = min(remaining, size) layout_size = min(remaining, layout_size)
if not size: if not layout_size:
break break
total += size total += layout_size
add_widget(widget, Region(render_x - size, y, size, height), order) add_widget(
render_x -= size widget,
remaining = max(0, remaining - size) 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) region = Region(x, y, width - total, height)
layers[dock.z] = region layers[dock.z] = region

View File

@@ -12,8 +12,8 @@ from rich.console import Console
from .._layout_resolve import layout_resolve from .._layout_resolve import layout_resolve
from ..geometry import Dimensions, Point, Region from ..geometry import Dimensions, Point, Region
from ..layout import Layout, RenderRegion from ..layout import Layout
from ..view import View from ..layout_map import LayoutMap
from ..widget import Widget from ..widget import Widget
if sys.version_info >= (3, 8): if sys.version_info >= (3, 8):
@@ -264,8 +264,8 @@ class GridLayout(Layout):
return self.widgets.keys() return self.widgets.keys()
def generate_map( def generate_map(
self, console: Console, size: Dimensions, offset: Point self, console: Console, size: Dimensions, viewport: Region
) -> dict[Widget, RenderRegion]: ) -> LayoutMap:
"""Generate a map that associates widgets with their location on screen. """Generate a map that associates widgets with their location on screen.
Args: Args:
@@ -276,6 +276,7 @@ class GridLayout(Layout):
Returns: Returns:
dict[Widget, OrderedRegion]: [description] dict[Widget, OrderedRegion]: [description]
""" """
map: LayoutMap = LayoutMap(size)
width, height = size width, height = size
def resolve( def resolve(
@@ -327,13 +328,14 @@ class GridLayout(Layout):
return names, tracks, len(spans), max_size return names, tracks, len(spans), max_size
def add_widget(widget: Widget, region: Region, order: tuple[int, int]): def add_widget(widget: Widget, region: Region, order: tuple[int, int]):
region = region + widget.layout_offset map.add_widget(console, widget, region, order, viewport)
map[widget] = RenderRegion(region, order, offset) # region = region + widget.layout_offset
if isinstance(widget, View): # map[widget] = RenderRegion(region, order, offset)
sub_map = widget.layout.generate_map( # if isinstance(widget, View):
region.width, region.height, region.origin + offset # sub_map = widget.layout.generate_map(
) # region.width, region.height, region.origin + offset
map.update(sub_map) # )
# map.update(sub_map)
container = Dimensions( container = Dimensions(
width - self.column_gutter * 2, height - self.row_gutter * 2 width - self.column_gutter * 2, height - self.row_gutter * 2
@@ -365,8 +367,6 @@ class GridLayout(Layout):
free_slots = { free_slots = {
(col, row) for col, row in product(range(column_count), range(row_count)) (col, row) for col, row in product(range(column_count), range(row_count))
} }
map: dict[Widget, RenderRegion] = {}
order = 1 order = 1
from_corners = Region.from_corners from_corners = Region.from_corners
gutter = Point(self.column_gutter, self.row_gutter) gutter = Point(self.column_gutter, self.row_gutter)

View File

@@ -4,7 +4,7 @@ from rich.console import Console
from ..geometry import Point, Region, Dimensions from ..geometry import Point, Region, Dimensions
from ..layout import Layout, RenderRegion, WidgetMap from ..layout import Layout
from ..widget import Widget from ..widget import Widget
from ..view import View from ..view import View
@@ -19,8 +19,9 @@ class VerticalLayout(Layout):
self._widgets.append(widget) self._widgets.append(widget)
def generate_map( def generate_map(
self, console: Console, size: Dimensions, offset: Point self, console: Console, size: Dimensions, viewport: Region
) -> WidgetMap: ) -> WidgetMap:
offset = viewport.origin
width, height = size width, height = size
gutter_width, gutter_height = self.gutter gutter_width, gutter_height = self.gutter
render_width = width - gutter_width * 2 render_width = width - gutter_width * 2

View File

@@ -62,6 +62,12 @@ class View(Widget):
(virtual_height if virtual_height is not None else self.size.height), (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 @property
def offset(self) -> Point: def offset(self) -> Point:
return Point(self.offset_x, self.offset_y) return Point(self.offset_x, self.offset_y)
@@ -133,7 +139,7 @@ class View(Widget):
async def refresh_layout(self) -> None: async def refresh_layout(self) -> None:
await self.layout.mount_all(self) await self.layout.mount_all(self)
if not self.size or not self.is_root_view: if not self.size:
return return
width, height = self.console.size width, height = self.console.size

View File

@@ -61,8 +61,8 @@ class Widget(MessagePump):
layout_size: Reactive[int | None] = Reactive(None, layout=True) layout_size: Reactive[int | None] = Reactive(None, layout=True)
layout_fraction: Reactive[int] = Reactive(1, layout=True) layout_fraction: Reactive[int] = Reactive(1, layout=True)
layout_min_size: 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_x: Reactive[float] = Reactive(0.0, layout=True)
layout_offset_y: Reactive[int] = Reactive(0, layout=True) layout_offset_y: Reactive[float] = Reactive(0.0, layout=True)
def validate_layout_offset_x(self, value) -> int: def validate_layout_offset_x(self, value) -> int:
return int(value) return int(value)

View File

@@ -3,6 +3,57 @@ import pytest
from textual.geometry import clamp, Point, Dimensions, Region 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(): def test_clamp():
assert clamp(5, 0, 10) == 5 assert clamp(5, 0, 10) == 5
assert clamp(-1, 0, 10) == 0 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), 0) == Point(1, 2)
assert Point(1, 2).blend(Point(3, 4), 1) == Point(3, 4) 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) 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))