diff --git a/CHANGELOG.md b/CHANGELOG.md index 19d46a7..3b35905 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. 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 410435b..e9f389e 100644 --- a/tests/test_data_widgets.py +++ b/tests/test_data_widgets.py @@ -148,6 +148,74 @@ 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 +@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}, + ]) + await pilot.pause() + 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 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..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,13 +205,19 @@ 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 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: - 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 @@ -230,13 +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: - self._suppress_header_click = False - event.stop() - return - await super()._on_click(event) - def _render_cell(self, *args, **kwargs): lines = super()._render_cell(*args, **kwargs) row_index = args[0]