diff --git a/CHANGELOG.md b/CHANGELOG.md
index 650cc0371..e08383776 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added `DataTable.get_row_at` to retrieve the values from a row by index https://github.com/Textualize/textual/pull/1786
- Added `DataTable.get_column` to retrieve the values from a column by key https://github.com/Textualize/textual/pull/1786
- Added `DataTable.get_column_at` to retrieve the values from a column by index https://github.com/Textualize/textual/pull/1786
+- Added `DataTable.HeaderSelected` which is posted when header label clicked https://github.com/Textualize/textual/pull/1788
- Added `DOMNode.watch` and `DOMNode.is_attached` methods https://github.com/Textualize/textual/pull/1750
- Added `DOMNode.css_tree` which is a renderable that shows the DOM and CSS https://github.com/Textualize/textual/pull/1778
- Added `DOMNode.children_view` which is a view on to a nodes children list, use for querying https://github.com/Textualize/textual/pull/1778
diff --git a/docs/widgets/data_table.md b/docs/widgets/data_table.md
index 289636afc..7cc0a00d6 100644
--- a/docs/widgets/data_table.md
+++ b/docs/widgets/data_table.md
@@ -48,6 +48,8 @@ The example below populates a table with CSV data.
### ::: textual.widgets.DataTable.ColumnSelected
+### ::: textual.widgets.DataTable.HeaderSelected
+
## Bindings
The data table widget defines directly the following bindings:
diff --git a/src/textual/message.py b/src/textual/message.py
index 5f46cc97a..7ed72789b 100644
--- a/src/textual/message.py
+++ b/src/textual/message.py
@@ -10,7 +10,6 @@ from .case import camel_to_snake
if TYPE_CHECKING:
from .message_pump import MessagePump
- from .widget import Widget
@rich.repr.auto
diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py
index fe80b409d..b9e48d326 100644
--- a/src/textual/widgets/_data_table.py
+++ b/src/textual/widgets/_data_table.py
@@ -438,6 +438,29 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
yield "cursor_column", self.cursor_column
yield "column_key", self.column_key
+ class HeaderSelected(Message, bubble=True):
+ """Posted when a column header/label is clicked."""
+
+ def __init__(
+ self,
+ sender: DataTable,
+ column_key: ColumnKey,
+ column_index: int,
+ label: Text,
+ ):
+ self.column_key = column_key
+ """The key for the column."""
+ self.column_index = column_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 "column_key", self.column_key
+ yield "label", self.label.plain
+
def __init__(
self,
*,
@@ -1327,8 +1350,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
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:
- if not is_header_row:
- 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
cell = self._get_row_renderables(row_index)[column_index]
lines = self.app.console.render_lines(
@@ -1643,14 +1665,26 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
def on_click(self, event: events.Click) -> None:
self._set_hover_cursor(True)
- if self.show_cursor and self.cursor_type != "none":
+ meta = self.get_style_at(event.x, event.y).meta
+ if not meta:
+ return
+
+ row_index = meta["row"]
+ column_index = meta["column"]
+ is_header_click = self.show_header and row_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]
+ message = DataTable.HeaderSelected(
+ self, column.key, column_index, label=column.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.
- meta = self.get_style_at(event.x, event.y).meta
- if meta:
- self.cursor_coordinate = Coordinate(meta["row"], meta["column"])
- self._post_selected_message()
- self._scroll_cursor_into_view(animate=True)
- event.stop()
+ self.cursor_coordinate = Coordinate(row_index, column_index)
+ self._post_selected_message()
+ self._scroll_cursor_into_view(animate=True)
+ event.stop()
def action_cursor_up(self) -> None:
self._set_hover_cursor(False)
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index 075a214a7..8fc2f8904 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -10015,134 +10015,134 @@
font-weight: 700;
}
- .terminal-3633944034-matrix {
+ .terminal-3966238525-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-3633944034-title {
+ .terminal-3966238525-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-3633944034-r1 { fill: #dde6ed;font-weight: bold }
- .terminal-3633944034-r2 { fill: #1e1201;font-weight: bold }
- .terminal-3633944034-r3 { fill: #e1e1e1 }
- .terminal-3633944034-r4 { fill: #c5c8c6 }
- .terminal-3633944034-r5 { fill: #211505 }
+ .terminal-3966238525-r1 { fill: #dde6ed;font-weight: bold }
+ .terminal-3966238525-r2 { fill: #1e1201;font-weight: bold }
+ .terminal-3966238525-r3 { fill: #e1e1e1 }
+ .terminal-3966238525-r4 { fill: #c5c8c6 }
+ .terminal-3966238525-r5 { 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -10173,133 +10173,133 @@
font-weight: 700;
}
- .terminal-108526495-matrix {
+ .terminal-1288566407-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-108526495-title {
+ .terminal-1288566407-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-108526495-r1 { fill: #dde6ed;font-weight: bold }
- .terminal-108526495-r2 { fill: #e1e1e1 }
- .terminal-108526495-r3 { fill: #c5c8c6 }
- .terminal-108526495-r4 { fill: #211505 }
+ .terminal-1288566407-r1 { fill: #dde6ed;font-weight: bold }
+ .terminal-1288566407-r2 { fill: #e1e1e1 }
+ .terminal-1288566407-r3 { fill: #c5c8c6 }
+ .terminal-1288566407-r4 { 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
- 10 Darren Burns Scotland 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
+ 10 Darren Burns Scotland 51.84
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -10330,133 +10330,133 @@
font-weight: 700;
}
- .terminal-512278738-matrix {
+ .terminal-3001793466-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-512278738-title {
+ .terminal-3001793466-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-512278738-r1 { fill: #dde6ed;font-weight: bold }
- .terminal-512278738-r2 { fill: #e1e1e1 }
- .terminal-512278738-r3 { fill: #c5c8c6 }
- .terminal-512278738-r4 { fill: #211505 }
+ .terminal-3001793466-r1 { fill: #dde6ed;font-weight: bold }
+ .terminal-3001793466-r2 { fill: #e1e1e1 }
+ .terminal-3001793466-r3 { fill: #c5c8c6 }
+ .terminal-3001793466-r4 { 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -10487,133 +10487,133 @@
font-weight: 700;
}
- .terminal-480181151-matrix {
+ .terminal-1660221063-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-480181151-title {
+ .terminal-1660221063-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-480181151-r1 { fill: #dde6ed;font-weight: bold }
- .terminal-480181151-r2 { fill: #e1e1e1 }
- .terminal-480181151-r3 { fill: #c5c8c6 }
- .terminal-480181151-r4 { fill: #211505 }
+ .terminal-1660221063-r1 { fill: #dde6ed;font-weight: bold }
+ .terminal-1660221063-r2 { fill: #e1e1e1 }
+ .terminal-1660221063-r3 { fill: #c5c8c6 }
+ .terminal-1660221063-r4 { 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
- 10 Darren Burns Scotland 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
+ 10 Darren Burns Scotland 51.84
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/test_data_table.py b/tests/test_data_table.py
index 5dc6abaf2..2a000332d 100644
--- a/tests/test_data_table.py
+++ b/tests/test_data_table.py
@@ -2,6 +2,7 @@ from __future__ import annotations
import pytest
from rich.style import Style
+from rich.text import Text
from textual._wait import wait_for_idle
from textual.actions import SkipAction
@@ -33,6 +34,7 @@ class DataTableApp(App):
"RowSelected",
"ColumnHighlighted",
"ColumnSelected",
+ "HeaderSelected",
}
def __init__(self):
@@ -73,11 +75,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 pilot.pause(2 / 100)
+ await wait_for_idle(0)
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)
expected_messages.append("CellSelected")
assert app.message_names == expected_messages
@@ -90,11 +93,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 pilot.pause(2 / 100)
+ await wait_for_idle(0)
assert app.message_names == expected_messages
# Select the row...
await pilot.press("enter")
+ await wait_for_idle(0)
expected_messages.append("RowSelected")
assert app.message_names == expected_messages
@@ -102,18 +106,20 @@ async def test_datatable_message_emission():
# Switching to the column cursor emits a `ColumnHighlighted`
table.cursor_type = "column"
expected_messages.append("ColumnHighlighted")
- await pilot.pause(2 / 100)
+ await wait_for_idle(0)
assert app.message_names == expected_messages
# Select the column...
await pilot.press("enter")
expected_messages.append("ColumnSelected")
+ await wait_for_idle(0)
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)
# No new messages since cursor not visible
assert app.message_names == expected_messages
@@ -123,6 +129,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)
# No new messages since show_cursor = False
assert app.message_names == expected_messages
@@ -130,7 +137,7 @@ async def test_datatable_message_emission():
# message should be emitted for highlighting the cell.
table.show_cursor = True
expected_messages.append("CellHighlighted")
- await pilot.pause(2 / 100)
+ await wait_for_idle(0)
assert app.message_names == expected_messages
# Similarly for showing the cursor again when row or column
@@ -139,14 +146,14 @@ async def test_datatable_message_emission():
table.cursor_type = "row"
table.show_cursor = True
expected_messages.append("RowHighlighted")
- await pilot.pause(2 / 100)
+ await wait_for_idle(0)
assert app.message_names == expected_messages
table.show_cursor = False
table.cursor_type = "column"
table.show_cursor = True
expected_messages.append("ColumnHighlighted")
- await pilot.pause(2 / 100)
+ await wait_for_idle(0)
assert app.message_names == expected_messages
# Likewise, if the cursor_type is "none", and we change the
@@ -154,6 +161,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)
assert app.message_names == expected_messages
@@ -673,6 +681,40 @@ async def test_hover_coordinate():
assert table.hover_coordinate == Coordinate(1, 2)
+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():
+ table = app.query_one(DataTable)
+ column = table.add_column("number")
+ table.add_row(3)
+ click_event = Click(
+ sender=table,
+ x=3,
+ y=0,
+ delta_x=0,
+ delta_y=0,
+ button=0,
+ shift=False,
+ meta=False,
+ ctrl=False,
+ )
+ table.on_click(click_event)
+ await wait_for_idle(0)
+ 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
+
+ # 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)
+ assert app.message_names.count("HeaderSelected") == 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."""