diff --git a/docs/examples/widgets/table.py b/docs/examples/widgets/table.py new file mode 100644 index 000000000..87b2c0ce8 --- /dev/null +++ b/docs/examples/widgets/table.py @@ -0,0 +1,29 @@ +import csv +import io + +from textual.app import App, ComposeResult +from textual.widgets import DataTable + +CSV = """lane,swimmer,country,time +4,Joseph Schooling,Singapore,50.39 +2,Michael Phelps,United States,51.14 +5,Chad le Clos,South Africa,51.14 +6,László Cseh,Hungary,51.14 +3,Li Zhuhao,China,51.26 +8,Mehdy Metella,France,51.58 +7,Tom Shields,United States,51.73 +1,Aleksandr Sadovnikov,Russia,51.84""" + + +class TableApp(App): + def compose(self) -> ComposeResult: + yield DataTable() + + def on_mount(self) -> None: + table = self.query_one(DataTable) + rows = csv.reader(io.StringIO(CSV)) + table.add_columns(*next(rows)) + table.add_rows(rows) + + +app = TableApp() diff --git a/docs/getting_started.md b/docs/getting_started.md index 626353e5e..9ddf67e9a 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -25,13 +25,13 @@ You can install Textual via PyPI. If you plan on developing Textual apps, then you should install `textual[dev]`. The `[dev]` part installs a few extra dependencies for development. ``` -pip install "textual[dev]==0.2.0b4" +pip install "textual[dev]==0.2.0b5" ``` If you only plan on _running_ Textual apps, then you can drop the `[dev]` part: ``` -pip install textual==0.2.0b4 +pip install textual==0.2.0b5 ``` !!! important diff --git a/docs/reference/data_table.md b/docs/reference/data_table.md new file mode 100644 index 000000000..c8ac87cde --- /dev/null +++ b/docs/reference/data_table.md @@ -0,0 +1 @@ +::: textual.widgets.DataTable diff --git a/docs/widgets/data_table.md b/docs/widgets/data_table.md index 580cba85f..a89935708 100644 --- a/docs/widgets/data_table.md +++ b/docs/widgets/data_table.md @@ -1 +1,38 @@ # DataTable + +A data table widget. + +- [x] Focusable +- [ ] Container + +## Example + +The example below populates a table with CSV data. + +=== "Output" + + ```{.textual path="docs/examples/widgets/table.py"} + ``` + +=== "table.py" + + ```python + --8<-- "docs/examples/widgets/table.py" + ``` + + +## Reactive Attributes + +| Name | Type | Default | Description | +| --------------- | ------ | ------- | ---------------------------------- | +| `show_header` | `bool` | `True` | Show the table header | +| `fixed_rows` | `int` | `0` | Number of fixed rows | +| `fixed_columns` | `int` | `0` | Number of fixed columns | +| `zebra_stripes` | `bool` | `False` | Display alternating colors on rows | +| `header_height` | `int` | `1` | Height of header row | +| `show_cursor` | `bool` | `True` | Show a cell cursor | + + +## See Also + +* [Table][textual.widgets.DataTable] code reference diff --git a/poetry.lock b/poetry.lock index 0ebc3a509..e420e1682 100644 --- a/poetry.lock +++ b/poetry.lock @@ -429,11 +429,11 @@ python-versions = ">=3.7" [[package]] name = "mypy" -version = "0.950" +version = "0.982" description = "Optional static typing for Python" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] mypy-extensions = ">=0.4.3" @@ -842,7 +842,7 @@ dev = ["aiohttp", "click", "msgpack"] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "6289417d4b26235ab102bacd8444ece650647bfd11e60b2b26709664052a28c0" +content-hash = "84203bb5193474eb9204f4f808739cb25e61f02a38d0062ea2ea71d3703573c1" [metadata.files] aiohttp = [] @@ -1086,31 +1086,7 @@ multidict = [ {file = "multidict-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:4bae31803d708f6f15fd98be6a6ac0b6958fcf68fda3c77a048a4f9073704aae"}, {file = "multidict-6.0.2.tar.gz", hash = "sha256:5ff3bd75f38e4c43f1f470f2df7a4d430b821c4ce22be384e1459cb57d6bb013"}, ] -mypy = [ - {file = "mypy-0.950-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cf9c261958a769a3bd38c3e133801ebcd284ffb734ea12d01457cb09eacf7d7b"}, - {file = "mypy-0.950-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5b5bd0ffb11b4aba2bb6d31b8643902c48f990cc92fda4e21afac658044f0c0"}, - {file = "mypy-0.950-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5e7647df0f8fc947388e6251d728189cfadb3b1e558407f93254e35abc026e22"}, - {file = "mypy-0.950-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:eaff8156016487c1af5ffa5304c3e3fd183edcb412f3e9c72db349faf3f6e0eb"}, - {file = "mypy-0.950-cp310-cp310-win_amd64.whl", hash = "sha256:563514c7dc504698fb66bb1cf897657a173a496406f1866afae73ab5b3cdb334"}, - {file = "mypy-0.950-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:dd4d670eee9610bf61c25c940e9ade2d0ed05eb44227275cce88701fee014b1f"}, - {file = "mypy-0.950-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ca75ecf2783395ca3016a5e455cb322ba26b6d33b4b413fcdedfc632e67941dc"}, - {file = "mypy-0.950-cp36-cp36m-win_amd64.whl", hash = "sha256:6003de687c13196e8a1243a5e4bcce617d79b88f83ee6625437e335d89dfebe2"}, - {file = "mypy-0.950-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4c653e4846f287051599ed8f4b3c044b80e540e88feec76b11044ddc5612ffed"}, - {file = "mypy-0.950-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e19736af56947addedce4674c0971e5dceef1b5ec7d667fe86bcd2b07f8f9075"}, - {file = "mypy-0.950-cp37-cp37m-win_amd64.whl", hash = "sha256:ef7beb2a3582eb7a9f37beaf38a28acfd801988cde688760aea9e6cc4832b10b"}, - {file = "mypy-0.950-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0112752a6ff07230f9ec2f71b0d3d4e088a910fdce454fdb6553e83ed0eced7d"}, - {file = "mypy-0.950-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ee0a36edd332ed2c5208565ae6e3a7afc0eabb53f5327e281f2ef03a6bc7687a"}, - {file = "mypy-0.950-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:77423570c04aca807508a492037abbd72b12a1fb25a385847d191cd50b2c9605"}, - {file = "mypy-0.950-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5ce6a09042b6da16d773d2110e44f169683d8cc8687e79ec6d1181a72cb028d2"}, - {file = "mypy-0.950-cp38-cp38-win_amd64.whl", hash = "sha256:5b231afd6a6e951381b9ef09a1223b1feabe13625388db48a8690f8daa9b71ff"}, - {file = "mypy-0.950-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0384d9f3af49837baa92f559d3fa673e6d2652a16550a9ee07fc08c736f5e6f8"}, - {file = "mypy-0.950-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1fdeb0a0f64f2a874a4c1f5271f06e40e1e9779bf55f9567f149466fc7a55038"}, - {file = "mypy-0.950-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:61504b9a5ae166ba5ecfed9e93357fd51aa693d3d434b582a925338a2ff57fd2"}, - {file = "mypy-0.950-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a952b8bc0ae278fc6316e6384f67bb9a396eb30aced6ad034d3a76120ebcc519"}, - {file = "mypy-0.950-cp39-cp39-win_amd64.whl", hash = "sha256:eaea21d150fb26d7b4856766e7addcf929119dd19fc832b22e71d942835201ef"}, - {file = "mypy-0.950-py3-none-any.whl", hash = "sha256:a4d9898f46446bfb6405383b57b96737dcfd0a7f25b748e78ef3e8c576bba3cb"}, - {file = "mypy-0.950.tar.gz", hash = "sha256:1b333cfbca1762ff15808a0ef4f71b5d3eed8528b23ea1c3fb50543c867d68de"}, -] +mypy = [] mypy-extensions = [ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, diff --git a/pyproject.toml b/pyproject.toml index c38c1faf3..460ba3d9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.2.0b4" +version = "0.2.0b5" homepage = "https://github.com/Textualize/textual" description = "Modern Text User Interface framework" authors = ["Will McGugan "] @@ -19,14 +19,7 @@ classifiers = [ "Typing :: Typed", ] include = [ - "src/textual/py.typed", - "src/textual/cli/py.typed", - "src/textual/css/py.typed", - "src/textual/devtools/py.typed", - "src/textual/drivers/py.typed", - "src/textual/layouts/py.typed", - "src/textual/renderables/py.typed", - "src/textual/widgets/py.typed" + "src/textual/py.typed" ] [tool.poetry.scripts] diff --git a/sandbox/will/table.py b/sandbox/will/table.py index a3dcdb384..7458d68dd 100644 --- a/sandbox/will/table.py +++ b/sandbox/will/table.py @@ -38,12 +38,12 @@ class TableApp(App): 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) - table.add_column("Foo", width=16) - table.add_column("Bar", width=16) - table.add_column("Baz", width=16) + table.add_column("Foo") + table.add_column("Bar") + table.add_column("Baz") + table.add_column("Foo") + table.add_column("Bar") + table.add_column("Baz") for n in range(200): height = 1 diff --git a/src/textual/py.typed b/src/textual/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 5a1dcfcaf..b00c0036a 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: Reactable = Union[Widget, App] -ReactiveType = TypeVar("ReactiveType", covariant=True) +ReactiveType = TypeVar("ReactiveType") T = TypeVar("T") diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index c9c1d154f..322756c4d 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -1,9 +1,9 @@ from __future__ import annotations -from dataclasses import dataclass, field -from itertools import chain import sys -from typing import ClassVar, Generic, NamedTuple, TypeVar, cast +from dataclasses import dataclass, field +from itertools import chain, zip_longest +from typing import ClassVar, Generic, Iterable, NamedTuple, TypeVar, cast from rich.console import RenderableType from rich.padding import Padding @@ -12,18 +12,16 @@ from rich.segment import Segment from rich.style import Style from rich.text import Text, TextType -from .. import events +from .. import events, messages from .._cache import LRUCache +from .._profile import timer from .._segment_tools import line_crop from .._types import Lines -from ..geometry import clamp, Region, Size, Spacing +from ..geometry import Region, Size, Spacing, clamp from ..reactive import Reactive -from .._profile import timer +from ..render import measure from ..scroll_view import ScrollView -from .. import messages - - if sys.version_info >= (3, 8): from typing import Literal else: @@ -55,10 +53,22 @@ class Column: """Table column.""" label: Text - width: int + width: int = 0 visible: bool = False index: int = 0 + content_width: int = 0 + auto_width: bool = False + + @property + def render_width(self) -> int: + """Width in cells, required to render a column.""" + # +2 is to account for space padding either side of the cell + if self.auto_width: + return self.content_width + 2 + else: + return self.width + 2 + @dataclass class Row: @@ -168,23 +178,21 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.rows: dict[int, Row] = {} self.data: dict[int, list[CellType]] = {} self.row_count = 0 - self._y_offsets: list[tuple[int, int]] = [] - 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, bool, bool], Lines] self._cell_render_cache = LRUCache(10000) - self._line_cache: LRUCache[ tuple[int, int, int, int, int, int, Style], list[Segment] ] self._line_cache = LRUCache(1000) self._line_no = 0 + self._require_update_dimensions: bool = False + self._new_rows: set[int] = set() show_header = Reactive(True) fixed_rows = Reactive(0) @@ -251,9 +259,16 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): column = clamp(column, self.fixed_columns, len(self.columns) - 1) return Coord(row, column) - def _update_dimensions(self) -> None: + def _update_dimensions(self, new_rows: Iterable[int]) -> None: """Called to recalculate the virtual (scrollable) size.""" - total_width = sum(column.width for column in self.columns) + for row_index in new_rows: + for column, renderable in zip( + self.columns, self._get_row_renderables(row_index) + ): + content_width = measure(self.app.console, renderable, 1) + column.content_width = max(column.content_width, content_width) + + total_width = sum(column.render_width for column in self.columns) self.virtual_size = Size( total_width, max(len(self._y_offsets), (self.header_height if self.show_header else 0)), @@ -263,8 +278,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): 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 + x = sum(column.render_width for column in self.columns[:column_index]) + width = self.columns[column_index].render_width height = row.height y = row.y if self.show_header: @@ -272,25 +287,52 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): cell_region = Region(x, y, width, height) return cell_region - def add_column(self, label: TextType, *, width: int = 10) -> None: + def add_columns(self, *labels: TextType) -> None: + """Add a number of columns. + + Args: + *labels: Column headers. + + """ + for label in labels: + self.add_column(label, width=None) + + def add_column(self, label: TextType, *, width: int | None = None) -> None: """Add a column to the table. Args: - label (TextType): A str or Text object containing the label (shown top of column) - width (int, optional): Width of the column in cells. Defaults to 10. + label (TextType): A str or Text object containing the label (shown top of column). + width (int, optional): Width of the column in cells or None to fit content. Defaults to None. """ text_label = Text.from_markup(label) if isinstance(label, str) else label - self.columns.append(Column(text_label, width, index=len(self.columns))) - self._update_dimensions() - self.refresh() + + content_width = measure(self.app.console, text_label, 1) + if width is None: + column = Column( + text_label, + content_width, + index=len(self.columns), + content_width=content_width, + auto_width=True, + ) + else: + column = Column( + text_label, width, content_width=content_width, index=len(self.columns) + ) + + self.columns.append(column) + self._require_update_dimensions = True + self.check_idle() def add_row(self, *cells: CellType, height: int = 1) -> None: """Add a row. Args: + *cells: Positional arguments should contain cell data. height (int, optional): The height of a row (in lines). Defaults to 1. """ row_index = self.row_count + self.data[row_index] = list(cells) self.rows[row_index] = Row(row_index, height, self._line_no) @@ -299,10 +341,34 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.row_count += 1 self._line_no += height - self._update_dimensions() - self.refresh() + + self._new_rows.add(row_index) + self._require_update_dimensions = True + self.check_idle() + + def add_rows(self, rows: Iterable[Iterable[CellType]]) -> None: + """Add a number of rows. + + Args: + rows (Iterable[Iterable[CellType]]): Iterable of rows. A row is an iterable of cells. + """ + for row in rows: + self.add_row(*row) + + def on_idle(self) -> None: + if self._require_update_dimensions: + self._require_update_dimensions = False + new_rows = self._new_rows.copy() + self._new_rows.clear() + self._update_dimensions(new_rows) def refresh_cell(self, row_index: int, column_index: int) -> None: + """Refresh a cell. + + Args: + row_index (int): Row index. + column_index (int): Column index. + """ if row_index < 0 or column_index < 0: return region = self._get_cell_region(row_index, column_index) @@ -330,7 +396,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): if data is None: return [empty for _ in self.columns] else: - return [default_cell_formatter(datum) or empty for datum in data] + return [ + Text() if datum is None else default_cell_formatter(datum) or empty + for datum, _ in zip_longest(data, range(len(self.columns))) + ] def _render_cell( self, @@ -401,7 +470,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): fixed_style = self.get_component_styles("datatable--fixed").rich_style fixed_style += Style.from_meta({"fixed": True}) fixed_row = [ - render_cell(row_index, column.index, fixed_style, column.width)[line_no] + render_cell(row_index, column.index, fixed_style, column.render_width)[ + line_no + ] for column in self.columns[: self.fixed_columns] ] else: @@ -423,7 +494,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row_index, column.index, row_style, - column.width, + column.render_width, cursor=cursor_column == column.index, hover=hover_column == column.index, )[line_no] @@ -490,7 +561,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): cursor_column=cursor_column, hover_column=hover_column, ) - fixed_width = sum(column.width for column in self.columns[: self.fixed_columns]) + fixed_width = sum( + column.render_width for column in self.columns[: self.fixed_columns] + ) fixed_line: list[Segment] = list(chain.from_iterable(fixed)) if fixed else [] scrollable_line: list[Segment] = list(chain.from_iterable(scrollable)) @@ -503,14 +576,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): return segments def render_line(self, y: int) -> list[Segment]: - """Render a line of content. - - Args: - y (int): Y Coordinate of line. - - Returns: - list[Segment]: A rendered line. - """ width, height = self.size scroll_x, scroll_y = self.scroll_offset fixed_top_row_count = sum( @@ -534,9 +599,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): except KeyError: pass - 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( @@ -544,7 +606,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): 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]) + left = sum(column.render_width for column in self.columns[: self.fixed_columns]) return Spacing(top, 0, 0, left) def _scroll_cursor_in_to_view(self, animate: bool = False) -> None: