Repository: Textualize/textual
Pull request: DataTable new rows can have auto height.
Fixes #3122.

Adds `auto_height` to `Row` to try and keep `Row` and `Column` as similar as possible, as per a conversation with @darrenburns.
Temporarily sets the row height to 0 to signal the row height hasn't been computed yet (we can't set to `None` because we really need an `int` there for computations that run in the interim).

Problem statement:
Auto expanding height
You can set a height when you add a row to the DataTable, but there is currently no way to make it expand to fit the content.

I think that `add_row` should grow an option to automatically calculate the optimal height of a row.

If we make this the default, we should be able to explicitly override it with a height. Perhaps a `height` of None means auto-detect, and a integer sets it explicitly. An explicit height will be faster, which might be significant if the user adds a lot of rows.

Hints from issue discussion:
+1 this would be great to have. Rich's `Table` currently supports this behavior.
This does actually work. These is a edge case with an auto height table, in an auto height container. The max height of the table is set to 100% which prevents the table from expanding. We need a workaround for that/

Commit message: Test auto height computation in DataTable.add_row

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c3c5ce003f..8939ba7d70 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
 
diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py
index 6e7e3e543f..950467937d 100644
--- a/src/textual/widgets/_data_table.py
+++ b/src/textual/widgets/_data_table.py
@@ -187,6 +187,7 @@ class Row:
     key: RowKey
     height: int
     label: Text | None = None
+    auto_height: bool = False
 
 
 class RowRenderables(NamedTuple):
@@ -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)
 
@@ -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
@@ -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:
+            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())
@@ -1373,7 +1399,7 @@ 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:
@@ -1381,13 +1407,14 @@ def add_row(
 
         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).
         """
@@ -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
@@ -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()
diff --git a/tests/test_data_table.py b/tests/test_data_table.py
index e00b9432a4..763f624a27 100644
--- a/tests/test_data_table.py
+++ b/tests/test_data_table.py
@@ -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
@@ -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():
@@ -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():
@@ -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():
@@ -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():
@@ -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():
@@ -591,6 +592,7 @@ 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():
@@ -598,12 +600,12 @@ async def test_get_column_index_returns_index():
         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():
@@ -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():
@@ -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


Code review comments:
diff_hunk: @@ -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:
path: src/textual/widgets/_data_table.py
text: @user1:
Since we will need to render the cells anyway, can we get the height without rendering and throwing away the result ?

@author:
I.e., can we store the renderings?

@author:
I'm not sure how feasible it is.
I currently render the cell in a quick & dirty way just to count lines.

I looked into modifying `DataTable._render_cell`, which is responsible for rendering a cell and caching its result.
However, `_render_cell` needs a lot of information that I don't have computed, of which the base style is the most troublesome to compute.
`DataTable._render_line_in_row` spends ~90 lines on that, and I am not sure whether it's faster to do this simpler throwaway rendering here or if we should go over all that trouble at this point.

@user1:
I think its a clear win, and worth perservering.

@author:
See c9ae50d.