diff --git a/src/Window.zig b/src/Window.zig index b884a1756..e7a06619c 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -137,20 +137,25 @@ const Cursor = struct { /// Mouse state for the window. const Mouse = struct { - /// The current state of mouse click. - click_state: ClickState = .none, + /// The last tracked mouse button state by button. + click_state: [input.MouseButton.max]input.MouseButtonState = .{.release} ** input.MouseButton.max, - /// The point at which the mouse click happened. This is in screen + /// The last mods state when the last mouse button (whatever it was) was + /// pressed or release. + mods: input.Mods = .{}, + + /// The point at which the left mouse click happened. This is in screen /// coordinates so that scrolling preserves the location. - click_point: terminal.point.ScreenPoint = .{}, + left_click_point: terminal.point.ScreenPoint = .{}, - /// The starting xpos/ypos of the click. This is only useful initially. - /// As soon as scrolling occurs, these are no longer accurate to calculate - /// the screen point. - click_xpos: f64 = 0, - click_ypos: f64 = 0, + /// The starting xpos/ypos of the left click. Note that if scrolling occurs, + /// these will point to different "cells", but the xpos/ypos will stay + /// stable during scrolling relative to the window. + left_click_xpos: f64 = 0, + left_click_ypos: f64 = 0, - const ClickState = enum { none, left }; + /// The last x/y sent for mouse reports. + event_point: terminal.point.Viewport = .{}, }; /// Create a new window. This allocates and returns a pointer because we @@ -754,6 +759,19 @@ fn scrollCallback(window: glfw.Window, xoff: f64, yoff: f64) void { const win = window.getUserPointer(Window) orelse return; + // If we're scrolling up or down, then send a mouse event + if (yoff != 0) { + const pos = window.getCursorPos() catch |err| { + log.err("error reading cursor position: {}", .{err}); + return; + }; + + win.mouseReport(if (yoff < 0) .four else .five, .press, win.mouse.mods, pos) catch |err| { + log.err("error reporting mouse event: {}", .{err}); + return; + }; + } + //log.info("SCROLL: {} {}", .{ xoff, yoff }); _ = xoff; @@ -769,10 +787,178 @@ fn scrollCallback(window: glfw.Window, xoff: f64, yoff: f64) void { win.render_timer.schedule() catch unreachable; } +/// The type of action to report for a mouse event. +const MouseReportAction = enum { press, release, motion }; + +fn mouseReport( + self: *Window, + button: ?input.MouseButton, + action: MouseReportAction, + mods: input.Mods, + unscaled_pos: glfw.Window.CursorPos, +) !void { + // TODO: posToViewport currently clamps to the window boundary, + // do we want to not report mouse events at all outside the window? + + // Depending on the event, we may do nothing at all. + switch (self.terminal.modes.mouse_event) { + .none => return, + + // 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 => {}, + } + + // This format reports X/Y + const pos = self.cursorPosToPixels(unscaled_pos); + const viewport_point = self.posToViewport(pos.xpos, pos.ypos); + + // For button events, we only report if we moved cells + if (self.terminal.modes.mouse_event == .button or + self.terminal.modes.mouse_event == .any) + { + if (self.mouse.event_point.x == viewport_point.x and + self.mouse.event_point.y == viewport_point.y) return; + + // Record our new point + 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.terminal.modes.mouse_format != .sgr) { + // 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, + .right => 1, + .middle => 2, + .four => 64, + .five => 65, + else => return, // unsupported + }; + } + + // X10 doesn't have modifiers + if (self.terminal.modes.mouse_event != .x10) { + if (mods.shift) acc += 4; + if (mods.super) acc += 8; + if (mods.ctrl) acc += 16; + } + + // Motion adds another bit + if (action == .motion) acc += 32; + + break :code acc; + }; + + switch (self.terminal.modes.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 proto wants 1 + var buf = [_]u8{ '\x1b', '[', 'M', 0, 0, 0 }; + buf[3] = 32 + button_code; + buf[4] = 32 + @intCast(u8, viewport_point.x) + 1; + buf[5] = 32 + @intCast(u8, viewport_point.y) + 1; + try self.queueWrite(&buf); + }, + + .utf8 => { + // Maximum of 12 because at most we have 2 fully UTF-8 encoded chars + var buf: [12]u8 = undefined; + buf[0] = '\x1b'; + buf[1] = '['; + buf[2] = 'M'; + + // The button code will always fit in a single u8 + buf[3] = 32 + button_code; + + // UTF-8 encode the x/y + var i: usize = 4; + i += try std.unicode.utf8Encode(@intCast(u21, 32 + viewport_point.x + 1), buf[i..]); + i += try std.unicode.utf8Encode(@intCast(u21, 32 + viewport_point.y + 1), buf[i..]); + + try self.queueWrite(buf[0..i]); + }, + + .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 buf: [32]u8 = undefined; + const resp = try std.fmt.bufPrint(&buf, "\x1B[<{d};{d};{d}{c}", .{ + button_code, + viewport_point.x + 1, + viewport_point.y + 1, + final, + }); + + try self.queueWrite(resp); + }, + + .urxvt => { + // Response always is at least 4 chars, so this leaves the + // remainder for numbers which are very large... + var buf: [32]u8 = undefined; + const resp = try std.fmt.bufPrint(&buf, "\x1B[{d};{d};{d}M", .{ + 32 + button_code, + viewport_point.x + 1, + viewport_point.y + 1, + }); + + try self.queueWrite(resp); + }, + + .sgr_pixels => { + // 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 buf: [32]u8 = undefined; + const resp = try std.fmt.bufPrint(&buf, "\x1B[<{d};{d};{d}{c}", .{ + button_code, + pos.xpos, + pos.ypos, + final, + }); + + try self.queueWrite(resp); + }, + } +} + fn mouseButtonCallback( window: glfw.Window, - button: glfw.MouseButton, - action: glfw.Action, + glfw_button: glfw.MouseButton, + glfw_action: glfw.Action, mods: glfw.Mods, ) void { _ = mods; @@ -780,42 +966,71 @@ fn mouseButtonCallback( const tracy = trace(@src()); defer tracy.end(); - if (button == .left) { - switch (action) { - .press => { - const win = window.getUserPointer(Window) orelse return; - const pos = win.cursorPosToPixels(window.getCursorPos() catch |err| { - log.err("error reading cursor position: {}", .{err}); - return; - }); + const win = window.getUserPointer(Window) orelse return; - // Store it - const point = win.posToViewport(pos.xpos, pos.ypos); - win.mouse.click_state = .left; - win.mouse.click_point = point.toScreen(&win.terminal.screen); - win.mouse.click_xpos = pos.xpos; - win.mouse.click_ypos = pos.ypos; - log.debug("click start state={} viewport={} screen={}", .{ - win.mouse.click_state, - point, - win.mouse.click_point, - }); + // Convert glfw button to input button + const button: input.MouseButton = switch (glfw_button) { + .left => .left, + .right => .right, + .middle => .middle, + .four => .four, + .five => .five, + .six => .six, + .seven => .seven, + .eight => .eight, + }; + const action: input.MouseButtonState = switch (glfw_action) { + .press => .press, + .release => .release, + else => unreachable, + }; - // Selection is always cleared - if (win.terminal.selection != null) { - win.terminal.selection = null; - win.render_timer.schedule() catch |err| - log.err("error scheduling render in mouseButtinCallback err={}", .{err}); - } - }, + // Always record our latest mouse state + win.mouse.click_state[@enumToInt(button)] = action; + win.mouse.mods = @bitCast(input.Mods, mods); - .release => { - const win = window.getUserPointer(Window) orelse return; - win.mouse.click_state = .none; - log.debug("click end", .{}); - }, + // Report mouse events if enabled + if (win.terminal.modes.mouse_event != .none) { + const pos = window.getCursorPos() catch |err| { + log.err("error reading cursor position: {}", .{err}); + return; + }; - .repeat => {}, + const report_action: MouseReportAction = switch (action) { + .press => .press, + .release => .release, + }; + + win.mouseReport( + button, + report_action, + win.mouse.mods, + pos, + ) catch |err| { + log.err("error reporting mouse event: {}", .{err}); + return; + }; + } + + // For left button clicks we always record some information for + // selection/highlighting purposes. + if (button == .left and action == .press) { + const pos = win.cursorPosToPixels(window.getCursorPos() catch |err| { + log.err("error reading cursor position: {}", .{err}); + return; + }); + + // Store it + const point = win.posToViewport(pos.xpos, pos.ypos); + win.mouse.left_click_point = point.toScreen(&win.terminal.screen); + win.mouse.left_click_xpos = pos.xpos; + win.mouse.left_click_ypos = pos.ypos; + + // Selection is always cleared + if (win.terminal.selection != null) { + win.terminal.selection = null; + win.render_timer.schedule() catch |err| + log.err("error scheduling render in mouseButtinCallback err={}", .{err}); } } } @@ -830,8 +1045,30 @@ fn cursorPosCallback( const win = window.getUserPointer(Window) orelse return; + // Do a mouse report + if (win.terminal.modes.mouse_event != .none) { + // We use the first mouse button we find pressed in order to report + // since the spec (afaict) does not say... + const button: ?input.MouseButton = button: for (win.mouse.click_state) |state, i| { + if (state == .press) + break :button @intToEnum(input.MouseButton, i); + } else null; + + win.mouseReport(button, .motion, win.mouse.mods, .{ + .xpos = unscaled_xpos, + .ypos = unscaled_ypos, + }) catch |err| { + log.err("error reporting mouse event: {}", .{err}); + return; + }; + + // If we're doing mouse motion tracking, we do not support text + // selection. + return; + } + // If the cursor isn't clicked currently, it doesn't matter - if (win.mouse.click_state != .left) return; + if (win.mouse.click_state[@enumToInt(input.MouseButton.left)] != .press) return; // All roads lead to requiring a re-render at this pont. win.render_timer.schedule() catch |err| @@ -880,13 +1117,13 @@ fn cursorPosCallback( const cell_xboundary = win.grid.cell_size.width * 0.6; // first xpos of the clicked cell - const cell_xstart = @intToFloat(f32, win.mouse.click_point.x) * win.grid.cell_size.width; - const cell_start_xpos = win.mouse.click_xpos - cell_xstart; + const cell_xstart = @intToFloat(f32, win.mouse.left_click_point.x) * win.grid.cell_size.width; + const cell_start_xpos = win.mouse.left_click_xpos - cell_xstart; // If this is the same cell, then we only start the selection if weve // moved past the boundary point the opposite direction from where we // started. - if (std.meta.eql(screen_point, win.mouse.click_point)) { + if (std.meta.eql(screen_point, win.mouse.left_click_point)) { const cell_xpos = xpos - cell_xstart; const selected: bool = if (cell_start_xpos < cell_xboundary) cell_xpos >= cell_xboundary @@ -908,9 +1145,9 @@ fn cursorPosCallback( // the starting cell if we started after the boundary, else // we start selection of the prior cell. // - Inverse logic for a point after the start. - const click_point = win.mouse.click_point; + const click_point = win.mouse.left_click_point; const start: terminal.point.ScreenPoint = if (screen_point.before(click_point)) start: { - if (win.mouse.click_xpos > cell_xboundary) { + if (win.mouse.left_click_xpos > cell_xboundary) { break :start click_point; } else { break :start if (click_point.x > 0) terminal.point.ScreenPoint{ @@ -922,7 +1159,7 @@ fn cursorPosCallback( }; } } else start: { - if (win.mouse.click_xpos < cell_xboundary) { + if (win.mouse.left_click_xpos < cell_xboundary) { break :start click_point; } else { break :start if (click_point.x < win.terminal.screen.cols - 1) terminal.point.ScreenPoint{ @@ -1069,7 +1306,7 @@ fn renderTimerCallback(t: *libuv.Timer) void { win.grid.background = bg; win.grid.foreground = fg; } - if (win.terminal.modes.reverse_colors == 1) { + if (win.terminal.modes.reverse_colors) { win.grid.background = fg; win.grid.foreground = bg; } @@ -1080,7 +1317,7 @@ fn renderTimerCallback(t: *libuv.Timer) void { g: f32, b: f32, a: f32, - } = if (win.terminal.modes.reverse_colors == 1) .{ + } = if (win.terminal.modes.reverse_colors) .{ .r = @intToFloat(f32, fg.r) / 255, .g = @intToFloat(f32, fg.g) / 255, .b = @intToFloat(f32, fg.b) / 255, @@ -1167,7 +1404,7 @@ pub fn setCursorCol(self: *Window, col: u16) !void { } pub fn setCursorRow(self: *Window, row: u16) !void { - if (self.terminal.modes.origin == 1) { + if (self.terminal.modes.origin) { // TODO log.err("setCursorRow: implement origin mode", .{}); unreachable; @@ -1234,19 +1471,19 @@ pub fn setTopAndBottomMargin(self: *Window, top: u16, bot: u16) !void { pub fn setMode(self: *Window, mode: terminal.Mode, enabled: bool) !void { switch (mode) { .reverse_colors => { - self.terminal.modes.reverse_colors = @boolToInt(enabled); + self.terminal.modes.reverse_colors = enabled; // Schedule a render since we changed colors try self.render_timer.schedule(); }, .origin => { - self.terminal.modes.origin = @boolToInt(enabled); + self.terminal.modes.origin = enabled; self.terminal.setCursorPos(1, 1); }, .autowrap => { - self.terminal.modes.autowrap = @boolToInt(enabled); + self.terminal.modes.autowrap = enabled; }, .cursor_visible => { @@ -1284,6 +1521,16 @@ pub fn setMode(self: *Window, mode: terminal.Mode, enabled: bool) !void { if (enabled) .@"132_cols" else .@"80_cols", ), + .mouse_event_x10 => self.terminal.modes.mouse_event = if (enabled) .x10 else .none, + .mouse_event_normal => self.terminal.modes.mouse_event = if (enabled) .normal else .none, + .mouse_event_button => self.terminal.modes.mouse_event = if (enabled) .button else .none, + .mouse_event_any => self.terminal.modes.mouse_event = if (enabled) .any else .none, + + .mouse_format_utf8 => self.terminal.modes.mouse_format = if (enabled) .utf8 else .x10, + .mouse_format_sgr => self.terminal.modes.mouse_format = if (enabled) .sgr else .x10, + .mouse_format_urxvt => self.terminal.modes.mouse_format = if (enabled) .urxvt else .x10, + .mouse_format_sgr_pixels => self.terminal.modes.mouse_format = if (enabled) .sgr_pixels else .x10, + else => if (enabled) log.warn("unimplemented mode: {}", .{mode}), } } @@ -1323,7 +1570,7 @@ pub fn deviceStatusReport( const pos: struct { x: usize, y: usize, - } = if (self.terminal.modes.origin == 1) .{ + } = if (self.terminal.modes.origin) .{ // TODO: what do we do if cursor is outside scrolling region? .x = self.terminal.screen.cursor.x, .y = self.terminal.screen.cursor.y -| self.terminal.scrolling_region.top, diff --git a/src/input.zig b/src/input.zig index 14efd40cc..da121890f 100644 --- a/src/input.zig +++ b/src/input.zig @@ -1,5 +1,6 @@ const std = @import("std"); +pub usingnamespace @import("input/mouse.zig"); pub usingnamespace @import("input/key.zig"); pub const Binding = @import("input/Binding.zig"); diff --git a/src/input/mouse.zig b/src/input/mouse.zig new file mode 100644 index 000000000..113521813 --- /dev/null +++ b/src/input/mouse.zig @@ -0,0 +1,38 @@ +/// The state of a mouse button. +pub const MouseButtonState = enum(u1) { + release = 0, + press = 1, +}; + +/// Possible mouse buttons. We only track up to 11 because thats the maximum +/// button input that terminal mouse tracking handles without becoming +/// ambiguous. +/// +/// Its a bit silly to name numbers like this but given its a restricted +/// set, it feels better than passing around raw numeric literals. +pub const MouseButton = enum(u4) { + const Self = @This(); + + /// The maximum value in this enum. This can be used to create a densely + /// packed array, for example. + pub const max = max: { + var cur = 0; + for (@typeInfo(Self).Enum.fields) |field| { + if (field.value > cur) cur = field.value; + } + + break :max cur; + }; + + left = 1, + right = 2, + middle = 3, + four = 4, + five = 5, + six = 6, + seven = 7, + eight = 8, + nine = 9, + ten = 10, + eleven = 11, +}; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 976d0c070..0433ed553 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -61,21 +61,44 @@ scrolling_region: ScrollingRegion, modes: packed struct { const Self = @This(); - reverse_colors: u1 = 0, // 5, - origin: u1 = 0, // 6 - autowrap: u1 = 1, // 7 + reverse_colors: bool = false, // 5, + origin: bool = false, // 6 + autowrap: bool = true, // 7 - deccolm: u1 = 0, // 3, - deccolm_supported: u1 = 0, // 40 + deccolm: bool = false, // 3, + deccolm_supported: bool = false, // 40 + + mouse_event: MouseEvents = .none, + mouse_format: MouseFormat = .x10, test { // We have this here so that we explicitly fail when we change the // size of modes. The size of modes is NOT particularly important, // we just want to be mentally aware when it happens. - try std.testing.expectEqual(1, @sizeOf(Self)); + try std.testing.expectEqual(2, @sizeOf(Self)); } } = .{}, +/// 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) { + none = 0, + x10 = 1, // 9 + normal = 2, // 1000 + button = 3, // 1002 + any = 4, // 1003 +}; + +/// The format of mouse events when enabled. +/// These are all mutually exclusive (hence in a single enum). +pub const MouseFormat = enum(u3) { + x10 = 0, + utf8 = 1, // 1005 + sgr = 2, // 1006 + urxvt = 3, // 1015 + sgr_pixels = 4, // 1016 +}; + /// Scrolling region is the area of the screen designated where scrolling /// occurs. Wen scrolling the screen, only this viewport is scrolled. const ScrollingRegion = struct { @@ -197,10 +220,10 @@ pub fn deccolm(self: *Terminal, alloc: Allocator, mode: DeccolmMode) !void { // bit. If the mode "?40" is set, then "?3" (DECCOLM) is supported. This // doesn't exactly match VT100 semantics but modern terminals no longer // blindly accept mode 3 since its so weird in modern practice. - if (self.modes.deccolm_supported == 0) return; + if (!self.modes.deccolm_supported) return; // Enable it - self.modes.deccolm = @enumToInt(mode); + self.modes.deccolm = mode == .@"132_cols"; // Resize -- we can set cols to 0 because deccolm will force it try self.resize(alloc, 0, self.rows); @@ -217,7 +240,7 @@ pub fn setDeccolmSupported(self: *Terminal, v: bool) void { const tracy = trace(@src()); defer tracy.end(); - self.modes.deccolm_supported = @boolToInt(v); + self.modes.deccolm_supported = v; } /// Resize the underlying terminal. @@ -228,8 +251,8 @@ pub fn resize(self: *Terminal, alloc: Allocator, cols_req: usize, rows: usize) ! // If we have deccolm supported then we are fixed at either 80 or 132 // columns depending on if mode 3 is set or not. // TODO: test - const cols: usize = if (self.modes.deccolm_supported == 1) - @as(usize, if (self.modes.deccolm == 1) 132 else 80) + const cols: usize = if (self.modes.deccolm_supported) + @as(usize, if (self.modes.deccolm) 132 else 80) else cols_req; @@ -370,7 +393,7 @@ pub fn print(self: *Terminal, c: u21) !void { if (width == 0) return; // If we're soft-wrapping, then handle that first. - if (self.screen.cursor.pending_wrap and self.modes.autowrap == 1) + if (self.screen.cursor.pending_wrap and self.modes.autowrap) _ = self.printWrap(); switch (width) { @@ -598,7 +621,7 @@ pub fn setCursorPos(self: *Terminal, row_req: usize, col_req: usize) void { y_offset: usize = 0, x_max: usize, y_max: usize, - } = if (self.modes.origin == 1) .{ + } = if (self.modes.origin) .{ .x_offset = 0, // TODO: left/right margins .y_offset = self.scrolling_region.top, .x_max = self.cols, // TODO: left/right margins @@ -630,7 +653,7 @@ pub fn setCursorColAbsolute(self: *Terminal, col_req: usize) void { // TODO: test - assert(self.modes.origin == 0); // TODO + assert(!self.modes.origin); // TODO if (self.status_display != .main) return; // TODO @@ -1325,7 +1348,7 @@ test "Terminal: setCursorPosition" { try testing.expect(!t.screen.cursor.pending_wrap); // Origin mode - t.modes.origin = 1; + t.modes.origin = true; // No change without a scroll region t.setCursorPos(81, 81); diff --git a/src/terminal/ansi.zig b/src/terminal/ansi.zig index 817f3b02e..bb2a962e8 100644 --- a/src/terminal/ansi.zig +++ b/src/terminal/ansi.zig @@ -58,6 +58,9 @@ pub const Mode = enum(u16) { /// Enable or disable automatic line wrapping. autowrap = 7, + /// Click-only (press) mouse reporting. + mouse_event_x10 = 9, + /// Set whether the cursor is visible or not. cursor_visible = 25, @@ -66,6 +69,29 @@ pub const Mode = enum(u16) { /// mode ?3 is set or unset. enable_mode_3 = 40, + /// "Normal" mouse events: click/release, scroll + mouse_event_normal = 1000, + + /// Same as normal mode but also send events for mouse motion + /// while the button is pressed when the cell in the grid changes. + mouse_event_button = 1002, + + /// Same as button mode but doesn't require a button to be pressed + /// to track mouse movement. + mouse_event_any = 1003, + + /// Report mouse position in the utf8 format to support larger screens. + mouse_format_utf8 = 1005, + + /// Report mouse position in the SGR format. + mouse_format_sgr = 1006, + + /// Report mouse position in the urxvt format. + mouse_format_urxvt = 1015, + + /// Report mouse position in the SGR format as pixels, instead of cells. + mouse_format_sgr_pixels = 1016, + /// Alternate screen mode with save cursor and clear on enter. alt_screen_save_cursor_clear_enter = 1049,