Extract click/drag selection handling into SelectionGesture (#12830)

Refactor terminal text selection into a reusable `SelectionGesture`
state machine. Most importantly, this means our click+drag logic around
selection is now fully unit tested! And we found bugs! And fixed them!

The large line increase in this diff is mainly comments + tests.

I've wanted to do this forever so we can unit test this, but I was
kicked in the butt to do it recently because reimplementing selection
logic in libghostty consumers turns out to be complex and error prone
and we have a perfectly battle tested logic machine here so why not
extract it?

Behavioral changes from main surfaced via unit testing:

- Dragging now drags by output across semantic output blocks when the
initial press was an output selection. This matches the behavior of
dragging continuing whatever the initial selection logic was.
- Selection autoscroll now stops when the click anchor is invalidated by
a screen change (e.g. primary to alt)
- Deep press (macOS force touch) now selects the word at the original
press location and consumes the active drag gesture, preventing later
movement from dragging or autoscrolling that selection. This matches
built-in macOS apps.
- Mouse release records whether the gesture moved away from the pressed
cell, so link and prompt clicks are skipped after a drag while normal
clicks still activate them.

Example usage:

```zig
var gesture: terminal.SelectionGesture = .init;
defer gesture.deinit(t);

const press_selection = try gesture.press(t, .{
    .time = try std.time.Instant.now(),
    .pin = press_pin,
    .xpos = mouse_x,
    .ypos = mouse_y,
    .max_distance = cell_width,
    .repeat_interval = mouse_interval,
    .word_boundary_codepoints = selection_word_chars,
    .behaviors = &.{ .cell, .word, .output },
});
try t.screens.active.select(press_selection);

if (gesture.drag(t, drag_event)) |drag_selection| {
    try t.screens.active.select(drag_selection);
}

gesture.release(t, .{ .pin = release_pin });
```
This commit is contained in:
Mitchell Hashimoto
2026-05-27 07:48:23 -07:00
committed by GitHub
4 changed files with 2184 additions and 876 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -462,7 +462,8 @@ fn mouseTable(
{
const left_click_point: terminal.point.Coordinate = pt: {
const p = surface_mouse.left_click_pin orelse break :pt .{};
const p = surface_mouse.selection_gesture.validatedLeftClickPin(&t.screens) orelse
break :pt .{};
const pt = t.screens.active.pages.pointFromPin(
.active,
p.*,
@@ -495,8 +496,8 @@ fn mouseTable(
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text(
"(%dpx, %dpx)",
@as(u32, @intFromFloat(surface_mouse.left_click_xpos)),
@as(u32, @intFromFloat(surface_mouse.left_click_ypos)),
@as(u32, @intFromFloat(surface_mouse.selection_gesture.left_click_xpos)),
@as(u32, @intFromFloat(surface_mouse.selection_gesture.left_click_ypos)),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -49,6 +49,7 @@ pub const Screen = @import("Screen.zig");
pub const ScreenSet = @import("ScreenSet.zig");
pub const Scrollbar = PageList.Scrollbar;
pub const Selection = @import("Selection.zig");
pub const SelectionGesture = @import("SelectionGesture.zig");
pub const SizeReportStyle = csi.SizeReportStyle;
pub const StringMap = @import("StringMap.zig");
pub const Style = style.Style;