diff --git a/CHANGELOG.md b/CHANGELOG.md index 418c2b0d9..3a4a9d135 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [0.1.12] - Unreleased + +### Added + +- Added geometry.Spacing + ## [0.1.11] - 2021-09-12 ### Changed diff --git a/examples/big_table.py b/examples/big_table.py index 33b576e3d..a11ce385b 100644 --- a/examples/big_table.py +++ b/examples/big_table.py @@ -20,10 +20,10 @@ class MyApp(App): async def add_content(): table = Table(title="Demo") - for i in range(40): + for i in range(20): table.add_column(f"Col {i + 1}", style="magenta") - for i in range(200): - table.add_row(*[f"cell {i},{j}" for j in range(40)]) + for i in range(100): + table.add_row(*[f"cell {i},{j}" for j in range(20)]) await body.update(table) diff --git a/examples/calculator.py b/examples/calculator.py index 8fc80f40a..3457eb7eb 100644 --- a/examples/calculator.py +++ b/examples/calculator.py @@ -12,7 +12,6 @@ from rich.padding import Padding from rich.text import Text from textual.app import App -from textual import events from textual.reactive import Reactive from textual.views import GridView from textual.widget import Widget diff --git a/examples/simple.py b/examples/simple.py index 7f2a250bf..c3389441a 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -17,7 +17,7 @@ class MyApp(App): """Create and dock the widgets.""" # A scrollview to contain the markdown file - body = ScrollView() + body = ScrollView(gutter=1) # Header / footer / dock await self.view.dock(Header(), edge="top") diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 464198fdc..861e11fc0 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -1,6 +1,9 @@ from __future__ import annotations -from typing import Any, NamedTuple, TypeVar +from typing import Any, cast, NamedTuple, Tuple, Union, TypeVar + + +SpacingDimensions = Union[int, Tuple[int], Tuple[int, int], Tuple[int, int, int, int]] T = TypeVar("T", int, float) @@ -262,7 +265,7 @@ class Region(NamedTuple): return NotImplemented def expand(self, size: tuple[int, int]) -> Region: - """Add additional height. + """Increase the size of the region by adding a border. Args: size (tuple[int, int]): Additional width and height. @@ -270,9 +273,14 @@ class Region(NamedTuple): Returns: Region: A new region. """ - add_width, add_height = size + expand_width, expand_height = size x, y, width, height = self - return Region(x, y, width + add_width, height + add_height) + return Region( + x - expand_width, + y - expand_height, + width + expand_width * 2, + height + expand_height * 2, + ) def overlaps(self, other: Region) -> bool: """Check if another region overlaps this region. @@ -419,3 +427,48 @@ class Region(NamedTuple): min(x1, ox1), min(y1, oy1), max(x2, ox2), max(y2, oy2) ) return union_region + + +class Spacing(NamedTuple): + """The spacing around a renderable.""" + + top: int = 0 + right: int = 0 + bottom: int = 0 + left: int = 0 + + @property + def width(self) -> int: + """Total space in width.""" + return self.left + self.right + + @property + def height(self) -> int: + """Total space in height.""" + return self.top + self.bottom + + @property + def top_left(self) -> tuple[int, int]: + """Top left space.""" + return (self.left, self.top) + + @property + def bottom_right(self) -> tuple[int, int]: + """Bottom right space.""" + return (self.right, self.bottom) + + @classmethod + def unpack(cls, pad: SpacingDimensions) -> Spacing: + """Unpack padding specified in CSS style.""" + if isinstance(pad, int): + return cls(pad, pad, pad, pad) + if len(pad) == 1: + _pad = pad[0] + return cls(_pad, _pad, _pad, _pad) + if len(pad) == 2: + pad_top, pad_right = cast(Tuple[int, int], pad) + return cls(pad_top, pad_right, pad_top, pad_right) + if len(pad) == 4: + top, right, bottom, left = cast(Tuple[int, int, int, int], pad) + return cls(top, right, bottom, left) + raise ValueError(f"1, 2 or 4 integers required for spacing; {len(pad)} given") diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 081a74ecb..c0cf10080 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -2,9 +2,10 @@ from __future__ import annotations from typing import Iterable -from ..geometry import Offset, Region, Size +from ..geometry import Offset, Region, Size, Spacing, SpacingDimensions from ..layout import Layout, WidgetPlacement from ..widget import Widget +from .._loop import loop_last class VerticalLayout(Layout): @@ -13,11 +14,11 @@ class VerticalLayout(Layout): *, auto_width: bool = False, z: int = 0, - gutter: tuple[int, int] | None = None + gutter: SpacingDimensions = (0, 0, 0, 0) ): self.auto_width = auto_width self.z = z - self.gutter = gutter or (0, 0) + self.gutter = Spacing.unpack(gutter) self._widgets: list[Widget] = [] self._max_widget_width = 0 super().__init__() @@ -36,18 +37,19 @@ class VerticalLayout(Layout): def arrange(self, size: Size, scroll: Offset) -> Iterable[WidgetPlacement]: index = 0 width, _height = size - gutter_height, gutter_width = self.gutter + gutter = self.gutter + x, y = self.gutter.top_left render_width = ( - max(width, self._max_widget_width) + gutter_width * 2 + max(width, self._max_widget_width) if self.auto_width - else width - gutter_width * 2 + else width - gutter.width ) - x = gutter_width - y = gutter_height + total_width = render_width - total_region = Region() - for widget in self._widgets: + gutter_height = max(gutter.top, gutter.bottom) + + for last, widget in loop_last(self._widgets): if ( not widget.render_cache or widget.render_cache.size.width != render_width @@ -57,6 +59,6 @@ class VerticalLayout(Layout): render_height = widget.render_cache.size.height region = Region(x, y, render_width, render_height) yield WidgetPlacement(region, widget, (self.z, index)) - total_region = total_region.union(region) + y += render_height + (gutter.bottom if last else gutter_height) - yield WidgetPlacement(total_region) + yield WidgetPlacement(Region(0, 0, total_width + gutter.width, y)) diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 62b5c3726..89c6ca8cc 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -1,5 +1,6 @@ from __future__ import annotations + import rich.repr from rich.color import Color from rich.console import Console, ConsoleOptions, RenderResult, RenderableType @@ -237,14 +238,20 @@ class ScrollBar(Widget): x: float | None = None y: float | None = None if self.vertical: - y = self.grabbed_position + ( - (event.screen_y - self.grabbed.y) - * (self.virtual_size / self.window_size) + y = round( + self.grabbed_position + + ( + (event.screen_y - self.grabbed.y) + * (self.virtual_size / self.window_size) + ) ) else: - x = self.grabbed_position + ( - (event.screen_x - self.grabbed.x) - * (self.virtual_size / self.window_size) + x = round( + self.grabbed_position + + ( + (event.screen_x - self.grabbed.x) + * (self.virtual_size / self.window_size) + ) ) await self.emit(ScrollTo(self, x=x, y=y)) diff --git a/src/textual/views/_window_view.py b/src/textual/views/_window_view.py index 5e424edd6..6e9a7b20e 100644 --- a/src/textual/views/_window_view.py +++ b/src/textual/views/_window_view.py @@ -3,7 +3,7 @@ from __future__ import annotations from rich.console import RenderableType from .. import events -from ..geometry import Size +from ..geometry import Size, SpacingDimensions from ..layouts.vertical import VerticalLayout from ..view import View from ..message import Message @@ -23,7 +23,7 @@ class WindowView(View, layout=VerticalLayout): widget: RenderableType | Widget, *, auto_width: bool = False, - gutter: tuple[int, int] = (0, 1), + gutter: SpacingDimensions = (0, 0), name: str | None = None ) -> None: layout = VerticalLayout(gutter=gutter, auto_width=auto_width) diff --git a/src/textual/widget.py b/src/textual/widget.py index 996993d2e..24fb16cc0 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -16,9 +16,8 @@ from rich import box from rich.align import Align from rich.console import Console, RenderableType from rich.panel import Panel -from rich.padding import Padding, PaddingDimensions +from rich.padding import Padding from rich.pretty import Pretty -from rich.segment import Segment from rich.style import Style from rich.styled import Styled from rich.text import TextType @@ -27,7 +26,7 @@ from . import events from ._animator import BoundAnimator from ._callback import invoke from ._context import active_app -from .geometry import Size +from .geometry import Size, Spacing, SpacingDimensions from .message import Message from .message_pump import MessagePump from .messages import Layout, Update @@ -41,15 +40,6 @@ if TYPE_CHECKING: log = getLogger("rich") -class Spacing(NamedTuple): - """The spacing around a renderable.""" - - top: int = 0 - right: int = 0 - bottom: int = 0 - left: int = 0 - - class RenderCache(NamedTuple): size: Size lines: Lines @@ -103,11 +93,11 @@ class Widget(MessagePump): BOX_MAP = {"normal": box.SQUARE, "round": box.ROUNDED, "bold": box.HEAVY} - def validate_padding(self, padding: PaddingDimensions) -> Spacing: - return Spacing(*Padding.unpack(padding)) + def validate_padding(self, padding: SpacingDimensions) -> Spacing: + return Spacing.unpack(padding) - def validate_margin(self, padding: PaddingDimensions) -> Spacing: - return Spacing(*Padding.unpack(padding)) + def validate_margin(self, margin: SpacingDimensions) -> Spacing: + return Spacing.unpack(margin) def validate_layout_offset_x(self, value) -> int: return int(value) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 69da24fae..0808d0fe3 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -8,7 +8,6 @@ import os.path from rich.console import RenderableType import rich.repr from rich.text import Text -from rich.tree import Tree from .. import events from ..message import Message diff --git a/src/textual/widgets/_scroll_view.py b/src/textual/widgets/_scroll_view.py index ffe448d05..2d8c2317a 100644 --- a/src/textual/widgets/_scroll_view.py +++ b/src/textual/widgets/_scroll_view.py @@ -5,12 +5,14 @@ from rich.style import StyleType from .. import events +from ..geometry import SpacingDimensions from ..layouts.grid import GridLayout from ..message import Message from ..messages import CursorMove from ..scrollbar import ScrollTo, ScrollBar from ..geometry import clamp from ..view import View + from ..widget import Widget from ..reactive import Reactive @@ -25,6 +27,7 @@ class ScrollView(View): name: str | None = None, style: StyleType = "", fluid: bool = True, + gutter: SpacingDimensions = (0, 0) ) -> None: from ..views import WindowView @@ -32,7 +35,7 @@ class ScrollView(View): self.vscroll = ScrollBar(vertical=True) self.hscroll = ScrollBar(vertical=False) self.window = WindowView( - "" if contents is None else contents, auto_width=auto_width + "" if contents is None else contents, auto_width=auto_width, gutter=gutter ) layout = GridLayout() layout.add_column("main") @@ -66,11 +69,11 @@ class ScrollView(View): @property def max_scroll_y(self) -> float: - return max(0, self.window.virtual_size.height - self.size.height) + return max(0, self.window.virtual_size.height - self.window.size.height) @property def max_scroll_x(self) -> float: - return max(0, self.window.virtual_size.width - self.size.width) + return max(0, self.window.virtual_size.width - self.window.size.width) async def watch_x(self, new_value: float) -> None: self.window.scroll_x = round(new_value) @@ -193,7 +196,10 @@ class ScrollView(View): self.animate("x", self.target_x, speed=150, easing="out_cubic") self.animate("y", self.target_y, speed=150, easing="out_cubic") - def handle_window_change(self, message) -> None: + async def handle_window_change(self, message: Message) -> None: + + message.stop() + virtual_width, virtual_height = self.window.virtual_size width, height = self.size @@ -207,10 +213,10 @@ class ScrollView(View): assert isinstance(self.layout, GridLayout) - if self.layout.show_column("vscroll", virtual_height > height): - self.refresh() - if self.layout.show_row("hscroll", virtual_width > width): - self.refresh() + vscroll_change = self.layout.show_column("vscroll", virtual_height > height) + hscroll_change = self.layout.show_row("hscroll", virtual_width > width) + if hscroll_change or vscroll_change: + self.refresh(layout=True) def handle_cursor_move(self, message: CursorMove) -> None: self.scroll_to_center(message.line)