mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into toggle-boxen
This commit is contained in:
@@ -31,6 +31,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
|
||||
@@ -38,6 +40,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
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ RowCacheKey: TypeAlias = (
|
||||
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 @@ class Column:
|
||||
"""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 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
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 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
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 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
"""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 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
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 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
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 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
|
||||
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 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
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 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
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 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
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 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
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 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
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 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
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 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
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 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
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
|
||||
|
||||
# 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
|
||||
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)
|
||||
)
|
||||
|
||||
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 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
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 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
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 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
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 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
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 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
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 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
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)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -21,6 +21,7 @@ class TableApp(App):
|
||||
table.focus()
|
||||
table.cursor_type = "column"
|
||||
table.fixed_columns = 1
|
||||
table.fixed_rows = 1
|
||||
yield table
|
||||
|
||||
def on_mount(self) -> None:
|
||||
|
||||
@@ -20,6 +20,8 @@ class TableApp(App):
|
||||
table = DataTable()
|
||||
table.focus()
|
||||
table.cursor_type = "row"
|
||||
table.fixed_columns = 1
|
||||
table.fixed_rows = 1
|
||||
yield table
|
||||
|
||||
def on_mount(self) -> None:
|
||||
|
||||
37
tests/snapshot_tests/snapshot_apps/data_table_row_labels.py
Normal file
37
tests/snapshot_tests/snapshot_apps/data_table_row_labels.py
Normal 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()
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user