ghostty/src/surface_mouse.zig
Mitchell Hashimoto c97c0858be macos: rectangle select only requires option + drag
Fixes #2537

This matches Terminal.app. iTerm2 requires cmd+option (our old
behavior). Kitty doesn't seem to support rectangle select or I couldn't
figure out how to make it work. WezTerm matches Terminal.app too.
Outside of terminal emulators, this is also the rectangular select
binding for neovim.
2024-10-30 20:47:24 -04:00

338 lines
9.9 KiB
Zig

/// SurfaceMouse represents mouse helper functionality for the core surface.
///
/// It's currently small in scope; its purpose is to isolate mouse logic that
/// has gotten a bit complex (e.g. pointer shape handling for key events), but
/// the intention is to grow it later so that we can better test said logic).
const SurfaceMouse = @This();
const std = @import("std");
const builtin = @import("builtin");
const input = @import("input.zig");
const apprt = @import("apprt.zig");
const terminal = @import("terminal/main.zig");
const MouseShape = @import("terminal/mouse_shape.zig").MouseShape;
/// For processing key events; the key that was physically pressed on the
/// keyboard.
physical_key: input.Key,
/// The mouse event tracking mode, if any.
mouse_event: terminal.Terminal.MouseEvents,
/// The current terminal's mouse shape.
mouse_shape: MouseShape,
/// The last mods state when the last mouse button (whatever it was) was
/// pressed or release.
mods: input.Mods,
/// True if the mouse position is currently over a link.
over_link: bool,
/// True if the mouse pointer is currently hidden.
hidden: bool,
/// Translates key state to mouse shape (cursor) state, based on a state
/// machine.
///
/// There are 4 current states:
///
/// * text: starting state, displays a text bar.
/// * default: default state when in a mouse tracking mode. (e.g. vim, etc).
/// Displays an arrow pointer.
/// * pointer: default state when over a link. Displays a pointing finger.
/// * crosshair: any above state can transition to this when the rectangle
/// select keys are pressed (ctrl/super+alt).
///
/// Additionally, default can transition back to text if one of the shift keys
/// are pressed during mouse tracking mode.
///
/// Any secondary state transitions back to its default state when the
/// appropriate keys are released.
///
/// null is returned when the mouse shape does not need changing.
pub fn keyToMouseShape(self: SurfaceMouse) ?MouseShape {
// Filter for appropriate key events
if (!eligibleMouseShapeKeyEvent(self.physical_key)) return null;
// Exceptions: link hover or hidden state overrides any other shape
// processing and does not change state.
//
// TODO: As we unravel mouse state, we can fix this to be more explicit.
if (self.over_link or self.hidden) {
return null;
}
// Set our current default state
var current_shape_state: MouseShape = undefined;
if (self.mouse_event != .none) {
// In mouse tracking mode, should be default (arrow pointer)
current_shape_state = .default;
} else {
// Default terminal mode, should be text (text bar)
current_shape_state = .text;
}
// Transition table.
//
// TODO: This could be updated eventually to be a true transition table if
// we move to a full stateful mouse surface, e.g. very specific inputs
// transitioning state based on previous state, versus flags like "is the
// mouse over a link", etc.
switch (current_shape_state) {
.default => {
if (isMouseModeOverrideState(self.mods) and isRectangleSelectState(self.mods)) {
// Crosshair (rectangle select), only set if we are also
// overriding (e.g. shift+ctrl+alt)
return .crosshair;
} else if (isMouseModeOverrideState(self.mods)) {
// Normal override state
return .text;
} else {
return .default;
}
},
.text => {
if (isRectangleSelectState(self.mods)) {
// Crosshair (rectangle select)
return .crosshair;
} else {
return .text;
}
},
// Fall back on default state
else => unreachable,
}
}
fn eligibleMouseShapeKeyEvent(physical_key: input.Key) bool {
return physical_key.ctrlOrSuper() or
physical_key.leftOrRightShift() or
physical_key.leftOrRightAlt();
}
fn isMouseModeOverrideState(mods: input.Mods) bool {
return mods.shift;
}
/// Returns true if our modifiers put us in a state where dragging
/// should cause a rectangle select.
pub fn isRectangleSelectState(mods: input.Mods) bool {
return if (comptime builtin.target.isDarwin())
mods.alt
else
mods.ctrlOrSuper() and mods.alt;
}
test "keyToMouseShape" {
const testing = std.testing;
{
// No specific key pressed
const m: SurfaceMouse = .{
.physical_key = .invalid,
.mouse_event = .none,
.mouse_shape = .progress,
.mods = .{},
.over_link = false,
.hidden = false,
};
const got = m.keyToMouseShape();
try testing.expect(got == null);
}
{
// Over a link. NOTE: This tests that we don't touch the inbound state,
// not necessarily if we're over a link.
const m: SurfaceMouse = .{
.physical_key = .left_shift,
.mouse_event = .none,
.mouse_shape = .progress,
.mods = .{},
.over_link = true,
.hidden = false,
};
const got = m.keyToMouseShape();
try testing.expect(got == null);
}
{
// Mouse is currently hidden
const m: SurfaceMouse = .{
.physical_key = .left_shift,
.mouse_event = .none,
.mouse_shape = .progress,
.mods = .{},
.over_link = true,
.hidden = true,
};
const got = m.keyToMouseShape();
try testing.expect(got == null);
}
{
// default, no mods (mouse tracking)
const m: SurfaceMouse = .{
.physical_key = .left_shift,
.mouse_event = .x10,
.mouse_shape = .default,
.mods = .{},
.over_link = false,
.hidden = false,
};
const want: MouseShape = .default;
const got = m.keyToMouseShape();
try testing.expect(want == got);
}
{
// default -> crosshair (mouse tracking)
const m: SurfaceMouse = .{
.physical_key = .left_alt,
.mouse_event = .x10,
.mouse_shape = .default,
.mods = .{ .ctrl = true, .super = true, .alt = true, .shift = true },
.over_link = false,
.hidden = false,
};
const want: MouseShape = .crosshair;
const got = m.keyToMouseShape();
try testing.expect(want == got);
}
{
// default -> text (mouse tracking)
const m: SurfaceMouse = .{
.physical_key = .left_shift,
.mouse_event = .x10,
.mouse_shape = .default,
.mods = .{ .shift = true },
.over_link = false,
.hidden = false,
};
const want: MouseShape = .text;
const got = m.keyToMouseShape();
try testing.expect(want == got);
}
{
// crosshair -> text (mouse tracking)
const m: SurfaceMouse = .{
.physical_key = .left_alt,
.mouse_event = .x10,
.mouse_shape = .crosshair,
.mods = .{ .shift = true },
.over_link = false,
.hidden = false,
};
const want: MouseShape = .text;
const got = m.keyToMouseShape();
try testing.expect(want == got);
}
{
// crosshair -> default (mouse tracking)
const m: SurfaceMouse = .{
.physical_key = .left_alt,
.mouse_event = .x10,
.mouse_shape = .crosshair,
.mods = .{},
.over_link = false,
.hidden = false,
};
const want: MouseShape = .default;
const got = m.keyToMouseShape();
try testing.expect(want == got);
}
{
// text -> crosshair (mouse tracking)
const m: SurfaceMouse = .{
.physical_key = .left_alt,
.mouse_event = .x10,
.mouse_shape = .text,
.mods = .{ .ctrl = true, .super = true, .alt = true, .shift = true },
.over_link = false,
.hidden = false,
};
const want: MouseShape = .crosshair;
const got = m.keyToMouseShape();
try testing.expect(want == got);
}
{
// text -> default (mouse tracking)
const m: SurfaceMouse = .{
.physical_key = .left_shift,
.mouse_event = .x10,
.mouse_shape = .text,
.mods = .{},
.over_link = false,
.hidden = false,
};
const want: MouseShape = .default;
const got = m.keyToMouseShape();
try testing.expect(want == got);
}
{
// text, no mods (no mouse tracking)
const m: SurfaceMouse = .{
.physical_key = .left_shift,
.mouse_event = .none,
.mouse_shape = .text,
.mods = .{},
.over_link = false,
.hidden = false,
};
const want: MouseShape = .text;
const got = m.keyToMouseShape();
try testing.expect(want == got);
}
{
// text -> crosshair (no mouse tracking)
const m: SurfaceMouse = .{
.physical_key = .left_alt,
.mouse_event = .none,
.mouse_shape = .text,
.mods = .{ .ctrl = true, .super = true, .alt = true },
.over_link = false,
.hidden = false,
};
const want: MouseShape = .crosshair;
const got = m.keyToMouseShape();
try testing.expect(want == got);
}
{
// crosshair -> text (no mouse tracking)
const m: SurfaceMouse = .{
.physical_key = .left_alt,
.mouse_event = .none,
.mouse_shape = .crosshair,
.mods = .{},
.over_link = false,
.hidden = false,
};
const want: MouseShape = .text;
const got = m.keyToMouseShape();
try testing.expect(want == got);
}
}