diff --git a/CHANGELOG.md b/CHANGELOG.md
index 706651f229..5f70e9cfde 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added horizontal rule to Markdown https://github.com/Textualize/textual/pull/1832
- Added `Widget.disabled` https://github.com/Textualize/textual/pull/1785
- Added `DOMNode.notify_style_update` to replace `messages.StylesUpdated` message https://github.com/Textualize/textual/pull/1861
+- Added `DataTable.show_row_labels` reactive to show and hide row labels https://github.com/Textualize/textual/pull/1868
+- Added `DataTable.RowLabelSelected` event, which is emitted when a row label is clicked https://github.com/Textualize/textual/pull/1868
- Added `MessagePump.prevent` context manager to temporarily suppress a given message type https://github.com/Textualize/textual/pull/1866
### Changed
@@ -32,6 +34,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Scrolling by page now adds to current position.
- Markdown lists have been polished: a selection of bullets, better alignment of numbers, style tweaks https://github.com/Textualize/textual/pull/1832
- Added alternative method of composing Widgets https://github.com/Textualize/textual/pull/1847
+- Added `label` parameter to `DataTable.add_row` https://github.com/Textualize/textual/pull/1868
+- Breaking change: Some `DataTable` component classes were renamed - see PR for details https://github.com/Textualize/textual/pull/1868
### Removed
diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py
index fe04dfa07b..3ae85ca767 100644
--- a/src/textual/widgets/_data_table.py
+++ b/src/textual/widgets/_data_table.py
@@ -39,6 +39,8 @@
CursorType = Literal["cell", "row", "column", "none"]
CellType = TypeVar("CellType")
+CELL_X_PADDING = 2
+
class CellDoesNotExist(Exception):
"""The cell key/index was invalid.
@@ -171,9 +173,9 @@ 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
+ return self.content_width + CELL_X_PADDING
else:
- return self.width + 2
+ return self.width + CELL_X_PADDING
@dataclass
@@ -182,6 +184,14 @@ class Row:
key: RowKey
height: int
+ label: Text | None = None
+
+
+class RowRenderables(NamedTuple):
+ """Container for a row, which contains an optional label and some data cells."""
+
+ label: RenderableType | None
+ cells: list[RenderableType]
class DataTable(ScrollView, Generic[CellType], can_focus=True):
@@ -205,25 +215,27 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
"""
COMPONENT_CLASSES: ClassVar[set[str]] = {
- "datatable--header",
- "datatable--cursor-fixed",
- "datatable--highlight-fixed",
+ "datatable--cursor",
+ "datatable--hover",
"datatable--fixed",
+ "datatable--fixed-cursor",
+ "datatable--header",
+ "datatable--header-cursor",
+ "datatable--header-hover",
"datatable--odd-row",
"datatable--even-row",
- "datatable--highlight",
- "datatable--cursor",
}
"""
| Class | Description |
| :- | :- |
| `datatable--cursor` | Target the cursor. |
- | `datatable--cursor-fixed` | Target fixed columns or header under the cursor. |
- | `datatable--even-row` | Target even rows (row indices start at 0). |
- | `datatable--fixed` | Target fixed columns or header. |
+ | `datatable--hover` | Target the cells under the hover cursor. |
+ | `datatable--fixed` | Target fixed columns and fixed rows. |
+ | `datatable--fixed-cursor` | Target highlighted and fixed columns or header. |
| `datatable--header` | Target the header of the data table. |
- | `datatable--highlight` | Target the highlighted cell(s). |
- | `datatable--highlight-fixed` | Target highlighted and fixed columns or header. |
+ | `datatable--header-cursor` | Target cells highlighted by the cursor. |
+ | `datatable--header-hover` | Target hovered header or row label cells. |
+ | `datatable--even-row` | Target even rows (row indices start at 0). |
| `datatable--odd-row` | Target odd rows (row indices start at 0). |
"""
@@ -241,8 +253,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
color: $text;
}
DataTable > .datatable--fixed {
- text-style: bold;
- background: $primary;
+ background: $primary 50%;
color: $text;
}
@@ -259,12 +270,17 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
color: $text;
}
- DataTable > .datatable--cursor-fixed {
+ DataTable > .datatable--fixed-cursor {
+ background: $secondary 92%;
+ color: $text;
+ }
+
+ DataTable > .datatable--header-cursor {
background: $secondary-darken-1;
color: $text;
}
- DataTable > .datatable--highlight-fixed {
+ DataTable > .datatable--header-hover {
background: $secondary 30%;
}
@@ -272,12 +288,13 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
background: $primary 15%;
}
- DataTable > .datatable--highlight {
+ DataTable > .datatable--hover {
background: $secondary 20%;
}
"""
show_header = Reactive(True)
+ show_row_labels = Reactive(True)
fixed_rows = Reactive(0)
fixed_columns = Reactive(0)
zebra_stripes = Reactive(False)
@@ -459,12 +476,38 @@ def __init__(
def __rich_repr__(self) -> rich.repr.Result:
yield "sender", self.sender
yield "column_key", self.column_key
+ yield "column_index", self.column_index
+ yield "label", self.label.plain
+
+ class RowLabelSelected(Message, bubble=True):
+ """Posted when a row label is clicked."""
+
+ def __init__(
+ self,
+ sender: DataTable,
+ row_key: RowKey,
+ row_index: int,
+ label: Text,
+ ):
+ self.row_key = row_key
+ """The key for the column."""
+ self.row_index = row_index
+ """The index for the column."""
+ self.label = label
+ """The text of the label."""
+ super().__init__(sender)
+
+ def __rich_repr__(self) -> rich.repr.Result:
+ yield "sender", self.sender
+ yield "row_key", self.row_key
+ yield "row_index", self.row_index
yield "label", self.label.plain
def __init__(
self,
*,
show_header: bool = True,
+ show_row_labels: bool = True,
fixed_rows: int = 0,
fixed_columns: int = 0,
zebra_stripes: bool = False,
@@ -519,8 +562,23 @@ def __init__(
self._updated_cells: set[CellKey] = set()
"""Track which cells were updated, so that we can refresh them once on idle."""
+ self._show_hover_cursor = False
+ """Used to hide the mouse hover cursor when the user uses the keyboard."""
+ self._update_count = 0
+ """Number of update (INCLUDING SORT) operations so far. Used for cache invalidation."""
+ self._header_row_key = RowKey()
+ """The header is a special row - not part of the data. Retrieve via this key."""
+ self._label_column_key = ColumnKey()
+ """The column containing row labels is not part of the data. This key identifies it."""
+ self._labelled_row_exists = False
+ """Whether or not the user has supplied any rows with labels."""
+ self._label_column = Column(self._label_column_key, Text(), auto_width=True)
+ """The largest content width out of all row labels in the table."""
+
self.show_header = show_header
"""Show/hide the header row (the row of column labels)."""
+ self.show_row_labels = show_row_labels
+ """Show/hide the column containing the labels of rows."""
self.header_height = header_height
"""The height of the header row (the row of column labels)."""
self.fixed_rows = fixed_rows
@@ -531,12 +589,6 @@ def __init__(
"""Apply zebra effect on row backgrounds (light, dark, light, dark, ...)."""
self.show_cursor = show_cursor
"""Show/hide both the keyboard and hover cursor."""
- self._show_hover_cursor = False
- """Used to hide the mouse hover cursor when the user uses the keyboard."""
- self._update_count = 0
- """Number of update (INCLUDING SORT) operations so far. Used for cache invalidation."""
- self._header_row_key = RowKey()
- """The header is a special row - not part of the data. Retrieve via this key."""
@property
def hover_row(self) -> int:
@@ -796,9 +848,20 @@ def watch_show_header(self, show: bool) -> None:
self._scroll_cursor_into_view()
self._clear_caches()
+ def watch_show_row_labels(self, show: bool) -> None:
+ width, height = self.virtual_size
+ column_width = self._label_column.render_width
+ width_change = column_width if show else -column_width
+ self.virtual_size = Size(width + width_change, height)
+ self._scroll_cursor_into_view()
+ self._clear_caches()
+
def watch_fixed_rows(self) -> None:
self._clear_caches()
+ def watch_fixed_columns(self) -> None:
+ self._clear_caches()
+
def watch_zebra_stripes(self) -> None:
self._clear_caches()
@@ -919,6 +982,11 @@ def _highlight_cursor(self) -> None:
elif cursor_type == "column":
self._highlight_column(column_index)
+ @property
+ def _row_label_column_width(self) -> int:
+ """The render width of the column containing row labels"""
+ return self._label_column.render_width if self._should_render_row_labels else 0
+
def _update_column_widths(self, updated_cells: set[CellKey]) -> None:
"""Update the widths of the columns based on the newly updated cell widths."""
for row_key, column_key in updated_cells:
@@ -944,18 +1012,31 @@ def _update_column_widths(self, updated_cells: set[CellKey]) -> None:
def _update_dimensions(self, new_rows: Iterable[RowKey]) -> None:
"""Called to recalculate the virtual (scrollable) size."""
+ console = self.app.console
for row_key in new_rows:
row_index = self._row_locations.get(row_key)
+ row = self.rows.get(row_key)
+
+ if row.label is not None:
+ self._labelled_row_exists = True
+
+ row_label, cells_in_row = self._get_row_renderables(row_index)
+ label_content_width = measure(console, row_label, 1) if row_label else 0
+ self._label_column.content_width = max(
+ self._label_column.content_width, label_content_width
+ )
+
if row_index is None:
continue
- for column, renderable in zip(
- self.ordered_columns, self._get_row_renderables(row_index)
- ):
- content_width = measure(self.app.console, renderable, 1)
+
+ for column, renderable in zip(self.ordered_columns, cells_in_row):
+ content_width = measure(console, renderable, 1)
column.content_width = max(column.content_width, content_width)
self._clear_caches()
- total_width = sum(column.render_width for column in self.columns.values())
+
+ data_cells_width = sum(column.render_width for column in self.columns.values())
+ total_width = data_cells_width + self._row_label_column_width
header_height = self.header_height if self.show_header else 0
self.virtual_size = Size(
total_width,
@@ -971,8 +1052,12 @@ def _get_cell_region(self, coordinate: Coordinate) -> Region:
row_key = self._row_locations.get_key(row_index)
row = self.rows[row_key]
- # The x-coordinate of a cell is the sum of widths of cells to the left.
- x = sum(column.render_width for column in self.ordered_columns[:column_index])
+ # The x-coordinate of a cell is the sum of widths of the data cells to the left
+ # plus the width of the render width of the longest row label.
+ x = (
+ sum(column.render_width for column in self.ordered_columns[:column_index])
+ + self._row_label_column_width
+ )
column_key = self._column_locations.get_key(column_index)
width = self.columns[column_key].render_width
height = row.height
@@ -1003,7 +1088,10 @@ def _get_column_region(self, column_index: int) -> Region:
return Region(0, 0, 0, 0)
columns = self.columns
- x = sum(column.render_width for column in self.ordered_columns[:column_index])
+ x = (
+ sum(column.render_width for column in self.ordered_columns[:column_index])
+ + self._row_label_column_width
+ )
column_key = self._column_locations.get_key(column_index)
width = columns[column_key].render_width
header_height = self.header_height if self.show_header else 0
@@ -1028,6 +1116,8 @@ def clear(self, columns: bool = False) -> None:
self._require_update_dimensions = True
self.cursor_coordinate = Coordinate(0, 0)
self.hover_coordinate = Coordinate(0, 0)
+ self._label_column = Column(self._label_column_key, Text(), auto_width=True)
+ self._labelled_row_exists = False
self.refresh()
def add_column(
@@ -1076,7 +1166,11 @@ def add_column(
return column_key
def add_row(
- self, *cells: CellType, height: int = 1, key: str | None = None
+ self,
+ *cells: CellType,
+ height: int = 1,
+ key: str | None = None,
+ label: TextType | None = None,
) -> RowKey:
"""Add a row at the bottom of the DataTable.
@@ -1085,6 +1179,7 @@ def add_row(
height: The height of a row (in lines).
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
@@ -1106,7 +1201,8 @@ def add_row(
column.key: cell
for column, cell in zip_longest(self.ordered_columns, cells)
}
- self.rows[row_key] = Row(row_key, height)
+ label = Text.from_markup(label) if isinstance(label, str) else label
+ self.rows[row_key] = Row(row_key, height, label)
self._new_rows.add(row_key)
self._require_update_dimensions = True
self.cursor_coordinate = self.cursor_coordinate
@@ -1288,26 +1384,45 @@ def ordered_rows(self) -> list[Row]:
self._ordered_row_cache[cache_key] = ordered_rows
return ordered_rows
- def _get_row_renderables(self, row_index: int) -> list[RenderableType]:
- """Get renderables for the row currently at the given row index.
+ @property
+ def _should_render_row_labels(self) -> bool:
+ """Whether row labels should be rendered or not."""
+ return self._labelled_row_exists and self.show_row_labels
+
+ def _get_row_renderables(self, row_index: int) -> RowRenderables:
+ """Get renderables for the row currently at the given row index. The renderables
+ returned here have already been passed through the default_cell_formatter.
Args:
row_index: Index of the row.
Returns:
- List of renderables
+ A RowRenderables containing the optional label and the rendered cells.
"""
ordered_columns = self.ordered_columns
if row_index == -1:
- row: list[RenderableType] = [column.label for column in ordered_columns]
- return row
+ header_row: list[RenderableType] = [
+ column.label for column in ordered_columns
+ ]
+ # This is the cell where header and row labels intersect
+ return RowRenderables(None, header_row)
ordered_row = self.get_row_at(row_index)
empty = Text()
- return [
+
+ formatted_row_cells = [
Text() if datum is None else default_cell_formatter(datum) or empty
for datum, _ in zip_longest(ordered_row, range(len(self.columns)))
]
+ label = None
+ if self._should_render_row_labels:
+ row_metadata = self.rows.get(self._row_locations.get_key(row_index))
+ label = (
+ default_cell_formatter(row_metadata.label)
+ if row_metadata.label
+ else None
+ )
+ return RowRenderables(label, formatted_row_cells)
def _render_cell(
self,
@@ -1331,44 +1446,59 @@ def _render_cell(
Returns:
A list of segments per line.
"""
- is_header_row = row_index == -1
+ is_header_cell = row_index == -1
+ is_row_label_cell = column_index == -1
+
+ is_fixed_style_cell = (
+ not is_header_cell
+ and not is_row_label_cell
+ and (row_index < self.fixed_rows or column_index < self.fixed_columns)
+ )
- # The header row *and* fixed columns both have a different style (blue bg)
- is_fixed_style = is_header_row or column_index < self.fixed_columns
+ get_component = self.get_component_styles
show_cursor = self.show_cursor
if hover and show_cursor and self._show_hover_cursor:
- style += self.get_component_styles("datatable--highlight").rich_style
- if is_fixed_style:
- # Apply subtle variation in style for the fixed (blue background by
+ style += get_component("datatable--hover").rich_style
+ if is_header_cell or is_row_label_cell:
+ # Apply subtle variation in style for the header/label (blue background by
# default) rows and columns affected by the cursor, to ensure we can
# still differentiate between the labels and the data.
- style += self.get_component_styles(
- "datatable--highlight-fixed"
- ).rich_style
+ style += get_component("datatable--header-hover").rich_style
if cursor and show_cursor:
- style += self.get_component_styles("datatable--cursor").rich_style
- if is_fixed_style:
- style += self.get_component_styles("datatable--cursor-fixed").rich_style
+ style += get_component("datatable--cursor").rich_style
+ if is_header_cell or is_row_label_cell:
+ style += get_component("datatable--header-cursor").rich_style
+ elif is_fixed_style_cell:
+ style += get_component("datatable--fixed-cursor").rich_style
- if is_header_row:
+ if is_header_cell:
row_key = self._header_row_key
else:
row_key = self._row_locations.get_key(row_index)
column_key = self._column_locations.get_key(column_index)
cell_cache_key = (row_key, column_key, style, cursor, hover, self._update_count)
+
if cell_cache_key not in self._cell_render_cache:
style += Style.from_meta({"row": row_index, "column": column_index})
- height = self.header_height if is_header_row else self.rows[row_key].height
- cell = self._get_row_renderables(row_index)[column_index]
+ height = self.header_height if is_header_cell else self.rows[row_key].height
+ row_label, row_cells = self._get_row_renderables(row_index)
+
+ if is_row_label_cell:
+ cell = row_label if row_label is not None else ""
+ else:
+ cell = row_cells[column_index]
+
lines = self.app.console.render_lines(
Padding(cell, (0, 1)),
self.app.console.options.update_dimensions(width, height),
style=style,
)
+
self._cell_render_cache[cell_cache_key] = lines
+
return self._cell_render_cache[cell_cache_key]
def _render_line_in_row(
@@ -1431,16 +1561,34 @@ def _should_highlight(
else:
return False
+ is_header_row = row_key is self._header_row_key
+ render_cell = self._render_cell
+
if row_key in self._row_locations:
row_index = self._row_locations.get(row_key)
else:
row_index = -1
- render_cell = self._render_cell
+ # If the row has a label, add it to fixed_row here with correct style.
+ fixed_row = []
+ header_style = self.get_component_styles("datatable--header").rich_style
+
+ if self._labelled_row_exists and self.show_row_labels:
+ # The width of the row label is updated again on idle
+ cell_location = Coordinate(row_index, -1)
+ label_cell_lines = render_cell(
+ row_index,
+ -1,
+ header_style,
+ width=self._row_label_column_width,
+ cursor=_should_highlight(cursor_location, cell_location, cursor_type),
+ hover=_should_highlight(hover_location, cell_location, cursor_type),
+ )[line_no]
+ fixed_row.append(label_cell_lines)
+
if self.fixed_columns:
fixed_style = self.get_component_styles("datatable--fixed").rich_style
fixed_style += Style.from_meta({"fixed": True})
- fixed_row = []
for column_index, column in enumerate(
self.ordered_columns[: self.fixed_columns]
):
@@ -1448,7 +1596,7 @@ def _should_highlight(
fixed_cell_lines = render_cell(
row_index,
column_index,
- fixed_style,
+ header_style if is_header_row else fixed_style,
column.render_width,
cursor=_should_highlight(
cursor_location, cell_location, cursor_type
@@ -1456,12 +1604,12 @@ def _should_highlight(
hover=_should_highlight(hover_location, cell_location, cursor_type),
)[line_no]
fixed_row.append(fixed_cell_lines)
- else:
- fixed_row = []
is_header_row = row_key is self._header_row_key
if is_header_row:
row_style = self.get_component_styles("datatable--header").rich_style
+ elif row_index < self.fixed_rows:
+ row_style = self.get_component_styles("datatable--fixed").rich_style
else:
if self.zebra_stripes:
component_row_style = (
@@ -1601,8 +1749,12 @@ def _get_fixed_offset(self) -> Spacing:
are rows and columns that do not participate in scrolling."""
top = self.header_height if self.show_header else 0
top += sum(row.height for row in self.ordered_rows[: self.fixed_rows])
- left = sum(
- column.render_width for column in self.ordered_columns[: self.fixed_columns]
+ left = (
+ sum(
+ column.render_width
+ for column in self.ordered_columns[: self.fixed_columns]
+ )
+ + self._row_label_column_width
)
return Spacing(top, 0, 0, left)
@@ -1678,6 +1830,7 @@ def on_click(self, event: events.Click) -> None:
row_index = meta["row"]
column_index = meta["column"]
is_header_click = self.show_header and row_index == -1
+ is_row_label_click = self.show_row_labels and column_index == -1
if is_header_click:
# Header clicks work even if cursor is off, and doesn't move the cursor.
column = self.ordered_columns[column_index]
@@ -1685,6 +1838,12 @@ def on_click(self, event: events.Click) -> None:
self, column.key, column_index, label=column.label
)
self.post_message_no_wait(message)
+ elif is_row_label_click:
+ row = self.ordered_rows[row_index]
+ message = DataTable.RowLabelSelected(
+ self, row.key, row_index, label=row.label
+ )
+ self.post_message_no_wait(message)
elif self.show_cursor and self.cursor_type != "none":
# Only post selection events if there is a visible row/col/cell cursor.
self.cursor_coordinate = Coordinate(row_index, column_index)
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index 7cfa15288f..45b7105030 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -10015,134 +10015,294 @@
font-weight: 700;
}
- .terminal-234487613-matrix {
+ .terminal-600825441-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-234487613-title {
+ .terminal-600825441-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-234487613-r1 { fill: #dde6ed;font-weight: bold }
- .terminal-234487613-r2 { fill: #1e1201;font-weight: bold }
- .terminal-234487613-r3 { fill: #e1e1e1 }
- .terminal-234487613-r4 { fill: #c5c8c6 }
- .terminal-234487613-r5 { fill: #211505 }
+ .terminal-600825441-r1 { fill: #dde6ed;font-weight: bold }
+ .terminal-600825441-r2 { fill: #1e1201;font-weight: bold }
+ .terminal-600825441-r3 { fill: #e1e1e1 }
+ .terminal-600825441-r4 { fill: #c5c8c6 }
+ .terminal-600825441-r5 { fill: #dfe4e7 }
+ .terminal-600825441-r6 { fill: #1e1405 }
+ .terminal-600825441-r7 { fill: #211505 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- TableApp
+ TableApp
-
-
-
- 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
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ '''
+# ---
+# name: test_datatable_labels_and_fixed_data
+ '''
+