From ac5e57ce67d3c6913935aa265617cb4d3f46aba4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 15 Mar 2026 07:43:34 -0700 Subject: [PATCH] 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. --- src/Surface.zig | 260 +++---------- src/input.zig | 2 + src/input/mouse.zig | 6 + src/input/mouse_encode.zig | 780 +++++++++++++++++++++++++++++++++++++ src/surface_mouse.zig | 2 +- src/terminal/Terminal.zig | 6 +- 6 files changed, 839 insertions(+), 217 deletions(-) create mode 100644 src/input/mouse_encode.zig diff --git a/src/Surface.zig b/src/Surface.zig index c948d4408..e8d549d80 100644 --- a/src/Surface.zig +++ b/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. diff --git a/src/input.zig b/src/input.zig index bad3ac1f3..833e05820 100644 --- a/src/input.zig +++ b/src/input.zig @@ -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; diff --git a/src/input/mouse.zig b/src/input/mouse.zig index bdf967ed2..8a769557f 100644 --- a/src/input/mouse.zig +++ b/src/input/mouse.zig @@ -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. diff --git a/src/input/mouse_encode.zig b/src/input/mouse_encode.zig new file mode 100644 index 000000000..2dfe084fb --- /dev/null +++ b/src/input/mouse_encode.zig @@ -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); + } +} diff --git a/src/surface_mouse.zig b/src/surface_mouse.zig index 691f1b23c..6d3c11394 100644 --- a/src/surface_mouse.zig +++ b/src/surface_mouse.zig @@ -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, diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 1ea915c67..9e21ba97a 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -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; } };