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 + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TableApp + + + + + + + + + +  lane  swimmer               country        time   +  0  5     Chad le Clos          South Africa   51.14  +  1  4     Joseph Schooling      Singapore      50.39  +  2  2     Michael Phelps        United States  51.14  +  3  6     László Cseh           Hungary        51.14  +  4  3     Li Zhuhao             China          51.26  +  5  8     Mehdy Metella         France         51.58  +  6  7     Tom Shields           United States  51.73  +  7  10    Darren Burns          Scotland       51.84  +  8  1     Aleksandr Sadovnikov  Russia         51.84  + + + + + + + + + + + + + @@ -10330,133 +10490,135 @@ font-weight: 700; } - .terminal-3001793466-matrix { + .terminal-2548097554-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3001793466-title { + .terminal-2548097554-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3001793466-r1 { fill: #dde6ed;font-weight: bold } - .terminal-3001793466-r2 { fill: #e1e1e1 } - .terminal-3001793466-r3 { fill: #c5c8c6 } - .terminal-3001793466-r4 { fill: #211505 } + .terminal-2548097554-r1 { fill: #dde6ed;font-weight: bold } + .terminal-2548097554-r2 { fill: #e1e1e1 } + .terminal-2548097554-r3 { fill: #c5c8c6 } + .terminal-2548097554-r4 { fill: #dfe4e7 } + .terminal-2548097554-r5 { fill: #1e1405 } + .terminal-2548097554-r6 { 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  + + + + + + + + + + + + + + diff --git a/tests/snapshot_tests/snapshot_apps/data_table_column_cursor.py b/tests/snapshot_tests/snapshot_apps/data_table_column_cursor.py index 06d32707e4..2075172b3c 100644 --- a/tests/snapshot_tests/snapshot_apps/data_table_column_cursor.py +++ b/tests/snapshot_tests/snapshot_apps/data_table_column_cursor.py @@ -21,6 +21,7 @@ def compose(self) -> ComposeResult: table.focus() table.cursor_type = "column" table.fixed_columns = 1 + table.fixed_rows = 1 yield table def on_mount(self) -> None: diff --git a/tests/snapshot_tests/snapshot_apps/data_table_row_cursor.py b/tests/snapshot_tests/snapshot_apps/data_table_row_cursor.py index ba58d9afc3..122c7993de 100644 --- a/tests/snapshot_tests/snapshot_apps/data_table_row_cursor.py +++ b/tests/snapshot_tests/snapshot_apps/data_table_row_cursor.py @@ -20,6 +20,8 @@ def compose(self) -> ComposeResult: table = DataTable() table.focus() table.cursor_type = "row" + table.fixed_columns = 1 + table.fixed_rows = 1 yield table def on_mount(self) -> None: diff --git a/tests/snapshot_tests/snapshot_apps/data_table_row_labels.py b/tests/snapshot_tests/snapshot_apps/data_table_row_labels.py new file mode 100644 index 0000000000..0cf6a67298 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/data_table_row_labels.py @@ -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() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 05d48a5fea..6f9bc78528 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -108,6 +108,11 @@ def test_datatable_sort_multikey(snap_compare): 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): assert snap_compare(WIDGET_EXAMPLES_DIR / "footer.py") diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 5ef92e47bf..5121410b4b 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -35,6 +35,7 @@ class DataTableApp(App): "ColumnHighlighted", "ColumnSelected", "HeaderSelected", + "RowLabelSelected", } def __init__(self): @@ -75,12 +76,12 @@ async def test_datatable_message_emission(): # therefore no highlighted cells), but then a row was added, and # so the cell at (0, 0) became highlighted. expected_messages.append("CellHighlighted") - await wait_for_idle(0) + await pilot.pause() assert app.message_names == expected_messages # Pressing Enter when the cursor is on a cell emits a CellSelected await pilot.press("enter") - await wait_for_idle(0) + await pilot.pause() expected_messages.append("CellSelected") 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` table.cursor_type = "row" expected_messages.append("RowHighlighted") - await wait_for_idle(0) + await pilot.pause() assert app.message_names == expected_messages # Select the row... await pilot.press("enter") - await wait_for_idle(0) + await pilot.pause() expected_messages.append("RowSelected") assert app.message_names == expected_messages @@ -106,20 +107,20 @@ async def test_datatable_message_emission(): # Switching to the column cursor emits a `ColumnHighlighted` table.cursor_type = "column" expected_messages.append("ColumnHighlighted") - await wait_for_idle(0) + await pilot.pause() assert app.message_names == expected_messages # Select the column... await pilot.press("enter") expected_messages.append("ColumnSelected") - await wait_for_idle(0) + await pilot.pause() assert app.message_names == expected_messages # NONE CURSOR # No messages get emitted at all... table.cursor_type = "none" await pilot.press("up", "down", "left", "right", "enter") - await wait_for_idle(0) + await pilot.pause() # No new messages since cursor not visible assert app.message_names == expected_messages @@ -129,7 +130,7 @@ async def test_datatable_message_emission(): table.show_cursor = False table.cursor_type = "cell" await pilot.press("up", "down", "left", "right", "enter") - await wait_for_idle(0) + await pilot.pause() # No new messages since show_cursor = False assert app.message_names == expected_messages @@ -137,7 +138,7 @@ async def test_datatable_message_emission(): # message should be emitted for highlighting the cell. table.show_cursor = True expected_messages.append("CellHighlighted") - await wait_for_idle(0) + await pilot.pause() assert app.message_names == expected_messages # 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.show_cursor = True expected_messages.append("RowHighlighted") - await wait_for_idle(0) + await pilot.pause() assert app.message_names == expected_messages table.show_cursor = False table.cursor_type = "column" table.show_cursor = True expected_messages.append("ColumnHighlighted") - await wait_for_idle(0) + await pilot.pause() assert app.message_names == expected_messages # 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. table.cursor_type = "none" await pilot.press("up", "down", "left", "right", "enter") - await wait_for_idle(0) + await pilot.pause() 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. Regression test for https://github.com/Textualize/textual/issues/1723""" app = DataTableApp() - async with app.run_test(): + async with app.run_test() as pilot: table = app.query_one(DataTable) click = make_click_event(app) column_key = table.add_column("ABC") table.add_row("123") row_key = table.add_row("456") 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, # then when we click, another cell is highlighted (and selected). assert app.message_names == [ @@ -688,9 +689,9 @@ async def test_header_selected(): """Ensure that a HeaderSelected event gets posted when we click on the header in the DataTable.""" app = DataTableApp() - async with app.run_test(): + async with app.run_test() as pilot: table = app.query_one(DataTable) - column = table.add_column("number") + column_key = table.add_column("number") table.add_row(3) click_event = Click( sender=table, @@ -698,26 +699,62 @@ async def test_header_selected(): y=0, delta_x=0, delta_y=0, - button=0, + button=1, shift=False, meta=False, ctrl=False, ) + await pilot.pause() table.on_click(click_event) - await wait_for_idle(0) + await pilot.pause() message: DataTable.HeaderSelected = app.messages[-1] assert message.sender is table assert message.label == Text("number") 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. table.show_header = False table.on_click(click_event) - await wait_for_idle(0) + await pilot.pause() 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(): """Ensure that, after sorting, that coordinates and cell keys 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(): app = DataTableApp() - async with app.run_test(): + async with app.run_test() as pilot: table = app.query_one(DataTable) column_one_key, column_two_key = table.add_columns("A", "B") _ = table.add_row(0, 1) @@ -796,14 +833,14 @@ async def test_cell_cursor_highlight_events(): table.action_cursor_up() table.action_cursor_left() - await wait_for_idle(0) + await pilot.pause() assert table.app.message_names == [ "CellHighlighted" ] # Initial highlight on load # Move the cursor one cell down, and check the highlighted event posted table.action_cursor_down() - await wait_for_idle(0) + await pilot.pause() assert len(table.app.messages) == 2 latest_message: DataTable.CellHighlighted = table.app.messages[-1] 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 table.action_cursor_right() - await wait_for_idle(0) + await pilot.pause() assert len(table.app.messages) == 3 latest_message = table.app.messages[-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(): app = DataTableApp() - async with app.run_test(): + async with app.run_test() as pilot: table = app.query_one(DataTable) table.cursor_type = "row" table.add_columns("A", "B") @@ -835,12 +872,12 @@ async def test_row_cursor_highlight_events(): table.action_cursor_left() table.action_cursor_right() - await wait_for_idle(0) + await pilot.pause() assert table.app.message_names == ["RowHighlighted"] # Initial highlight # Move the row cursor from row 0 to row 1, check the highlighted event posted table.action_cursor_down() - await wait_for_idle(0) + await pilot.pause() assert len(table.app.messages) == 2 latest_message: DataTable.RowHighlighted = table.app.messages[-1] 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 table.action_cursor_up() - await wait_for_idle(0) + await pilot.pause() assert len(table.app.messages) == 3 latest_message = table.app.messages[-1] 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(): app = DataTableApp() - async with app.run_test(): + async with app.run_test() as pilot: table = app.query_one(DataTable) table.cursor_type = "column" 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_down() - await wait_for_idle(0) + await pilot.pause() assert table.app.message_names == ["ColumnHighlighted"] # Initial highlight # Move the column cursor from column 0 to column 1, # check the highlighted event posted table.action_cursor_right() - await wait_for_idle(0) + await pilot.pause() assert len(table.app.messages) == 2 latest_message: DataTable.ColumnHighlighted = table.app.messages[-1] 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, # check the highlighted event posted again. table.action_cursor_left() - await wait_for_idle(0) + await pilot.pause() assert len(table.app.messages) == 3 latest_message = table.app.messages[-1] assert latest_message.column_key == column_one_key