Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
68 changes: 68 additions & 0 deletions tests/test_data_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('''<ui><data-table id="usage" row-key="id">
<column key="name" width="10">Name</column><column key="requests" width="10">Requests</column>
</data-table></ui>'''))
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('''<ui><vertical>
<data-table id="usage" resizable="true" row-key="id">
<column key="name" width="8">Name</column><column key="requests" width="8">Requests</column>
</data-table><label id="below">below</label></vertical></ui>'''))
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('''<ui><data-table id="usage" resizable="true">
Expand Down
18 changes: 8 additions & 10 deletions textui/widgets/data_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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]
Expand Down
Loading