diff --git a/examples/animation.py b/examples/animation.py index 18b5ce2ff..4c9df9e8e 100644 --- a/examples/animation.py +++ b/examples/animation.py @@ -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") diff --git a/examples/code_viewer.py b/examples/code_viewer.py index 6fbdbafbf..e4c3a4c42 100644 --- a/examples/code_viewer.py +++ b/examples/code_viewer.py @@ -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() diff --git a/examples/grid.py b/examples/grid.py index c48eb0c94..4bb48929f 100644 --- a/examples/grid.py +++ b/examples/grid.py @@ -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"), ) diff --git a/src/textual/app.py b/src/textual/app.py index cdb89c8ad..ef9dbb84a 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -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 diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 8ae4438c9..94c97c2ba 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -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 diff --git a/src/textual/layout.py b/src/textual/layout.py index cc09c5497..bb01da481 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -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) diff --git a/src/textual/layout_map.py b/src/textual/layout_map.py new file mode 100644 index 000000000..33ab2e26e --- /dev/null +++ b/src/textual/layout_map.py @@ -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) diff --git a/src/textual/layouts/dock.py b/src/textual/layouts/dock.py index 7c8d71106..330f47e43 100644 --- a/src/textual/layouts/dock.py +++ b/src/textual/layouts/dock.py @@ -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 diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index a0d480db7..48363a095 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -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) diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index f94f9e563..2847b2216 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -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 diff --git a/src/textual/view.py b/src/textual/view.py index 447d5c6a0..e01245485 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -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 diff --git a/src/textual/widget.py b/src/textual/widget.py index 58d4d9b6f..d3ceed38e 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -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) diff --git a/tests/test_geometry.py b/tests/test_geometry.py index a4a19a26c..cef706f1f 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -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))