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
15 changes: 15 additions & 0 deletions evidence/captured-pointer-motion-20261007/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Captured absolute motion

The original App waits for each routed pointer packet's DOM completion before reading the next packet. That ordering is necessary for press capture, release and Click ownership. It also made an absolute-position resize control replay every queued old width after the pointer had stopped. Dropping all MouseMoves would break drawing, selection and other ordered consumers.

The original MessagePump queue now asks its receiver `_can_replace_message`. Default behavior delegates the existing Message predicate. App admits only consecutive exact raw MouseMove packets from its original driver, with unchanged button/modifiers/prevention, no routed recipient, and no completion owner. It borrows current capture after the prior delivery completes and asks `Widget.can_replace_mouse_move(event, pending)`. Default False preserves FIFO. Parent owns the ResizeHandle override, whose primary drag depends on the absolute screen position and original starting width. No new queue, timer, motion state or global dropping.

Press/release, wheel, key, completion-owned packets, modified gesture inputs, subclasses and forwarded messages are barriers. The original capture resource and App/Screen routing acquire the actual final geometry; native selection, hit, frame/held-root, Click and cleanup owners are unchanged. Receiver permission explicitly includes intermediate positions/deltas/callbacks: arbitrary controls must not opt in merely because their final coordinates happen to match.

The original refactor-audit Package parsed 250 production modules without omissions. Original queue combination has one caller; Driver admission, App capture, Message completion/bubbling and Widget capture/release source were read before changes. No other replacement/capture writer is migrated or bypassed.

Fifteen actual source Driver/Pilot/App checks passed in 1.87s. New controls verify default ordered motion, opted latest absolute motion across modifier and wheel barriers, uncaptured motion after release, final pointer/capture state, and every completion-owned packet. Existing checks cover delayed and bubbled press capture, editors, real run_async, Click chaining and target changes. Raw source-controls.log prints the actual source import. Initial controls.log accidentally imported host Textual and failed; it is preserved, not a failure attributed to this source. A generated test initially aimed below its one-line widget; the corrected control declares its actual 3-line target.

Parent's one matched original raw-driver App: baseline pointer posted 746.8ms/final width 60 at 3516.7ms; matching a5f64c26 plus ResizeHandle override posted 728ms/final width 60 at 1092ms, Layout154 to59, no App error or capture leak. Original raw result/profile: /home/ts/.cache/agent-scratch/sidebar-drag-hotpath-20261007/captured-latest-motion and baseline-spawn-corrected. This demonstrates backlog improvement at headless completed-display strength, not terminal pixels/live smoothness. Roughly 100ms frame gaps and width-dependent wrapping/layout remain.

Native control originals: /home/ts/.cache/agent-scratch/native-captured-motion-20261007. One normal source union retains Native100's independent empty-collection removal; no dispatch speed claim. Its original 14-check command also selected host Textual; the corrected explicit-source check passes 14 in 2.02s at this union, raw source-controls.log under native-handler-allocation-20261007. The earlier matched channel App already used the actual source. No provider, package/pin/public operation or additional matched drag run.
15 changes: 15 additions & 0 deletions evidence/handler-allocation-20261007/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Handler lookup allocations

The existing metaclass already collects decorated declarations and parses selectors. No declaration registry or event cache is needed. Named handlers must remain live: an awaited decorated handler can replace the named handler that follows it. C3 order, instance binding, selectors and bubbling remain runtime work.

The original lookup constructed an empty list on every missing decorated-handler match, including each message ancestor in each decorated class table. It also allocated a duplicate-tracking set even when no decorated candidate existed. Lookup now uses the immutable empty tuple on a miss and creates its set at the first decorated candidate. Existing declaration lists and post-yield duplicate accounting are unchanged.

The existing refactor-audit Package parsed 250 production modules without omissions. `_get_dispatch_methods` is used by `_on_message` and idle delivery; both use the same changed implementation. No other declaration-table writer was found beyond `_MessagePumpMeta`; dynamic class mappings and decorated lists remain live.

Fourteen affected original decorator/message checks passed in 2.18 seconds, including inherited duplicate suppression, C3/private precedence, selector matching and live replacement during awaited dispatch.

