Merge pull request #837 from Textualize/table-polish

Table polish
This commit is contained in:
Will McGugan
2022-10-06 13:43:27 +01:00
committed by GitHub
10 changed files with 186 additions and 88 deletions

View File

@@ -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()

View File

@@ -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

View File

@@ -0,0 +1 @@
::: textual.widgets.DataTable

View File

@@ -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

32
poetry.lock generated
View File

@@ -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"},

View File

@@ -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 <will@textualize.io>"]
@@ -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]

View File

@@ -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

0
src/textual/py.typed Normal file
View File

View File

@@ -17,7 +17,7 @@ if TYPE_CHECKING:
Reactable = Union[Widget, App]
ReactiveType = TypeVar("ReactiveType", covariant=True)
ReactiveType = TypeVar("ReactiveType")
T = TypeVar("T")

View File

@@ -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: