Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DataTable new rows can have auto height. #3213

Merged
merged 15 commits into from
Sep 20, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Reactive callbacks are now scheduled on the message pump of the reactable that is watching instead of the owner of reactive attribute https://github.com/Textualize/textual/pull/3065
- Callbacks scheduled with `call_next` will now have the same prevented messages as when the callback was scheduled https://github.com/Textualize/textual/pull/3065
- Added `cursor_type` to the `DataTable` constructor.
- `DataTable.add_row` accepts `height=None` to automatically compute optimal height for a row https://github.com/Textualize/textual/pull/3213

### Fixed

Expand Down
45 changes: 39 additions & 6 deletions src/textual/widgets/_data_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ class Row:
key: RowKey
height: int
label: Text | None = None
auto_height: bool = False


class RowRenderables(NamedTuple):
Expand Down Expand Up @@ -1190,8 +1191,16 @@ def _update_column_widths(self, updated_cells: set[CellKey]) -> None:
self._require_update_dimensions = True

def _update_dimensions(self, new_rows: Iterable[RowKey]) -> None:
"""Called to recalculate the virtual (scrollable) size."""
"""Called to recalculate the virtual (scrollable) size.

This recomputes column widths and then checks if any of the new rows need
to have their height computed.

Args:
new_rows: The new rows that will affect the `DataTable` dimensions.
"""
console = self.app.console
auto_height_rows: list[tuple[Row, list[RenderableType]]] = []
for row_key in new_rows:
row_index = self._row_locations.get(row_key)

Expand All @@ -1201,6 +1210,7 @@ def _update_dimensions(self, new_rows: Iterable[RowKey]) -> None:
continue

row = self.rows.get(row_key)
assert row is not None

if row.label is not None:
self._labelled_row_exists = True
Expand All @@ -1215,6 +1225,22 @@ def _update_dimensions(self, new_rows: Iterable[RowKey]) -> None:
content_width = measure(console, renderable, 1)
column.content_width = max(column.content_width, content_width)

if row.auto_height:
auto_height_rows.append((row, cells_in_row))

for row, cells_in_row in auto_height_rows:
rodrigogiraoserrao marked this conversation as resolved.
Show resolved Hide resolved
height = 0
for column, renderable in zip(self.ordered_columns, cells_in_row):
height = max(
height,
len(
console.render_lines(
renderable, console.options.update_width(column.width)
)
),
)
row.height = height

self._clear_caches()

data_cells_width = sum(column.render_width for column in self.columns.values())
Expand Down Expand Up @@ -1373,21 +1399,22 @@ def add_column(
def add_row(
self,
*cells: CellType,
height: int = 1,
height: int | None = 1,
key: str | None = None,
label: TextType | None = None,
) -> RowKey:
"""Add a row at the bottom of the DataTable.

Args:
*cells: Positional arguments should contain cell data.
height: The height of a row (in lines).
height: The height of a row (in lines). Use `None` to auto-detect the optimal
height.
key: A key which uniquely identifies this row. If None, it will be generated
for you and returned.
label: The label for the row. Will be displayed to the left if supplied.

Returns:
Uniquely identifies this row. Can be used to retrieve this row regardless
Unique identifier for this row. Can be used to retrieve this row regardless
of its current location in the DataTable (it could have moved after
being added due to sorting or insertion/deletion of other rows).
"""
Expand All @@ -1407,7 +1434,12 @@ def add_row(
for column, cell in zip_longest(self.ordered_columns, cells)
}
label = Text.from_markup(label) if isinstance(label, str) else label
self.rows[row_key] = Row(row_key, height, label)
self.rows[row_key] = Row(
row_key,
height if height is not None else 1,
label,
height is None,
)
self._new_rows.add(row_key)
self._require_update_dimensions = True
self.cursor_coordinate = self.cursor_coordinate
Expand Down Expand Up @@ -1546,7 +1578,8 @@ async def _on_idle(self, _: events.Idle) -> None:

if self._require_update_dimensions:
# Add the new rows *before* updating the column widths, since
# cells in a new row may influence the final width of a column
# cells in a new row may influence the final width of a column.
# Only then can we compute optimal height of rows with "auto" height.
self._require_update_dimensions = False
new_rows = self._new_rows.copy()
self._new_rows.clear()
Expand Down
60 changes: 41 additions & 19 deletions tests/test_data_table.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import pytest
from rich.panel import Panel
from rich.text import Text

from textual._wait import wait_for_idle
Expand Down Expand Up @@ -419,11 +420,11 @@ async def test_get_cell_coordinate_returns_coordinate():
table.add_row("ValR2C1", "ValR2C2", "ValR2C3", key="R2")
table.add_row("ValR3C1", "ValR3C2", "ValR3C3", key="R3")

assert table.get_cell_coordinate('R1', 'C1') == Coordinate(0, 0)
assert table.get_cell_coordinate('R2', 'C2') == Coordinate(1, 1)
assert table.get_cell_coordinate('R1', 'C3') == Coordinate(0, 2)
assert table.get_cell_coordinate('R3', 'C1') == Coordinate(2, 0)
assert table.get_cell_coordinate('R3', 'C2') == Coordinate(2, 1)
assert table.get_cell_coordinate("R1", "C1") == Coordinate(0, 0)
assert table.get_cell_coordinate("R2", "C2") == Coordinate(1, 1)
assert table.get_cell_coordinate("R1", "C3") == Coordinate(0, 2)
assert table.get_cell_coordinate("R3", "C1") == Coordinate(2, 0)
assert table.get_cell_coordinate("R3", "C2") == Coordinate(2, 1)


async def test_get_cell_coordinate_invalid_row_key():
Expand All @@ -434,7 +435,7 @@ async def test_get_cell_coordinate_invalid_row_key():
table.add_row("TargetValue", key="R1")

with pytest.raises(CellDoesNotExist):
coordinate = table.get_cell_coordinate('INVALID_ROW', 'C1')
coordinate = table.get_cell_coordinate("INVALID_ROW", "C1")


async def test_get_cell_coordinate_invalid_column_key():
Expand All @@ -445,7 +446,7 @@ async def test_get_cell_coordinate_invalid_column_key():
table.add_row("TargetValue", key="R1")

with pytest.raises(CellDoesNotExist):
coordinate = table.get_cell_coordinate('R1', 'INVALID_COLUMN')
coordinate = table.get_cell_coordinate("R1", "INVALID_COLUMN")


async def test_get_cell_at_returns_value_at_cell():
Expand Down Expand Up @@ -531,9 +532,9 @@ async def test_get_row_index_returns_index():
table.add_row("ValR2C1", "ValR2C2", key="R2")
table.add_row("ValR3C1", "ValR3C2", key="R3")

assert table.get_row_index('R1') == 0
assert table.get_row_index('R2') == 1
assert table.get_row_index('R3') == 2
assert table.get_row_index("R1") == 0
assert table.get_row_index("R2") == 1
assert table.get_row_index("R3") == 2


async def test_get_row_index_invalid_row_key():
Expand All @@ -544,7 +545,7 @@ async def test_get_row_index_invalid_row_key():
table.add_row("TargetValue", key="R1")

with pytest.raises(RowDoesNotExist):
index = table.get_row_index('InvalidRow')
index = table.get_row_index("InvalidRow")


async def test_get_column():
Expand Down Expand Up @@ -591,19 +592,20 @@ async def test_get_column_at_invalid_index(index):
with pytest.raises(ColumnDoesNotExist):
list(table.get_column_at(index))


async def test_get_column_index_returns_index():
app = DataTableApp()
async with app.run_test():
table = app.query_one(DataTable)
table.add_column("Column1", key="C1")
table.add_column("Column2", key="C2")
table.add_column("Column3", key="C3")
table.add_row("ValR1C1", "ValR1C2", "ValR1C3", key="R1")
table.add_row("ValR2C1", "ValR2C2", "ValR2C3", key="R2")
table.add_row("ValR1C1", "ValR1C2", "ValR1C3", key="R1")
table.add_row("ValR2C1", "ValR2C2", "ValR2C3", key="R2")

assert table.get_column_index('C1') == 0
assert table.get_column_index('C2') == 1
assert table.get_column_index('C3') == 2
assert table.get_column_index("C1") == 0
assert table.get_column_index("C2") == 1
assert table.get_column_index("C3") == 2


async def test_get_column_index_invalid_column_key():
Expand All @@ -613,11 +615,10 @@ async def test_get_column_index_invalid_column_key():
table.add_column("Column1", key="C1")
table.add_column("Column2", key="C2")
table.add_column("Column3", key="C3")
table.add_row("TargetValue1", "TargetValue2", "TargetValue3", key="R1")
table.add_row("TargetValue1", "TargetValue2", "TargetValue3", key="R1")

with pytest.raises(ColumnDoesNotExist):
index = table.get_column_index('InvalidCol')

index = table.get_column_index("InvalidCol")


async def test_update_cell_cell_exists():
Expand Down Expand Up @@ -1161,3 +1162,24 @@ async def test_unset_hover_highlight_when_no_table_cell_under_mouse():
# the widget, and the hover cursor is hidden
await pilot.hover(DataTable, offset=Offset(42, 1))
assert not table._show_hover_cursor


@pytest.mark.parametrize(
["cell", "height"],
[
("hey there", 1),
(Text("hey there"), 1),
(Text("long string", overflow="fold"), 2),
(Panel.fit("Hello\nworld"), 4),
("1\n2\n3\n4\n5\n6\n7", 7),
],
)
async def test_add_row_auto_height(cell: RenderableType, height: int):
app = DataTableApp()
async with app.run_test() as pilot:
table = app.query_one(DataTable)
table.add_column("C", width=10)
row_key = table.add_row(cell, height=None)
row = table.rows.get(row_key)
await pilot.pause()
assert row.height == height