One original channel App check passed: 160 authored private messages, 20 arrivals, focused history paging; sequence113 to73, 88 mounted rows, no App error, empty stderr. Installed Toad/Core came from viewer-cut-and-header-delivery-20261007; only native source was supplied. The installed message-pump diff consists exactly of these allocation changes. No provider, installed-package write or public operation. Original fixture teardown joined its private work; individual child birth identities were not collected.

Raw result/profile/logs: /home/ts/.cache/agent-scratch/native-handler-allocation-20261007. Previous profile: /home/ts/.cache/agent-scratch/installed-viewer-cut-header-20261007/ui.pstats.

Profiling recorded229908→214884 lookup calls, own time.9755→.9351s and inclusive1.5684→1.4606s. Own time per invocation4.24→4.35microseconds did not improve. Heartbeatp95 went46.13→35.11ms, maximum182.39→227.40ms; arrangement workload and final scroll position differed. These runs do not establish a reliable responsiveness or overall speed gain. Layout remains substantial required work.
15 changes: 15 additions & 0 deletions evidence/handler-allocation-20261007/app-result.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"main_loop_samples": 2387,
"median_ms": 6.166572915390134,
"p95_ms": 35.106428898870945,
"max_ms": 227.4037009337917,
"gaps_above_50ms": 61,
"mounted_rows": 88,
"oldest_sequence": 73,
"app_error": null,
"profiling": true,
"initial_scroll": 215,
"final_scroll": 803,
"initial_oldest": 113,
"older_page": 73
}
21 changes: 21 additions & 0 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4098,6 +4098,27 @@ def handle_bindings_clash(
"""
pass

def _can_replace_message(self, message: Message, pending: Message) -> bool:
captured = self.mouse_captured
if (
captured is not None
and type(message) is events.MouseMove
and type(pending) is events.MouseMove
and not message.is_forwarded
and not pending.is_forwarded
and message.widget is None
and pending.widget is None
and message._sender is self
and pending._sender is self
and message._dispatch_completion is None
and pending._dispatch_completion is None
and message._prevent == pending._prevent
and (message.button, message.shift, message.meta, message.ctrl)
== (pending.button, pending.shift, pending.meta, pending.ctrl)
):
return captured.can_replace_mouse_move(message, pending)
return super()._can_replace_message(message, pending)

async def on_event(self, event: events.Event) -> events.MouseEvent | None:
# Handle input events that haven't been forwarded
# If the event has been forwarded it may have bubbled up back to the App
Expand Down
16 changes: 13 additions & 3 deletions src/textual/message_pump.py
Original file line number Diff line number Diff line change
Expand Up @@ -726,7 +726,7 @@ async def _process_messages_loop(self) -> None:
pending = self._peek_message()
except MessagePumpClosed:
break
if pending is None or not message.can_replace(pending):
if pending is None or not self._can_replace_message(message, pending):
break
try:
message = await self._get_message()
Expand Down Expand Up @@ -775,6 +775,14 @@ async def _process_messages_loop(self) -> None:
break
await self._flush_next_callbacks()

def _can_replace_message(self, message: Message, pending: Message) -> bool:
"""Whether this receiver may consume the pending message instead.

The original queue owns replacement; receivers may supply semantics
that depend on their current delivery owner.
"""
return message.can_replace(pending)

async def _flush_next_callbacks(self) -> None:
"""Invoke pending callbacks in next callbacks queue."""
callbacks = self._next_callbacks.copy()
Expand Down Expand Up @@ -836,7 +844,7 @@ def _get_dispatch_methods(
"""
from textual.widget import Widget

methods_dispatched: set[Callable] = set()
methods_dispatched: set[Callable] | None = None
message_types = message.__class__.__mro__
message_mro: list[type[Message]] | None = None
private_method_name = f"_{method_name}"
Expand All @@ -856,9 +864,11 @@ def _get_dispatch_methods(
_type for _type in message_types if issubclass(_type, Message)
]
for message_class in message_mro:
handlers = decorated_handlers.get(message_class, [])
handlers = decorated_handlers.get(message_class, ())

for method, selectors in handlers:
if methods_dispatched is None:
methods_dispatched = set()
if method in methods_dispatched:
continue
if not selectors:
Expand Down
12 changes: 12 additions & 0 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -4943,6 +4943,18 @@ def blur(self) -> Self:
pass
return self

def can_replace_mouse_move(
self, event: events.MouseMove, pending: events.MouseMove
) -> bool:
"""Whether a later captured motion supersedes this absolute position.

Defaults to ordered delivery. A captured absolute-position control may
opt in if intermediate positions, deltas and callbacks are unnecessary.
App applies this only to consecutive raw packets in the same gesture;
press, release and other messages retain their original ordering.
"""
return False

def capture_mouse(self, capture: bool = True) -> None:
"""Capture (or release) the mouse.

Expand Down
104 changes: 104 additions & 0 deletions tests/test_pointer_capture_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,107 @@ async def on_mouse_down(self, event):
assert target._task is None
assert app.mouse_position == Offset(21, 4)
assert app.mouse_captured is None


@pytest.mark.parametrize('latest_position', [False, True])
async def test_captured_absolute_motion_replacement_preserves_gesture_boundaries(latest_position):
entered, release = asyncio.Event(), asyncio.Event()
delivered = []

class Handle(Static):
ALLOW_SELECT = False

def can_replace_mouse_move(self, event, pending):
return latest_position and event.button == 1

async def on_mouse_down(self, event):
event.stop()
entered.set()
await release.wait()
self.capture_mouse()
delivered.append(('down', event.screen_x))

def on_mouse_move(self, event):
event.stop()
delivered.append(('move', event.screen_x, event.shift))

def on_mouse_up(self, event):
event.stop()
delivered.append(('up', event.screen_x))
self.release_mouse()

def on_mouse_scroll_up(self, event):
event.stop()
delivered.append(('wheel', event.screen_x))

class PointerApp(App):
CSS = 'Handle {width: 30; height: 3;}'

def compose(self):
yield Handle('handle')

app = PointerApp()
async with app.run_test(size=(40, 8)) as pilot:
await pilot.pause()
handle = app.query_one(Handle)
send_pointer(app, events.MouseDown, Offset(1, 1))
await asyncio.wait_for(entered.wait(), 5)
for x, shift in [(2, False), (3, False), (4, True), (5, True)]:
app._driver.process_message(events.MouseMove(None, x, 1, 1, 0, 1, shift, False, False))
send_pointer(app, events.MouseScrollUp, Offset(6, 1), button=0)
for x in (7, 8):
send_pointer(app, events.MouseMove, Offset(x, 1))
send_pointer(app, events.MouseUp, Offset(9, 1))
for x in (10, 11):
send_pointer(app, events.MouseMove, Offset(x, 1), button=0)
release.set()
await pilot.pause()
moves = [('move', 3, False), ('move', 5, True)] if latest_position else [
('move', 2, False), ('move', 3, False), ('move', 4, True), ('move', 5, True)]
second = [('move', 8, False)] if latest_position else [
('move', 7, False), ('move', 8, False)]
assert delivered == [('down', 1), *moves, ('wheel', 6), *second,
('up', 9), ('move', 10, False), ('move', 11, False)]
assert app.mouse_captured is None
assert app.mouse_position == Offset(11, 1)


async def test_captured_motion_keeps_each_owned_delivery_completion():
entered, release = asyncio.Event(), asyncio.Event()
delivered = []

class Handle(Static):
ALLOW_SELECT = False

def can_replace_mouse_move(self, event, pending):
return True

async def on_mouse_down(self, event):
event.stop()
entered.set()
await release.wait()
self.capture_mouse()

def on_mouse_move(self, event):
event.stop()
delivered.append(event.screen_x)

class PointerApp(App):
CSS = 'Handle {width: 30; height: 3;}'

def compose(self):
yield Handle('handle')

app = PointerApp()
async with app.run_test(size=(40, 8)) as pilot:
await pilot.pause()
send_pointer(app, events.MouseDown, Offset(1, 1))
await asyncio.wait_for(entered.wait(), 5)
completions = [app._post_message_and_wait(
events.MouseMove(None, x, 1, 1, 0, 1, False, False, False).set_sender(app))
for x in (2, 3, 4)]
release.set()
await asyncio.wait_for(asyncio.gather(*completions), 5)
await pilot.pause()
assert delivered == [2, 3, 4]
app.capture_mouse(None)
Loading