From 10721c3744e5fdea693e68b55a72bc4ce5fbfa9a Mon Sep 17 00:00:00 2001
From: thunderballfists <130205658+thunderballfists@users.noreply.github.com>
Date: Wed, 30 Sep 2026 16:27:24 -0500
Subject: [PATCH 1/2] Sort a data-table column once per heading click
---
CHANGELOG.md | 1 +
tests/test_data_widgets.py | 60 ++++++++++++++++++++++++++++++++++
textui/widgets/data_widgets.py | 10 ++++--
3 files changed, 68 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 19d46a7..0e1afed 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ All notable changes to TextUI are documented here.
### Fixed
+- Clicking a data-table column heading now sorts once per click. The table's click and mouse-move handlers also called `super()`, which Textual had already run, so every heading click was delivered twice and the ascending/descending toggle always ended on descending. The click that ends a column-resize press no longer sorts either.
- Controller resize hooks now receive terminal dimensions even when the active screen has padding or a border.
- Declared autofocus now waits for hidden ancestors to be displayed and runs again when controls are revealed after startup. Disabled controls and background screens do not take focus; anonymous modal autofocus registrations are released on dismissal.
- Transcript mouse selections are cleared when bounded history evicts rows, the log is cleared, or a streamed entry is rewritten. Stable append operations and selections in other widgets are preserved.
diff --git a/tests/test_data_widgets.py b/tests/test_data_widgets.py
index 410435b..e6edc74 100644
--- a/tests/test_data_widgets.py
+++ b/tests/test_data_widgets.py
@@ -148,6 +148,66 @@ async def test_resizable_table_supports_keyboard_and_drag_without_sorting():
assert [key.value for key in table.rows] == ["zulu", "alpha"]
+@pytest.mark.asyncio
+async def test_clicking_a_header_sorts_ascending_then_descending_once_per_click():
+ """A click reaches the header handler once. Textual runs the ``_on_click`` of every
+ class in the MRO, so a subclass handler that also calls ``super()`` delivers the
+ message twice, and a two-step ascending/descending toggle always lands on descending."""
+ app = TextUI(DocumentLoader().from_string('''
+ NameRequests
+ '''))
+ async with app.run_test(size=(40, 10)) as pilot:
+ table = app.document.get_by_id("usage")
+ table.set_rows([
+ {"id": "high", "name": "High", "requests": 42},
+ {"id": "low", "name": "Low", "requests": 7},
+ {"id": "middle", "name": "Middle", "requests": 12},
+ ])
+ selected = []
+ table.post_message = lambda message, _post=table.post_message: (
+ selected.append(message.column_index) if isinstance(message, DataTable.HeaderSelected) else None,
+ _post(message),
+ )[1]
+
+ await pilot.click(table, offset=(13, 0)) # inside the "Requests" heading
+ await pilot.pause()
+ assert selected == [1], "one click, one message"
+ assert [key.value for key in table.rows] == ["low", "middle", "high"]
+ assert table.columns["requests"].label.plain == "Requests ↑"
+
+ await pilot.click(table, offset=(13, 0))
+ await pilot.pause()
+ assert [key.value for key in table.rows] == ["high", "middle", "low"]
+ assert table.columns["requests"].label.plain == "Requests ↓"
+
+ await pilot.click(table, offset=(13, 0))
+ await pilot.pause()
+ assert [key.value for key in table.rows] == ["low", "middle", "high"]
+
+
+@pytest.mark.asyncio
+async def test_the_click_that_ends_a_resize_press_does_not_sort():
+ app = TextUI(DocumentLoader().from_string('''
+ NameRequests
+ '''))
+ async with app.run_test(size=(40, 8)) as pilot:
+ table = app.document.get_by_id("usage")
+ table.set_rows([
+ {"id": "zulu", "name": "Zulu", "requests": 1},
+ {"id": "alpha", "name": "Alpha", "requests": 2},
+ ])
+ table._suppress_header_click = True # what pressing a column divider does
+
+ await pilot.click(table, offset=(3, 0)) # inside the "Name" heading, away from its edge
+ await pilot.pause()
+ assert [key.value for key in table.rows] == ["zulu", "alpha"]
+ assert table.columns["name"].label.plain == "Name"
+
+ await pilot.click(table, offset=(3, 0)) # the next, ordinary click sorts
+ await pilot.pause()
+ assert [key.value for key in table.rows] == ["alpha", "zulu"]
+
+
@pytest.mark.asyncio
async def test_resizing_a_narrow_fixed_width_column_never_enlarges_it():
app = TextUI(DocumentLoader().from_string('''
diff --git a/textui/widgets/data_widgets.py b/textui/widgets/data_widgets.py
index cced2a2..c285d7d 100644
--- a/textui/widgets/data_widgets.py
+++ b/textui/widgets/data_widgets.py
@@ -210,9 +210,12 @@ def on_mouse_down(self, event: MouseDown) -> None:
self.capture_mouse()
event.stop()
+ # Textual calls the ``_on_*`` handler of every class in the MRO, so the two
+ # handlers below must not also call ``super()``: that runs DataTable's own a
+ # second time (a header click would post two ``HeaderSelected`` messages, and
+ # the ascending/descending toggle would always end on descending).
def _on_mouse_move(self, event: events.MouseMove) -> None:
if self._resize_drag is None:
- super()._on_mouse_move(event)
self._set_resize_hover(self._resize_edge_at(event) if self.column_borders else None)
return
column_index, width, start_x = self._resize_drag
@@ -232,10 +235,11 @@ def on_mouse_up(self, event: MouseUp) -> None:
async def _on_click(self, event: events.Click) -> None:
if self._suppress_header_click:
+ # The click that ends a resize press is not a header selection.
+ # ``prevent_default`` is what keeps DataTable's handler from running.
self._suppress_header_click = False
+ event.prevent_default()
event.stop()
- return
- await super()._on_click(event)
def _render_cell(self, *args, **kwargs):
lines = super()._render_cell(*args, **kwargs)
From 785567b1a328519cbbde78450e021d6f71d70ffb Mon Sep 17 00:00:00 2001
From: thunderballfists <130205658+thunderballfists@users.noreply.github.com>
Date: Wed, 30 Sep 2026 16:38:20 -0500
Subject: [PATCH 2/2] Suppress the click after a column divider press with
Textual's own hook
---
CHANGELOG.md | 2 +-
tests/test_data_widgets.py | 26 +++++++++++++++++---------
textui/widgets/data_widgets.py | 22 ++++++++--------------
3 files changed, 26 insertions(+), 24 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0e1afed..3b35905 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,7 +6,7 @@ All notable changes to TextUI are documented here.
### Fixed
-- Clicking a data-table column heading now sorts once per click. The table's click and mouse-move handlers also called `super()`, which Textual had already run, so every heading click was delivered twice and the ascending/descending toggle always ended on descending. The click that ends a column-resize press no longer sorts either.
+- Clicking a data-table column heading now sorts once per click. The table's click and mouse-move handlers also called `super()`, which Textual had already run, so every heading click was delivered twice and the ascending/descending toggle always ended on descending. A column-resize press no longer sorts either, and no longer swallows the next heading click when the drag is released.
- Controller resize hooks now receive terminal dimensions even when the active screen has padding or a border.
- Declared autofocus now waits for hidden ancestors to be displayed and runs again when controls are revealed after startup. Disabled controls and background screens do not take focus; anonymous modal autofocus registrations are released on dismissal.
- Transcript mouse selections are cleared when bounded history evicts rows, the log is cleared, or a streamed entry is rewritten. Stable append operations and selections in other widgets are preserved.
diff --git a/tests/test_data_widgets.py b/tests/test_data_widgets.py
index e6edc74..e9f389e 100644
--- a/tests/test_data_widgets.py
+++ b/tests/test_data_widgets.py
@@ -186,26 +186,34 @@ class in the MRO, so a subclass handler that also calls ``super()`` delivers the
@pytest.mark.asyncio
-async def test_the_click_that_ends_a_resize_press_does_not_sort():
- app = TextUI(DocumentLoader().from_string('''
- NameRequests
- '''))
- async with app.run_test(size=(40, 8)) as pilot:
+@pytest.mark.parametrize("release", ["on the table", "outside the table"])
+async def test_releasing_a_divider_drag_does_not_sort_and_leaves_the_next_click_alone(release):
+ app = TextUI(DocumentLoader().from_string('''
+
+ NameRequests
+ '''))
+ async with app.run_test(size=(40, 10)) as pilot:
table = app.document.get_by_id("usage")
table.set_rows([
{"id": "zulu", "name": "Zulu", "requests": 1},
{"id": "alpha", "name": "Alpha", "requests": 2},
])
- table._suppress_header_click = True # what pressing a column divider does
-
- await pilot.click(table, offset=(3, 0)) # inside the "Name" heading, away from its edge
await pilot.pause()
- assert [key.value for key in table.rows] == ["zulu", "alpha"]
+ edge = table.columns["name"].get_render_width(table) - 1
+ await pilot.mouse_down(table, offset=(edge, 0))
+ await pilot.hover(table, offset=(edge + 2, 0))
+ if release == "on the table":
+ await pilot.mouse_up(table, offset=(edge + 2, 0))
+ else:
+ await pilot.mouse_up(app.document.get_by_id("below"), offset=(1, 0))
+ await pilot.pause()
+ assert [key.value for key in table.rows] == ["zulu", "alpha"], "a divider drag never sorts"
assert table.columns["name"].label.plain == "Name"
await pilot.click(table, offset=(3, 0)) # the next, ordinary click sorts
await pilot.pause()
assert [key.value for key in table.rows] == ["alpha", "zulu"]
+ assert table.columns["name"].label.plain == "Name ↑"
@pytest.mark.asyncio
diff --git a/textui/widgets/data_widgets.py b/textui/widgets/data_widgets.py
index c285d7d..09229cd 100644
--- a/textui/widgets/data_widgets.py
+++ b/textui/widgets/data_widgets.py
@@ -118,7 +118,6 @@ def __init__(
self._seeded = False
self._resize_drag: tuple[int, int, int] | None = None
self._hover_resize_column: int | None = None
- self._suppress_header_click = False
def on_mount(self) -> None:
if self._seeded:
@@ -206,14 +205,17 @@ def on_mouse_down(self, event: MouseDown) -> None:
column = self.ordered_columns[column_index]
width = column.content_width if column.auto_width else column.width
self._resize_drag = (column_index, width, event.x)
- self._suppress_header_click = True
+ # Pressing a divider is not a heading click. Textual then sends no Click after the
+ # release, whether it lands on the table or outside it (where none would be sent
+ # anyway, so a flag cleared by the Click would stay set and swallow the next real one).
+ self.suppress_click()
self.capture_mouse()
event.stop()
- # Textual calls the ``_on_*`` handler of every class in the MRO, so the two
- # handlers below must not also call ``super()``: that runs DataTable's own a
- # second time (a header click would post two ``HeaderSelected`` messages, and
- # the ascending/descending toggle would always end on descending).
+ # Textual calls the ``_on_*`` handler of every class in the MRO, so a handler here
+ # must not also call ``super()``: that runs DataTable's own a second time (a header
+ # click would post two ``HeaderSelected`` messages, and the ascending/descending
+ # toggle would always end on descending).
def _on_mouse_move(self, event: events.MouseMove) -> None:
if self._resize_drag is None:
self._set_resize_hover(self._resize_edge_at(event) if self.column_borders else None)
@@ -233,14 +235,6 @@ def on_mouse_up(self, event: MouseUp) -> None:
self._set_resize_hover(None)
event.stop()
- async def _on_click(self, event: events.Click) -> None:
- if self._suppress_header_click:
- # The click that ends a resize press is not a header selection.
- # ``prevent_default`` is what keeps DataTable's handler from running.
- self._suppress_header_click = False
- event.prevent_default()
- event.stop()
-
def _render_cell(self, *args, **kwargs):
lines = super()._render_cell(*args, **kwargs)
row_index = args[0]