diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 1184b55dc..6249f5dd4 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -326,27 +326,28 @@ class Compositor: # The region covered by children relative to parent widget total_region = child_region.reset_origin - # Arrange the layout - placements, arranged_widgets = widget._arrange(child_region.size) - widgets.update(arranged_widgets) - placements = sorted(placements, key=get_order) + if widget.is_container: + # Arrange the layout + placements, arranged_widgets = widget._arrange(child_region.size) + widgets.update(arranged_widgets) + placements = sorted(placements, key=get_order) - # An offset added to all placements - placement_offset = ( - container_region.origin + layout_offset - widget.scroll_offset - ) + # An offset added to all placements + placement_offset = ( + container_region.origin + layout_offset - widget.scroll_offset + ) - # Add all the widgets - for sub_region, sub_widget, z in placements: - # Combine regions with children to calculate the "virtual size" - total_region = total_region.union(sub_region) - if sub_widget is not None: - add_widget( - sub_widget, - sub_region + placement_offset, - order + (z,), - sub_clip, - ) + # Add all the widgets + for sub_region, sub_widget, z in placements: + # Combine regions with children to calculate the "virtual size" + total_region = total_region.union(sub_region) + if sub_widget is not None: + add_widget( + sub_widget, + sub_region + placement_offset, + order + (z,), + sub_clip, + ) # Add any scrollbars for chrome_widget, chrome_region in widget._arrange_scrollbars( @@ -360,14 +361,24 @@ class Compositor: container_size, ) - # Add the container widget, which will render a background - map[widget] = MapGeometry( - region + layout_offset, - order, - clip, - total_region.size, - container_size, - ) + if widget.is_container: + # Add the container widget, which will render a background + map[widget] = MapGeometry( + region + layout_offset, + order, + clip, + total_region.size, + container_size, + ) + else: + + map[widget] = MapGeometry( + child_region + layout_offset, + order, + clip, + child_region.size, + container_size, + ) else: # Add the widget to the map diff --git a/src/textual/_lru_cache.py b/src/textual/_lru_cache.py new file mode 100644 index 000000000..77f78c08b --- /dev/null +++ b/src/textual/_lru_cache.py @@ -0,0 +1,38 @@ +from typing import TypeVar +import sys + +CacheKey = TypeVar("CacheKey") +CacheValue = TypeVar("CacheValue") + +if sys.version_info < (3, 9): + from typing_extensions import OrderedDict +else: + from collections import OrderedDict + + +class LRUCache(OrderedDict[CacheKey, CacheValue]): + """ + A dictionary-like container that stores a given maximum items. + + If an additional item is added when the LRUCache is full, the least + recently used key is discarded to make room for the new item. + + """ + + def __init__(self, cache_size: int) -> None: + self.cache_size = cache_size + super().__init__() + + def __setitem__(self, key: CacheKey, value: CacheValue) -> None: + """Store a new views, potentially discarding an old value.""" + if key not in self: + if len(self) >= self.cache_size: + self.popitem(last=False) + super().__setitem__(key, value) + + def __getitem__(self, key: CacheKey) -> CacheValue: + """Gets the item, but also makes it most recent.""" + value: CacheValue = super().__getitem__(key) + super().__delitem__(key) + super().__setitem__(key, value) + return value diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py index d8902dacf..c418ecf44 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -11,8 +11,8 @@ class ScrollView(Widget): ScrollView { background: blue; - overflow-y: scroll; - overflow-x: scroll; + overflow-y: auto; + overflow-x: auto; scrollbar-size-vertical: 2; scrollbar-size-horizontal: 1; } @@ -31,7 +31,6 @@ class ScrollView(Widget): def on_mount(self): self.virtual_size = Size(200, 200) self._refresh_scrollbars() - self.refresh(layout=True) def size_updated( self, size: Size, virtual_size: Size, container_size: Size @@ -57,7 +56,9 @@ class ScrollView(Widget): self.call_later(self.scroll_to, self.scroll_x, self.scroll_y) def render(self) -> RenderableType: - return f"{self.scroll_offset} {self.show_vertical_scrollbar}" + from rich.panel import Panel + + return Panel(f"{self.scroll_offset} {self.show_vertical_scrollbar}") def watch_scroll_x(self, new_value: float) -> None: self.horizontal_scrollbar.position = int(new_value) diff --git a/src/textual/widget.py b/src/textual/widget.py index 73d09e549..9d67c6c4f 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -622,15 +622,19 @@ class Widget(DOMNode): horizontal_scrollbar_thickness = self.scrollbar_size_horizontal vertical_scrollbar_thickness = self.scrollbar_size_vertical + print(self, horizontal_scrollbar_thickness, vertical_scrollbar_thickness) + if self.styles.scrollbar_gutter == "stable": # Let's _always_ reserve some space, whether the scrollbar is actually displayed or not: show_vertical_scrollbar = True vertical_scrollbar_thickness = self.styles.scrollbar_size_vertical if show_horizontal_scrollbar and show_vertical_scrollbar: + print(1, region) (region, _, _, _) = region.split( -vertical_scrollbar_thickness, -horizontal_scrollbar_thickness ) + print(2, region) elif show_vertical_scrollbar: region, _ = region.split_vertical(-vertical_scrollbar_thickness) elif show_horizontal_scrollbar: @@ -780,7 +784,7 @@ class Widget(DOMNode): Returns: bool: ``True`` if there is background color, otherwise ``False``. """ - return self.is_container and self.styles.background.is_transparent + return self.is_scrollable and self.styles.background.is_transparent @property def console(self) -> Console: diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index 0646452be..7f7838818 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -1,3 +1,4 @@ +from ._datatable import DataTable from ._footer import Footer from ._header import Header from ._button import Button @@ -8,6 +9,7 @@ from ._directory_tree import DirectoryTree, FileClick __all__ = [ "Button", + "DataTable", "DirectoryTree", "FileClick", "Footer", diff --git a/src/textual/widgets/_datatable.py b/src/textual/widgets/_datatable.py new file mode 100644 index 000000000..ed8b135e1 --- /dev/null +++ b/src/textual/widgets/_datatable.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from abc import abstractmethod, ABC + +from rich.console import Console, ConsoleOptions, RenderResult +from rich.text import Text + +from dataclasses import dataclass +from typing import Awaitable, Callable, Generic, TypeVar + +from ..geometry import Size +from .._lru_cache import LRUCache +from ..scroll_view import ScrollView + + +RowType = TypeVar("RowType") + +RowSetter = Callable[[RowType | None], Awaitable[None]] + + +class DataProvider(ABC): + @abstractmethod + async def start(self) -> None: + pass + + @abstractmethod + async def get_size(self) -> int | None: + ... + + @abstractmethod + async def request_row(self, row_no: int, set_row: RowSetter) -> None: + ... + + +class DictListProvider: + def __init__(self, data: list[list]) -> None: + self.data = data + + async def start(self) -> None: + pass + + async def get_size(self) -> int | None: + return len(self.data) + + async def request_row(self, row_no: int, set_row: RowSetter) -> None: + if row_no > len(self.data): + await set_row(None) + else: + row = self.data[row_no] + await set_row(row) + + +@dataclass +class Column: + label: Text + width: int + + +class _TableRenderable: + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + pass + + +class DataTable(ScrollView, Generic[RowType]): + def __init__( + self, + data_provider: DataProvider | None, + *, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ) -> None: + super().__init__(name=name, id=id, classes=classes) + + self._data_provider = data_provider + self._columns: list[Column] + self._rows = LRUCache[int, RowType] + + self.height = 0 + + def get_content_height(self, container: Size, viewport: Size, width: int) -> int: + return super().get_content_height(container, viewport, width) + + async def set_row(self, row_no: int, row: RowType): + pass