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:
Mitchell Hashimoto
2026-03-15 07:43:34 -07:00
parent a7514d389b
commit ac5e57ce67
6 changed files with 839 additions and 217 deletions

View File

@@ -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.