Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782const std = @import("std");const testing = std.testing;const terminal = @import("../terminal/main.zig");const Terminal = terminal.Terminal;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 = extern 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 (!terminal.mouse.eventSendsMotion(opts.event)) 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); }}