Datatable labelling rows (#1868)

* Renaming some component classes in DataTable

* Some more renaming of DataTable component classes

* Separate styling for fixed rows/columns from labels

* Highlight fixed rows/cols affected by colour differently

* Tweaking styles for fixed data in DataTable

* Update DataTable snapshots

* Add row label to Row metadata

* Wiring up some labelled row logic behind flags

* Renaming variable in DataTable

* Variable renaming in DataTable

* [no ci] Labelling rows progress

* Add RenderedRow abstraction to DataTable

* Computing label widths

* Use the Column object to represent row label column

* Ability to toggle row labels reactively

* Adjust width calculation for label widths

* Add DataTable.RowLabelSelected

* Posting the RowLabelClick message

* Hovering of row labels applies new style

* Remove a print

* Ensure horizontal scrolling with column cursor accounts for row label column

* Account for possible row labels in cell cursor horizontal scrolling

* Ensure cursor highlighting is correct on row label cells

* Document component class for DataTable label hover

* Test to ensure clicking row label emits correct event

* Add snapshot test for DataTable with fixed rows/cols and row labels

* Using pilot pause instead of wait_for_idle directly

* Update CHANGELOG

* Add a docstring

* Add a note to CHANGELOG.md about RowLabelSelected
This commit is contained in:
darrenburns
2023-02-27 10:29:17 +00:00
committed by GitHub
parent f16f9c2a5c
commit cbe2ab87c6
8 changed files with 615 additions and 208 deletions

View File

@@ -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 horizontal rule to Markdown https://github.com/Textualize/textual/pull/1832
- Added `Widget.disabled` https://github.com/Textualize/textual/pull/1785 - 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 `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 - Added `MessagePump.prevent` context manager to temporarily suppress a given message type https://github.com/Textualize/textual/pull/1866
### Changed ### Changed
@@ -32,6 +34,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Scrolling by page now adds to current position. - 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 - 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 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 ### Removed

View File

@@ -39,6 +39,8 @@ RowCacheKey: TypeAlias = (
CursorType = Literal["cell", "row", "column", "none"] CursorType = Literal["cell", "row", "column", "none"]
CellType = TypeVar("CellType") CellType = TypeVar("CellType")
CELL_X_PADDING = 2
class CellDoesNotExist(Exception): class CellDoesNotExist(Exception):
"""The cell key/index was invalid. """The cell key/index was invalid.
@@ -171,9 +173,9 @@ class Column:
"""Width in cells, required to render a column.""" """Width in cells, required to render a column."""
# +2 is to account for space padding either side of the cell # +2 is to account for space padding either side of the cell
if self.auto_width: if self.auto_width:
return self.content_width + 2 return self.content_width + CELL_X_PADDING
else: else:
return self.width + 2 return self.width + CELL_X_PADDING
@dataclass @dataclass
@@ -182,6 +184,14 @@ class Row:
key: RowKey key: RowKey
height: int 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): 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]] = { COMPONENT_CLASSES: ClassVar[set[str]] = {
"datatable--header", "datatable--cursor",
"datatable--cursor-fixed", "datatable--hover",
"datatable--highlight-fixed",
"datatable--fixed", "datatable--fixed",
"datatable--fixed-cursor",
"datatable--header",
"datatable--header-cursor",
"datatable--header-hover",
"datatable--odd-row", "datatable--odd-row",
"datatable--even-row", "datatable--even-row",
"datatable--highlight",
"datatable--cursor",
} }
""" """
| Class | Description | | Class | Description |
| :- | :- | | :- | :- |
| `datatable--cursor` | Target the cursor. | | `datatable--cursor` | Target the cursor. |
| `datatable--cursor-fixed` | Target fixed columns or header under the cursor. | | `datatable--hover` | Target the cells under the hover cursor. |
| `datatable--even-row` | Target even rows (row indices start at 0). | | `datatable--fixed` | Target fixed columns and fixed rows. |
| `datatable--fixed` | Target fixed columns or header. | | `datatable--fixed-cursor` | Target highlighted and fixed columns or header. |
| `datatable--header` | Target the header of the data table. | | `datatable--header` | Target the header of the data table. |
| `datatable--highlight` | Target the highlighted cell(s). | | `datatable--header-cursor` | Target cells highlighted by the cursor. |
| `datatable--highlight-fixed` | Target highlighted and fixed columns or header. | | `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). | | `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; color: $text;
} }
DataTable > .datatable--fixed { DataTable > .datatable--fixed {
text-style: bold; background: $primary 50%;
background: $primary;
color: $text; color: $text;
} }
@@ -259,12 +270,17 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
color: $text; color: $text;
} }
DataTable > .datatable--cursor-fixed { DataTable > .datatable--fixed-cursor {
background: $secondary 92%;
color: $text;
}
DataTable > .datatable--header-cursor {
background: $secondary-darken-1; background: $secondary-darken-1;
color: $text; color: $text;
} }
DataTable > .datatable--highlight-fixed { DataTable > .datatable--header-hover {
background: $secondary 30%; background: $secondary 30%;
} }
@@ -272,12 +288,13 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
background: $primary 15%; background: $primary 15%;
} }
DataTable > .datatable--highlight { DataTable > .datatable--hover {
background: $secondary 20%; background: $secondary 20%;
} }
""" """
show_header = Reactive(True) show_header = Reactive(True)
show_row_labels = Reactive(True)
fixed_rows = Reactive(0) fixed_rows = Reactive(0)
fixed_columns = Reactive(0) fixed_columns = Reactive(0)
zebra_stripes = Reactive(False) zebra_stripes = Reactive(False)
@@ -459,12 +476,38 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
def __rich_repr__(self) -> rich.repr.Result: def __rich_repr__(self) -> rich.repr.Result:
yield "sender", self.sender yield "sender", self.sender
yield "column_key", self.column_key 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 yield "label", self.label.plain
def __init__( def __init__(
self, self,
*, *,
show_header: bool = True, show_header: bool = True,
show_row_labels: bool = True,
fixed_rows: int = 0, fixed_rows: int = 0,
fixed_columns: int = 0, fixed_columns: int = 0,
zebra_stripes: bool = False, zebra_stripes: bool = False,
@@ -519,8 +562,23 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
self._updated_cells: set[CellKey] = set() self._updated_cells: set[CellKey] = set()
"""Track which cells were updated, so that we can refresh them once on idle.""" """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 self.show_header = show_header
"""Show/hide the header row (the row of column labels).""" """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 self.header_height = header_height
"""The height of the header row (the row of column labels).""" """The height of the header row (the row of column labels)."""
self.fixed_rows = fixed_rows self.fixed_rows = fixed_rows
@@ -531,12 +589,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
"""Apply zebra effect on row backgrounds (light, dark, light, dark, ...).""" """Apply zebra effect on row backgrounds (light, dark, light, dark, ...)."""
self.show_cursor = show_cursor self.show_cursor = show_cursor
"""Show/hide both the keyboard and hover 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 @property
def hover_row(self) -> int: def hover_row(self) -> int:
@@ -796,9 +848,20 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
self._scroll_cursor_into_view() self._scroll_cursor_into_view()
self._clear_caches() 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: def watch_fixed_rows(self) -> None:
self._clear_caches() self._clear_caches()
def watch_fixed_columns(self) -> None:
self._clear_caches()
def watch_zebra_stripes(self) -> None: def watch_zebra_stripes(self) -> None:
self._clear_caches() self._clear_caches()
@@ -919,6 +982,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
elif cursor_type == "column": elif cursor_type == "column":
self._highlight_column(column_index) 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: def _update_column_widths(self, updated_cells: set[CellKey]) -> None:
"""Update the widths of the columns based on the newly updated cell widths.""" """Update the widths of the columns based on the newly updated cell widths."""
for row_key, column_key in updated_cells: for row_key, column_key in updated_cells:
@@ -944,18 +1012,31 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
def _update_dimensions(self, new_rows: Iterable[RowKey]) -> None: def _update_dimensions(self, new_rows: Iterable[RowKey]) -> None:
"""Called to recalculate the virtual (scrollable) size.""" """Called to recalculate the virtual (scrollable) size."""
console = self.app.console
for row_key in new_rows: for row_key in new_rows:
row_index = self._row_locations.get(row_key) 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: if row_index is None:
continue continue
for column, renderable in zip(
self.ordered_columns, self._get_row_renderables(row_index) for column, renderable in zip(self.ordered_columns, cells_in_row):
): content_width = measure(console, renderable, 1)
content_width = measure(self.app.console, renderable, 1)
column.content_width = max(column.content_width, content_width) column.content_width = max(column.content_width, content_width)
self._clear_caches() 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 header_height = self.header_height if self.show_header else 0
self.virtual_size = Size( self.virtual_size = Size(
total_width, total_width,
@@ -971,8 +1052,12 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
row_key = self._row_locations.get_key(row_index) row_key = self._row_locations.get_key(row_index)
row = self.rows[row_key] row = self.rows[row_key]
# The x-coordinate of a cell is the sum of widths of cells to the left. # The x-coordinate of a cell is the sum of widths of the data cells to the left
x = sum(column.render_width for column in self.ordered_columns[:column_index]) # 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) column_key = self._column_locations.get_key(column_index)
width = self.columns[column_key].render_width width = self.columns[column_key].render_width
height = row.height height = row.height
@@ -1003,7 +1088,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
return Region(0, 0, 0, 0) return Region(0, 0, 0, 0)
columns = self.columns 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) column_key = self._column_locations.get_key(column_index)
width = columns[column_key].render_width width = columns[column_key].render_width
header_height = self.header_height if self.show_header else 0 header_height = self.header_height if self.show_header else 0
@@ -1028,6 +1116,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
self._require_update_dimensions = True self._require_update_dimensions = True
self.cursor_coordinate = Coordinate(0, 0) self.cursor_coordinate = Coordinate(0, 0)
self.hover_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() self.refresh()
def add_column( def add_column(
@@ -1076,7 +1166,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
return column_key return column_key
def add_row( 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: ) -> RowKey:
"""Add a row at the bottom of the DataTable. """Add a row at the bottom of the DataTable.
@@ -1085,6 +1179,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
height: The height of a row (in lines). height: The height of a row (in lines).
key: A key which uniquely identifies this row. If None, it will be generated key: A key which uniquely identifies this row. If None, it will be generated
for you and returned. for you and returned.
label: The label for the row. Will be displayed to the left if supplied.
Returns: Returns:
Uniquely identifies this row. Can be used to retrieve this row regardless Uniquely identifies this row. Can be used to retrieve this row regardless
@@ -1106,7 +1201,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
column.key: cell column.key: cell
for column, cell in zip_longest(self.ordered_columns, cells) 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._new_rows.add(row_key)
self._require_update_dimensions = True self._require_update_dimensions = True
self.cursor_coordinate = self.cursor_coordinate self.cursor_coordinate = self.cursor_coordinate
@@ -1288,26 +1384,45 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
self._ordered_row_cache[cache_key] = ordered_rows self._ordered_row_cache[cache_key] = ordered_rows
return ordered_rows return ordered_rows
def _get_row_renderables(self, row_index: int) -> list[RenderableType]: @property
"""Get renderables for the row currently at the given row index. 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: Args:
row_index: Index of the row. row_index: Index of the row.
Returns: Returns:
List of renderables A RowRenderables containing the optional label and the rendered cells.
""" """
ordered_columns = self.ordered_columns ordered_columns = self.ordered_columns
if row_index == -1: if row_index == -1:
row: list[RenderableType] = [column.label for column in ordered_columns] header_row: list[RenderableType] = [
return row 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) ordered_row = self.get_row_at(row_index)
empty = Text() empty = Text()
return [
formatted_row_cells = [
Text() if datum is None else default_cell_formatter(datum) or empty Text() if datum is None else default_cell_formatter(datum) or empty
for datum, _ in zip_longest(ordered_row, range(len(self.columns))) 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( def _render_cell(
self, self,
@@ -1331,44 +1446,59 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
Returns: Returns:
A list of segments per line. 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
# The header row *and* fixed columns both have a different style (blue bg) is_fixed_style_cell = (
is_fixed_style = is_header_row or column_index < self.fixed_columns not is_header_cell
and not is_row_label_cell
and (row_index < self.fixed_rows or column_index < self.fixed_columns)
)
get_component = self.get_component_styles
show_cursor = self.show_cursor show_cursor = self.show_cursor
if hover and show_cursor and self._show_hover_cursor: if hover and show_cursor and self._show_hover_cursor:
style += self.get_component_styles("datatable--highlight").rich_style style += get_component("datatable--hover").rich_style
if is_fixed_style: if is_header_cell or is_row_label_cell:
# Apply subtle variation in style for the fixed (blue background by # Apply subtle variation in style for the header/label (blue background by
# default) rows and columns affected by the cursor, to ensure we can # default) rows and columns affected by the cursor, to ensure we can
# still differentiate between the labels and the data. # still differentiate between the labels and the data.
style += self.get_component_styles( style += get_component("datatable--header-hover").rich_style
"datatable--highlight-fixed"
).rich_style
if cursor and show_cursor: if cursor and show_cursor:
style += self.get_component_styles("datatable--cursor").rich_style style += get_component("datatable--cursor").rich_style
if is_fixed_style: if is_header_cell or is_row_label_cell:
style += self.get_component_styles("datatable--cursor-fixed").rich_style 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 row_key = self._header_row_key
else: else:
row_key = self._row_locations.get_key(row_index) row_key = self._row_locations.get_key(row_index)
column_key = self._column_locations.get_key(column_index) column_key = self._column_locations.get_key(column_index)
cell_cache_key = (row_key, column_key, style, cursor, hover, self._update_count) cell_cache_key = (row_key, column_key, style, cursor, hover, self._update_count)
if cell_cache_key not in self._cell_render_cache: if cell_cache_key not in self._cell_render_cache:
style += Style.from_meta({"row": row_index, "column": column_index}) style += Style.from_meta({"row": row_index, "column": column_index})
height = self.header_height if is_header_row else self.rows[row_key].height height = self.header_height if is_header_cell else self.rows[row_key].height
cell = self._get_row_renderables(row_index)[column_index] 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( lines = self.app.console.render_lines(
Padding(cell, (0, 1)), Padding(cell, (0, 1)),
self.app.console.options.update_dimensions(width, height), self.app.console.options.update_dimensions(width, height),
style=style, style=style,
) )
self._cell_render_cache[cell_cache_key] = lines self._cell_render_cache[cell_cache_key] = lines
return self._cell_render_cache[cell_cache_key] return self._cell_render_cache[cell_cache_key]
def _render_line_in_row( def _render_line_in_row(
@@ -1431,16 +1561,34 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
else: else:
return False return False
is_header_row = row_key is self._header_row_key
render_cell = self._render_cell
if row_key in self._row_locations: if row_key in self._row_locations:
row_index = self._row_locations.get(row_key) row_index = self._row_locations.get(row_key)
else: else:
row_index = -1 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: if self.fixed_columns:
fixed_style = self.get_component_styles("datatable--fixed").rich_style fixed_style = self.get_component_styles("datatable--fixed").rich_style
fixed_style += Style.from_meta({"fixed": True}) fixed_style += Style.from_meta({"fixed": True})
fixed_row = []
for column_index, column in enumerate( for column_index, column in enumerate(
self.ordered_columns[: self.fixed_columns] self.ordered_columns[: self.fixed_columns]
): ):
@@ -1448,7 +1596,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
fixed_cell_lines = render_cell( fixed_cell_lines = render_cell(
row_index, row_index,
column_index, column_index,
fixed_style, header_style if is_header_row else fixed_style,
column.render_width, column.render_width,
cursor=_should_highlight( cursor=_should_highlight(
cursor_location, cell_location, cursor_type cursor_location, cell_location, cursor_type
@@ -1456,12 +1604,12 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
hover=_should_highlight(hover_location, cell_location, cursor_type), hover=_should_highlight(hover_location, cell_location, cursor_type),
)[line_no] )[line_no]
fixed_row.append(fixed_cell_lines) fixed_row.append(fixed_cell_lines)
else:
fixed_row = []
is_header_row = row_key is self._header_row_key is_header_row = row_key is self._header_row_key
if is_header_row: if is_header_row:
row_style = self.get_component_styles("datatable--header").rich_style 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: else:
if self.zebra_stripes: if self.zebra_stripes:
component_row_style = ( component_row_style = (
@@ -1601,8 +1749,12 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
are rows and columns that do not participate in scrolling.""" are rows and columns that do not participate in scrolling."""
top = self.header_height if self.show_header else 0 top = self.header_height if self.show_header else 0
top += sum(row.height for row in self.ordered_rows[: self.fixed_rows]) top += sum(row.height for row in self.ordered_rows[: self.fixed_rows])
left = sum( left = (
column.render_width for column in self.ordered_columns[: self.fixed_columns] sum(
column.render_width
for column in self.ordered_columns[: self.fixed_columns]
)
+ self._row_label_column_width
) )
return Spacing(top, 0, 0, left) return Spacing(top, 0, 0, left)
@@ -1678,6 +1830,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
row_index = meta["row"] row_index = meta["row"]
column_index = meta["column"] column_index = meta["column"]
is_header_click = self.show_header and row_index == -1 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: if is_header_click:
# Header clicks work even if cursor is off, and doesn't move the cursor. # Header clicks work even if cursor is off, and doesn't move the cursor.
column = self.ordered_columns[column_index] column = self.ordered_columns[column_index]
@@ -1685,6 +1838,12 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
self, column.key, column_index, label=column.label self, column.key, column_index, label=column.label
) )
self.post_message_no_wait(message) 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": elif self.show_cursor and self.cursor_type != "none":
# Only post selection events if there is a visible row/col/cell cursor. # Only post selection events if there is a visible row/col/cell cursor.
self.cursor_coordinate = Coordinate(row_index, column_index) self.cursor_coordinate = Coordinate(row_index, column_index)

File diff suppressed because one or more lines are too long

View File

@@ -21,6 +21,7 @@ class TableApp(App):
table.focus() table.focus()
table.cursor_type = "column" table.cursor_type = "column"
table.fixed_columns = 1 table.fixed_columns = 1
table.fixed_rows = 1
yield table yield table
def on_mount(self) -> None: def on_mount(self) -> None:

View File

@@ -20,6 +20,8 @@ class TableApp(App):
table = DataTable() table = DataTable()
table.focus() table.focus()
table.cursor_type = "row" table.cursor_type = "row"
table.fixed_columns = 1
table.fixed_rows = 1
yield table yield table
def on_mount(self) -> None: def on_mount(self) -> None:

View File

@@ -0,0 +1,37 @@
from textual.app import App, ComposeResult
from textual.widgets import DataTable
ROWS = [
("lane", "swimmer", "country", "time"),
(5, "Chad le Clos", "South Africa", 51.14),
(4, "Joseph Schooling", "Singapore", 50.39),
(2, "Michael Phelps", "United States", 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),
(10, "Darren Burns", "Scotland", 51.84),
(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)
table.fixed_rows = 1
table.fixed_columns = 1
table.focus()
rows = iter(ROWS)
column_labels = next(rows)
for column in column_labels:
table.add_column(column, key=column)
for index, row in enumerate(rows):
table.add_row(*row, label=str(index))
app = TableApp()
if __name__ == "__main__":
app.run()

View File

@@ -108,6 +108,11 @@ def test_datatable_sort_multikey(snap_compare):
assert snap_compare(SNAPSHOT_APPS_DIR / "data_table_sort.py", press=press) assert snap_compare(SNAPSHOT_APPS_DIR / "data_table_sort.py", press=press)
def test_datatable_labels_and_fixed_data(snap_compare):
# Ensure that we render correctly when there are fixed rows/cols and labels.
assert snap_compare(SNAPSHOT_APPS_DIR / "data_table_row_labels.py")
def test_footer_render(snap_compare): def test_footer_render(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "footer.py") assert snap_compare(WIDGET_EXAMPLES_DIR / "footer.py")

View File

@@ -35,6 +35,7 @@ class DataTableApp(App):
"ColumnHighlighted", "ColumnHighlighted",
"ColumnSelected", "ColumnSelected",
"HeaderSelected", "HeaderSelected",
"RowLabelSelected",
} }
def __init__(self): def __init__(self):
@@ -75,12 +76,12 @@ async def test_datatable_message_emission():
# therefore no highlighted cells), but then a row was added, and # therefore no highlighted cells), but then a row was added, and
# so the cell at (0, 0) became highlighted. # so the cell at (0, 0) became highlighted.
expected_messages.append("CellHighlighted") expected_messages.append("CellHighlighted")
await wait_for_idle(0) await pilot.pause()
assert app.message_names == expected_messages assert app.message_names == expected_messages
# Pressing Enter when the cursor is on a cell emits a CellSelected # Pressing Enter when the cursor is on a cell emits a CellSelected
await pilot.press("enter") await pilot.press("enter")
await wait_for_idle(0) await pilot.pause()
expected_messages.append("CellSelected") expected_messages.append("CellSelected")
assert app.message_names == expected_messages assert app.message_names == expected_messages
@@ -93,12 +94,12 @@ async def test_datatable_message_emission():
# Switch over to the row cursor... should emit a `RowHighlighted` # Switch over to the row cursor... should emit a `RowHighlighted`
table.cursor_type = "row" table.cursor_type = "row"
expected_messages.append("RowHighlighted") expected_messages.append("RowHighlighted")
await wait_for_idle(0) await pilot.pause()
assert app.message_names == expected_messages assert app.message_names == expected_messages
# Select the row... # Select the row...
await pilot.press("enter") await pilot.press("enter")
await wait_for_idle(0) await pilot.pause()
expected_messages.append("RowSelected") expected_messages.append("RowSelected")
assert app.message_names == expected_messages assert app.message_names == expected_messages
@@ -106,20 +107,20 @@ async def test_datatable_message_emission():
# Switching to the column cursor emits a `ColumnHighlighted` # Switching to the column cursor emits a `ColumnHighlighted`
table.cursor_type = "column" table.cursor_type = "column"
expected_messages.append("ColumnHighlighted") expected_messages.append("ColumnHighlighted")
await wait_for_idle(0) await pilot.pause()
assert app.message_names == expected_messages assert app.message_names == expected_messages
# Select the column... # Select the column...
await pilot.press("enter") await pilot.press("enter")
expected_messages.append("ColumnSelected") expected_messages.append("ColumnSelected")
await wait_for_idle(0) await pilot.pause()
assert app.message_names == expected_messages assert app.message_names == expected_messages
# NONE CURSOR # NONE CURSOR
# No messages get emitted at all... # No messages get emitted at all...
table.cursor_type = "none" table.cursor_type = "none"
await pilot.press("up", "down", "left", "right", "enter") await pilot.press("up", "down", "left", "right", "enter")
await wait_for_idle(0) await pilot.pause()
# No new messages since cursor not visible # No new messages since cursor not visible
assert app.message_names == expected_messages assert app.message_names == expected_messages
@@ -129,7 +130,7 @@ async def test_datatable_message_emission():
table.show_cursor = False table.show_cursor = False
table.cursor_type = "cell" table.cursor_type = "cell"
await pilot.press("up", "down", "left", "right", "enter") await pilot.press("up", "down", "left", "right", "enter")
await wait_for_idle(0) await pilot.pause()
# No new messages since show_cursor = False # No new messages since show_cursor = False
assert app.message_names == expected_messages assert app.message_names == expected_messages
@@ -137,7 +138,7 @@ async def test_datatable_message_emission():
# message should be emitted for highlighting the cell. # message should be emitted for highlighting the cell.
table.show_cursor = True table.show_cursor = True
expected_messages.append("CellHighlighted") expected_messages.append("CellHighlighted")
await wait_for_idle(0) await pilot.pause()
assert app.message_names == expected_messages assert app.message_names == expected_messages
# Similarly for showing the cursor again when row or column # Similarly for showing the cursor again when row or column
@@ -146,14 +147,14 @@ async def test_datatable_message_emission():
table.cursor_type = "row" table.cursor_type = "row"
table.show_cursor = True table.show_cursor = True
expected_messages.append("RowHighlighted") expected_messages.append("RowHighlighted")
await wait_for_idle(0) await pilot.pause()
assert app.message_names == expected_messages assert app.message_names == expected_messages
table.show_cursor = False table.show_cursor = False
table.cursor_type = "column" table.cursor_type = "column"
table.show_cursor = True table.show_cursor = True
expected_messages.append("ColumnHighlighted") expected_messages.append("ColumnHighlighted")
await wait_for_idle(0) await pilot.pause()
assert app.message_names == expected_messages assert app.message_names == expected_messages
# Likewise, if the cursor_type is "none", and we change the # Likewise, if the cursor_type is "none", and we change the
@@ -161,7 +162,7 @@ async def test_datatable_message_emission():
# the cursor is still not visible to the user. # the cursor is still not visible to the user.
table.cursor_type = "none" table.cursor_type = "none"
await pilot.press("up", "down", "left", "right", "enter") await pilot.press("up", "down", "left", "right", "enter")
await wait_for_idle(0) await pilot.pause()
assert app.message_names == expected_messages assert app.message_names == expected_messages
@@ -574,14 +575,14 @@ async def test_datatable_on_click_cell_cursor():
*and* a CellSelected message for the cell that was clicked. *and* a CellSelected message for the cell that was clicked.
Regression test for https://github.com/Textualize/textual/issues/1723""" Regression test for https://github.com/Textualize/textual/issues/1723"""
app = DataTableApp() app = DataTableApp()
async with app.run_test(): async with app.run_test() as pilot:
table = app.query_one(DataTable) table = app.query_one(DataTable)
click = make_click_event(app) click = make_click_event(app)
column_key = table.add_column("ABC") column_key = table.add_column("ABC")
table.add_row("123") table.add_row("123")
row_key = table.add_row("456") row_key = table.add_row("456")
table.on_click(event=click) table.on_click(event=click)
await wait_for_idle(0) await pilot.pause()
# There's two CellHighlighted events since a cell is highlighted on initial load, # There's two CellHighlighted events since a cell is highlighted on initial load,
# then when we click, another cell is highlighted (and selected). # then when we click, another cell is highlighted (and selected).
assert app.message_names == [ assert app.message_names == [
@@ -688,9 +689,9 @@ async def test_header_selected():
"""Ensure that a HeaderSelected event gets posted when we click """Ensure that a HeaderSelected event gets posted when we click
on the header in the DataTable.""" on the header in the DataTable."""
app = DataTableApp() app = DataTableApp()
async with app.run_test(): async with app.run_test() as pilot:
table = app.query_one(DataTable) table = app.query_one(DataTable)
column = table.add_column("number") column_key = table.add_column("number")
table.add_row(3) table.add_row(3)
click_event = Click( click_event = Click(
sender=table, sender=table,
@@ -698,26 +699,62 @@ async def test_header_selected():
y=0, y=0,
delta_x=0, delta_x=0,
delta_y=0, delta_y=0,
button=0, button=1,
shift=False, shift=False,
meta=False, meta=False,
ctrl=False, ctrl=False,
) )
await pilot.pause()
table.on_click(click_event) table.on_click(click_event)
await wait_for_idle(0) await pilot.pause()
message: DataTable.HeaderSelected = app.messages[-1] message: DataTable.HeaderSelected = app.messages[-1]
assert message.sender is table assert message.sender is table
assert message.label == Text("number") assert message.label == Text("number")
assert message.column_index == 0 assert message.column_index == 0
assert message.column_key == column assert message.column_key == column_key
# Now hide the header and click in the exact same place - no additional message emitted. # Now hide the header and click in the exact same place - no additional message emitted.
table.show_header = False table.show_header = False
table.on_click(click_event) table.on_click(click_event)
await wait_for_idle(0) await pilot.pause()
assert app.message_names.count("HeaderSelected") == 1 assert app.message_names.count("HeaderSelected") == 1
async def test_row_label_selected():
"""Ensure that the DataTable sends a RowLabelSelected event when
the user clicks on a row label."""
app = DataTableApp()
async with app.run_test() as pilot:
table = app.query_one(DataTable)
table.add_column("number")
row_key = table.add_row(3, label="A")
click_event = Click(
sender=table,
x=1,
y=1,
delta_x=0,
delta_y=0,
button=1,
shift=False,
meta=False,
ctrl=False,
)
await pilot.pause()
table.on_click(click_event)
await pilot.pause()
message: DataTable.RowLabelSelected = app.messages[-1]
assert message.sender is table
assert message.label == Text("A")
assert message.row_index == 0
assert message.row_key == row_key
# Now hide the row label and click in the same place - no additional message emitted.
table.show_row_labels = False
table.on_click(click_event)
await pilot.pause()
assert app.message_names.count("RowLabelSelected") == 1
async def test_sort_coordinate_and_key_access(): async def test_sort_coordinate_and_key_access():
"""Ensure that, after sorting, that coordinates and cell keys """Ensure that, after sorting, that coordinates and cell keys
can still be used to retrieve the correct cell.""" can still be used to retrieve the correct cell."""
@@ -786,7 +823,7 @@ async def test_sort_reverse_coordinate_and_key_access():
async def test_cell_cursor_highlight_events(): async def test_cell_cursor_highlight_events():
app = DataTableApp() app = DataTableApp()
async with app.run_test(): async with app.run_test() as pilot:
table = app.query_one(DataTable) table = app.query_one(DataTable)
column_one_key, column_two_key = table.add_columns("A", "B") column_one_key, column_two_key = table.add_columns("A", "B")
_ = table.add_row(0, 1) _ = table.add_row(0, 1)
@@ -796,14 +833,14 @@ async def test_cell_cursor_highlight_events():
table.action_cursor_up() table.action_cursor_up()
table.action_cursor_left() table.action_cursor_left()
await wait_for_idle(0) await pilot.pause()
assert table.app.message_names == [ assert table.app.message_names == [
"CellHighlighted" "CellHighlighted"
] # Initial highlight on load ] # Initial highlight on load
# Move the cursor one cell down, and check the highlighted event posted # Move the cursor one cell down, and check the highlighted event posted
table.action_cursor_down() table.action_cursor_down()
await wait_for_idle(0) await pilot.pause()
assert len(table.app.messages) == 2 assert len(table.app.messages) == 2
latest_message: DataTable.CellHighlighted = table.app.messages[-1] latest_message: DataTable.CellHighlighted = table.app.messages[-1]
assert isinstance(latest_message, DataTable.CellHighlighted) assert isinstance(latest_message, DataTable.CellHighlighted)
@@ -813,7 +850,7 @@ async def test_cell_cursor_highlight_events():
# Now move the cursor to the right, and check highlighted event posted # Now move the cursor to the right, and check highlighted event posted
table.action_cursor_right() table.action_cursor_right()
await wait_for_idle(0) await pilot.pause()
assert len(table.app.messages) == 3 assert len(table.app.messages) == 3
latest_message = table.app.messages[-1] latest_message = table.app.messages[-1]
assert latest_message.coordinate == Coordinate(1, 1) assert latest_message.coordinate == Coordinate(1, 1)
@@ -822,7 +859,7 @@ async def test_cell_cursor_highlight_events():
async def test_row_cursor_highlight_events(): async def test_row_cursor_highlight_events():
app = DataTableApp() app = DataTableApp()
async with app.run_test(): async with app.run_test() as pilot:
table = app.query_one(DataTable) table = app.query_one(DataTable)
table.cursor_type = "row" table.cursor_type = "row"
table.add_columns("A", "B") table.add_columns("A", "B")
@@ -835,12 +872,12 @@ async def test_row_cursor_highlight_events():
table.action_cursor_left() table.action_cursor_left()
table.action_cursor_right() table.action_cursor_right()
await wait_for_idle(0) await pilot.pause()
assert table.app.message_names == ["RowHighlighted"] # Initial highlight assert table.app.message_names == ["RowHighlighted"] # Initial highlight
# Move the row cursor from row 0 to row 1, check the highlighted event posted # Move the row cursor from row 0 to row 1, check the highlighted event posted
table.action_cursor_down() table.action_cursor_down()
await wait_for_idle(0) await pilot.pause()
assert len(table.app.messages) == 2 assert len(table.app.messages) == 2
latest_message: DataTable.RowHighlighted = table.app.messages[-1] latest_message: DataTable.RowHighlighted = table.app.messages[-1]
assert isinstance(latest_message, DataTable.RowHighlighted) assert isinstance(latest_message, DataTable.RowHighlighted)
@@ -849,7 +886,7 @@ async def test_row_cursor_highlight_events():
# Move the row cursor back up to row 0, check the highlighted event posted # Move the row cursor back up to row 0, check the highlighted event posted
table.action_cursor_up() table.action_cursor_up()
await wait_for_idle(0) await pilot.pause()
assert len(table.app.messages) == 3 assert len(table.app.messages) == 3
latest_message = table.app.messages[-1] latest_message = table.app.messages[-1]
assert latest_message.row_key == row_one_key assert latest_message.row_key == row_one_key
@@ -858,7 +895,7 @@ async def test_row_cursor_highlight_events():
async def test_column_cursor_highlight_events(): async def test_column_cursor_highlight_events():
app = DataTableApp() app = DataTableApp()
async with app.run_test(): async with app.run_test() as pilot:
table = app.query_one(DataTable) table = app.query_one(DataTable)
table.cursor_type = "column" table.cursor_type = "column"
column_one_key, column_two_key = table.add_columns("A", "B") column_one_key, column_two_key = table.add_columns("A", "B")
@@ -871,13 +908,13 @@ async def test_column_cursor_highlight_events():
table.action_cursor_up() table.action_cursor_up()
table.action_cursor_down() table.action_cursor_down()
await wait_for_idle(0) await pilot.pause()
assert table.app.message_names == ["ColumnHighlighted"] # Initial highlight assert table.app.message_names == ["ColumnHighlighted"] # Initial highlight
# Move the column cursor from column 0 to column 1, # Move the column cursor from column 0 to column 1,
# check the highlighted event posted # check the highlighted event posted
table.action_cursor_right() table.action_cursor_right()
await wait_for_idle(0) await pilot.pause()
assert len(table.app.messages) == 2 assert len(table.app.messages) == 2
latest_message: DataTable.ColumnHighlighted = table.app.messages[-1] latest_message: DataTable.ColumnHighlighted = table.app.messages[-1]
assert isinstance(latest_message, DataTable.ColumnHighlighted) assert isinstance(latest_message, DataTable.ColumnHighlighted)
@@ -887,7 +924,7 @@ async def test_column_cursor_highlight_events():
# Move the column cursor left, back to column 0, # Move the column cursor left, back to column 0,
# check the highlighted event posted again. # check the highlighted event posted again.
table.action_cursor_left() table.action_cursor_left()
await wait_for_idle(0) await pilot.pause()
assert len(table.app.messages) == 3 assert len(table.app.messages) == 3
latest_message = table.app.messages[-1] latest_message = table.app.messages[-1]
assert latest_message.column_key == column_one_key assert latest_message.column_key == column_one_key