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]