From b2ed540c5048ed4dd1800734efab95f9299bcc82 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 21 Jun 2022 17:49:28 +0100 Subject: [PATCH 01/13] keyboard control of cursor --- sandbox/table.py | 8 +- src/textual/widget.py | 61 ++++++++++++++ src/textual/widgets/_data_table.py | 124 +++++++++++++++++++++++++---- 3 files changed, 178 insertions(+), 15 deletions(-) diff --git a/sandbox/table.py b/sandbox/table.py index 5f013c338..545cd5f83 100644 --- a/sandbox/table.py +++ b/sandbox/table.py @@ -47,7 +47,13 @@ class TableApp(App): height = 1 row = [f"row [b]{n}[/b] col [i]{c}[/i]" for c in range(6)] if n == 10: - row[1] = Syntax(CODE, "python", line_numbers=True, indent_guides=True) + row[1] = Syntax( + CODE, + "python", + theme="ansi_dark", + line_numbers=True, + indent_guides=True, + ) height = 13 if n == 30: diff --git a/src/textual/widget.py b/src/textual/widget.py index 2100f70fd..f845b5da3 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -624,6 +624,67 @@ class Widget(DOMNode): return any(scrolls) + def scroll_to_region(self, region: Region, *, animate: bool = True) -> bool: + """Scrolls a given region in to view. + + Args: + region (Region): A region that should be visible. + animate (bool, optional): Enable animation. Defaults to True. + + Returns: + bool: True if the window was scrolled. + """ + + scroll_x, scroll_y = self.scroll_offset + width, height = self.region.size + container_region = Region(scroll_x, scroll_y, width, height) + + if region in container_region: + # Widget is visible, nothing to do + return False + + ( + container_left, + container_top, + container_right, + container_bottom, + ) = container_region.corners + ( + child_left, + child_top, + child_right, + child_bottom, + ) = region.corners + + delta_x = 0 + delta_y = 0 + + if not ( + (container_right >= child_left > container_left) + and (container_right >= child_right > container_left) + ): + delta_x = min( + child_left - container_left, + child_left - (container_right - region.width), + key=abs, + ) + + if not ( + (container_bottom >= child_top > container_top) + and (container_bottom >= child_bottom > container_top) + ): + delta_y = min( + child_top - container_top, + child_top - (container_bottom - region.height), + key=abs, + ) + + scrolled = self.scroll_relative( + delta_x or None, delta_y or None, animate=abs(delta_y) != 1, duration=0.2 + ) + + return scrolled + def __init_subclass__( cls, can_focus: bool = True, diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index d337ea92f..558f6d612 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -2,7 +2,8 @@ from __future__ import annotations from dataclasses import dataclass, field from itertools import chain -from typing import Callable, ClassVar, Generic, TypeVar, cast +import sys +from typing import ClassVar, Generic, TypeVar, cast from rich.console import RenderableType from rich.padding import Padding @@ -11,16 +12,25 @@ from rich.segment import Segment from rich.style import Style from rich.text import Text, TextType +from .. import events from .._cache import LRUCache from .._segment_tools import line_crop from .._types import Lines -from ..geometry import Region, Size +from ..geometry import clamp, Region, Size from ..reactive import Reactive from .._profile import timer from ..scroll_view import ScrollView from ..widget import Widget from .. import messages + +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal + +CursorType = Literal["cell", "row", "column"] +CELL: CursorType = "cell" CellType = TypeVar("CellType") @@ -44,6 +54,7 @@ class Column: class Row: index: int height: int + y: int cell_renderables: list[RenderableType] = field(default_factory=list) @@ -82,6 +93,11 @@ class DataTable(ScrollView, Generic[CellType]): background: $primary 10%; } + DataTable > .datatable--cursor { + background: $secondary; + color: $text-secondary; + } + .-dark-mode DataTable > .datatable--even-row { background: $primary 15%; } @@ -98,6 +114,7 @@ class DataTable(ScrollView, Generic[CellType]): "datatable--odd-row", "datatable--even-row", "datatable--highlight", + "datatable--cursor", } def __init__( @@ -123,11 +140,17 @@ class DataTable(ScrollView, Generic[CellType]): self._line_cache: LRUCache[tuple[int, int, int, int], list[Segment]] self._line_cache = LRUCache(1000) + self._line_no = 0 + show_header = Reactive(True) fixed_rows = Reactive(0) - fixed_columns = Reactive(1) + fixed_columns = Reactive(0) zebra_stripes = Reactive(False) header_height = Reactive(1) + show_cursor = Reactive(True) + cursor_type = Reactive(CELL) + cursor_row = Reactive(0) + cursor_column = Reactive(0) def _clear_caches(self) -> None: self._row_render_cache.clear() @@ -151,6 +174,12 @@ class DataTable(ScrollView, Generic[CellType]): def watch_zebra_stripes(self, zebra_stripes: bool) -> None: self._clear_caches() + def validate_cursor_row(self, value: int) -> int: + return clamp(value, 0, self.row_count - 1) + + def validate_cursor_column(self, value: int) -> int: + return clamp(value, self.fixed_columns, len(self.columns) - 1) + def _update_dimensions(self) -> None: """Called to recalculate the virtual (scrollable) size.""" total_width = sum(column.width for column in self.columns) @@ -159,6 +188,16 @@ class DataTable(ScrollView, Generic[CellType]): len(self._y_offsets) + (self.header_height if self.show_header else 0), ) + def _get_cursor_region(self, row_index: int, column_index: int) -> Region: + row = self.rows[row_index] + x = sum(column.width for column in self.columns[:column_index]) + width = self.columns[column_index].width + height = row.height + y = row.y + if self.show_header: + y += self.header_height + return Region(x, y, width, height) + def add_column(self, label: TextType, *, width: int = 10) -> None: """Add a column to the table. @@ -179,12 +218,13 @@ class DataTable(ScrollView, Generic[CellType]): """ row_index = self.row_count self.data[row_index] = list(cells) - self.rows[row_index] = Row(row_index, height=height) + self.rows[row_index] = Row(row_index, height, self._line_no) for line_no in range(height): self._y_offsets.append((row_index, line_no)) self.row_count += 1 + self._line_no += height self._update_dimensions() self.refresh() @@ -210,7 +250,12 @@ class DataTable(ScrollView, Generic[CellType]): return [default_cell_formatter(datum) or empty for datum in data] def _render_cell( - self, row_index: int, column_index: int, style: Style, width: int + self, + row_index: int, + column_index: int, + style: Style, + width: int, + cursor: bool = False, ) -> Lines: """Render the given cell. @@ -223,6 +268,8 @@ class DataTable(ScrollView, Generic[CellType]): Returns: Lines: A list of segments per line. """ + if cursor: + style += self.component_styles["datatable--cursor"].node.rich_style cell_key = (row_index, column_index, style) if cell_key not in self._cell_render_cache: style += Style.from_meta({"row": row_index, "column": column_index}) @@ -239,7 +286,7 @@ class DataTable(ScrollView, Generic[CellType]): return self._cell_render_cache[cell_key] def _render_row( - self, row_index: int, line_no: int, base_style: Style + self, row_index: int, line_no: int, base_style: Style, cursor: int = -1 ) -> tuple[Lines, Lines]: """Render a row in to lines for each cell. @@ -281,7 +328,13 @@ class DataTable(ScrollView, Generic[CellType]): row_style = base_style scrollable_row = [ - render_cell(row_index, column.index, row_style, column.width)[line_no] + render_cell( + row_index, + column.index, + row_style, + column.width, + cursor=cursor == column.index, + )[line_no] for column in self.columns ] @@ -319,7 +372,7 @@ class DataTable(ScrollView, Generic[CellType]): list[Segment]: List of segments for rendering. """ - width = self.content_region.width + width = self.region.width cache_key = (y, x1, x2, width) if cache_key in self._line_cache: @@ -327,7 +380,14 @@ class DataTable(ScrollView, Generic[CellType]): row_index, line_no = self._get_offsets(y) - fixed, scrollable = self._render_row(row_index, line_no, base_style) + fixed, scrollable = self._render_row( + row_index, + line_no, + base_style, + cursor=self.cursor_column + if (self.show_cursor and self.cursor_row == row_index) + else -1, + ) fixed_width = sum(column.width for column in self.columns[: self.fixed_columns]) fixed_line: list[Segment] = list(chain.from_iterable(fixed)) if fixed else [] @@ -335,11 +395,11 @@ class DataTable(ScrollView, Generic[CellType]): segments = fixed_line + line_crop(scrollable_line, x1 + fixed_width, x2, width) - remaining_width = width - (fixed_width + min(width, (x2 - x1 + fixed_width))) - if remaining_width > 0: - segments.append(Segment(" " * remaining_width, base_style)) - elif remaining_width < 0: - segments = Segment.adjust_line_length(segments, width, style=base_style) + # remaining_width = width - (fixed_width + min(width, (x2 - x1 + fixed_width))) + # if remaining_width > 0: + # segments.append(Segment(" " * remaining_width, base_style)) + # elif remaining_width < 0: + segments = Segment.adjust_line_length(segments, width, style=base_style) simplified_segments = list(Segment.simplify(segments)) @@ -382,3 +442,39 @@ class DataTable(ScrollView, Generic[CellType]): def on_mouse_move(self, event): print(self.get_style_at(event.x, event.y).meta) + + async def on_key(self, event) -> None: + await self.dispatch_key(event) + + def _scroll_cursor_in_to_view(self) -> None: + region = self._get_cursor_region(self.cursor_row, self.cursor_column) + print("CURSOR", region) + self.scroll_to_region(region) + + def key_down(self, event: events.Key): + self.cursor_row += 1 + self._clear_caches() + event.stop() + event.prevent_default() + self._scroll_cursor_in_to_view() + + def key_up(self, event: events.Key): + self.cursor_row -= 1 + self._clear_caches() + event.stop() + event.prevent_default() + self._scroll_cursor_in_to_view() + + def key_right(self, event: events.Key): + self.cursor_column += 1 + self._clear_caches() + event.stop() + event.prevent_default() + self._scroll_cursor_in_to_view() + + def key_left(self, event: events.Key): + self.cursor_column -= 1 + self._clear_caches() + event.stop() + event.prevent_default() + self._scroll_cursor_in_to_view() From 34511529936a5df11927fa50f34ea48a70ac2c3e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 22 Jun 2022 11:03:04 +0100 Subject: [PATCH 02/13] cursor and hover --- src/textual/widget.py | 50 ++++++++----------- src/textual/widgets/_data_table.py | 80 ++++++++++++++++++++---------- 2 files changed, 76 insertions(+), 54 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index f845b5da3..948ff32d1 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -29,7 +29,7 @@ from ._context import active_app from ._types import Lines from .dom import DOMNode from ._layout import ArrangeResult -from .geometry import clamp, Offset, Region, Size +from .geometry import clamp, Offset, Region, Size, Spacing from .layouts.vertical import VerticalLayout from .message import Message from . import messages @@ -624,12 +624,15 @@ class Widget(DOMNode): return any(scrolls) - def scroll_to_region(self, region: Region, *, animate: bool = True) -> bool: + def scroll_to_region( + self, region: Region, *, spacing: Spacing | None = None, animate: bool = True + ) -> bool: """Scrolls a given region in to view. Args: region (Region): A region that should be visible. animate (bool, optional): Enable animation. Defaults to True. + spacing (Spacing): Space to subtract from the window region. Returns: bool: True if the window was scrolled. @@ -637,50 +640,41 @@ class Widget(DOMNode): scroll_x, scroll_y = self.scroll_offset width, height = self.region.size - container_region = Region(scroll_x, scroll_y, width, height) + window = Region(scroll_x, scroll_y, width, height) + if spacing is not None: + window = window.shrink(spacing) - if region in container_region: - # Widget is visible, nothing to do + if region in window: + # Widget is entirely visible, nothing to do return False - ( - container_left, - container_top, - container_right, - container_bottom, - ) = container_region.corners - ( - child_left, - child_top, - child_right, - child_bottom, - ) = region.corners + window_left, window_top, window_right, window_bottom = window.corners + left, top, right, bottom = region.corners - delta_x = 0 - delta_y = 0 + delta_x = delta_y = 0 if not ( - (container_right >= child_left > container_left) - and (container_right >= child_right > container_left) + (window_right > left >= window_left) + and (window_right > right >= window_left) ): delta_x = min( - child_left - container_left, - child_left - (container_right - region.width), + left - window_left, + left - (window_right - region.width), key=abs, ) if not ( - (container_bottom >= child_top > container_top) - and (container_bottom >= child_bottom > container_top) + (window_bottom > top >= window_top) + and (window_bottom > bottom >= window_top) ): delta_y = min( - child_top - container_top, - child_top - (container_bottom - region.height), + top - window_top, + top - (window_bottom - region.height), key=abs, ) scrolled = self.scroll_relative( - delta_x or None, delta_y or None, animate=abs(delta_y) != 1, duration=0.2 + delta_x or None, delta_y or None, animate=animate, duration=0.2 ) return scrolled diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 558f6d612..e9681a7d1 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -16,7 +16,7 @@ from .. import events from .._cache import LRUCache from .._segment_tools import line_crop from .._types import Lines -from ..geometry import clamp, Region, Size +from ..geometry import clamp, Region, Size, Spacing from ..reactive import Reactive from .._profile import timer from ..scroll_view import ScrollView @@ -103,8 +103,7 @@ class DataTable(ScrollView, Generic[CellType]): } DataTable > .datatable--highlight { - background: $secondary; - color: $text-secondary; + background: $primary 20%; } """ @@ -135,7 +134,7 @@ class DataTable(ScrollView, Generic[CellType]): self._row_render_cache = LRUCache(1000) self._cell_render_cache: LRUCache[tuple[int, int, Style], Lines] - self._cell_render_cache = LRUCache(10000) + self._cell_render_cache = LRUCache(1000) self._line_cache: LRUCache[tuple[int, int, int, int], list[Segment]] self._line_cache = LRUCache(1000) @@ -144,13 +143,15 @@ class DataTable(ScrollView, Generic[CellType]): show_header = Reactive(True) fixed_rows = Reactive(0) - fixed_columns = Reactive(0) + fixed_columns = Reactive(1) zebra_stripes = Reactive(False) header_height = Reactive(1) show_cursor = Reactive(True) cursor_type = Reactive(CELL) cursor_row = Reactive(0) - cursor_column = Reactive(0) + cursor_column = Reactive(1) + hover_row = Reactive(0) + hover_column = Reactive(0) def _clear_caches(self) -> None: self._row_render_cache.clear() @@ -188,7 +189,7 @@ class DataTable(ScrollView, Generic[CellType]): len(self._y_offsets) + (self.header_height if self.show_header else 0), ) - def _get_cursor_region(self, row_index: int, column_index: int) -> Region: + def _get_cell_region(self, row_index: int, column_index: int) -> Region: row = self.rows[row_index] x = sum(column.width for column in self.columns[:column_index]) width = self.columns[column_index].width @@ -256,6 +257,7 @@ class DataTable(ScrollView, Generic[CellType]): style: Style, width: int, cursor: bool = False, + hover: bool = False, ) -> Lines: """Render the given cell. @@ -268,9 +270,11 @@ class DataTable(ScrollView, Generic[CellType]): Returns: Lines: A list of segments per line. """ + if hover: + style += self.component_styles["datatable--highlight"].node.rich_style if cursor: style += self.component_styles["datatable--cursor"].node.rich_style - cell_key = (row_index, column_index, style) + cell_key = (row_index, column_index, style, cursor, hover) if cell_key not in self._cell_render_cache: style += Style.from_meta({"row": row_index, "column": column_index}) height = ( @@ -286,7 +290,12 @@ class DataTable(ScrollView, Generic[CellType]): return self._cell_render_cache[cell_key] def _render_row( - self, row_index: int, line_no: int, base_style: Style, cursor: int = -1 + self, + row_index: int, + line_no: int, + base_style: Style, + cursor_column: int = -1, + hover_column: int = -1, ) -> tuple[Lines, Lines]: """Render a row in to lines for each cell. @@ -299,7 +308,7 @@ class DataTable(ScrollView, Generic[CellType]): tuple[Lines, Lines]: Lines for fixed cells, and Lines for scrollable cells. """ - cache_key = (row_index, line_no, base_style) + cache_key = (row_index, line_no, base_style, cursor_column, hover_column) if cache_key in self._row_render_cache: return self._row_render_cache[cache_key] @@ -333,7 +342,8 @@ class DataTable(ScrollView, Generic[CellType]): column.index, row_style, column.width, - cursor=cursor == column.index, + cursor=cursor_column == column.index, + hover=hover_column == column.index, )[line_no] for column in self.columns ] @@ -373,20 +383,24 @@ class DataTable(ScrollView, Generic[CellType]): """ width = self.region.width + row_index, line_no = self._get_offsets(y) + cursor_column = ( + self.cursor_column + if (self.show_cursor and self.cursor_row == row_index) + else -1 + ) + hover_column = self.hover_column if (self.hover_row == row_index) else -1 - cache_key = (y, x1, x2, width) + cache_key = (y, x1, x2, width, cursor_column, hover_column) if cache_key in self._line_cache: return self._line_cache[cache_key] - row_index, line_no = self._get_offsets(y) - fixed, scrollable = self._render_row( row_index, line_no, base_style, - cursor=self.cursor_column - if (self.show_cursor and self.cursor_row == row_index) - else -1, + cursor_column=cursor_column, + hover_column=hover_column, ) fixed_width = sum(column.width for column in self.columns[: self.fixed_columns]) @@ -440,41 +454,55 @@ class DataTable(ScrollView, Generic[CellType]): return lines - def on_mouse_move(self, event): - print(self.get_style_at(event.x, event.y).meta) + def on_mouse_move(self, event: events.MouseMove): + meta = self.get_style_at(event.x, event.y).meta + self.hover_row = meta.get("row") + self.hover_column = meta.get("column") async def on_key(self, event) -> None: await self.dispatch_key(event) + def _get_cell_border(self) -> Spacing: + top = self.header_height if self.show_header else 0 + top += sum( + self.rows[row_index].height + for row_index in range(self.fixed_rows) + if row_index in self.rows + ) + left = sum(column.width for column in self.columns[: self.fixed_columns]) + return Spacing(top, 0, 0, left) + def _scroll_cursor_in_to_view(self) -> None: - region = self._get_cursor_region(self.cursor_row, self.cursor_column) - print("CURSOR", region) - self.scroll_to_region(region) + region = self._get_cell_region(self.cursor_row, self.cursor_column) + spacing = self._get_cell_border() + self.scroll_to_region(region, animate=False, spacing=spacing) + + def on_click(self, event: events.Click) -> None: + meta = self.get_style_at(event.x, event.y).meta + self.cursor_row = meta.get("row") + self.cursor_column = meta.get("column") + self._scroll_cursor_in_to_view() def key_down(self, event: events.Key): self.cursor_row += 1 - self._clear_caches() event.stop() event.prevent_default() self._scroll_cursor_in_to_view() def key_up(self, event: events.Key): self.cursor_row -= 1 - self._clear_caches() event.stop() event.prevent_default() self._scroll_cursor_in_to_view() def key_right(self, event: events.Key): self.cursor_column += 1 - self._clear_caches() event.stop() event.prevent_default() self._scroll_cursor_in_to_view() def key_left(self, event: events.Key): self.cursor_column -= 1 - self._clear_caches() event.stop() event.prevent_default() self._scroll_cursor_in_to_view() From e85438c9e6a6f8b03f5e05135e9b1867e61b39f9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 22 Jun 2022 11:47:58 +0100 Subject: [PATCH 03/13] renamed Region.origin to offset --- sandbox/basic.py | 1 - sandbox/will/basic.css | 231 +++++++++++++++++++++++++++++ sandbox/will/basic.py | 185 +++++++++++++++++++++++ sandbox/{ => will}/table.py | 0 src/textual/_compositor.py | 8 +- src/textual/geometry.py | 27 +++- src/textual/widget.py | 16 +- src/textual/widgets/_data_table.py | 34 ++--- tests/test_geometry.py | 6 +- 9 files changed, 468 insertions(+), 40 deletions(-) create mode 100644 sandbox/will/basic.css create mode 100644 sandbox/will/basic.py rename sandbox/{ => will}/table.py (100%) diff --git a/sandbox/basic.py b/sandbox/basic.py index f52d29b8d..acae838d7 100644 --- a/sandbox/basic.py +++ b/sandbox/basic.py @@ -112,7 +112,6 @@ class BasicApp(App, css_path="basic.css"): Tweet(TweetBody()), Widget( Static(Syntax(CODE, "python"), classes="code"), - self.scroll_to_target, classes="scrollable", ), Error(), diff --git a/sandbox/will/basic.css b/sandbox/will/basic.css new file mode 100644 index 000000000..eed5e0b5e --- /dev/null +++ b/sandbox/will/basic.css @@ -0,0 +1,231 @@ +/* CSS file for basic.py */ + + + +* { + transition: color 300ms linear, background 300ms linear; +} + +* { + scrollbar-background: $panel-darken-2; + scrollbar-background-hover: $panel-darken-3; + scrollbar-color: $system; + scrollbar-color-active: $accent-darken-1; + scrollbar-size-horizontal: 1; + scrollbar-size-vertical: 2; +} + +App > Screen { + layout: dock; + docks: side=left/1; + background: $surface; + color: $text-surface; +} + + +#sidebar { + color: $text-primary; + background: $primary-background; + dock: side; + width: 30; + offset-x: -100%; + layout: dock; + transition: offset 500ms in_out_cubic; +} + +#sidebar.-active { + offset-x: 0; +} + +#sidebar .title { + height: 3; + background: $primary-background-darken-2; + color: $text-primary-darken-2 ; + border-right: outer $primary-darken-3; + content-align: center middle; +} + +#sidebar .user { + height: 8; + background: $primary-background-darken-1; + color: $text-primary-darken-1; + border-right: outer $primary-background-darken-3; + content-align: center middle; +} + +#sidebar .content { + background: $primary-background; + color: $text-primary-background; + border-right: outer $primary-background-darken-3; + content-align: center middle; +} + +#header { + color: $text-primary-darken-1; + background: $primary-darken-1; + height: 3; + content-align: center middle; +} + +#content { + color: $text-background; + background: $background; + layout: vertical; + overflow-y: scroll; +} + + +Tweet { + height:12; + width: 100%; + + margin: 1 3; + background: $panel; + color: $text-panel; + layout: vertical; + /* border: outer $primary; */ + padding: 1; + border: wide $panel-darken-2; + overflow: auto; + /* scrollbar-gutter: stable; */ + align-horizontal: center; + box-sizing: border-box; +} + + +.scrollable { + + overflow-y: scroll; + margin: 1 2; + height: 20; + align-horizontal: center; + layout: vertical; +} + +.code { + + height: auto; + +} + + +TweetHeader { + height:1; + background: $accent; + color: $text-accent +} + +TweetBody { + width: 100%; + background: $panel; + color: $text-panel; + height: auto; + padding: 0 1 0 0; +} + +Tweet.scroll-horizontal TweetBody { + width: 350; +} + +.button { + background: $accent; + color: $text-accent; + width:20; + height: 3; + /* border-top: hidden $accent-darken-3; */ + border: tall $accent-darken-2; + /* border-left: tall $accent-darken-1; */ + + + /* padding: 1 0 0 0 ; */ + + transition: background 400ms in_out_cubic, color 400ms in_out_cubic; + +} + +.button:hover { + background: $accent-lighten-1; + color: $text-accent-lighten-1; + width: 20; + height: 3; + border: tall $accent-darken-1; + /* border-left: tall $accent-darken-3; */ + + + + +} + +#footer { + color: $text-accent; + background: $accent; + height: 1; + border-top: hkey $accent-darken-2; + content-align: center middle; +} + + +#sidebar .content { + layout: vertical +} + +OptionItem { + height: 3; + background: $primary-background; + border-right: outer $primary-background-darken-2; + border-left: blank; + content-align: center middle; +} + +OptionItem:hover { + height: 3; + color: $accent; + background: $primary-background-darken-1; + /* border-top: hkey $accent2-darken-3; + border-bottom: hkey $accent2-darken-3; */ + text-style: bold; + border-left: outer $accent-darken-2; +} + +Error { + width: 100%; + height:3; + background: $error; + color: $text-error; + border-top: hkey $error-darken-2; + border-bottom: hkey $error-darken-2; + margin: 1 3; + + text-style: bold; + align-horizontal: center; +} + +Warning { + width: 100%; + height:3; + background: $warning; + color: $text-warning-fade-1; + border-top: hkey $warning-darken-2; + border-bottom: hkey $warning-darken-2; + margin: 1 2; + text-style: bold; + align-horizontal: center; +} + +Success { + width: 100%; + height:3; + box-sizing: border-box; + background: $success-lighten-3; + color: $text-success-lighten-3-fade-1; + border-top: hkey $success; + border-bottom: hkey $success; + margin: 1 2; + text-style: bold; + align-horizontal: center; +} + + +.horizontal { + layout: horizontal +} diff --git a/sandbox/will/basic.py b/sandbox/will/basic.py new file mode 100644 index 000000000..acae838d7 --- /dev/null +++ b/sandbox/will/basic.py @@ -0,0 +1,185 @@ +from rich.console import RenderableType +from rich.style import Style +from rich.syntax import Syntax +from rich.text import Text + +from textual.app import App +from textual.reactive import Reactive +from textual.widget import Widget +from textual.widgets import Static + +CODE = ''' +class Offset(NamedTuple): + """A point defined by x and y coordinates.""" + + x: int = 0 + y: int = 0 + + @property + def is_origin(self) -> bool: + """Check if the point is at the origin (0, 0)""" + return self == (0, 0) + + def __bool__(self) -> bool: + return self != (0, 0) + + def __add__(self, other: object) -> Offset: + if isinstance(other, tuple): + _x, _y = self + x, y = other + return Offset(_x + x, _y + y) + return NotImplemented + + def __sub__(self, other: object) -> Offset: + if isinstance(other, tuple): + _x, _y = self + x, y = other + return Offset(_x - x, _y - y) + return NotImplemented + + def __mul__(self, other: object) -> Offset: + if isinstance(other, (float, int)): + x, y = self + return Offset(int(x * other), int(y * other)) + return NotImplemented +''' + + +lorem_short = """Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit liber a a a, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum.""" +lorem = ( + lorem_short + + """ In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. """ +) + +lorem_short_text = Text.from_markup(lorem_short) +lorem_long_text = Text.from_markup(lorem * 2) + + +class TweetHeader(Widget): + def render(self) -> RenderableType: + return Text("Lorem Impsum", justify="center") + + +class TweetBody(Widget): + short_lorem = Reactive(False) + + def render(self) -> Text: + return lorem_short_text if self.short_lorem else lorem_long_text + + +class Tweet(Widget): + pass + + +class OptionItem(Widget): + def render(self) -> Text: + return Text("Option") + + +class Error(Widget): + def render(self) -> Text: + return Text("This is an error message", justify="center") + + +class Warning(Widget): + def render(self) -> Text: + return Text("This is a warning message", justify="center") + + +class Success(Widget): + def render(self) -> Text: + return Text("This is a success message", justify="center") + + +class BasicApp(App, css_path="basic.css"): + """A basic app demonstrating CSS""" + + def on_load(self): + """Bind keys here.""" + self.bind("s", "toggle_class('#sidebar', '-active')") + + def on_mount(self): + """Build layout here.""" + + self.scroll_to_target = Tweet(TweetBody()) + self.mount( + header=Static( + Text.from_markup( + "[b]This is a [u]Textual[/u] app, running in the terminal" + ), + ), + content=Widget( + Tweet(TweetBody()), + Widget( + Static(Syntax(CODE, "python"), classes="code"), + classes="scrollable", + ), + Error(), + Tweet(TweetBody(), classes="scrollbar-size-custom"), + Warning(), + Tweet(TweetBody(), classes="scroll-horizontal"), + Success(), + Tweet(TweetBody(), classes="scroll-horizontal"), + Tweet(TweetBody(), classes="scroll-horizontal"), + Tweet(TweetBody(), classes="scroll-horizontal"), + Tweet(TweetBody(), classes="scroll-horizontal"), + Tweet(TweetBody(), classes="scroll-horizontal"), + ), + footer=Widget(), + sidebar=Widget( + Widget(classes="title"), + Widget(classes="user"), + OptionItem(), + OptionItem(), + OptionItem(), + Widget(classes="content"), + ), + ) + + async def on_key(self, event) -> None: + await self.dispatch_key(event) + + def key_d(self): + self.dark = not self.dark + + async def key_q(self): + await self.shutdown() + + def key_x(self): + self.panic(self.tree) + + def key_escape(self): + self.app.bell() + + def key_t(self): + # Pressing "t" toggles the content of the TweetBody widget, from a long "Lorem ipsum..." to a shorter one. + tweet_body = self.query("TweetBody").first() + tweet_body.short_lorem = not tweet_body.short_lorem + + def key_v(self): + self.get_child(id="content").scroll_to_widget(self.scroll_to_target) + + def key_space(self): + self.bell() + + +app = BasicApp() + +if __name__ == "__main__": + app.run() + + from textual.geometry import Region + from textual.color import Color + + print(Region.intersection.cache_info()) + print(Region.overlaps.cache_info()) + print(Region.union.cache_info()) + print(Region.split_vertical.cache_info()) + print(Region.__contains__.cache_info()) + from textual.css.scalar import Scalar + + print(Scalar.resolve_dimension.cache_info()) + + from rich.style import Style + + print(Style._add.cache_info()) diff --git a/sandbox/table.py b/sandbox/will/table.py similarity index 100% rename from sandbox/table.py rename to sandbox/will/table.py diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 7aa0fe7ee..bcd7207cc 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -328,7 +328,7 @@ class Compositor: sub_clip = clip.intersection(child_region) # The region covered by children relative to parent widget - total_region = child_region.reset_origin + total_region = child_region.reset_offset if widget.is_container: # Arrange the layout @@ -338,7 +338,7 @@ class Compositor: # An offset added to all placements placement_offset = ( - container_region.origin + layout_offset - widget.scroll_offset + container_region.offset + layout_offset - widget.scroll_offset ) # Add all the widgets @@ -358,7 +358,7 @@ class Compositor: container_size ): map[chrome_widget] = MapGeometry( - chrome_region + container_region.origin + layout_offset, + chrome_region + container_region.offset + layout_offset, order, clip, container_size, @@ -414,7 +414,7 @@ class Compositor: def get_offset(self, widget: Widget) -> Offset: """Get the offset of a widget.""" try: - return self.map[widget].region.origin + return self.map[widget].region.offset except KeyError: raise errors.NoWidget("Widget is not in layout") diff --git a/src/textual/geometry.py b/src/textual/geometry.py index fb7ac4cb8..3b7b1337c 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -224,17 +224,17 @@ 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. + def from_offset(cls, offset: tuple[int, int], size: tuple[int, int]) -> Region: + """Create a region from offset and size. Args: - origin (Point): Origin (top left point) + offset (Point): Offset (top left point) size (tuple[int, int]): Dimensions of region. Returns: Region: A region instance. """ - x, y = origin + x, y = offset width, height = size return cls(x, y, width, height) @@ -280,7 +280,7 @@ class Region(NamedTuple): return self.width * self.height @property - def origin(self) -> Offset: + def offset(self) -> Offset: """Get the start point of the region.""" return Offset(self.x, self.y) @@ -328,7 +328,7 @@ class Region(NamedTuple): return range(self.y, self.y + self.height) @property - def reset_origin(self) -> Region: + def reset_offset(self) -> Region: """An region of the same size at (0, 0).""" _, _, width, height = self return Region(0, 0, width, height) @@ -347,6 +347,19 @@ class Region(NamedTuple): return Region(x - ox, y - oy, width, height) return NotImplemented + def at_offset(self, offset: tuple[int, int]) -> Region: + """Get a new Region with the same size at a given offset. + + Args: + offset (tuple[int, int]): An offset. + + Returns: + Region: New Region with adjusted offset. + """ + x, y = offset + _x, _y, width, height = self + return Region(x, y, width, height) + def expand(self, size: tuple[int, int]) -> Region: """Increase the size of the region by adding a border. @@ -430,7 +443,7 @@ class Region(NamedTuple): ) def translate(self, x: int = 0, y: int = 0) -> Region: - """Move the origin of the Region. + """Move the offset of the Region. Args: translate_x (int): Value to add to x coordinate. diff --git a/src/textual/widget.py b/src/textual/widget.py index 948ff32d1..1e94cbba9 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -595,9 +595,9 @@ class Widget(DOMNode): # We can either scroll so the widget is at the top of the container, or so that # it is at the bottom. We want to pick which has the shortest distance - top_delta = widget_region.origin - container_region.origin + top_delta = widget_region.offset - container_region.origin - bottom_delta = widget_region.origin - ( + bottom_delta = widget_region.offset - ( container_region.origin + Offset(0, container_region.height - widget_region.height) ) @@ -627,7 +627,10 @@ class Widget(DOMNode): def scroll_to_region( self, region: Region, *, spacing: Spacing | None = None, animate: bool = True ) -> bool: - """Scrolls a given region in to view. + """Scrolls a given region in to view, if required. + + This method will scroll the least distance required to move `region` fully within + the scrollable area. Args: region (Region): A region that should be visible. @@ -638,9 +641,7 @@ class Widget(DOMNode): bool: True if the window was scrolled. """ - scroll_x, scroll_y = self.scroll_offset - width, height = self.region.size - window = Region(scroll_x, scroll_y, width, height) + window = self.region.at_offset(self.scroll_offset) if spacing is not None: window = window.shrink(spacing) @@ -650,13 +651,13 @@ class Widget(DOMNode): window_left, window_top, window_right, window_bottom = window.corners left, top, right, bottom = region.corners - delta_x = delta_y = 0 if not ( (window_right > left >= window_left) and (window_right > right >= window_left) ): + # The window needs to scroll on the X axis to bring region in to view delta_x = min( left - window_left, left - (window_right - region.width), @@ -667,6 +668,7 @@ class Widget(DOMNode): (window_bottom > top >= window_top) and (window_bottom > bottom >= window_top) ): + # The window needs to scroll on the Y axis to bring region in to view delta_y = min( top - window_top, top - (window_bottom - region.height), diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index e9681a7d1..f1f2d0929 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -130,13 +130,15 @@ class DataTable(ScrollView, Generic[CellType]): self._y_offsets: list[tuple[int, int]] = [] - self._row_render_cache: LRUCache[tuple[int, int, Style], tuple[Lines, Lines]] + self._row_render_cache: LRUCache[ + tuple[int, int, Style, int, int], tuple[Lines, Lines] + ] self._row_render_cache = LRUCache(1000) - self._cell_render_cache: LRUCache[tuple[int, int, Style], Lines] + self._cell_render_cache: LRUCache[tuple[int, int, Style, bool, bool], Lines] self._cell_render_cache = LRUCache(1000) - self._line_cache: LRUCache[tuple[int, int, int, int], list[Segment]] + self._line_cache: LRUCache[tuple[int, int, int, int, int, int], list[Segment]] self._line_cache = LRUCache(1000) self._line_no = 0 @@ -148,10 +150,10 @@ class DataTable(ScrollView, Generic[CellType]): header_height = Reactive(1) show_cursor = Reactive(True) cursor_type = Reactive(CELL) - cursor_row = Reactive(0) - cursor_column = Reactive(1) - hover_row = Reactive(0) - hover_column = Reactive(0) + cursor_row: Reactive[int] = Reactive(0) + cursor_column: Reactive[int] = Reactive(1) + hover_row: Reactive[int] = Reactive(0) + hover_column: Reactive[int] = Reactive(0) def _clear_caches(self) -> None: self._row_render_cache.clear() @@ -408,13 +410,7 @@ class DataTable(ScrollView, Generic[CellType]): scrollable_line: list[Segment] = list(chain.from_iterable(scrollable)) segments = fixed_line + line_crop(scrollable_line, x1 + fixed_width, x2, width) - - # remaining_width = width - (fixed_width + min(width, (x2 - x1 + fixed_width))) - # if remaining_width > 0: - # segments.append(Segment(" " * remaining_width, base_style)) - # elif remaining_width < 0: segments = Segment.adjust_line_length(segments, width, style=base_style) - simplified_segments = list(Segment.simplify(segments)) self._line_cache[cache_key] = simplified_segments @@ -456,8 +452,9 @@ class DataTable(ScrollView, Generic[CellType]): def on_mouse_move(self, event: events.MouseMove): meta = self.get_style_at(event.x, event.y).meta - self.hover_row = meta.get("row") - self.hover_column = meta.get("column") + if meta: + self.hover_row = meta["row"] + self.hover_column = meta["column"] async def on_key(self, event) -> None: await self.dispatch_key(event) @@ -479,9 +476,10 @@ class DataTable(ScrollView, Generic[CellType]): def on_click(self, event: events.Click) -> None: meta = self.get_style_at(event.x, event.y).meta - self.cursor_row = meta.get("row") - self.cursor_column = meta.get("column") - self._scroll_cursor_in_to_view() + if meta: + self.cursor_row = meta["row"] + self.cursor_column = meta["column"] + self._scroll_cursor_in_to_view() def key_down(self, event: events.Key): self.cursor_row += 1 diff --git a/tests/test_geometry.py b/tests/test_geometry.py index b408a8ad4..c5e35438e 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -126,7 +126,7 @@ def test_region_from_union(): def test_region_from_origin(): - assert Region.from_origin(Offset(3, 4), (5, 6)) == Region(3, 4, 5, 6) + assert Region.from_offset(Offset(3, 4), (5, 6)) == Region(3, 4, 5, 6) def test_region_area(): @@ -140,7 +140,7 @@ def test_region_size(): def test_region_origin(): - assert Region(1, 2, 3, 4).origin == Offset(1, 2) + assert Region(1, 2, 3, 4).offset == Offset(1, 2) def test_region_bottom_left(): @@ -275,7 +275,7 @@ def test_region_y_range(): def test_region_reset_origin(): - assert Region(5, 10, 20, 30).reset_origin == Region(0, 0, 20, 30) + assert Region(5, 10, 20, 30).reset_offset == Region(0, 0, 20, 30) def test_region_expand(): From fa8885d674c3ea33382de9c418dc7684f715ebb0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 22 Jun 2022 13:54:28 +0100 Subject: [PATCH 04/13] refactored to translate_inside method --- src/textual/geometry.py | 45 +++++++++++++++++++++++++++++++++++++++++ src/textual/widget.py | 44 +++++++++------------------------------- 2 files changed, 54 insertions(+), 35 deletions(-) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 3b7b1337c..b20bea9cf 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -238,6 +238,51 @@ class Region(NamedTuple): width, height = size return cls(x, y, width, height) + @classmethod + def translate_inside(cls, window_region: Region, region: Region) -> Offset: + """Calculate the smallest offset required to move a region inside another region. + + This method is used to calculate the required offset to scroll something in to view. + + Args: + window_region (Region): The window region. + region (Region): The region to move inside the window. + + Returns: + Offset: An offset required to add to region to move it inside window_region. + """ + + if region in window_region: + # Region is already inside the window, so no need to move it. + return Offset(0, 0) + + window_left, window_top, window_right, window_bottom = window_region.corners + left, top, right, bottom = region.corners + delta_x = delta_y = 0 + + if not ( + (window_right > left >= window_left) + and (window_right > right >= window_left) + ): + # The window needs to scroll on the X axis to bring region in to view + delta_x = min( + left - window_left, + left - (window_right - region.width), + key=abs, + ) + + if not ( + (window_bottom > top >= window_top) + and (window_bottom > bottom >= window_top) + ): + # The window needs to scroll on the Y axis to bring region in to view + delta_y = min( + top - window_top, + top - (window_bottom - region.height), + key=abs, + ) + return Offset(delta_x, delta_y) + def __bool__(self) -> bool: """A Region is considered False when it has no area.""" return bool(self.width and self.height) diff --git a/src/textual/widget.py b/src/textual/widget.py index 1e94cbba9..8917196c4 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -626,7 +626,7 @@ class Widget(DOMNode): def scroll_to_region( self, region: Region, *, spacing: Spacing | None = None, animate: bool = True - ) -> bool: + ) -> Offset: """Scrolls a given region in to view, if required. This method will scroll the least distance required to move `region` fully within @@ -644,42 +644,16 @@ class Widget(DOMNode): window = self.region.at_offset(self.scroll_offset) if spacing is not None: window = window.shrink(spacing) - - if region in window: - # Widget is entirely visible, nothing to do - return False - - window_left, window_top, window_right, window_bottom = window.corners - left, top, right, bottom = region.corners - delta_x = delta_y = 0 - - if not ( - (window_right > left >= window_left) - and (window_right > right >= window_left) - ): - # The window needs to scroll on the X axis to bring region in to view - delta_x = min( - left - window_left, - left - (window_right - region.width), - key=abs, + delta = Region.translate_inside(window, region) + if delta: + self.scroll_relative( + delta.x or None, + delta.y or None, + animate=animate, + duration=0.2, ) - if not ( - (window_bottom > top >= window_top) - and (window_bottom > bottom >= window_top) - ): - # The window needs to scroll on the Y axis to bring region in to view - delta_y = min( - top - window_top, - top - (window_bottom - region.height), - key=abs, - ) - - scrolled = self.scroll_relative( - delta_x or None, delta_y or None, animate=animate, duration=0.2 - ) - - return scrolled + return delta def __init_subclass__( cls, From 03bfe10e69d7edf5e25263aef99000fa31e0212c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 24 Jun 2022 16:00:12 +0100 Subject: [PATCH 05/13] inter-widget updates --- sandbox/will/table.py | 5 +- src/textual/_compositor.py | 24 +++++--- src/textual/_layout.py | 2 +- src/textual/app.py | 17 +++++- src/textual/dom.py | 2 +- src/textual/geometry.py | 49 ++++++++++++++-- src/textual/scroll_view.py | 9 ++- src/textual/widget.py | 47 ++++++++++----- src/textual/widgets/_data_table.py | 91 ++++++++++++++++++++++++------ tests/test_geometry.py | 4 +- 10 files changed, 196 insertions(+), 54 deletions(-) diff --git a/sandbox/will/table.py b/sandbox/will/table.py index 545cd5f83..a3dcdb384 100644 --- a/sandbox/will/table.py +++ b/sandbox/will/table.py @@ -36,6 +36,8 @@ test_table.add_row("Dec 16, 2016", "Rogue One: A Star Wars Story", "$1,332,439,8 class TableApp(App): def compose(self) -> ComposeResult: table = self.table = DataTable(id="data") + yield table + table.add_column("Foo", width=20) table.add_column("Bar", width=60) table.add_column("Baz", width=20) @@ -60,7 +62,8 @@ class TableApp(App): row[1] = test_table height = 13 table.add_row(*row, height=height) - yield table + + table.focus() def on_mount(self): self.bind("d", "toggle_dark") diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index bcd7207cc..e79200c46 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -116,12 +116,14 @@ class ChopsUpdate: new_line = Segment.line() chops = self.chops crop = self.crop - last_y = crop.y_max - 1 + last_y = crop.bottom - 1 x1, x2 = crop.x_extents for y in crop.y_range: line = chops[y] for x, segments in line.items(): - if segments is not None and x2 > x >= x1: + # TODO: crop to x extents + if segments is not None: + # if segments is not None and x2 > x >= x1: yield move_to(x, y) yield from segments if y != last_y: @@ -635,8 +637,10 @@ class Compositor: continue first_cut, last_cut = render_region.x_extents - final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)] - + cuts_line = cuts[y] + final_cuts = [ + cut for cut in cuts_line if (last_cut >= cut >= first_cut) + ] if len(final_cuts) <= 2: # Two cuts, which means the entire line cut_segments = [line] @@ -673,8 +677,12 @@ class Compositor: regions: list[Region] = [] add_region = regions.append for widget in self.regions.keys() & widgets: - region, clip = self.regions[widget] - update_region = region.intersection(clip) - if update_region: - add_region(update_region) + (x, y, _, _), clip = self.regions[widget] + intersection = clip.intersection + + for dirty_region in widget.get_dirty_regions(): + update_region = intersection(dirty_region.translate(x, y)) + if update_region: + add_region(update_region) + self.add_dirty_regions(regions) diff --git a/src/textual/_layout.py b/src/textual/_layout.py index f652a6f4a..b4c98f0b3 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -85,5 +85,5 @@ class Layout(ABC): height = container.height else: placements, widgets = widget._arrange(Size(width, container.height)) - height = max(placement.region.y_max for placement in placements) + height = max(placement.region.bottom for placement in placements) return height diff --git a/src/textual/app.py b/src/textual/app.py index 5cc0261c9..318bc8dec 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -588,8 +588,21 @@ class App(Generic[ReturnType], DOMNode): self.check_idle() def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None: + """Mount widgets. Widgets specified as positional args, or keywords args. If supplied + as keyword args they will be assigned an id of the key. + + """ self.register(self.screen, *anon_widgets, **widgets) + def mount_all(self, widgets: Iterable[Widget]) -> None: + """Mount widgets from an iterable. + + Args: + widgets (Iterable[Widget]): An iterable of widgets. + """ + for widget in widgets: + self.register(self.screen, widget) + def push_screen(self, screen: Screen | None = None) -> Screen: """Push a new screen on the screen stack. @@ -811,9 +824,9 @@ class App(Generic[ReturnType], DOMNode): self.set_timer(screenshot_timer, on_screenshot) def on_mount(self) -> None: - widgets = list(self.compose()) + widgets = self.compose() if widgets: - self.mount(*widgets) + self.mount_all(widgets) async def on_idle(self) -> None: """Perform actions when there are no messages in the queue.""" diff --git a/src/textual/dom.py b/src/textual/dom.py index 8520b9f2c..84eef48e9 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -428,7 +428,7 @@ class DOMNode(MessagePump): for node in self.walk_children(): node._css_styles.reset() if isinstance(node, Widget): - node.set_dirty() + node._set_dirty() node._layout_required = True def add_child(self, node: DOMNode) -> None: diff --git a/src/textual/geometry.py b/src/textual/geometry.py index b20bea9cf..827632c61 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -77,6 +77,10 @@ class Offset(NamedTuple): return Offset(int(x * other), int(y * other)) return NotImplemented + def __neg__(self) -> Offset: + x, y = self + return Offset(-x, -y) + def blend(self, destination: Offset, factor: float) -> Offset: """Blend (interpolate) to a new point. @@ -240,7 +244,8 @@ class Region(NamedTuple): @classmethod def translate_inside(cls, window_region: Region, region: Region) -> Offset: - """Calculate the smallest offset required to move a region inside another region. + """Calculate the smallest offset required to translate a region so that is is within + another region. This method is used to calculate the required offset to scroll something in to view. @@ -257,6 +262,7 @@ class Region(NamedTuple): return Offset(0, 0) window_left, window_top, window_right, window_bottom = window_region.corners + region = region.crop_size(window_region.size) left, top, right, bottom = region.corners delta_x = delta_y = 0 @@ -264,6 +270,7 @@ class Region(NamedTuple): (window_right > left >= window_left) and (window_right > right >= window_left) ): + # The region does not fit # The window needs to scroll on the X axis to bring region in to view delta_x = min( left - window_left, @@ -310,12 +317,12 @@ class Region(NamedTuple): return (self.y, self.y + self.height) @property - def x_max(self) -> int: + def right(self) -> int: """Maximum X value (non inclusive)""" return self.x + self.width @property - def y_max(self) -> int: + def bottom(self) -> int: """Maximum Y value (non inclusive)""" return self.y + self.height @@ -347,6 +354,11 @@ class Region(NamedTuple): x, y, width, height = self return Offset(x + width, y + height) + @property + def offset(self) -> Offset: + x, y, _, _ = self + return Offset(x, y) + @property def size(self) -> Size: """Get the size of the region.""" @@ -354,10 +366,10 @@ class Region(NamedTuple): @property def corners(self) -> tuple[int, int, int, int]: - """Get the maxima and minima of region. + """Get the top left and bottom right coordinates as a tuple of integers. Returns: - tuple[int, int, int, int]: A tuple of `(, , , )` + tuple[int, int, int, int]: A tuple of `(, , , )` """ x, y, width, height = self return x, y, x + width, y + height @@ -405,6 +417,19 @@ class Region(NamedTuple): _x, _y, width, height = self return Region(x, y, width, height) + def crop_size(self, size: tuple[int, int]) -> Region: + """Get a region with the same offset, with a size no larger than `size`. + + Args: + size (tuple[int, int]): Maximum width and height (WIDTH, HEIGHT). + + Returns: + Region: New region that could fit within `size`. + """ + x, y, width1, height1 = self + width2, height2 = size + return Region(x, y, min(width1, width2), min(height1, height2)) + def expand(self, size: tuple[int, int]) -> Region: """Increase the size of the region by adding a border. @@ -501,6 +526,20 @@ class Region(NamedTuple): self_x, self_y, width, height = self return Region(self_x + x, self_y + y, width, height) + def translate_negative(self, x: int = 0, y: int = 0) -> Region: + """Move the offset of the Region in the opposite direction. + + Args: + translate_x (int): Value to subtract to x coordinate. + translate_y (int): Value to subtract to y coordinate. + + Returns: + Region: A new region shifted by x, y + """ + + self_x, self_y, width, height = self + return Region(self_x - x, self_y - y, width, height) + @lru_cache(maxsize=4096) def __contains__(self, other: Any) -> bool: """Check if a point is in this region.""" diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py index 003e64c2f..bf84282be 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -1,9 +1,11 @@ from __future__ import annotations +from typing import Collection + from rich.console import RenderableType -from .geometry import Size +from .geometry import Region, Size from .widget import Widget @@ -38,6 +40,11 @@ class ScrollView(Widget): """Not transparent, i.e. renders something.""" return False + def get_dirty_regions(self) -> Collection[Region]: + regions = self._dirty_regions.copy() + self._dirty_regions.clear() + return regions + def on_mount(self): self._refresh_scrollbars() diff --git a/src/textual/widget.py b/src/textual/widget.py index 8917196c4..042598318 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -5,6 +5,7 @@ from typing import ( Any, Awaitable, ClassVar, + Collection, TYPE_CHECKING, Callable, Iterable, @@ -17,8 +18,6 @@ from rich.console import Console, RenderableType from rich.measure import Measurement from rich.padding import Padding from rich.style import Style -from rich.styled import Styled - from . import errors from . import events @@ -109,7 +108,7 @@ class Widget(DOMNode): self._horizontal_scrollbar: ScrollBar | None = None self._render_cache = RenderCache(Size(0, 0), []) - self._dirty_regions: list[Region] = [] + self._dirty_regions: set[Region] = set() # Cache the auto content dimensions # TODO: add mechanism to explicitly clear this @@ -428,10 +427,24 @@ class Widget(DOMNode): else 0 ) - def set_dirty(self) -> None: + def _set_dirty(self, *regions: Region) -> None: """Set the Widget as 'dirty' (requiring re-render).""" - self._dirty_regions.clear() - self._dirty_regions.append(self.size.region) + + # self._dirty_regions.clear() + # # TODO: Does this need to be content region? + # self._dirty_regions.append(self.size.region) + + if regions: + self._dirty_regions.update(regions) + else: + self._dirty_regions.clear() + # TODO: Does this need to be content region? + # self._dirty_regions.append(self.size.region) + self._dirty_regions.add(self.content_region.size.region) + + def get_dirty_regions(self) -> Collection[Region]: + regions = self._dirty_regions.copy() + return regions def scroll_to( self, @@ -686,23 +699,23 @@ class Widget(DOMNode): """ show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled - horizontal_scrollbar_thickness = self.scrollbar_size_horizontal - vertical_scrollbar_thickness = self.scrollbar_size_vertical + scrollbar_size_horizontal = self.styles.scrollbar_size_horizontal + scrollbar_size_vertical = self.styles.scrollbar_size_vertical 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 + scrollbar_size_vertical = self.styles.scrollbar_size_vertical if show_horizontal_scrollbar and show_vertical_scrollbar: (region, _, _, _) = region.split( - -vertical_scrollbar_thickness, - -horizontal_scrollbar_thickness, + -scrollbar_size_vertical, + -scrollbar_size_horizontal, ) elif show_vertical_scrollbar: - region, _ = region.split_vertical(-vertical_scrollbar_thickness) + region, _ = region.split_vertical(-scrollbar_size_vertical) elif show_horizontal_scrollbar: - region, _ = region.split_horizontal(-horizontal_scrollbar_thickness) + region, _ = region.split_horizontal(-scrollbar_size_horizontal) return region def _arrange_scrollbars(self, size: Size) -> Iterable[tuple[Widget, Region]]: @@ -720,6 +733,7 @@ class Widget(DOMNode): scrollbar_size_horizontal = self.scrollbar_size_horizontal scrollbar_size_vertical = self.scrollbar_size_vertical + if show_horizontal_scrollbar and show_vertical_scrollbar: ( _, @@ -954,7 +968,9 @@ class Widget(DOMNode): event.set_forwarded() await self.post_message(event) - def refresh(self, *, repaint: bool = True, layout: bool = False) -> None: + def refresh( + self, *regions: Region, repaint: bool = True, layout: bool = False + ) -> None: """Initiate a refresh of the widget. This method sets an internal flag to perform a refresh, which will be done on the @@ -964,13 +980,14 @@ class Widget(DOMNode): repaint (bool, optional): Repaint the widget (will call render() again). Defaults to True. layout (bool, optional): Also layout widgets in the view. Defaults to False. """ + if layout: self._layout_required = True self._clear_arrangement_cache() if repaint: + self._set_dirty(*regions) self._content_width_cache = (None, 0) self._content_height_cache = (None, 0) - self.set_dirty() self._repaint_required = True if isinstance(self.parent, Widget) and self.styles.auto_dimensions: self.parent.refresh(layout=True) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index f1f2d0929..d6138f4a2 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field from itertools import chain import sys -from typing import ClassVar, Generic, TypeVar, cast +from typing import ClassVar, Generic, NamedTuple, TypeVar, cast from rich.console import RenderableType from rich.padding import Padding @@ -63,6 +63,27 @@ class Cell: value: object +class Coord(NamedTuple): + row: int + column: int + + def left(self) -> Coord: + row, column = self + return Coord(row, column - 1) + + def right(self) -> Coord: + row, column = self + return Coord(row, column + 1) + + def up(self) -> Coord: + row, column = self + return Coord(row - 1, column) + + def down(self) -> Coord: + row, column = self + return Coord(row + 1, column) + + class Header(Widget): pass @@ -103,7 +124,7 @@ class DataTable(ScrollView, Generic[CellType]): } DataTable > .datatable--highlight { - background: $primary 20%; + background: $secondary 20%; } """ @@ -150,10 +171,25 @@ class DataTable(ScrollView, Generic[CellType]): header_height = Reactive(1) show_cursor = Reactive(True) cursor_type = Reactive(CELL) - cursor_row: Reactive[int] = Reactive(0) - cursor_column: Reactive[int] = Reactive(1) - hover_row: Reactive[int] = Reactive(0) - hover_column: Reactive[int] = Reactive(0) + + cursor_cell: Reactive[Coord] = Reactive(Coord(0, 0), repaint=False) + hover_cell: Reactive[Coord] = Reactive(Coord(0, 0), repaint=False) + + @property + def hover_row(self) -> int: + return self.hover_cell.row + + @property + def hover_column(self) -> int: + return self.hover_cell.column + + @property + def cursor_row(self) -> int: + return self.cursor_cell.row + + @property + def cursor_column(self) -> int: + return self.cursor_cell.column def _clear_caches(self) -> None: self._row_render_cache.clear() @@ -177,11 +213,19 @@ class DataTable(ScrollView, Generic[CellType]): def watch_zebra_stripes(self, zebra_stripes: bool) -> None: self._clear_caches() - def validate_cursor_row(self, value: int) -> int: - return clamp(value, 0, self.row_count - 1) + def watch_hover_cell(self, old: Coord, value: Coord) -> None: + self.refresh_cell(*old) + self.refresh_cell(*value) - def validate_cursor_column(self, value: int) -> int: - return clamp(value, self.fixed_columns, len(self.columns) - 1) + def watch_cursor_cell(self, old: Coord, value: Coord) -> None: + self.refresh_cell(*old) + self.refresh_cell(*value) + + def validate_cursor_cell(self, value: Coord) -> Coord: + row, column = value + row = clamp(row, 0, self.row_count - 1) + column = clamp(column, self.fixed_columns, len(self.columns) - 1) + return Coord(row, column) def _update_dimensions(self) -> None: """Called to recalculate the virtual (scrollable) size.""" @@ -231,6 +275,15 @@ class DataTable(ScrollView, Generic[CellType]): self._update_dimensions() self.refresh() + def refresh_cell(self, row_index: int, column_index: int) -> None: + if row_index < 0 or column_index < 0: + return + region = self._get_cell_region(row_index, column_index) + region = region.translate_negative(*self.scroll_offset) + refresh_region = self.content_region.intersection(region) + if refresh_region: + self.refresh(refresh_region) + def _get_row_renderables(self, row_index: int) -> list[RenderableType]: """Get renderables for the given row. @@ -448,13 +501,16 @@ class DataTable(ScrollView, Generic[CellType]): if y - scroll_y < fixed_top_row_count: lines[line_index] = fixed_lines[line_index] + # self._dirty_regions.clear() return lines def on_mouse_move(self, event: events.MouseMove): meta = self.get_style_at(event.x, event.y).meta if meta: - self.hover_row = meta["row"] - self.hover_column = meta["column"] + try: + self.hover_cell = Coord(meta["row"], meta["column"]) + except KeyError: + pass async def on_key(self, event) -> None: await self.dispatch_key(event) @@ -477,30 +533,29 @@ class DataTable(ScrollView, Generic[CellType]): def on_click(self, event: events.Click) -> None: meta = self.get_style_at(event.x, event.y).meta if meta: - self.cursor_row = meta["row"] - self.cursor_column = meta["column"] + self.cursor_cell = Coord(meta["row"], meta["column"]) self._scroll_cursor_in_to_view() def key_down(self, event: events.Key): - self.cursor_row += 1 + self.cursor_cell = self.cursor_cell.down() event.stop() event.prevent_default() self._scroll_cursor_in_to_view() def key_up(self, event: events.Key): - self.cursor_row -= 1 + self.cursor_cell = self.cursor_cell.up() event.stop() event.prevent_default() self._scroll_cursor_in_to_view() def key_right(self, event: events.Key): - self.cursor_column += 1 + self.cursor_cell = self.cursor_cell.right() event.stop() event.prevent_default() self._scroll_cursor_in_to_view() def key_left(self, event: events.Key): - self.cursor_column -= 1 + self.cursor_cell = self.cursor_cell.left() event.stop() event.prevent_default() self._scroll_cursor_in_to_view() diff --git a/tests/test_geometry.py b/tests/test_geometry.py index c5e35438e..102228daf 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -259,11 +259,11 @@ def test_region_y_extents(): def test_region_x_max(): - assert Region(5, 10, 20, 30).x_max == 25 + assert Region(5, 10, 20, 30).right == 25 def test_region_y_max(): - assert Region(5, 10, 20, 30).y_max == 40 + assert Region(5, 10, 20, 30).bottom == 40 def test_region_x_range(): From 15039fa1ad67750ed369c309f4aedc277cc8b3fe Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 24 Jun 2022 16:09:54 +0100 Subject: [PATCH 06/13] simplify --- src/textual/_compositor.py | 12 ++---------- src/textual/screen.py | 5 ++--- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index e79200c46..04fdb7908 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -161,14 +161,6 @@ class Compositor: # Regions that require an update self._dirty_regions: set[Region] = set() - def add_dirty_regions(self, regions: Iterable[Region]) -> None: - """Add dirty regions to be repainted next call to render. - - Args: - regions (Iterable[Region]): Regions that are "dirty" (changed since last render). - """ - self._dirty_regions.update(regions) - @classmethod def _regions_to_spans( cls, regions: Iterable[Region] @@ -679,10 +671,10 @@ class Compositor: for widget in self.regions.keys() & widgets: (x, y, _, _), clip = self.regions[widget] intersection = clip.intersection - for dirty_region in widget.get_dirty_regions(): update_region = intersection(dirty_region.translate(x, y)) if update_region: add_region(update_region) - self.add_dirty_regions(regions) + self._dirty_regions.update(regions) + # self.add_dirty_regions(regions) diff --git a/src/textual/screen.py b/src/textual/screen.py index f1dd2c60d..acf9552ff 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -117,15 +117,14 @@ class Screen(Widget): self._refresh_layout() self._layout_required = False self._dirty_widgets.clear() - elif self._dirty_widgets or self._dirty_regions: + elif self._dirty_widgets: self.update_timer.resume() def _on_update(self) -> None: """Called by the _update_timer.""" # Render widgets together - if self._dirty_widgets or self._dirty_regions: + if self._dirty_widgets: self._compositor.update_widgets(self._dirty_widgets) - self._compositor.add_dirty_regions(self._dirty_regions) self.app._display(self._compositor.render()) self._dirty_widgets.clear() self.update_timer.pause() From 4f02f17a384ad38d864dea3397e260de193f35c2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 25 Jun 2022 11:26:44 +0100 Subject: [PATCH 07/13] more granular partial updates --- poetry.lock | 108 +++++++++++++++-------------- pyproject.toml | 4 +- src/textual/_compositor.py | 65 +++++++++++++---- src/textual/_segment_tools.py | 11 +-- src/textual/app.py | 2 + src/textual/widgets/_data_table.py | 3 +- 6 files changed, 117 insertions(+), 76 deletions(-) diff --git a/poetry.lock b/poetry.lock index 32aab753c..65dff0562 100644 --- a/poetry.lock +++ b/poetry.lock @@ -345,7 +345,7 @@ mkdocs = ">=1.1" [[package]] name = "mkdocs-material" -version = "8.3.6" +version = "8.3.8" description = "Documentation that simply works" category = "dev" optional = false @@ -658,16 +658,21 @@ version = "12.4.4" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" category = "main" optional = false -python-versions = ">=3.6.3,<4.0.0" +python-versions = "^3.6.3" +develop = true [package.dependencies] -commonmark = ">=0.9.0,<0.10.0" -pygments = ">=2.6.0,<3.0.0" -typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} +commonmark = "^0.9.0" +pygments = "^2.6.0" +typing-extensions = {version = ">=4.0.0, <5.0", markers = "python_version < \"3.9\""} [package.extras] jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"] +[package.source] +type = "directory" +url = "../rich" + [[package]] name = "six" version = "1.16.0" @@ -678,7 +683,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "time-machine" -version = "2.7.0" +version = "2.7.1" description = "Travel through time in your tests." category = "dev" optional = false @@ -780,7 +785,7 @@ dev = ["aiohttp", "click", "msgpack"] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "8ce8d66466dad1b984673595ebd0cc7bc0d28c7a672269e9b5620c242d87d9ad" +content-hash = "08c432accbe56db11ca9b2d69c2b4e15967e1a0142d4508f245715cae6f3d239" [metadata.files] aiohttp = [ @@ -1125,8 +1130,8 @@ mkdocs-autorefs = [ {file = "mkdocs_autorefs-0.4.1-py3-none-any.whl", hash = "sha256:a2248a9501b29dc0cc8ba4c09f4f47ff121945f6ce33d760f145d6f89d313f5b"}, ] mkdocs-material = [ - {file = "mkdocs-material-8.3.6.tar.gz", hash = "sha256:be8f95c0dfb927339b55b2cc066423dc0b381be9828ff74a5b02df979a859b66"}, - {file = "mkdocs_material-8.3.6-py2.py3-none-any.whl", hash = "sha256:01f3fbab055751b3b75a64b538e86b9ce0c6a0f8d43620f6287dfa16534443e5"}, + {file = "mkdocs-material-8.3.8.tar.gz", hash = "sha256:b9cd305c3c29ef758931dae06e4aea0ca9f8bcc8ac6b2d45f10f932a015d6b83"}, + {file = "mkdocs_material-8.3.8-py2.py3-none-any.whl", hash = "sha256:949c75fa934d4b9ecc7b519964e58f0c9fc29f2ceb04736c85809cdbc403dfb5"}, ] mkdocs-material-extensions = [ {file = "mkdocs-material-extensions-1.0.3.tar.gz", hash = "sha256:bfd24dfdef7b41c312ede42648f9eb83476ea168ec163b613f9abd12bbfddba2"}, @@ -1384,55 +1389,52 @@ pyyaml-env-tag = [ {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, ] -rich = [ - {file = "rich-12.4.4-py3-none-any.whl", hash = "sha256:d2bbd99c320a2532ac71ff6a3164867884357da3e3301f0240090c5d2fdac7ec"}, - {file = "rich-12.4.4.tar.gz", hash = "sha256:4c586de507202505346f3e32d1363eb9ed6932f0c2f63184dea88983ff4971e2"}, -] +rich = [] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] time-machine = [ - {file = "time-machine-2.7.0.tar.gz", hash = "sha256:0aa0ccd531d7d98e71f7945b65d26d92d31ab74a21a111b9afe61b981c1eb7b2"}, - {file = "time_machine-2.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:aac9e9fa665d0ed7d105ddaaebda9ef3a2de30aaaf56cfe894f15ba60de8ae09"}, - {file = "time_machine-2.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:93c6baea546a8edffa57dc691cbb61e5070d6ec791ff7b4a025d4f34b9808516"}, - {file = "time_machine-2.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f056cc4b9212424eb0796389cb019fb61ed0691674649f824585973f001ab66"}, - {file = "time_machine-2.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dacd42e9f4a81a7c9e308874802e801aab1fed119698958fc00920038a652145"}, - {file = "time_machine-2.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d607303fe6c046b246f1befa82218624db7e92695950c4f6e11f416b8f61129"}, - {file = "time_machine-2.7.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:adf738e2d21b265cab4dcb2dc3c0592087f44614a03657c87a6e79131e3be715"}, - {file = "time_machine-2.7.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e0a774c3d94b6b4dd58a939ae886d4533ca9f308f71ab6e174d41aa092fcd807"}, - {file = "time_machine-2.7.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b1115628d39ebe7af89bb187634f119b58f163ee38d9a8369d779a496ac6105"}, - {file = "time_machine-2.7.0-cp310-cp310-win32.whl", hash = "sha256:6ad4b6dfec23acc7b5549fcbe9d632347bd62781471d79e44001242821635393"}, - {file = "time_machine-2.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:ef7cf446a19536a6c4fcdc74c6023bf4c85f6ebe97c63b18b4bf97905bf7919d"}, - {file = "time_machine-2.7.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:252f53e72da83e876c004e312d15a2e7c920b84dc60a469ad3c5404c5aa0d2b1"}, - {file = "time_machine-2.7.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f39695eb780795f7109ddf6b6c04d97f5610d75b08eb968deed3f0e09c43bb0"}, - {file = "time_machine-2.7.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c6236ea7f2744f8b9ceab85c9cd71f0aeb03530e2b47377678c39fcd77aafb6"}, - {file = "time_machine-2.7.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39aabce90c4c936abe78efe483a6b2fe99901ff431afbeab65c0815eeff66f0b"}, - {file = "time_machine-2.7.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:75cdf9aa1b0fba833ca4ebafd738a88983a6597caa4b6ac4a9bfa83c6ca7d8fc"}, - {file = "time_machine-2.7.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:038e642d88ba5b76bfa77ec95bcf0d35d7fc6c6ea264fc0aa36e02eff09d3ad2"}, - {file = "time_machine-2.7.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3cca3f11059ab6d39dc33495be94ecceaa3d6760b7a983a5c67e172d6b5f6781"}, - {file = "time_machine-2.7.0-cp37-cp37m-win32.whl", hash = "sha256:1d40c3be8b075868e73e09a9f600ecf383ad30c8921806c7dc0915820b8fa1c7"}, - {file = "time_machine-2.7.0-cp37-cp37m-win_amd64.whl", hash = "sha256:e125d7a406f8a3b48ddd3f836cf41be98de446db76147a2da8d1f1e82254946e"}, - {file = "time_machine-2.7.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:7c35a6971ebfe10ed4141a5e5da98cbb7b0f029385d7d97f47b19772e34cdd8a"}, - {file = "time_machine-2.7.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c2c3a7f36e3c12f229c651a9e58e5a034d2360c27bea24bfebf95001ce1359b4"}, - {file = "time_machine-2.7.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16ba783c4836a7dc74221a6b937deb88805ccb208a246a160d716c426c074462"}, - {file = "time_machine-2.7.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b7ba3a382b96db3f47c9f93207aefdc2817563e3ec727dbda38399035bcd475"}, - {file = "time_machine-2.7.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:514413eb7f0b55bd36a5e2ab67b357430c8038382ddb5896ff67f73668b1a23f"}, - {file = "time_machine-2.7.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:62a8ae6dbb9ed623f7e42089661974a8fa256c4c64c5391d1f6c60da2b8d54c8"}, - {file = "time_machine-2.7.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:fcebeff41e50445100f3f881699d8deeda8f1c6dd80c0c0381b61977aae9dedc"}, - {file = "time_machine-2.7.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:985e773fa70a03b41ef33e1e77b0a655ca1503b77f60d52b613fe1707636b93d"}, - {file = "time_machine-2.7.0-cp38-cp38-win32.whl", hash = "sha256:0579fa83e608ef4f1b16bd63e0a01c2bec5c4b3f8c2349a158af2c886cc8e0b5"}, - {file = "time_machine-2.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:fc735fdfa2cbe00f63ed66c48cf32c13b91b9dd9817bec37bdb9c2df5ea09da8"}, - {file = "time_machine-2.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a1ae378d4bd4101edd605e82da4859e0a5c509ddf2df0f8094160d795fc22777"}, - {file = "time_machine-2.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:747825e968290c8ec98202a60411586edd9b8669cbbf31f61224240e5951d1fb"}, - {file = "time_machine-2.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd4797046126bfa1524a9ef8acac83282ce9365ad2cbcab0843ee2662e103502"}, - {file = "time_machine-2.7.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50585410381f9b9799fdd0ab7b1ac5009a341127cf9e72222bc2cb870eddf44d"}, - {file = "time_machine-2.7.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd0b8cf6ad69ed54d2e11c2d5cc3681c6defc7ce022121312cead610d54685c3"}, - {file = "time_machine-2.7.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:72d03615caf11eb47c7efeb48c839e307a18c272637d967b697a02f6561dad3c"}, - {file = "time_machine-2.7.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:fa5a0a01f9562be8a13338255a9497b3c1fc8bb018582fa4a881184a9f480c9a"}, - {file = "time_machine-2.7.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:06473db7145f82992d47f02d04a164abe499ec3f4ab24fc9877d49a7e46ae7a0"}, - {file = "time_machine-2.7.0-cp39-cp39-win32.whl", hash = "sha256:b377991a5ead8f4d4c887293fde66cb1e8abb5c709e311c724016d16e2ef4c7d"}, - {file = "time_machine-2.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:5ba2f608684b19be35b6bc0dca362eca2ded0b7c6f1ff88ed1e55b8dfdfb869d"}, + {file = "time-machine-2.7.1.tar.gz", hash = "sha256:be6c1f0421a77a046db8fae00886fb364f683a86612b71dd5c74b22891590042"}, + {file = "time_machine-2.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ae93d2f761435d192bc80c148438a0c4261979db0610cef08dfe2c8d21ca1c67"}, + {file = "time_machine-2.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:342b431154fbfb1889f8d7aa3d857373a837106bba395a5cc99123f11a7cea03"}, + {file = "time_machine-2.7.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4011ea76f6ad2f932f00cf9e77a25b575a024d6bc15bcf891a3f9916ceeb6e"}, + {file = "time_machine-2.7.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43ae8192d370a90d2246fca565a55633f592b314264c65c5c9151c361b715fb9"}, + {file = "time_machine-2.7.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cea12d0592ebbe738db952ce6fd272ed90e7bbb095e802f4f2145f8f0e322fa3"}, + {file = "time_machine-2.7.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:732d5fd2d442fa87538b5a6ca623cb205b9b048d2c9aaf79e5cfc7ec7f637848"}, + {file = "time_machine-2.7.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c34e1f49cad2fd41d42c4aabd3d69a32c79d9a8e0779064554843823cd1fb1e4"}, + {file = "time_machine-2.7.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6af3e81cf663b6d5660953ae59da2bb2ae802452ecbc9907272979ed06253659"}, + {file = "time_machine-2.7.1-cp310-cp310-win32.whl", hash = "sha256:10c2937d3556f4358205dac5c7cd2d33832b8b911f3deff050f59e1fe2be3231"}, + {file = "time_machine-2.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:200974e9bb8a1cb227ce579caafeaeebb0f9de81758c444cbccc0ea464313caf"}, + {file = "time_machine-2.7.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d5e2376b7922c9d96921709c7e730498b9c69da889f359a465d0c43117b62da3"}, + {file = "time_machine-2.7.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ad9117abe223cdc7b4a4432e0a0cfebb1b351a091ee996c653e90f27a734fce"}, + {file = "time_machine-2.7.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:626ef686723147468e84da3edcd67ff757a463250fd35f8f6a8e5b899c43b43d"}, + {file = "time_machine-2.7.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9331946ed13acd50bc484f408e26b8eefa67e3dbca41927d2052f2148d3661d"}, + {file = "time_machine-2.7.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:3d0612e0323047f29c23732963d9926f1a95e2ce334d86fecd37c803ac240fc6"}, + {file = "time_machine-2.7.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b474499ad0083252240bc5be13f8116cc2ca8a89d1ca4967ed74a7b5f0883f95"}, + {file = "time_machine-2.7.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:7df0857709432585b62d2667c0e6e64029b652e2df776b9fb85223c60dce52c7"}, + {file = "time_machine-2.7.1-cp37-cp37m-win32.whl", hash = "sha256:77c8dfe8dc7f45bbfe73494c72f3728d99abec5a020460ad7ffee5247365eba4"}, + {file = "time_machine-2.7.1-cp37-cp37m-win_amd64.whl", hash = "sha256:c1fd1c231377ce076f99c8c16999a95510690f8dbd35db0e5fbbc74a17f84b39"}, + {file = "time_machine-2.7.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:462924fb87826882fc7830098e621116599f9259d181a7bbf5a4e49f74ec325b"}, + {file = "time_machine-2.7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:46bf3b4a52d43289b23f0015a9d8592ddf621a5058e566c275cb060347d430c1"}, + {file = "time_machine-2.7.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30afd5b978d8121334c80fa23119d7bd7c9f954169854edf5103e5c8b38358bb"}, + {file = "time_machine-2.7.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:633fb8c47f3cd64690591ca6981e4fdbcaa54c18d8a57a3cdc24638ca98f8216"}, + {file = "time_machine-2.7.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80b6093c3b70d1d1a66b65f18a6e53b233c8dd5d8ffe7ac59e9d048fb1d5e15c"}, + {file = "time_machine-2.7.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e62ed7d78694b7e0a2ab30b3dd52ebf26b03e17d6eda0f231fd77e24307a55a9"}, + {file = "time_machine-2.7.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:0eaf024d16482ec211a579fd389cbbd4fedd8a1f0a0c41642508815f880ca3a9"}, + {file = "time_machine-2.7.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2688091ce0c16151faa80625efb34e3096731fbdee6d5284c48c984bce95c311"}, + {file = "time_machine-2.7.1-cp38-cp38-win32.whl", hash = "sha256:2e54bf0521b6e397fcaa03060feb187bbe5aa63ac51dbb97d5bc59fb0c4725f8"}, + {file = "time_machine-2.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:cee72d9e14d36e4b8da6af1d2d784f14da53f76aeb5066540a38318aa907b551"}, + {file = "time_machine-2.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:06322d41d45d86e2dc2520794c95129ff25b8620b33851ed40700c859ebf8c30"}, + {file = "time_machine-2.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:193b14daa3b3cf67e6b55d6e2d63c2eb7c1d3f49017704d4b43963b198656888"}, + {file = "time_machine-2.7.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1367a89fb857f68cfa723e236cd47febaf201a3a625ad8423110fe0509d5fca8"}, + {file = "time_machine-2.7.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce350f7e8bd51a0bb064180486300283bec5cd1a21a318a8ffe5f7df11735f36"}, + {file = "time_machine-2.7.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68ff623d835760314e279aedc0d19a1dc4dec117c6bca388e1ff077c781256bd"}, + {file = "time_machine-2.7.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:05fecd818d41727d31109a0d039ce07c8311602b45ffc07bffd8ae8b6f266ee5"}, + {file = "time_machine-2.7.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:1fe4e604c5effc290c1bbecd3ea98687690d0a88fd98ba93e0246bf19ae2a520"}, + {file = "time_machine-2.7.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff07a5635d42957f2bd7eb5ca6579f64de368c842e754a4d3414520693b75db9"}, + {file = "time_machine-2.7.1-cp39-cp39-win32.whl", hash = "sha256:8c6314e7e0ffd7af82c8026786d5551aff973e0c86ec1368b0590be9a7620cad"}, + {file = "time_machine-2.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:d50a2620d726788cbde97c58e0f6f61d10337d16d088a1fad789f50a1b5ff4d1"}, ] toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, diff --git a/pyproject.toml b/pyproject.toml index 5b058e02a..0b932972b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,8 +22,8 @@ textual = "textual.cli.cli:run" [tool.poetry.dependencies] python = "^3.7" -rich = "^12.4.3" -#rich = {path="../rich", develop=true} +#rich = "^12.4.3" +rich = {path="../rich", develop=true} importlib-metadata = "^4.11.3" typing-extensions = { version = "^4.0.0", python = "<3.8" } diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 04fdb7908..c2b25f39b 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -19,6 +19,7 @@ import sys from typing import Callable, cast, Iterator, Iterable, NamedTuple, TYPE_CHECKING import rich.repr +from rich.cells import cached_cell_len from rich.console import Console, ConsoleOptions, RenderResult, RenderableType from rich.control import Control from rich.segment import Segment @@ -98,16 +99,21 @@ class ChopsUpdate: """A renderable that applies updated spans to the screen.""" def __init__( - self, chops: list[dict[int, list[Segment] | None]], crop: Region + self, + chops: list[dict[int, list[Segment] | None]], + spans: list[tuple[int, int, int]], + chop_ends: list[list[int]], ) -> None: """A renderable which updates chops (fragments of lines). Args: chops (list[dict[int, list[Segment] | None]]): A mapping of offsets to list of segments, per line. crop (Region): Region to restrict update to. + chop_ends (list[list[int]]): A list of the end offsets for each line """ self.chops = chops - self.crop = crop + self.spans = spans + self.chop_ends = chop_ends def __rich_console__( self, console: Console, options: ConsoleOptions @@ -115,22 +121,53 @@ class ChopsUpdate: move_to = Control.move_to new_line = Segment.line() chops = self.chops - crop = self.crop - last_y = crop.bottom - 1 - x1, x2 = crop.x_extents - for y in crop.y_range: + chop_ends = self.chop_ends + last_y = self.spans[-1][0] + + _cell_len = cached_cell_len + + for y, x1, x2 in self.spans: line = chops[y] - for x, segments in line.items(): + ends = chop_ends[y] + for end, (x, segments) in zip(ends, line.items()): # TODO: crop to x extents - if segments is not None: - # if segments is not None and x2 > x >= x1: + if segments is None: + continue + + if x > x2 or end <= x1: + continue + + if x2 > x >= x1 and end <= x2: yield move_to(x, y) yield from segments + continue + + iter_segments = iter(segments) + if x < x1: + for segment in iter_segments: + next_x = x + _cell_len(segment.text) + if next_x > x1: + yield move_to(x, y) + yield segment + break + x = next_x + else: + yield move_to(x, y) + if end <= x2: + yield from iter_segments + else: + for segment in iter_segments: + if x >= x2: + break + yield segment + x += _cell_len(segment.text) + if y != last_y: yield new_line def __rich_repr__(self) -> rich.repr.Result: - yield None, self.crop + return + yield @rich.repr.auto(angular=True) @@ -500,6 +537,7 @@ class Compositor: # Sort the cuts for each line self._cuts = [sorted(set(line_cuts)) for line_cuts in cuts] + return self._cuts def _get_renders( @@ -609,7 +647,7 @@ class Compositor: ) # A mapping of cut index to a list of segments for each line chops: list[dict[int, list[Segment] | None]] - chops = [fromkeys(cut_set) for cut_set in cuts] + chops = [fromkeys(cut_set[:-1]) for cut_set in cuts] cut_segments: Iterable[list[Segment]] @@ -625,8 +663,6 @@ class Compositor: continue chops_line = chops[y] - if all(chops_line): - continue first_cut, last_cut = render_region.x_extents cuts_line = cuts[y] @@ -650,7 +686,8 @@ class Compositor: render_lines = self._assemble_chops(chops) return LayoutUpdate(render_lines, screen_region) else: - return ChopsUpdate(chops, crop) + chop_ends = [cut_set[1:] for cut_set in cuts] + return ChopsUpdate(chops, spans, chop_ends) def __rich_console__( self, console: Console, options: ConsoleOptions diff --git a/src/textual/_segment_tools.py b/src/textual/_segment_tools.py index 496ba0b31..71bef7cb8 100644 --- a/src/textual/_segment_tools.py +++ b/src/textual/_segment_tools.py @@ -4,12 +4,11 @@ Tools for processing Segments, or lists of Segments. from __future__ import annotations +from rich.cells import cached_cell_len from rich.segment import Segment -def line_crop( - segments: list[Segment], start: int, end: int, total: int -) -> list[Segment]: +def line_crop(segments: list[Segment], start: int, end: int, total: int): """Crops a list of segments between two cell offsets. Args: @@ -23,13 +22,15 @@ def line_crop( # This is essentially a specialized version of Segment.divide # The following line has equivalent functionality (but a little slower) # return list(Segment.divide(segments, [start, end]))[1] + + _cell_len = cached_cell_len pos = 0 output_segments: list[Segment] = [] add_segment = output_segments.append iter_segments = iter(segments) segment: Segment | None = None for segment in iter_segments: - end_pos = pos + segment.cell_length + end_pos = pos + _cell_len(segment.text) if end_pos > start: segment = segment.split_cells(start - pos)[-1] break @@ -46,7 +47,7 @@ def line_crop( pos = start while segment is not None: - end_pos = pos + segment.cell_length + end_pos = pos + _cell_len(segment.text) if end_pos < end: add_segment(segment) else: diff --git a/src/textual/app.py b/src/textual/app.py index 318bc8dec..3b575e566 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -60,6 +60,7 @@ from .layouts.dock import Dock from .message_pump import MessagePump from .reactive import Reactive from .renderables.blank import Blank +from ._profile import timer from .screen import Screen from .widget import Widget @@ -924,6 +925,7 @@ class App(Generic[ReturnType], DOMNode): stylesheet.update(self.app, animate=animate) self.screen._refresh_layout(self.size, full=True) + @timer("_display") def _display(self, renderable: RenderableType | None) -> None: """Display a renderable within a sync. diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index d6138f4a2..e00fdcf6b 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -203,6 +203,7 @@ class DataTable(ScrollView, Generic[CellType]): async def handle_styles_updated(self, message: messages.StylesUpdated) -> None: self._clear_caches() + self.refresh() def watch_show_header(self, show_header: bool) -> None: self._clear_caches() @@ -469,7 +470,6 @@ class DataTable(ScrollView, Generic[CellType]): self._line_cache[cache_key] = simplified_segments return segments - @timer("render_lines") def render_lines(self, crop: Region) -> Lines: """Render lines within a given region. @@ -501,7 +501,6 @@ class DataTable(ScrollView, Generic[CellType]): if y - scroll_y < fixed_top_row_count: lines[line_index] = fixed_lines[line_index] - # self._dirty_regions.clear() return lines def on_mouse_move(self, event: events.MouseMove): From 82358fdb7b2bdcc4e40c96352fbd6e581272ff2c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 25 Jun 2022 14:55:19 +0100 Subject: [PATCH 08/13] data table as widget --- sandbox/will/basic.css | 4 +++ sandbox/will/basic.py | 15 ++++++++++- src/textual/geometry.py | 5 ---- src/textual/widgets/_data_table.py | 40 +++++++++++++++++++----------- 4 files changed, 43 insertions(+), 21 deletions(-) diff --git a/sandbox/will/basic.css b/sandbox/will/basic.css index eed5e0b5e..0a7fa15ec 100644 --- a/sandbox/will/basic.css +++ b/sandbox/will/basic.css @@ -22,6 +22,10 @@ App > Screen { color: $text-surface; } +DataTable { + margin: 2; + height: 12; +} #sidebar { color: $text-primary; diff --git a/sandbox/will/basic.py b/sandbox/will/basic.py index acae838d7..aca946a19 100644 --- a/sandbox/will/basic.py +++ b/sandbox/will/basic.py @@ -6,7 +6,7 @@ from rich.text import Text from textual.app import App from textual.reactive import Reactive from textual.widget import Widget -from textual.widgets import Static +from textual.widgets import Static, DataTable CODE = ''' class Offset(NamedTuple): @@ -101,6 +101,7 @@ class BasicApp(App, css_path="basic.css"): def on_mount(self): """Build layout here.""" + table = DataTable() self.scroll_to_target = Tweet(TweetBody()) self.mount( header=Static( @@ -114,6 +115,7 @@ class BasicApp(App, css_path="basic.css"): Static(Syntax(CODE, "python"), classes="code"), classes="scrollable", ), + table, Error(), Tweet(TweetBody(), classes="scrollbar-size-custom"), Warning(), @@ -135,6 +137,17 @@ class BasicApp(App, css_path="basic.css"): Widget(classes="content"), ), ) + table.add_column("Foo", width=80) + table.add_column("Bar", width=50) + table.add_column("Baz", width=40) + table.zebra_stripes = True + for n in range(100): + table.add_row( + f"{n} This is an example of a [b]DataTable widget[/b] within a larger [bold magenta]Textual UI", + "Cells may contain just about any kind of data", + "Where there is a Will there is a Way", + height=1, + ) async def on_key(self, event) -> None: await self.dispatch_key(event) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 827632c61..f8fdf8b1c 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -354,11 +354,6 @@ class Region(NamedTuple): x, y, width, height = self return Offset(x + width, y + height) - @property - def offset(self) -> Offset: - x, y, _, _ = self - return Offset(x, y) - @property def size(self) -> Size: """Get the size of the region.""" diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index e00fdcf6b..4948a8c1e 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -157,16 +157,18 @@ class DataTable(ScrollView, Generic[CellType]): self._row_render_cache = LRUCache(1000) self._cell_render_cache: LRUCache[tuple[int, int, Style, bool, bool], Lines] - self._cell_render_cache = LRUCache(1000) + self._cell_render_cache = LRUCache(10000) - self._line_cache: LRUCache[tuple[int, int, int, int, int, int], list[Segment]] + self._line_cache: LRUCache[ + tuple[int, int, int, int, int, int, Style], list[Segment] + ] self._line_cache = LRUCache(1000) self._line_no = 0 show_header = Reactive(True) fixed_rows = Reactive(0) - fixed_columns = Reactive(1) + fixed_columns = Reactive(0) zebra_stripes = Reactive(False) header_height = Reactive(1) show_cursor = Reactive(True) @@ -237,6 +239,8 @@ class DataTable(ScrollView, Generic[CellType]): ) def _get_cell_region(self, row_index: int, column_index: int) -> Region: + if row_index not in self.rows: + return Region(0, 0, 0, 0) row = self.rows[row_index] x = sum(column.width for column in self.columns[:column_index]) width = self.columns[column_index].width @@ -281,9 +285,11 @@ class DataTable(ScrollView, Generic[CellType]): return region = self._get_cell_region(row_index, column_index) region = region.translate_negative(*self.scroll_offset) - refresh_region = self.content_region.intersection(region) - if refresh_region: - self.refresh(refresh_region) + if region: + self.refresh(region) + # refresh_region = self.content_region.intersection(region) + # if refresh_region: + # self.refresh(refresh_region) def _get_row_renderables(self, row_index: int) -> list[RenderableType]: """Get renderables for the given row. @@ -421,6 +427,8 @@ class DataTable(ScrollView, Generic[CellType]): if y < self.header_height: return (-1, y) y -= self.header_height + if y > len(self._y_offsets): + raise LookupError("Y coord {y!r} is greater than total height") return self._y_offsets[y] def _render_line( @@ -439,7 +447,11 @@ class DataTable(ScrollView, Generic[CellType]): """ width = self.region.width - row_index, line_no = self._get_offsets(y) + + try: + row_index, line_no = self._get_offsets(y) + except LookupError: + return [Segment(" " * width, base_style)] cursor_column = ( self.cursor_column if (self.show_cursor and self.cursor_row == row_index) @@ -447,7 +459,7 @@ class DataTable(ScrollView, Generic[CellType]): ) hover_column = self.hover_column if (self.hover_row == row_index) else -1 - cache_key = (y, x1, x2, width, cursor_column, hover_column) + cache_key = (y, x1, x2, width, cursor_column, hover_column, base_style) if cache_key in self._line_cache: return self._line_cache[cache_key] @@ -479,7 +491,6 @@ class DataTable(ScrollView, Generic[CellType]): Returns: Lines: A list of segments for every line within crop region. """ - scroll_x, scroll_y = self.scroll_offset x1, y1, x2, y2 = crop.translate(scroll_x, scroll_y).corners @@ -500,11 +511,10 @@ class DataTable(ScrollView, Generic[CellType]): for line_index, y in enumerate(range(y1, y2)): if y - scroll_y < fixed_top_row_count: lines[line_index] = fixed_lines[line_index] - return lines def on_mouse_move(self, event: events.MouseMove): - meta = self.get_style_at(event.x, event.y).meta + meta = event.style.meta if meta: try: self.hover_cell = Coord(meta["row"], meta["column"]) @@ -524,10 +534,10 @@ class DataTable(ScrollView, Generic[CellType]): left = sum(column.width for column in self.columns[: self.fixed_columns]) return Spacing(top, 0, 0, left) - def _scroll_cursor_in_to_view(self) -> None: + def _scroll_cursor_in_to_view(self, animate: bool = False) -> None: region = self._get_cell_region(self.cursor_row, self.cursor_column) spacing = self._get_cell_border() - self.scroll_to_region(region, animate=False, spacing=spacing) + self.scroll_to_region(region, animate=animate, spacing=spacing) def on_click(self, event: events.Click) -> None: meta = self.get_style_at(event.x, event.y).meta @@ -551,10 +561,10 @@ class DataTable(ScrollView, Generic[CellType]): self.cursor_cell = self.cursor_cell.right() event.stop() event.prevent_default() - self._scroll_cursor_in_to_view() + self._scroll_cursor_in_to_view(animate=True) def key_left(self, event: events.Key): self.cursor_cell = self.cursor_cell.left() event.stop() event.prevent_default() - self._scroll_cursor_in_to_view() + self._scroll_cursor_in_to_view(animate=True) From 86fdc96ab37cb7cdac3446877337e627bd6fbb93 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 27 Jun 2022 10:11:57 +0100 Subject: [PATCH 09/13] geometry tests --- sandbox/will/basic.css | 8 +++++- sandbox/will/basic.py | 16 +++++------ src/textual/_compositor.py | 11 ++++---- src/textual/geometry.py | 28 +++++-------------- src/textual/layouts/grid.py | 8 +++--- src/textual/widget.py | 43 +++++++++++++++++++++++------- src/textual/widgets/_data_table.py | 12 +++++---- tests/test_geometry.py | 36 ++++++++++++++++++++++--- 8 files changed, 103 insertions(+), 59 deletions(-) diff --git a/sandbox/will/basic.css b/sandbox/will/basic.css index 0a7fa15ec..129b4e4a7 100644 --- a/sandbox/will/basic.css +++ b/sandbox/will/basic.css @@ -15,6 +15,10 @@ scrollbar-size-vertical: 2; } +*:hover { + tint: red 30%; +} + App > Screen { layout: dock; docks: side=left/1; @@ -23,7 +27,9 @@ App > Screen { } DataTable { - margin: 2; + border: solid red; + + margin: 1 1; height: 12; } diff --git a/sandbox/will/basic.py b/sandbox/will/basic.py index aca946a19..2b3b1ea7f 100644 --- a/sandbox/will/basic.py +++ b/sandbox/will/basic.py @@ -137,17 +137,15 @@ class BasicApp(App, css_path="basic.css"): Widget(classes="content"), ), ) - table.add_column("Foo", width=80) - table.add_column("Bar", width=50) - table.add_column("Baz", width=40) + table.add_column("Foo", width=20) + table.add_column("Bar", width=20) + table.add_column("Baz", width=20) + table.add_column("Foo", width=20) + table.add_column("Bar", width=20) + table.add_column("Baz", width=20) table.zebra_stripes = True for n in range(100): - table.add_row( - f"{n} This is an example of a [b]DataTable widget[/b] within a larger [bold magenta]Textual UI", - "Cells may contain just about any kind of data", - "Where there is a Will there is a Way", - height=1, - ) + table.add_row(*[f"Cell ([b]{n}[/b], {col})" for col in range(6)]) async def on_key(self, event) -> None: await self.dispatch_key(event) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index c2b25f39b..994b122d6 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -578,9 +578,8 @@ class Compositor: if not region: continue if region in clip: - yield region, clip, widget.render_lines( - Region(0, 0, region.width, region.height) - ) + lines = widget.render_lines(Region(0, 0, region.width, region.height)) + yield region, clip, lines elif overlaps(clip, region): clipped_region = intersection(region, clip) if not clipped_region: @@ -706,12 +705,12 @@ class Compositor: regions: list[Region] = [] add_region = regions.append for widget in self.regions.keys() & widgets: - (x, y, _, _), clip = self.regions[widget] + region, clip = self.regions[widget] + offset = region.offset intersection = clip.intersection for dirty_region in widget.get_dirty_regions(): - update_region = intersection(dirty_region.translate(x, y)) + update_region = intersection(dirty_region.translate(offset)) if update_region: add_region(update_region) self._dirty_regions.update(regions) - # self.add_dirty_regions(regions) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index f8fdf8b1c..2ff423efc 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -243,8 +243,8 @@ class Region(NamedTuple): return cls(x, y, width, height) @classmethod - def translate_inside(cls, window_region: Region, region: Region) -> Offset: - """Calculate the smallest offset required to translate a region so that is is within + def get_scroll_to_visible(cls, window_region: Region, region: Region) -> Offset: + """Calculate the smallest offset required to translate a window so that it contains another region. This method is used to calculate the required offset to scroll something in to view. @@ -507,33 +507,19 @@ class Region(NamedTuple): and (y2 >= oy2 >= y1) ) - def translate(self, x: int = 0, y: int = 0) -> Region: + def translate(self, offset: tuple[int, int]) -> Region: """Move the offset of the Region. Args: - translate_x (int): Value to add to x coordinate. - translate_y (int): Value to add to y coordinate. + translate (tuple[int, int]): Offset to add to region. Returns: - Region: A new region shifted by x, y + Region: A new region shifted by (x, y) """ self_x, self_y, width, height = self - return Region(self_x + x, self_y + y, width, height) - - def translate_negative(self, x: int = 0, y: int = 0) -> Region: - """Move the offset of the Region in the opposite direction. - - Args: - translate_x (int): Value to subtract to x coordinate. - translate_y (int): Value to subtract to y coordinate. - - Returns: - Region: A new region shifted by x, y - """ - - self_x, self_y, width, height = self - return Region(self_x - x, self_y - y, width, height) + offset_x, offset_y = offset + return Region(self_x + offset_x, self_y + offset_y, width, height) @lru_cache(maxsize=4096) def __contains__(self, other: Any) -> bool: diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index f925d1b4c..40526f29a 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -253,10 +253,12 @@ class GridLayout(Layout): offset = (container - size) // 2 return offset - offset_x = align(grid_size.width, container.width, col_align) - offset_y = align(grid_size.height, container.height, row_align) + offset = Offset( + align(grid_size.width, container.width, col_align), + align(grid_size.height, container.height, row_align), + ) - region = region.translate(offset_x, offset_y) + region = region.translate(offset) return region def get_widgets(self) -> Iterable[Widget]: diff --git a/src/textual/widget.py b/src/textual/widget.py index 042598318..8eb086677 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -435,12 +435,13 @@ class Widget(DOMNode): # self._dirty_regions.append(self.size.region) if regions: + content_offset = self.content_offset self._dirty_regions.update(regions) else: self._dirty_regions.clear() # TODO: Does this need to be content region? # self._dirty_regions.append(self.size.region) - self._dirty_regions.add(self.content_region.size.region) + self._dirty_regions.add(self.size.region) def get_dirty_regions(self) -> Collection[Region]: regions = self._dirty_regions.copy() @@ -589,6 +590,7 @@ class Widget(DOMNode): bool: True if any scrolling has occurred in any descendant, otherwise False. """ + # TODO: Update this to use scroll_to_region scrolls = set() node = widget.parent @@ -657,7 +659,7 @@ class Widget(DOMNode): window = self.region.at_offset(self.scroll_offset) if spacing is not None: window = window.shrink(spacing) - delta = Region.translate_inside(window, region) + delta = Region.get_scroll_to_visible(window, region) if delta: self.scroll_relative( delta.x or None, @@ -665,7 +667,6 @@ class Widget(DOMNode): animate=animate, duration=0.2, ) - return delta def __init_subclass__( @@ -769,18 +770,18 @@ class Widget(DOMNode): def watch(self, attribute_name, callback: Callable[[Any], Awaitable[None]]) -> None: watch(self, attribute_name, callback) - def render_styled(self) -> RenderableType: - """Applies style attributes to the default renderable. + def _style_renderable(self, renderable: RenderableType) -> RenderableType: + """Applies CSS styles to a renderable by wrapping it in another renderable. + + Args: + renderable (RenderableType): Renderable to apply styles to. Returns: - RenderableType: A new renderable. + RenderableType: An updated renderable. """ - (base_background, base_color), (background, color) = self.colors styles = self.styles - renderable = self.render() - content_align = (styles.content_align_horizontal, styles.content_align_vertical) if content_align != ("left", "top"): horizontal, vertical = content_align @@ -816,6 +817,17 @@ class Widget(DOMNode): return renderable + def render_styled(self) -> RenderableType: + """Applies style attributes to the default renderable. + + Returns: + RenderableType: A new renderable. + """ + + renderable = self.render() + renderable = self._style_renderable(renderable) + return renderable + @property def size(self) -> Size: return self._size @@ -843,6 +855,16 @@ class Widget(DOMNode): except errors.NoWidget: return Region() + @property + def window_region(self) -> Region: + """The region within the scrollable area that is currently visible. + + Returns: + Region: New region. + """ + window_region = self.region.at_offset(self.scroll_offset) + return window_region + @property def scroll_offset(self) -> Offset: return Offset(int(self.scroll_x), int(self.scroll_y)) @@ -937,7 +959,8 @@ class Widget(DOMNode): def _crop_lines(self, lines: Lines, x1, x2) -> Lines: width = self.size.width if (x1, x2) != (0, width): - lines = [line_crop(line, x1, x2, width) for line in lines] + _line_crop = line_crop + lines = [_line_crop(line, x1, x2, width) for line in lines] return lines def render_lines(self, crop: Region) -> Lines: diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 4948a8c1e..861cc19ab 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -284,9 +284,10 @@ class DataTable(ScrollView, Generic[CellType]): if row_index < 0 or column_index < 0: return region = self._get_cell_region(row_index, column_index) - region = region.translate_negative(*self.scroll_offset) - if region: - self.refresh(region) + if not self.window_region.overlaps(region): + return + region = region.translate(-self.scroll_offset) + self.refresh(region) # refresh_region = self.content_region.intersection(region) # if refresh_region: # self.refresh(refresh_region) @@ -491,8 +492,8 @@ class DataTable(ScrollView, Generic[CellType]): Returns: Lines: A list of segments for every line within crop region. """ - scroll_x, scroll_y = self.scroll_offset - x1, y1, x2, y2 = crop.translate(scroll_x, scroll_y).corners + scroll_y = self.scroll_offset.y + x1, y1, x2, y2 = crop.translate(self.scroll_offset).corners base_style = self.rich_style @@ -511,6 +512,7 @@ class DataTable(ScrollView, Generic[CellType]): for line_index, y in enumerate(range(y1, y2)): if y - scroll_y < fixed_top_row_count: lines[line_index] = fixed_lines[line_index] + return lines def on_mouse_move(self, event: events.MouseMove): diff --git a/tests/test_geometry.py b/tests/test_geometry.py index 102228daf..241acdd34 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -89,6 +89,11 @@ def test_offset_sub(): Offset(1, 1) - "foo" +def test_offset_neg(): + assert Offset(0, 0) == Offset(0, 0) + assert -Offset(2, -3) == Offset(-2, 3) + + def test_offset_mul(): assert Offset(2, 1) * 2 == Offset(4, 2) assert Offset(2, 1) * -2 == Offset(-4, -2) @@ -125,10 +130,23 @@ def test_region_from_union(): assert Region.from_union(regions) == Region(10, 20, 40, 40) -def test_region_from_origin(): +def test_region_from_offset(): assert Region.from_offset(Offset(3, 4), (5, 6)) == Region(3, 4, 5, 6) +@pytest.mark.parametrize( + "window,region,scroll", + [ + (Region(0, 0, 200, 100), Region(0, 0, 200, 100), Offset(0, 0)), + (Region(0, 0, 200, 100), Region(0, -100, 10, 10), Offset(0, -100)), + (Region(10, 15, 20, 10), Region(0, 0, 50, 50), Offset(-10, -15)), + ], +) +def test_get_scroll_to_visible(window, region, scroll): + assert Region.get_scroll_to_visible(window, region) == scroll + assert region.overlaps(window + scroll) + + def test_region_area(): assert Region(3, 4, 0, 0).area == 0 assert Region(3, 4, 5, 6).area == 30 @@ -167,6 +185,16 @@ def test_region_sub(): Region(1, 2, 3, 4) - "foo" +def test_region_at_offset(): + assert Region(10, 10, 30, 40).at_offset((0, 0)) == Region(0, 0, 30, 40) + assert Region(10, 10, 30, 40).at_offset((-15, 30)) == Region(-15, 30, 30, 40) + + +def test_crop_size(): + assert Region(10, 20, 100, 200).crop_size((50, 40)) == Region(10, 20, 50, 40) + assert Region(10, 20, 100, 200).crop_size((500, 40)) == Region(10, 20, 100, 40) + + 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)) @@ -201,8 +229,8 @@ def test_region_contains_region(): 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) + assert Region(1, 2, 3, 4).translate((10, 20)) == Region(11, 22, 3, 4) + assert Region(1, 2, 3, 4).translate((0, 20)) == Region(1, 22, 3, 4) def test_region_contains_special(): @@ -274,7 +302,7 @@ def test_region_y_range(): assert Region(5, 10, 20, 30).y_range == range(10, 40) -def test_region_reset_origin(): +def test_region_reset_offset(): assert Region(5, 10, 20, 30).reset_offset == Region(0, 0, 20, 30) From cec49dbbc2507f7b2cf5855b5523034817ca681b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 28 Jun 2022 10:36:16 +0100 Subject: [PATCH 10/13] optimize from_union add docstrings --- sandbox/will/basic.css | 4 ++-- src/textual/_border.py | 2 ++ src/textual/geometry.py | 23 +++++++++++++++++------ src/textual/widget.py | 18 +++++++++++++----- 4 files changed, 34 insertions(+), 13 deletions(-) diff --git a/sandbox/will/basic.css b/sandbox/will/basic.css index 129b4e4a7..791d3ee14 100644 --- a/sandbox/will/basic.css +++ b/sandbox/will/basic.css @@ -15,9 +15,9 @@ scrollbar-size-vertical: 2; } -*:hover { +/* *:hover { tint: red 30%; -} +} */ App > Screen { layout: dock; diff --git a/src/textual/_border.py b/src/textual/_border.py index a16e280ad..97a5ecd8b 100644 --- a/src/textual/_border.py +++ b/src/textual/_border.py @@ -186,6 +186,8 @@ class Border: if has_bottom and lines: lines.pop(-1) + # TODO: Divide is probably quite inefficient here, + # It could be much faster for the specific case of one off the start end end divide = Segment.divide if has_left and has_right: for line in lines: diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 2ff423efc..a3d611a15 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -8,6 +8,7 @@ from __future__ import annotations import sys from functools import lru_cache +from operator import itemgetter, attrgetter from typing import Any, Collection, NamedTuple, Tuple, TypeVar, Union, cast if sys.version_info >= (3, 10): @@ -93,7 +94,10 @@ class Offset(NamedTuple): """ x1, y1 = self x2, y2 = destination - return Offset(int(x1 + (x2 - x1) * factor), int((y1 + (y2 - y1) * factor))) + return Offset( + int(x1 + (x2 - x1) * factor), + int(y1 + (y2 - y1) * factor), + ) def get_distance_to(self, other: Offset) -> float: """Get the distance to another offset. @@ -195,7 +199,14 @@ class Region(NamedTuple): height: int = 0 @classmethod - def from_union(cls, regions: Collection[Region]) -> Region: + def from_union( + cls, + regions: Collection[Region], + _get_x=itemgetter(0), + _get_y=itemgetter(1), + _get_right=attrgetter("right"), + _get_bottom=attrgetter("bottom"), + ) -> Region: """Create a Region from the union of other regions. Args: @@ -206,10 +217,10 @@ class Region(NamedTuple): """ if not regions: raise ValueError("At least one region expected") - min_x = min([region.x for region in regions]) - max_x = max([x + width for x, _y, width, _height in regions]) - min_y = min([region.y for region in regions]) - max_y = max([y + height for _x, y, _width, height in regions]) + min_x = min(regions, key=_get_x).x + max_x = max(regions, key=_get_right).right + min_y = min(regions, key=_get_y).y + max_y = max(regions, key=_get_bottom).bottom return cls(min_x, min_y, max_x - min_x, max_y - min_y) @classmethod diff --git a/src/textual/widget.py b/src/textual/widget.py index 8eb086677..fefe087c8 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -428,14 +428,17 @@ class Widget(DOMNode): ) def _set_dirty(self, *regions: Region) -> None: - """Set the Widget as 'dirty' (requiring re-render).""" + """Set the Widget as 'dirty' (requiring re-paint). - # self._dirty_regions.clear() - # # TODO: Does this need to be content region? - # self._dirty_regions.append(self.size.region) + Regions should be specified as positional args. If no regions are added, then + the entire widget will be considered dirty. + + Args: + *regions (Region): Regions which require a repaint. + + """ if regions: - content_offset = self.content_offset self._dirty_regions.update(regions) else: self._dirty_regions.clear() @@ -444,6 +447,11 @@ class Widget(DOMNode): self._dirty_regions.add(self.size.region) def get_dirty_regions(self) -> Collection[Region]: + """Get regions which require a repaint. + + Returns: + Collection[Region]: Regions to repaint. + """ regions = self._dirty_regions.copy() return regions From 2ac4c1edd2e33886edaa1547827c1c823d881d8d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 28 Jun 2022 11:02:16 +0100 Subject: [PATCH 11/13] Added _cells --- poetry.lock | 100 +++++++++++++++++----------------- pyproject.toml | 4 +- sandbox/will/basic.py | 3 + src/textual/_cells.py | 6 ++ src/textual/_compositor.py | 6 +- src/textual/_segment_tools.py | 5 +- 6 files changed, 66 insertions(+), 58 deletions(-) create mode 100644 src/textual/_cells.py diff --git a/poetry.lock b/poetry.lock index 65dff0562..0bdbd738c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -74,7 +74,7 @@ tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (> [[package]] name = "black" -version = "22.3.0" +version = "22.6.0" description = "The uncompromising code formatter." category = "dev" optional = false @@ -85,7 +85,7 @@ click = ">=8.0.0" mypy-extensions = ">=0.4.3" pathspec = ">=0.9.0" platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} @@ -208,14 +208,14 @@ dev = ["twine", "markdown", "flake8", "wheel"] [[package]] name = "griffe" -version = "0.20.0" +version = "0.21.0" description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." category = "dev" optional = false python-versions = ">=3.7" [package.dependencies] -cached_property = {version = "*", markers = "python_version < \"3.8\""} +cached-property = {version = "*", markers = "python_version < \"3.8\""} [package.extras] async = ["aiofiles (>=0.7,<1.0)"] @@ -241,7 +241,7 @@ python-versions = ">=3.5" [[package]] name = "importlib-metadata" -version = "4.11.4" +version = "4.12.0" description = "Read metadata from Python packages" category = "main" optional = false @@ -254,7 +254,7 @@ zipp = ">=0.5" [package.extras] docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] perf = ["ipython"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] [[package]] name = "iniconfig" @@ -446,11 +446,11 @@ python-versions = "*" [[package]] name = "nodeenv" -version = "1.6.0" +version = "1.7.0" description = "Node.js virtual environment builder" category = "dev" optional = false -python-versions = "*" +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" [[package]] name = "packaging" @@ -658,21 +658,16 @@ version = "12.4.4" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" category = "main" optional = false -python-versions = "^3.6.3" -develop = true +python-versions = ">=3.6.3,<4.0.0" [package.dependencies] -commonmark = "^0.9.0" -pygments = "^2.6.0" -typing-extensions = {version = ">=4.0.0, <5.0", markers = "python_version < \"3.9\""} +commonmark = ">=0.9.0,<0.10.0" +pygments = ">=2.6.0,<3.0.0" +typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} [package.extras] jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"] -[package.source] -type = "directory" -url = "../rich" - [[package]] name = "six" version = "1.16.0" @@ -726,7 +721,7 @@ python-versions = ">=3.7" [[package]] name = "virtualenv" -version = "20.14.1" +version = "20.15.0" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -785,7 +780,7 @@ dev = ["aiohttp", "click", "msgpack"] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "08c432accbe56db11ca9b2d69c2b4e15967e1a0142d4508f245715cae6f3d239" +content-hash = "8ce8d66466dad1b984673595ebd0cc7bc0d28c7a672269e9b5620c242d87d9ad" [metadata.files] aiohttp = [ @@ -883,29 +878,29 @@ attrs = [ {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, ] black = [ - {file = "black-22.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2497f9c2386572e28921fa8bec7be3e51de6801f7459dffd6e62492531c47e09"}, - {file = "black-22.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5795a0375eb87bfe902e80e0c8cfaedf8af4d49694d69161e5bd3206c18618bb"}, - {file = "black-22.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e3556168e2e5c49629f7b0f377070240bd5511e45e25a4497bb0073d9dda776a"}, - {file = "black-22.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67c8301ec94e3bcc8906740fe071391bce40a862b7be0b86fb5382beefecd968"}, - {file = "black-22.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:fd57160949179ec517d32ac2ac898b5f20d68ed1a9c977346efbac9c2f1e779d"}, - {file = "black-22.3.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cc1e1de68c8e5444e8f94c3670bb48a2beef0e91dddfd4fcc29595ebd90bb9ce"}, - {file = "black-22.3.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2fc92002d44746d3e7db7cf9313cf4452f43e9ea77a2c939defce3b10b5c82"}, - {file = "black-22.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:a6342964b43a99dbc72f72812bf88cad8f0217ae9acb47c0d4f141a6416d2d7b"}, - {file = "black-22.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:328efc0cc70ccb23429d6be184a15ce613f676bdfc85e5fe8ea2a9354b4e9015"}, - {file = "black-22.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06f9d8846f2340dfac80ceb20200ea5d1b3f181dd0556b47af4e8e0b24fa0a6b"}, - {file = "black-22.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4efa5fad66b903b4a5f96d91461d90b9507a812b3c5de657d544215bb7877a"}, - {file = "black-22.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8477ec6bbfe0312c128e74644ac8a02ca06bcdb8982d4ee06f209be28cdf163"}, - {file = "black-22.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:637a4014c63fbf42a692d22b55d8ad6968a946b4a6ebc385c5505d9625b6a464"}, - {file = "black-22.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:863714200ada56cbc366dc9ae5291ceb936573155f8bf8e9de92aef51f3ad0f0"}, - {file = "black-22.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10dbe6e6d2988049b4655b2b739f98785a884d4d6b85bc35133a8fb9a2233176"}, - {file = "black-22.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:cee3e11161dde1b2a33a904b850b0899e0424cc331b7295f2a9698e79f9a69a0"}, - {file = "black-22.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5891ef8abc06576985de8fa88e95ab70641de6c1fca97e2a15820a9b69e51b20"}, - {file = "black-22.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:30d78ba6bf080eeaf0b7b875d924b15cd46fec5fd044ddfbad38c8ea9171043a"}, - {file = "black-22.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ee8f1f7228cce7dffc2b464f07ce769f478968bfb3dd1254a4c2eeed84928aad"}, - {file = "black-22.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ee227b696ca60dd1c507be80a6bc849a5a6ab57ac7352aad1ffec9e8b805f21"}, - {file = "black-22.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:9b542ced1ec0ceeff5b37d69838106a6348e60db7b8fdd245294dc1d26136265"}, - {file = "black-22.3.0-py3-none-any.whl", hash = "sha256:bc58025940a896d7e5356952228b68f793cf5fcb342be703c3a2669a1488cb72"}, - {file = "black-22.3.0.tar.gz", hash = "sha256:35020b8886c022ced9282b51b5a875b6d1ab0c387b31a065b84db7c33085ca79"}, + {file = "black-22.6.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f586c26118bc6e714ec58c09df0157fe2d9ee195c764f630eb0d8e7ccce72e69"}, + {file = "black-22.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b270a168d69edb8b7ed32c193ef10fd27844e5c60852039599f9184460ce0807"}, + {file = "black-22.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6797f58943fceb1c461fb572edbe828d811e719c24e03375fd25170ada53825e"}, + {file = "black-22.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c85928b9d5f83b23cee7d0efcb310172412fbf7cb9d9ce963bd67fd141781def"}, + {file = "black-22.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:f6fe02afde060bbeef044af7996f335fbe90b039ccf3f5eb8f16df8b20f77666"}, + {file = "black-22.6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cfaf3895a9634e882bf9d2363fed5af8888802d670f58b279b0bece00e9a872d"}, + {file = "black-22.6.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94783f636bca89f11eb5d50437e8e17fbc6a929a628d82304c80fa9cd945f256"}, + {file = "black-22.6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:2ea29072e954a4d55a2ff58971b83365eba5d3d357352a07a7a4df0d95f51c78"}, + {file = "black-22.6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e439798f819d49ba1c0bd9664427a05aab79bfba777a6db94fd4e56fae0cb849"}, + {file = "black-22.6.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:187d96c5e713f441a5829e77120c269b6514418f4513a390b0499b0987f2ff1c"}, + {file = "black-22.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:074458dc2f6e0d3dab7928d4417bb6957bb834434516f21514138437accdbe90"}, + {file = "black-22.6.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a218d7e5856f91d20f04e931b6f16d15356db1c846ee55f01bac297a705ca24f"}, + {file = "black-22.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:568ac3c465b1c8b34b61cd7a4e349e93f91abf0f9371eda1cf87194663ab684e"}, + {file = "black-22.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6c1734ab264b8f7929cef8ae5f900b85d579e6cbfde09d7387da8f04771b51c6"}, + {file = "black-22.6.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9a3ac16efe9ec7d7381ddebcc022119794872abce99475345c5a61aa18c45ad"}, + {file = "black-22.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:b9fd45787ba8aa3f5e0a0a98920c1012c884622c6c920dbe98dbd05bc7c70fbf"}, + {file = "black-22.6.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7ba9be198ecca5031cd78745780d65a3f75a34b2ff9be5837045dce55db83d1c"}, + {file = "black-22.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3db5b6409b96d9bd543323b23ef32a1a2b06416d525d27e0f67e74f1446c8f2"}, + {file = "black-22.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:560558527e52ce8afba936fcce93a7411ab40c7d5fe8c2463e279e843c0328ee"}, + {file = "black-22.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b154e6bbde1e79ea3260c4b40c0b7b3109ffcdf7bc4ebf8859169a6af72cd70b"}, + {file = "black-22.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:4af5bc0e1f96be5ae9bd7aaec219c901a94d6caa2484c21983d043371c733fc4"}, + {file = "black-22.6.0-py3-none-any.whl", hash = "sha256:ac609cf8ef5e7115ddd07d85d988d074ed00e10fbc3445aee393e70164a2219c"}, + {file = "black-22.6.0.tar.gz", hash = "sha256:6c6d39e28aed379aec40da1c65434c77d75e65bb59a1e1c283de545fb4e7c6c9"}, ] cached-property = [ {file = "cached-property-1.5.2.tar.gz", hash = "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130"}, @@ -1048,8 +1043,8 @@ ghp-import = [ {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, ] griffe = [ - {file = "griffe-0.20.0-py3-none-any.whl", hash = "sha256:899e0c9c09baf22b31de1c969a03edaf0ddf72d0a7183df8de746b6c26ed62f4"}, - {file = "griffe-0.20.0.tar.gz", hash = "sha256:bf181de6e661c0d2a229c1dc7e90db0def280ee3a89c6829fcc1695baee65f7f"}, + {file = "griffe-0.21.0-py3-none-any.whl", hash = "sha256:e9fb5eeb7c721e1d84804452bdc742bd57b120b13aba663157668ae2d217088a"}, + {file = "griffe-0.21.0.tar.gz", hash = "sha256:61ab3bc02b09afeb489f1aef44c646a09f1837d9cdf15943ac6021903a4d3984"}, ] identify = [ {file = "identify-2.5.1-py2.py3-none-any.whl", hash = "sha256:0dca2ea3e4381c435ef9c33ba100a78a9b40c0bab11189c7cf121f75815efeaa"}, @@ -1060,8 +1055,8 @@ idna = [ {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.11.4-py3-none-any.whl", hash = "sha256:c58c8eb8a762858f49e18436ff552e83914778e50e9d2f1660535ffb364552ec"}, - {file = "importlib_metadata-4.11.4.tar.gz", hash = "sha256:5d26852efe48c0a32b0509ffbc583fda1a2266545a78d104a6f4aff3db17d700"}, + {file = "importlib_metadata-4.12.0-py3-none-any.whl", hash = "sha256:7401a975809ea1fdc658c3aa4f78cc2195a0e019c5cbc4c06122884e9ae80c23"}, + {file = "importlib_metadata-4.12.0.tar.gz", hash = "sha256:637245b8bab2b6502fcbc752cc4b7a6f6243bb02b31c5c26156ad103d3d45670"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, @@ -1290,8 +1285,8 @@ mypy-extensions = [ {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, ] nodeenv = [ - {file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"}, - {file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"}, + {file = "nodeenv-1.7.0-py2.py3-none-any.whl", hash = "sha256:27083a7b96a25f2f5e1d8cb4b6317ee8aeda3bdd121394e5ac54e498028a042e"}, + {file = "nodeenv-1.7.0.tar.gz", hash = "sha256:e0e7f7dfb85fc5394c6fe1e8fa98131a2473e04311a45afb6508f7cf1836fa2b"}, ] packaging = [ {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, @@ -1389,7 +1384,10 @@ pyyaml-env-tag = [ {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, ] -rich = [] +rich = [ + {file = "rich-12.4.4-py3-none-any.whl", hash = "sha256:d2bbd99c320a2532ac71ff6a3164867884357da3e3301f0240090c5d2fdac7ec"}, + {file = "rich-12.4.4.tar.gz", hash = "sha256:4c586de507202505346f3e32d1363eb9ed6932f0c2f63184dea88983ff4971e2"}, +] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, @@ -1475,8 +1473,8 @@ typing-extensions = [ {file = "typing_extensions-4.2.0.tar.gz", hash = "sha256:f1c24655a0da0d1b67f07e17a5e6b2a105894e6824b92096378bb3668ef02376"}, ] virtualenv = [ - {file = "virtualenv-20.14.1-py2.py3-none-any.whl", hash = "sha256:e617f16e25b42eb4f6e74096b9c9e37713cf10bf30168fb4a739f3fa8f898a3a"}, - {file = "virtualenv-20.14.1.tar.gz", hash = "sha256:ef589a79795589aada0c1c5b319486797c03b67ac3984c48c669c0e4f50df3a5"}, + {file = "virtualenv-20.15.0-py2.py3-none-any.whl", hash = "sha256:804cce4de5b8a322f099897e308eecc8f6e2951f1a8e7e2b3598dff865f01336"}, + {file = "virtualenv-20.15.0.tar.gz", hash = "sha256:4c44b1d77ca81f8368e2d7414f9b20c428ad16b343ac6d226206c5b84e2b4fcc"}, ] watchdog = [ {file = "watchdog-2.1.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a735a990a1095f75ca4f36ea2ef2752c99e6ee997c46b0de507ba40a09bf7330"}, diff --git a/pyproject.toml b/pyproject.toml index 0b932972b..5b058e02a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,8 +22,8 @@ textual = "textual.cli.cli:run" [tool.poetry.dependencies] python = "^3.7" -#rich = "^12.4.3" -rich = {path="../rich", develop=true} +rich = "^12.4.3" +#rich = {path="../rich", develop=true} importlib-metadata = "^4.11.3" typing-extensions = { version = "^4.0.0", python = "<3.8" } diff --git a/sandbox/will/basic.py b/sandbox/will/basic.py index 2b3b1ea7f..3e5f02732 100644 --- a/sandbox/will/basic.py +++ b/sandbox/will/basic.py @@ -192,5 +192,8 @@ if __name__ == "__main__": print(Scalar.resolve_dimension.cache_info()) from rich.style import Style + from rich.cells import cached_cell_len print(Style._add.cache_info()) + + print(cached_cell_len.cache_info()) diff --git a/src/textual/_cells.py b/src/textual/_cells.py new file mode 100644 index 000000000..2b838b93c --- /dev/null +++ b/src/textual/_cells.py @@ -0,0 +1,6 @@ +__all__ = ["cell_len"] + +try: + from rich.cells import cached_cell_len as cell_len +except ImportError: + from rich.cells import cell_len diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 994b122d6..bb2523485 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -19,7 +19,7 @@ import sys from typing import Callable, cast, Iterator, Iterable, NamedTuple, TYPE_CHECKING import rich.repr -from rich.cells import cached_cell_len + from rich.console import Console, ConsoleOptions, RenderResult, RenderableType from rich.control import Control from rich.segment import Segment @@ -28,9 +28,9 @@ from rich.style import Style from . import errors from .geometry import Region, Offset, Size +from ._cells import cell_len from ._profile import timer from ._loop import loop_last -from ._segment_tools import line_crop from ._types import Lines if sys.version_info >= (3, 10): @@ -124,7 +124,7 @@ class ChopsUpdate: chop_ends = self.chop_ends last_y = self.spans[-1][0] - _cell_len = cached_cell_len + _cell_len = cell_len for y, x1, x2 in self.spans: line = chops[y] diff --git a/src/textual/_segment_tools.py b/src/textual/_segment_tools.py index 71bef7cb8..75a235d8a 100644 --- a/src/textual/_segment_tools.py +++ b/src/textual/_segment_tools.py @@ -4,9 +4,10 @@ Tools for processing Segments, or lists of Segments. from __future__ import annotations -from rich.cells import cached_cell_len from rich.segment import Segment +from ._cells import cell_len + def line_crop(segments: list[Segment], start: int, end: int, total: int): """Crops a list of segments between two cell offsets. @@ -23,7 +24,7 @@ def line_crop(segments: list[Segment], start: int, end: int, total: int): # The following line has equivalent functionality (but a little slower) # return list(Segment.divide(segments, [start, end]))[1] - _cell_len = cached_cell_len + _cell_len = cell_len pos = 0 output_segments: list[Segment] = [] add_segment = output_segments.append From 209a842ec6443f2c9716f60f4de0aa55748b66f3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 28 Jun 2022 11:08:26 +0100 Subject: [PATCH 12/13] docstring --- src/textual/scroll_view.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py index bf84282be..ffeca2421 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -41,6 +41,11 @@ class ScrollView(Widget): return False def get_dirty_regions(self) -> Collection[Region]: + """Get regions which require a repaint. + + Returns: + Collection[Region]: Regions to repaint. + """ regions = self._dirty_regions.copy() self._dirty_regions.clear() return regions From 8c623f9385a3a4df30bfed400bdbc50c6481e106 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 28 Jun 2022 11:15:40 +0100 Subject: [PATCH 13/13] comment data table --- src/textual/widgets/_data_table.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 861cc19ab..31782bc8a 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -35,15 +35,25 @@ CellType = TypeVar("CellType") def default_cell_formatter(obj: object) -> RenderableType | None: + """Format a cell in to a renderable. + + Args: + obj (object): Data for a cell. + + Returns: + RenderableType | None: A renderable or None if the object could not be rendered. + """ if isinstance(obj, str): return Text.from_markup(obj) if not is_renderable(obj): - raise TypeError(f"Table cell {obj!r} is not renderable") + return None return cast(RenderableType, obj) @dataclass class Column: + """Table column.""" + label: Text width: int visible: bool = False @@ -52,6 +62,8 @@ class Column: @dataclass class Row: + """Table row.""" + index: int height: int y: int @@ -60,34 +72,38 @@ class Row: @dataclass class Cell: + """Table cell.""" + value: object class Coord(NamedTuple): + """An object to represent the cordinate of a cell within the data table.""" + row: int column: int def left(self) -> Coord: + """Get coordinate to the left.""" row, column = self return Coord(row, column - 1) def right(self) -> Coord: + """Get coordinate to the right.""" row, column = self return Coord(row, column + 1) def up(self) -> Coord: + """Get coordinate above.""" row, column = self return Coord(row - 1, column) def down(self) -> Coord: + """Get coordinate below.""" row, column = self return Coord(row + 1, column) -class Header(Widget): - pass - - class DataTable(ScrollView, Generic[CellType]): CSS = """ @@ -288,9 +304,6 @@ class DataTable(ScrollView, Generic[CellType]): return region = region.translate(-self.scroll_offset) self.refresh(region) - # refresh_region = self.content_region.intersection(region) - # if refresh_region: - # self.refresh(refresh_region) def _get_row_renderables(self, row_index: int) -> list[RenderableType]: """Get renderables for the given row.