mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-06 07:38:21 +00:00
input: extract mouse encoding to a pure, testable file
Move mouse event encoding logic from Surface.zig into a new input/mouse_encode.zig file. The new file encapsulates event filtering (shouldReport), button code computation, viewport bounds checking, motion deduplication, and all five wire formats (X10, UTF-8, SGR, urxvt, SGR-pixels). This makes the encoding independently testable and adds unit tests covering each format and edge case. Additionally, Surface `mouseReport` can no longer fail, since the only failure mode is no buffer space which should be impossible. Updated the signature to remove the error set.
This commit is contained in:
260
src/Surface.zig
260
src/Surface.zig
@@ -3518,7 +3518,7 @@ pub fn scrollCallback(
|
||||
if (self.isMouseReporting()) {
|
||||
for (0..@abs(y.delta)) |_| {
|
||||
const pos = try self.rt_surface.getCursorPos();
|
||||
try self.mouseReport(switch (y.direction()) {
|
||||
self.mouseReport(switch (y.direction()) {
|
||||
.up_right => .four,
|
||||
.down_left => .five,
|
||||
}, .press, self.mouse.mods, pos);
|
||||
@@ -3526,7 +3526,7 @@ pub fn scrollCallback(
|
||||
|
||||
for (0..@abs(x.delta)) |_| {
|
||||
const pos = try self.rt_surface.getCursorPos();
|
||||
try self.mouseReport(switch (x.direction()) {
|
||||
self.mouseReport(switch (x.direction()) {
|
||||
.up_right => .six,
|
||||
.down_left => .seven,
|
||||
}, .press, self.mouse.mods, pos);
|
||||
@@ -3585,9 +3585,6 @@ pub fn contentScaleCallback(self: *Surface, content_scale: apprt.ContentScale) !
|
||||
try self.resize(self.size.screen);
|
||||
}
|
||||
|
||||
/// The type of action to report for a mouse event.
|
||||
const MouseReportAction = enum { press, release, motion };
|
||||
|
||||
/// Returns true if mouse reporting is enabled both in the config and
|
||||
/// the terminal state.
|
||||
fn isMouseReporting(self: *const Surface) bool {
|
||||
@@ -3598,228 +3595,65 @@ fn isMouseReporting(self: *const Surface) bool {
|
||||
fn mouseReport(
|
||||
self: *Surface,
|
||||
button: ?input.MouseButton,
|
||||
action: MouseReportAction,
|
||||
action: input.MouseAction,
|
||||
mods: input.Mods,
|
||||
pos: apprt.CursorPos,
|
||||
) !void {
|
||||
) void {
|
||||
// Mouse reporting must be enabled by both config and terminal state
|
||||
assert(self.config.mouse_reporting);
|
||||
assert(self.io.terminal.flags.mouse_event != .none);
|
||||
|
||||
// Depending on the event, we may do nothing at all.
|
||||
switch (self.io.terminal.flags.mouse_event) {
|
||||
.none => unreachable, // checked by assert above
|
||||
// Build our encoding options.
|
||||
const encoding_opts: input.mouse_encode.Options = opts: {
|
||||
// Terminal and size state.
|
||||
var opts: input.mouse_encode.Options = .fromTerminal(
|
||||
&self.io.terminal,
|
||||
self.size,
|
||||
);
|
||||
|
||||
// X10 only reports clicks with mouse button 1, 2, 3. We verify
|
||||
// the button later.
|
||||
.x10 => if (action != .press or
|
||||
button == null or
|
||||
!(button.? == .left or
|
||||
button.? == .right or
|
||||
button.? == .middle)) return,
|
||||
|
||||
// Doesn't report motion
|
||||
.normal => if (action == .motion) return,
|
||||
|
||||
// Button must be pressed
|
||||
.button => if (button == null) return,
|
||||
|
||||
// Everything
|
||||
.any => {},
|
||||
}
|
||||
|
||||
// Handle scenarios where the mouse position is outside the viewport.
|
||||
// We always report release events no matter where they happen.
|
||||
if (action != .release) {
|
||||
const pos_out_viewport = pos_out_viewport: {
|
||||
const max_x: f32 = @floatFromInt(self.size.screen.width);
|
||||
const max_y: f32 = @floatFromInt(self.size.screen.height);
|
||||
break :pos_out_viewport pos.x < 0 or pos.y < 0 or
|
||||
pos.x > max_x or pos.y > max_y;
|
||||
};
|
||||
if (pos_out_viewport) outside_viewport: {
|
||||
// If we don't have a motion-tracking event mode, do nothing.
|
||||
if (!self.io.terminal.flags.mouse_event.motion()) return;
|
||||
|
||||
// If any button is pressed, we still do the report. Otherwise,
|
||||
// we do not do the report.
|
||||
// Whether any button is pressed at all.
|
||||
opts.any_button_pressed = pressed: {
|
||||
for (self.mouse.click_state) |state| {
|
||||
if (state != .release) break :outside_viewport;
|
||||
if (state != .release) break :pressed true;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
break :pressed false;
|
||||
};
|
||||
|
||||
// This format reports X/Y
|
||||
const viewport_point = self.posToViewport(pos.x, pos.y);
|
||||
// Keep track of our last reported viewport cell for event
|
||||
// deduplication.
|
||||
opts.last_cell = &self.mouse.event_point;
|
||||
|
||||
// Record our new point. We only want to send a mouse event if the
|
||||
// cell changed, unless we're tracking raw pixels.
|
||||
if (action == .motion and self.io.terminal.flags.mouse_format != .sgr_pixels) {
|
||||
if (self.mouse.event_point) |last_point| {
|
||||
if (last_point.eql(viewport_point)) return;
|
||||
}
|
||||
}
|
||||
self.mouse.event_point = viewport_point;
|
||||
|
||||
// Get the code we'll actually write
|
||||
const button_code: u8 = code: {
|
||||
var acc: u8 = 0;
|
||||
|
||||
// Determine our initial button value
|
||||
if (button == null) {
|
||||
// Null button means motion without a button pressed
|
||||
acc = 3;
|
||||
} else if (action == .release and
|
||||
self.io.terminal.flags.mouse_format != .sgr and
|
||||
self.io.terminal.flags.mouse_format != .sgr_pixels)
|
||||
{
|
||||
// Release is 3. It is NOT 3 in SGR mode because SGR can tell
|
||||
// the application what button was released.
|
||||
acc = 3;
|
||||
} else {
|
||||
acc = switch (button.?) {
|
||||
.left => 0,
|
||||
.middle => 1,
|
||||
.right => 2,
|
||||
.four => 64,
|
||||
.five => 65,
|
||||
.six => 66,
|
||||
.seven => 67,
|
||||
.eight => 128,
|
||||
.nine => 129,
|
||||
else => return, // unsupported
|
||||
};
|
||||
}
|
||||
|
||||
// X10 doesn't have modifiers
|
||||
if (self.io.terminal.flags.mouse_event != .x10) {
|
||||
if (mods.shift) acc += 4;
|
||||
if (mods.alt) acc += 8;
|
||||
if (mods.ctrl) acc += 16;
|
||||
}
|
||||
|
||||
// Motion adds another bit
|
||||
if (action == .motion) acc += 32;
|
||||
|
||||
break :code acc;
|
||||
break :opts opts;
|
||||
};
|
||||
|
||||
switch (self.io.terminal.flags.mouse_format) {
|
||||
.x10 => {
|
||||
if (viewport_point.x > 222 or viewport_point.y > 222) {
|
||||
log.info("X10 mouse format can only encode X/Y up to 223", .{});
|
||||
return;
|
||||
}
|
||||
|
||||
// + 1 below is because our x/y is 0-indexed and the protocol wants 1
|
||||
var data: termio.Message.WriteReq.Small.Array = undefined;
|
||||
assert(data.len >= 6);
|
||||
data[0] = '\x1b';
|
||||
data[1] = '[';
|
||||
data[2] = 'M';
|
||||
data[3] = 32 + button_code;
|
||||
data[4] = 32 + @as(u8, @intCast(viewport_point.x)) + 1;
|
||||
data[5] = 32 + @as(u8, @intCast(viewport_point.y)) + 1;
|
||||
|
||||
// Ask our IO thread to write the data
|
||||
self.queueIo(.{ .write_small = .{
|
||||
.data = data,
|
||||
.len = 6,
|
||||
} }, .locked);
|
||||
var data: termio.Message.WriteReq.Small.Array = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&data);
|
||||
input.mouse_encode.encode(&writer, .{
|
||||
.button = button,
|
||||
.action = action,
|
||||
.mods = mods,
|
||||
.pos = .{
|
||||
.x = pos.x,
|
||||
.y = pos.y,
|
||||
},
|
||||
|
||||
.utf8 => {
|
||||
// Maximum of 12 because at most we have 2 fully UTF-8 encoded chars
|
||||
var data: termio.Message.WriteReq.Small.Array = undefined;
|
||||
assert(data.len >= 12);
|
||||
data[0] = '\x1b';
|
||||
data[1] = '[';
|
||||
data[2] = 'M';
|
||||
|
||||
// The button code will always fit in a single u8
|
||||
data[3] = 32 + button_code;
|
||||
|
||||
// UTF-8 encode the x/y
|
||||
var i: usize = 4;
|
||||
i += try std.unicode.utf8Encode(@intCast(32 + viewport_point.x + 1), data[i..]);
|
||||
i += try std.unicode.utf8Encode(@intCast(32 + viewport_point.y + 1), data[i..]);
|
||||
|
||||
// Ask our IO thread to write the data
|
||||
self.queueIo(.{ .write_small = .{
|
||||
.data = data,
|
||||
.len = @intCast(i),
|
||||
} }, .locked);
|
||||
}, encoding_opts) catch |err| switch (err) {
|
||||
error.WriteFailed => {
|
||||
// This should never happen since mouse events should never
|
||||
// be able to overflow the size of our small array. But if it
|
||||
// does, let's log it and return. No need to crash upstreams.
|
||||
// In the future we may want to fall back to allocation.
|
||||
log.warn("failed to encode mouse event err={}", .{err});
|
||||
return;
|
||||
},
|
||||
};
|
||||
const written = writer.buffered();
|
||||
if (written.len == 0) return;
|
||||
|
||||
.sgr => {
|
||||
// Final character to send in the CSI
|
||||
const final: u8 = if (action == .release) 'm' else 'M';
|
||||
|
||||
// Response always is at least 4 chars, so this leaves the
|
||||
// remainder for numbers which are very large...
|
||||
var data: termio.Message.WriteReq.Small.Array = undefined;
|
||||
const resp = try std.fmt.bufPrint(&data, "\x1B[<{d};{d};{d}{c}", .{
|
||||
button_code,
|
||||
viewport_point.x + 1,
|
||||
viewport_point.y + 1,
|
||||
final,
|
||||
});
|
||||
|
||||
// Ask our IO thread to write the data
|
||||
self.queueIo(.{ .write_small = .{
|
||||
.data = data,
|
||||
.len = @intCast(resp.len),
|
||||
} }, .locked);
|
||||
},
|
||||
|
||||
.urxvt => {
|
||||
// Response always is at least 4 chars, so this leaves the
|
||||
// remainder for numbers which are very large...
|
||||
var data: termio.Message.WriteReq.Small.Array = undefined;
|
||||
const resp = try std.fmt.bufPrint(&data, "\x1B[{d};{d};{d}M", .{
|
||||
32 + button_code,
|
||||
viewport_point.x + 1,
|
||||
viewport_point.y + 1,
|
||||
});
|
||||
|
||||
// Ask our IO thread to write the data
|
||||
self.queueIo(.{ .write_small = .{
|
||||
.data = data,
|
||||
.len = @intCast(resp.len),
|
||||
} }, .locked);
|
||||
},
|
||||
|
||||
.sgr_pixels => {
|
||||
// Final character to send in the CSI
|
||||
const final: u8 = if (action == .release) 'm' else 'M';
|
||||
|
||||
// The position has to be adjusted to the terminal space.
|
||||
const coord: rendererpkg.Coordinate.Terminal = (rendererpkg.Coordinate{
|
||||
.surface = .{
|
||||
.x = pos.x,
|
||||
.y = pos.y,
|
||||
},
|
||||
}).convert(.terminal, self.size).terminal;
|
||||
|
||||
// Response always is at least 4 chars, so this leaves the
|
||||
// remainder for numbers which are very large...
|
||||
var data: termio.Message.WriteReq.Small.Array = undefined;
|
||||
const resp = try std.fmt.bufPrint(&data, "\x1B[<{d};{d};{d}{c}", .{
|
||||
button_code,
|
||||
@as(i32, @intFromFloat(@round(coord.x))),
|
||||
@as(i32, @intFromFloat(@round(coord.y))),
|
||||
final,
|
||||
});
|
||||
|
||||
// Ask our IO thread to write the data
|
||||
self.queueIo(.{ .write_small = .{
|
||||
.data = data,
|
||||
.len = @intCast(resp.len),
|
||||
} }, .locked);
|
||||
},
|
||||
}
|
||||
self.queueIo(.{ .write_small = .{
|
||||
.data = data,
|
||||
.len = @intCast(written.len),
|
||||
} }, .locked);
|
||||
}
|
||||
|
||||
/// Returns true if the shift modifier is allowed to be captured by modifier
|
||||
@@ -4003,12 +3837,12 @@ pub fn mouseButtonCallback(
|
||||
|
||||
const pos = try self.rt_surface.getCursorPos();
|
||||
|
||||
const report_action: MouseReportAction = switch (action) {
|
||||
const report_action: input.MouseAction = switch (action) {
|
||||
.press => .press,
|
||||
.release => .release,
|
||||
};
|
||||
|
||||
try self.mouseReport(
|
||||
self.mouseReport(
|
||||
button,
|
||||
report_action,
|
||||
self.mouse.mods,
|
||||
@@ -4740,7 +4574,7 @@ pub fn cursorPosCallback(
|
||||
break :button @enumFromInt(i);
|
||||
} else null;
|
||||
|
||||
try self.mouseReport(button, .motion, self.mouse.mods, pos);
|
||||
self.mouseReport(button, .motion, self.mouse.mods, pos);
|
||||
|
||||
// If we're doing mouse motion tracking, we do not support text
|
||||
// selection.
|
||||
|
||||
@@ -12,6 +12,7 @@ pub const function_keys = @import("input/function_keys.zig");
|
||||
pub const keycodes = @import("input/keycodes.zig");
|
||||
pub const key_encode = @import("input/key_encode.zig");
|
||||
pub const kitty = @import("input/kitty.zig");
|
||||
pub const mouse_encode = @import("input/mouse_encode.zig");
|
||||
pub const paste = @import("input/paste.zig");
|
||||
|
||||
pub const ctrlOrSuper = key.ctrlOrSuper;
|
||||
@@ -25,6 +26,7 @@ pub const KeyEvent = key.KeyEvent;
|
||||
pub const KeyRemapSet = key_mods.RemapSet;
|
||||
pub const InspectorMode = Binding.Action.InspectorMode;
|
||||
pub const Mods = key_mods.Mods;
|
||||
pub const MouseAction = mouse.Action;
|
||||
pub const MouseButton = mouse.Button;
|
||||
pub const MouseButtonState = mouse.ButtonState;
|
||||
pub const MousePressureStage = mouse.PressureStage;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
const std = @import("std");
|
||||
|
||||
/// The type of action associated with a mouse event. This is different
|
||||
/// from ButtonState because button state is simply the current state
|
||||
/// of a mouse button but an action is something that triggers via
|
||||
/// an GUI event and supports more.
|
||||
pub const Action = enum { press, release, motion };
|
||||
|
||||
/// The state of a mouse button.
|
||||
///
|
||||
/// This is backed by a c_int so we can use this as-is for our embedding API.
|
||||
|
||||
780
src/input/mouse_encode.zig
Normal file
780
src/input/mouse_encode.zig
Normal file
@@ -0,0 +1,780 @@
|
||||
const std = @import("std");
|
||||
const testing = std.testing;
|
||||
const Terminal = @import("../terminal/Terminal.zig");
|
||||
const renderer_size = @import("../renderer/size.zig");
|
||||
const point = @import("../terminal/point.zig");
|
||||
const key = @import("key.zig");
|
||||
const mouse = @import("mouse.zig");
|
||||
|
||||
const log = std.log.scoped(.mouse_encode);
|
||||
|
||||
/// Options that affect mouse encoding behavior and provide runtime context.
|
||||
pub const Options = struct {
|
||||
/// Terminal mouse reporting mode (X10, normal, button, any).
|
||||
event: Terminal.MouseEvent = .none,
|
||||
|
||||
/// Terminal mouse reporting format.
|
||||
format: Terminal.MouseFormat = .x10,
|
||||
|
||||
/// Full renderer size used to convert surface-space pixel positions
|
||||
/// into grid cell coordinates (for most formats) and terminal-space
|
||||
/// pixel coordinates (for SGR-Pixels), as well as to determine
|
||||
/// whether a position falls outside the visible viewport.
|
||||
size: renderer_size.Size,
|
||||
|
||||
/// Whether any mouse button is currently pressed. When a motion
|
||||
/// event occurs outside the viewport, it is only reported if a
|
||||
/// button is held down and the event mode supports motion tracking.
|
||||
/// Without this, out-of-viewport motions are silently dropped.
|
||||
///
|
||||
/// This should reflect the state of the current event as well, so
|
||||
/// if the encoded event is a button press, this should be true.
|
||||
any_button_pressed: bool = false,
|
||||
|
||||
/// Last reported viewport cell for motion deduplication.
|
||||
/// If null, motion deduplication state is not tracked.
|
||||
last_cell: ?*?point.Coordinate = null,
|
||||
|
||||
/// Initialize from terminal and renderer state. The caller may still
|
||||
/// set any_button_pressed and last_cell on the returned value.
|
||||
pub fn fromTerminal(
|
||||
t: *const Terminal,
|
||||
size: renderer_size.Size,
|
||||
) Options {
|
||||
return .{
|
||||
.event = t.flags.mouse_event,
|
||||
.format = t.flags.mouse_format,
|
||||
.size = size,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// A normalized mouse event for protocol encoding.
|
||||
pub const Event = struct {
|
||||
/// The action of this mouse event.
|
||||
action: mouse.Action = .press,
|
||||
|
||||
/// The button involved in this event. This can be null in the
|
||||
/// case of a motion action with no pressed buttons.
|
||||
button: ?mouse.Button = null,
|
||||
|
||||
/// Keyboard modifiers held during this event.
|
||||
mods: key.Mods = .{},
|
||||
|
||||
/// Mouse position in terminal-space pixels, with (0, 0) at the top-left
|
||||
/// of the terminal. Negative values are allowed and indicate positions
|
||||
/// above or to the left of the terminal. Values larger than the terminal
|
||||
/// size are also allowed and indicate right or below the terminal.
|
||||
pos: Pos = .{},
|
||||
|
||||
/// Mouse position in surface-space pixels.
|
||||
pub const Pos = struct {
|
||||
x: f32 = 0,
|
||||
y: f32 = 0,
|
||||
};
|
||||
};
|
||||
|
||||
/// Encode the mouse event to the writer according to the options.
|
||||
///
|
||||
/// Not all events result in output.
|
||||
pub fn encode(
|
||||
writer: *std.Io.Writer,
|
||||
event: Event,
|
||||
opts: Options,
|
||||
) std.Io.Writer.Error!void {
|
||||
if (!shouldReport(event, opts)) return;
|
||||
|
||||
// Handle scenarios where the mouse position is outside the viewport.
|
||||
// We always report release events no matter where they happen.
|
||||
if (event.action != .release and
|
||||
posOutOfViewport(event.pos, opts.size))
|
||||
{
|
||||
// If we don't have a motion-tracking event mode, do nothing,
|
||||
// because events outside the viewport are never reported in
|
||||
// such cases.
|
||||
if (!opts.event.motion()) return;
|
||||
|
||||
// For motion modes, we only report if a button is currently pressed.
|
||||
// This lets a TUI detect a click over the surface + drag out
|
||||
// of the surface.
|
||||
if (!opts.any_button_pressed) return;
|
||||
}
|
||||
|
||||
const cell = posToCell(event.pos, opts.size);
|
||||
|
||||
// We only send motion events when the cell changed unless
|
||||
// we're tracking raw pixels.
|
||||
if (event.action == .motion and opts.format != .sgr_pixels) {
|
||||
if (opts.last_cell) |last| {
|
||||
if (last.*) |last_cell| {
|
||||
if (last_cell.eql(cell)) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the last reported cell if we are tracking it.
|
||||
if (opts.last_cell) |last| last.* = cell;
|
||||
|
||||
const button_code = buttonCode(event, opts) orelse return;
|
||||
switch (opts.format) {
|
||||
.x10 => {
|
||||
if (cell.x > 222 or cell.y > 222) {
|
||||
log.info("X10 mouse format can only encode X/Y up to 223", .{});
|
||||
return;
|
||||
}
|
||||
|
||||
// + 1 because our x/y are zero-indexed and the protocol uses 1-indexing.
|
||||
try writer.writeAll("\x1B[M");
|
||||
try writer.writeByte(32 + button_code);
|
||||
try writer.writeByte(32 + @as(u8, @intCast(cell.x)) + 1);
|
||||
try writer.writeByte(32 + @as(u8, @intCast(cell.y)) + 1);
|
||||
},
|
||||
|
||||
.utf8 => {
|
||||
try writer.writeAll("\x1B[M");
|
||||
|
||||
// The button code always fits in a single byte.
|
||||
try writer.writeByte(32 + button_code);
|
||||
|
||||
var buf: [4]u8 = undefined;
|
||||
const x_cp: u21 = @intCast(@as(u32, cell.x) + 33);
|
||||
const y_cp: u21 = @intCast(cell.y + 33);
|
||||
|
||||
const x_len = std.unicode.utf8Encode(x_cp, &buf) catch unreachable;
|
||||
try writer.writeAll(buf[0..x_len]);
|
||||
|
||||
const y_len = std.unicode.utf8Encode(y_cp, &buf) catch unreachable;
|
||||
try writer.writeAll(buf[0..y_len]);
|
||||
},
|
||||
|
||||
.sgr => try writer.print("\x1B[<{d};{d};{d}{c}", .{
|
||||
button_code,
|
||||
cell.x + 1,
|
||||
cell.y + 1,
|
||||
@as(u8, if (event.action == .release) 'm' else 'M'),
|
||||
}),
|
||||
|
||||
.urxvt => try writer.print("\x1B[{d};{d};{d}M", .{
|
||||
32 + button_code,
|
||||
cell.x + 1,
|
||||
cell.y + 1,
|
||||
}),
|
||||
|
||||
.sgr_pixels => {
|
||||
const pixels = posToPixels(event.pos, opts.size);
|
||||
try writer.print("\x1B[<{d};{d};{d}{c}", .{
|
||||
button_code,
|
||||
pixels.x,
|
||||
pixels.y,
|
||||
@as(u8, if (event.action == .release) 'm' else 'M'),
|
||||
});
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if this event should be reported for the given mouse
|
||||
/// event mode.
|
||||
fn shouldReport(event: Event, opts: Options) bool {
|
||||
return switch (opts.event) {
|
||||
.none => false,
|
||||
|
||||
// X10 only reports button presses of left, middle, and right.
|
||||
.x10 => event.action == .press and
|
||||
event.button != null and
|
||||
(event.button.? == .left or
|
||||
event.button.? == .middle or
|
||||
event.button.? == .right),
|
||||
|
||||
// Normal mode does not report motion.
|
||||
.normal => event.action != .motion,
|
||||
|
||||
// Button mode requires an active button for motion events.
|
||||
.button => event.button != null,
|
||||
|
||||
// Any mode reports everything.
|
||||
.any => true,
|
||||
};
|
||||
}
|
||||
|
||||
fn buttonCode(event: Event, opts: Options) ?u8 {
|
||||
var acc: u8 = code: {
|
||||
if (event.button == null) {
|
||||
// Null button means motion with no pressed button.
|
||||
break :code 3;
|
||||
}
|
||||
|
||||
if (event.action == .release and
|
||||
opts.format != .sgr and
|
||||
opts.format != .sgr_pixels)
|
||||
{
|
||||
// Legacy releases are always encoded as button 3.
|
||||
break :code 3;
|
||||
}
|
||||
|
||||
break :code switch (event.button.?) {
|
||||
.left => 0,
|
||||
.middle => 1,
|
||||
.right => 2,
|
||||
.four => 64,
|
||||
.five => 65,
|
||||
.six => 66,
|
||||
.seven => 67,
|
||||
.eight => 128,
|
||||
.nine => 129,
|
||||
else => return null,
|
||||
};
|
||||
};
|
||||
|
||||
// X10 does not include modifiers.
|
||||
if (opts.event != .x10) {
|
||||
if (event.mods.shift) acc += 4;
|
||||
if (event.mods.alt) acc += 8;
|
||||
if (event.mods.ctrl) acc += 16;
|
||||
}
|
||||
|
||||
// Motion adds another bit.
|
||||
if (event.action == .motion) acc += 32;
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
/// Terminal-space pixel position for SGR pixel reporting.
|
||||
const PixelPoint = struct {
|
||||
x: i32,
|
||||
y: i32,
|
||||
};
|
||||
|
||||
/// Returns true if the surface-space pixel position is outside the
|
||||
/// visible viewport bounds (negative or beyond screen dimensions).
|
||||
fn posOutOfViewport(pos: Event.Pos, size: renderer_size.Size) bool {
|
||||
const max_x: f32 = @floatFromInt(size.screen.width);
|
||||
const max_y: f32 = @floatFromInt(size.screen.height);
|
||||
return pos.x < 0 or pos.y < 0 or pos.x > max_x or pos.y > max_y;
|
||||
}
|
||||
|
||||
/// Converts a surface-space pixel position to a zero-based grid cell
|
||||
/// coordinate (column, row) within the terminal viewport. Out-of-bounds
|
||||
/// values are clamped to the valid grid range (0 to columns/rows - 1).
|
||||
fn posToCell(pos: Event.Pos, size: renderer_size.Size) point.Coordinate {
|
||||
const coord: renderer_size.Coordinate = .{ .surface = .{
|
||||
.x = @as(f64, @floatCast(pos.x)),
|
||||
.y = @as(f64, @floatCast(pos.y)),
|
||||
} };
|
||||
const grid = coord.convert(.grid, size).grid;
|
||||
return .{ .x = grid.x, .y = grid.y };
|
||||
}
|
||||
|
||||
/// Converts a surface-space pixel position to terminal-space pixel
|
||||
/// coordinates (accounting for padding/scaling) used by SGR-Pixels mode.
|
||||
/// Unlike grid conversion, terminal-space coordinates are not clamped
|
||||
/// and may be negative or exceed the terminal dimensions.
|
||||
fn posToPixels(pos: Event.Pos, size: renderer_size.Size) PixelPoint {
|
||||
const coord: renderer_size.Coordinate.Terminal = (renderer_size.Coordinate{ .surface = .{
|
||||
.x = @as(f64, @floatCast(pos.x)),
|
||||
.y = @as(f64, @floatCast(pos.y)),
|
||||
} }).convert(.terminal, size).terminal;
|
||||
|
||||
return .{
|
||||
.x = @as(i32, @intFromFloat(@round(coord.x))),
|
||||
.y = @as(i32, @intFromFloat(@round(coord.y))),
|
||||
};
|
||||
}
|
||||
|
||||
fn testSize() renderer_size.Size {
|
||||
return .{
|
||||
.screen = .{ .width = 1_000, .height = 1_000 },
|
||||
.cell = .{ .width = 1, .height = 1 },
|
||||
.padding = .{},
|
||||
};
|
||||
}
|
||||
|
||||
test "shouldReport: none mode never reports" {
|
||||
const size = testSize();
|
||||
inline for ([_]mouse.Action{ .press, .release, .motion }) |action| {
|
||||
try testing.expect(!shouldReport(.{
|
||||
.button = .left,
|
||||
.action = action,
|
||||
}, .{ .event = .none, .size = size }));
|
||||
}
|
||||
}
|
||||
|
||||
test "shouldReport: x10 reports only left/middle/right press" {
|
||||
const size = testSize();
|
||||
// Left, middle, right presses should report.
|
||||
inline for ([_]mouse.Button{ .left, .middle, .right }) |btn| {
|
||||
try testing.expect(shouldReport(.{
|
||||
.button = btn,
|
||||
.action = .press,
|
||||
}, .{ .event = .x10, .size = size }));
|
||||
}
|
||||
|
||||
// Release is not reported.
|
||||
try testing.expect(!shouldReport(.{
|
||||
.button = .left,
|
||||
.action = .release,
|
||||
}, .{ .event = .x10, .size = size }));
|
||||
|
||||
// Motion is not reported.
|
||||
try testing.expect(!shouldReport(.{
|
||||
.button = .left,
|
||||
.action = .motion,
|
||||
}, .{ .event = .x10, .size = size }));
|
||||
|
||||
// Other buttons are not reported.
|
||||
try testing.expect(!shouldReport(.{
|
||||
.button = .four,
|
||||
.action = .press,
|
||||
}, .{ .event = .x10, .size = size }));
|
||||
|
||||
// Null button is not reported.
|
||||
try testing.expect(!shouldReport(.{
|
||||
.button = null,
|
||||
.action = .press,
|
||||
}, .{ .event = .x10, .size = size }));
|
||||
}
|
||||
|
||||
test "shouldReport: normal reports press and release but not motion" {
|
||||
const size = testSize();
|
||||
try testing.expect(shouldReport(.{
|
||||
.button = .left,
|
||||
.action = .press,
|
||||
}, .{ .event = .normal, .size = size }));
|
||||
|
||||
try testing.expect(shouldReport(.{
|
||||
.button = .left,
|
||||
.action = .release,
|
||||
}, .{ .event = .normal, .size = size }));
|
||||
|
||||
try testing.expect(!shouldReport(.{
|
||||
.button = .left,
|
||||
.action = .motion,
|
||||
}, .{ .event = .normal, .size = size }));
|
||||
}
|
||||
|
||||
test "shouldReport: button mode requires a button" {
|
||||
const size = testSize();
|
||||
// With a button, all actions report.
|
||||
inline for ([_]mouse.Action{ .press, .release, .motion }) |action| {
|
||||
try testing.expect(shouldReport(.{
|
||||
.button = .left,
|
||||
.action = action,
|
||||
}, .{ .event = .button, .size = size }));
|
||||
}
|
||||
|
||||
// Without a button (null), nothing reports.
|
||||
inline for ([_]mouse.Action{ .press, .release, .motion }) |action| {
|
||||
try testing.expect(!shouldReport(.{
|
||||
.button = null,
|
||||
.action = action,
|
||||
}, .{ .event = .button, .size = size }));
|
||||
}
|
||||
}
|
||||
|
||||
test "shouldReport: any mode reports everything" {
|
||||
const size = testSize();
|
||||
inline for ([_]mouse.Action{ .press, .release, .motion }) |action| {
|
||||
try testing.expect(shouldReport(.{
|
||||
.button = .left,
|
||||
.action = action,
|
||||
}, .{ .event = .any, .size = size }));
|
||||
}
|
||||
|
||||
// Even null button + motion reports.
|
||||
try testing.expect(shouldReport(.{
|
||||
.button = null,
|
||||
.action = .motion,
|
||||
}, .{ .event = .any, .size = size }));
|
||||
}
|
||||
|
||||
test "x10 press left" {
|
||||
var data: [32]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&data);
|
||||
var last: ?point.Coordinate = null;
|
||||
try encode(&writer, .{
|
||||
.button = .left,
|
||||
.action = .press,
|
||||
.mods = .{ .shift = true, .alt = true, .ctrl = true },
|
||||
.pos = .{ .x = 0, .y = 0 },
|
||||
}, .{
|
||||
.event = .x10,
|
||||
.format = .x10,
|
||||
.size = testSize(),
|
||||
.last_cell = &last,
|
||||
});
|
||||
|
||||
try testing.expectEqualSlices(u8, &.{
|
||||
0x1B,
|
||||
'[',
|
||||
'M',
|
||||
32,
|
||||
33,
|
||||
33,
|
||||
}, writer.buffered());
|
||||
}
|
||||
|
||||
test "x10 ignores release" {
|
||||
var data: [32]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&data);
|
||||
var last: ?point.Coordinate = null;
|
||||
try encode(&writer, .{
|
||||
.button = .left,
|
||||
.action = .release,
|
||||
}, .{
|
||||
.event = .x10,
|
||||
.format = .x10,
|
||||
.size = testSize(),
|
||||
.last_cell = &last,
|
||||
});
|
||||
|
||||
try testing.expectEqual(@as(usize, 0), writer.buffered().len);
|
||||
}
|
||||
|
||||
test "normal ignores motion" {
|
||||
var data: [32]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&data);
|
||||
var last: ?point.Coordinate = null;
|
||||
try encode(&writer, .{
|
||||
.button = .left,
|
||||
.action = .motion,
|
||||
}, .{
|
||||
.event = .normal,
|
||||
.format = .sgr,
|
||||
.size = testSize(),
|
||||
.last_cell = &last,
|
||||
});
|
||||
|
||||
try testing.expectEqual(@as(usize, 0), writer.buffered().len);
|
||||
}
|
||||
|
||||
test "button mode requires button" {
|
||||
var data: [32]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&data);
|
||||
var last: ?point.Coordinate = null;
|
||||
try encode(&writer, .{
|
||||
.button = null,
|
||||
.action = .motion,
|
||||
}, .{
|
||||
.event = .button,
|
||||
.format = .sgr,
|
||||
.size = testSize(),
|
||||
.last_cell = &last,
|
||||
});
|
||||
|
||||
try testing.expectEqual(@as(usize, 0), writer.buffered().len);
|
||||
}
|
||||
|
||||
test "sgr release keeps button identity" {
|
||||
var data: [32]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&data);
|
||||
var last: ?point.Coordinate = null;
|
||||
try encode(&writer, .{
|
||||
.button = .right,
|
||||
.action = .release,
|
||||
.pos = .{ .x = 4, .y = 5 },
|
||||
}, .{
|
||||
.event = .any,
|
||||
.format = .sgr,
|
||||
.size = testSize(),
|
||||
.last_cell = &last,
|
||||
});
|
||||
|
||||
try testing.expectEqualStrings("\x1B[<2;5;6m", writer.buffered());
|
||||
}
|
||||
|
||||
test "sgr motion with no button" {
|
||||
var data: [32]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&data);
|
||||
var last: ?point.Coordinate = null;
|
||||
try encode(&writer, .{
|
||||
.button = null,
|
||||
.action = .motion,
|
||||
.pos = .{ .x = 1, .y = 2 },
|
||||
}, .{
|
||||
.event = .any,
|
||||
.format = .sgr,
|
||||
.size = testSize(),
|
||||
.last_cell = &last,
|
||||
});
|
||||
|
||||
try testing.expectEqualStrings("\x1B[<35;2;3M", writer.buffered());
|
||||
}
|
||||
|
||||
test "urxvt with modifiers" {
|
||||
var data: [32]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&data);
|
||||
var last: ?point.Coordinate = null;
|
||||
try encode(&writer, .{
|
||||
.button = .left,
|
||||
.action = .press,
|
||||
.mods = .{ .shift = true, .alt = true, .ctrl = true },
|
||||
.pos = .{ .x = 2, .y = 3 },
|
||||
}, .{
|
||||
.event = .any,
|
||||
.format = .urxvt,
|
||||
.size = testSize(),
|
||||
.last_cell = &last,
|
||||
});
|
||||
|
||||
try testing.expectEqualStrings("\x1B[60;3;4M", writer.buffered());
|
||||
}
|
||||
|
||||
test "utf8 encodes large coordinates" {
|
||||
var data: [32]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&data);
|
||||
var last: ?point.Coordinate = null;
|
||||
try encode(&writer, .{
|
||||
.button = .left,
|
||||
.action = .press,
|
||||
.pos = .{ .x = 300, .y = 400 },
|
||||
}, .{
|
||||
.event = .any,
|
||||
.format = .utf8,
|
||||
.size = testSize(),
|
||||
.last_cell = &last,
|
||||
});
|
||||
|
||||
const out = writer.buffered();
|
||||
try testing.expectEqualSlices(u8, &.{ 0x1B, '[', 'M', 32 }, out[0..4]);
|
||||
|
||||
const view = try std.unicode.Utf8View.init(out[4..]);
|
||||
var it = view.iterator();
|
||||
try testing.expectEqual(@as(u21, 333), it.nextCodepoint().?);
|
||||
try testing.expectEqual(@as(u21, 433), it.nextCodepoint().?);
|
||||
try testing.expectEqual(@as(?u21, null), it.nextCodepoint());
|
||||
}
|
||||
|
||||
test "x10 coordinate limit" {
|
||||
var data: [32]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&data);
|
||||
var last: ?point.Coordinate = null;
|
||||
try encode(&writer, .{
|
||||
.button = .left,
|
||||
.action = .press,
|
||||
.pos = .{ .x = 223, .y = 0 },
|
||||
}, .{
|
||||
.event = .x10,
|
||||
.format = .x10,
|
||||
.size = testSize(),
|
||||
.last_cell = &last,
|
||||
});
|
||||
|
||||
try testing.expectEqual(@as(usize, 0), writer.buffered().len);
|
||||
}
|
||||
|
||||
test "sgr wheel button mappings" {
|
||||
const Case = struct {
|
||||
button: mouse.Button,
|
||||
code: u8,
|
||||
};
|
||||
|
||||
inline for ([_]Case{
|
||||
.{ .button = .four, .code = 64 },
|
||||
.{ .button = .five, .code = 65 },
|
||||
.{ .button = .six, .code = 66 },
|
||||
.{ .button = .seven, .code = 67 },
|
||||
}) |c| {
|
||||
var data: [32]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&data);
|
||||
var last: ?point.Coordinate = null;
|
||||
try encode(&writer, .{
|
||||
.button = c.button,
|
||||
.action = .press,
|
||||
.pos = .{ .x = 0, .y = 0 },
|
||||
}, .{
|
||||
.event = .any,
|
||||
.format = .sgr,
|
||||
.size = testSize(),
|
||||
.last_cell = &last,
|
||||
});
|
||||
|
||||
var expected: [32]u8 = undefined;
|
||||
const want = try std.fmt.bufPrint(&expected, "\x1B[<{d};1;1M", .{c.code});
|
||||
try testing.expectEqualStrings(want, writer.buffered());
|
||||
}
|
||||
}
|
||||
|
||||
test "urxvt release uses legacy button 3 encoding" {
|
||||
var data: [32]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&data);
|
||||
var last: ?point.Coordinate = null;
|
||||
try encode(&writer, .{
|
||||
.button = .right,
|
||||
.action = .release,
|
||||
.pos = .{ .x = 2, .y = 3 },
|
||||
}, .{
|
||||
.event = .any,
|
||||
.format = .urxvt,
|
||||
.size = testSize(),
|
||||
.last_cell = &last,
|
||||
});
|
||||
|
||||
try testing.expectEqualStrings("\x1B[35;3;4M", writer.buffered());
|
||||
}
|
||||
|
||||
test "unsupported button is ignored" {
|
||||
var data: [32]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&data);
|
||||
var last: ?point.Coordinate = null;
|
||||
try encode(&writer, .{
|
||||
.button = .ten,
|
||||
.action = .press,
|
||||
.pos = .{ .x = 1, .y = 1 },
|
||||
}, .{
|
||||
.event = .any,
|
||||
.format = .sgr,
|
||||
.size = testSize(),
|
||||
.last_cell = &last,
|
||||
});
|
||||
|
||||
try testing.expectEqual(@as(usize, 0), writer.buffered().len);
|
||||
}
|
||||
|
||||
test "sgr pixels uses terminal-space cursor coordinates" {
|
||||
var data: [32]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&data);
|
||||
var last: ?point.Coordinate = null;
|
||||
try encode(&writer, .{
|
||||
.button = .left,
|
||||
.action = .press,
|
||||
.pos = .{ .x = 10, .y = 20 },
|
||||
}, .{
|
||||
.event = .any,
|
||||
.format = .sgr_pixels,
|
||||
.size = testSize(),
|
||||
.last_cell = &last,
|
||||
});
|
||||
|
||||
try testing.expectEqualStrings("\x1B[<0;10;20M", writer.buffered());
|
||||
}
|
||||
|
||||
test "sgr pixels release keeps button identity" {
|
||||
var data: [32]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&data);
|
||||
var last: ?point.Coordinate = null;
|
||||
try encode(&writer, .{
|
||||
.button = .right,
|
||||
.action = .release,
|
||||
.pos = .{ .x = 10, .y = 20 },
|
||||
}, .{
|
||||
.event = .any,
|
||||
.format = .sgr_pixels,
|
||||
.size = testSize(),
|
||||
.last_cell = &last,
|
||||
});
|
||||
|
||||
try testing.expectEqualStrings("\x1B[<2;10;20m", writer.buffered());
|
||||
}
|
||||
|
||||
test "position exactly at viewport boundary is encoded in final cell" {
|
||||
const size: renderer_size.Size = .{
|
||||
.screen = .{ .width = 10, .height = 10 },
|
||||
.cell = .{ .width = 2, .height = 2 },
|
||||
.padding = .{},
|
||||
};
|
||||
|
||||
var data: [32]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&data);
|
||||
var last: ?point.Coordinate = null;
|
||||
try encode(&writer, .{
|
||||
.button = .left,
|
||||
.action = .press,
|
||||
.pos = .{ .x = 10, .y = 10 },
|
||||
}, .{
|
||||
.event = .any,
|
||||
.format = .sgr,
|
||||
.size = size,
|
||||
.last_cell = &last,
|
||||
});
|
||||
|
||||
try testing.expectEqualStrings("\x1B[<0;5;5M", writer.buffered());
|
||||
}
|
||||
|
||||
test "outside viewport motion with no pressed button is ignored" {
|
||||
var data: [32]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&data);
|
||||
var last: ?point.Coordinate = null;
|
||||
try encode(&writer, .{
|
||||
.button = .left,
|
||||
.action = .motion,
|
||||
.pos = .{ .x = -1, .y = -1 },
|
||||
}, .{
|
||||
.event = .any,
|
||||
.format = .sgr,
|
||||
.size = testSize(),
|
||||
.any_button_pressed = false,
|
||||
.last_cell = &last,
|
||||
});
|
||||
|
||||
try testing.expectEqual(@as(usize, 0), writer.buffered().len);
|
||||
}
|
||||
|
||||
test "outside viewport motion with pressed button is reported" {
|
||||
var data: [32]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&data);
|
||||
var last: ?point.Coordinate = null;
|
||||
try encode(&writer, .{
|
||||
.button = .left,
|
||||
.action = .motion,
|
||||
.pos = .{ .x = -1, .y = -1 },
|
||||
}, .{
|
||||
.event = .any,
|
||||
.format = .sgr,
|
||||
.size = testSize(),
|
||||
.any_button_pressed = true,
|
||||
.last_cell = &last,
|
||||
});
|
||||
|
||||
try testing.expectEqualStrings("\x1B[<32;1;1M", writer.buffered());
|
||||
}
|
||||
|
||||
test "motion is deduped by last cell except sgr pixels" {
|
||||
var last: ?point.Coordinate = null;
|
||||
|
||||
{
|
||||
var data: [32]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&data);
|
||||
try encode(&writer, .{
|
||||
.button = .left,
|
||||
.action = .motion,
|
||||
.pos = .{ .x = 5, .y = 6 },
|
||||
}, .{
|
||||
.event = .any,
|
||||
.format = .sgr,
|
||||
.size = testSize(),
|
||||
.last_cell = &last,
|
||||
});
|
||||
try testing.expect(writer.buffered().len > 0);
|
||||
}
|
||||
|
||||
{
|
||||
var data: [32]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&data);
|
||||
try encode(&writer, .{
|
||||
.button = .left,
|
||||
.action = .motion,
|
||||
.pos = .{ .x = 5, .y = 6 },
|
||||
}, .{
|
||||
.event = .any,
|
||||
.format = .sgr,
|
||||
.size = testSize(),
|
||||
.last_cell = &last,
|
||||
});
|
||||
try testing.expectEqual(@as(usize, 0), writer.buffered().len);
|
||||
}
|
||||
|
||||
{
|
||||
var data: [32]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&data);
|
||||
try encode(&writer, .{
|
||||
.button = .left,
|
||||
.action = .motion,
|
||||
.pos = .{ .x = 5, .y = 6 },
|
||||
}, .{
|
||||
.event = .any,
|
||||
.format = .sgr_pixels,
|
||||
.size = testSize(),
|
||||
.last_cell = &last,
|
||||
});
|
||||
try testing.expect(writer.buffered().len > 0);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ const MouseShape = @import("terminal/mouse_shape.zig").MouseShape;
|
||||
physical_key: input.Key,
|
||||
|
||||
/// The mouse event tracking mode, if any.
|
||||
mouse_event: terminal.Terminal.MouseEvents,
|
||||
mouse_event: terminal.Terminal.MouseEvent,
|
||||
|
||||
/// The current terminal's mouse shape.
|
||||
mouse_shape: MouseShape,
|
||||
|
||||
@@ -96,7 +96,7 @@ flags: packed struct {
|
||||
/// set mode in modes. You can't get the right event/format to use
|
||||
/// based on modes alone because modes don't show you what order
|
||||
/// this was called so we have to track it separately.
|
||||
mouse_event: MouseEvents = .none,
|
||||
mouse_event: MouseEvent = .none,
|
||||
mouse_format: MouseFormat = .x10,
|
||||
|
||||
/// Set via the XTSHIFTESCAPE sequence. If true (XTSHIFTESCAPE = 1)
|
||||
@@ -169,7 +169,7 @@ pub const Dirty = packed struct {
|
||||
|
||||
/// The event types that can be reported for mouse-related activities.
|
||||
/// These are all mutually exclusive (hence in a single enum).
|
||||
pub const MouseEvents = enum(u3) {
|
||||
pub const MouseEvent = enum(u3) {
|
||||
none = 0,
|
||||
x10 = 1, // 9
|
||||
normal = 2, // 1000
|
||||
@@ -177,7 +177,7 @@ pub const MouseEvents = enum(u3) {
|
||||
any = 4, // 1003
|
||||
|
||||
/// Returns true if this event sends motion events.
|
||||
pub fn motion(self: MouseEvents) bool {
|
||||
pub fn motion(self: MouseEvent) bool {
|
||||
return self == .button or self == .any;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user