diff --git a/src/Surface.zig b/src/Surface.zig index 31886a908..9e3e39708 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -37,6 +37,7 @@ const input = @import("input.zig"); const App = @import("App.zig"); const internal_os = @import("os/main.zig"); const inspector = @import("inspector/main.zig"); +const SurfaceMouse = @import("surface_mouse.zig"); const log = std.log.scoped(.surface); @@ -1285,23 +1286,16 @@ pub fn keyCallback( if (rehide) self.hideMouse(); } - // When we are in the middle of a mouse event and we press shift, - // we change the mouse to a text shape so that selection appears - // possible. - if (self.io.terminal.flags.mouse_event != .none and - event.physical_key == .left_shift or - event.physical_key == .right_shift) - { - switch (event.action) { - .press => if (!self.mouse.over_link) { - // If the cursor is over a link then the pointer shape takes - // priority - try self.rt_surface.setMouseShape(.text); - }, - .release => try self.rt_surface.setMouseShape(self.io.terminal.mouse_shape), - .repeat => {}, - } - } + // Process the cursor state logic. This will update the cursor shape if + // needed, depending on the key state. + if ((SurfaceMouse{ + .physical_key = event.physical_key, + .mouse_event = self.io.terminal.flags.mouse_event, + .mouse_shape = self.io.terminal.mouse_shape, + .mods = self.mouse.mods, + .over_link = self.mouse.over_link, + }).keyToMouseShape()) |shape| + try self.rt_surface.setMouseShape(shape); // No binding, so we have to perform an encoding task. This // may still result in no encoding. Under different modes and @@ -2260,17 +2254,6 @@ pub fn cursorPosCallback( } } -// Checks to see if super is on in mods (MacOS) or ctrl. We use this for -// rectangle select along with alt. -// -// Not to be confused with ctrlOrSuper in Config. -fn ctrlOrSuper(mods: input.Mods) bool { - if (comptime builtin.target.isDarwin()) { - return mods.super; - } - return mods.ctrl; -} - /// Double-click dragging moves the selection one "word" at a time. fn dragLeftClickDouble( self: *Surface, @@ -2385,7 +2368,7 @@ fn dragLeftClickSingle( self.setSelection(if (selected) .{ .start = screen_point, .end = screen_point, - .rectangle = ctrlOrSuper(self.mouse.mods) and self.mouse.mods.alt, + .rectangle = self.mouse.mods.ctrlOrSuper() and self.mouse.mods.alt, } else null); return; @@ -2432,7 +2415,7 @@ fn dragLeftClickSingle( self.setSelection(.{ .start = start, .end = screen_point, - .rectangle = ctrlOrSuper(self.mouse.mods) and self.mouse.mods.alt, + .rectangle = self.mouse.mods.ctrlOrSuper() and self.mouse.mods.alt, }); return; } @@ -2491,7 +2474,7 @@ fn dragLeftClickBefore( click_point: terminal.point.ScreenPoint, mods: input.Mods, ) bool { - if (ctrlOrSuper(mods) and mods.alt) { + if (mods.ctrlOrSuper() and mods.alt) { return screen_point.x < click_point.x; } diff --git a/src/config/Config.zig b/src/config/Config.zig index 00b856ceb..1275c8a82 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -881,23 +881,23 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { // Fonts try result.keybind.set.put( alloc, - .{ .key = .equal, .mods = ctrlOrSuper(.{}) }, + .{ .key = .equal, .mods = inputpkg.ctrlOrSuper(.{}) }, .{ .increase_font_size = 1 }, ); try result.keybind.set.put( alloc, - .{ .key = .minus, .mods = ctrlOrSuper(.{}) }, + .{ .key = .minus, .mods = inputpkg.ctrlOrSuper(.{}) }, .{ .decrease_font_size = 1 }, ); try result.keybind.set.put( alloc, - .{ .key = .zero, .mods = ctrlOrSuper(.{}) }, + .{ .key = .zero, .mods = inputpkg.ctrlOrSuper(.{}) }, .{ .reset_font_size = {} }, ); try result.keybind.set.put( alloc, - .{ .key = .j, .mods = ctrlOrSuper(.{ .shift = true }) }, + .{ .key = .j, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, .{ .write_scrollback_file = {} }, ); @@ -1098,14 +1098,14 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { // Toggle fullscreen try result.keybind.set.put( alloc, - .{ .key = .enter, .mods = ctrlOrSuper(.{}) }, + .{ .key = .enter, .mods = inputpkg.ctrlOrSuper(.{}) }, .{ .toggle_fullscreen = {} }, ); // Toggle zoom a split try result.keybind.set.put( alloc, - .{ .key = .enter, .mods = ctrlOrSuper(.{ .shift = true }) }, + .{ .key = .enter, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, .{ .toggle_split_zoom = {} }, ); @@ -1289,21 +1289,6 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { return result; } -/// This sets either "ctrl" or "super" to true (but not both) -/// on mods depending on if the build target is Mac or not. On -/// Mac, we default to super (i.e. super+c for copy) and on -/// non-Mac we default to ctrl (i.e. ctrl+c for copy). -fn ctrlOrSuper(mods: inputpkg.Mods) inputpkg.Mods { - var copy = mods; - if (comptime builtin.target.isDarwin()) { - copy.super = true; - } else { - copy.ctrl = true; - } - - return copy; -} - /// Load configuration from an iterator that yields values that look like /// command-line arguments, i.e. `--key=value`. pub fn loadIter( diff --git a/src/input/key.zig b/src/input/key.zig index adbb4f60a..26e7e056f 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -145,6 +145,14 @@ pub const Mods = packed struct(Mods.Backing) { return result; } + /// Checks to see if super is on (MacOS) or ctrl. + pub fn ctrlOrSuper(self: Mods) bool { + if (comptime builtin.target.isDarwin()) { + return self.super; + } + return self.ctrl; + } + // For our own understanding test { const testing = std.testing; @@ -607,6 +615,26 @@ pub const Key = enum(c_int) { => null, }; } + + /// true if this key is one of the left or right versions of super (MacOS) + /// or ctrl. + pub fn ctrlOrSuper(self: Key) bool { + if (comptime builtin.target.isDarwin()) { + return self == .left_super or self == .right_super; + } + return self == .left_control or self == .right_control; + } + + /// true if this key is either left or right shift. + pub fn leftOrRightShift(self: Key) bool { + return self == .left_shift or self == .right_shift; + } + + /// true if this key is either left or right alt. + pub fn leftOrRightAlt(self: Key) bool { + return self == .left_alt or self == .right_alt; + } + test "fromASCII should not return keypad keys" { const testing = std.testing; try testing.expect(Key.fromASCII('0').? == .zero); @@ -689,3 +717,25 @@ pub const Key = enum(c_int) { .{ '=', .kp_equal }, }; }; + +/// This sets either "ctrl" or "super" to true (but not both) +/// on mods depending on if the build target is Mac or not. On +/// Mac, we default to super (i.e. super+c for copy) and on +/// non-Mac we default to ctrl (i.e. ctrl+c for copy). +pub fn ctrlOrSuper(mods: Mods) Mods { + var copy = mods; + if (comptime builtin.target.isDarwin()) { + copy.super = true; + } else { + copy.ctrl = true; + } + + return copy; +} + +test "ctrlOrSuper" { + const testing = std.testing; + var m: Mods = ctrlOrSuper(.{}); + + try testing.expect(m.ctrlOrSuper()); +} diff --git a/src/main.zig b/src/main.zig index 91167e721..66e39131e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -313,6 +313,7 @@ test { _ = @import("termio.zig"); _ = @import("input.zig"); _ = @import("cli.zig"); + _ = @import("surface_mouse.zig"); // Libraries _ = @import("segmented_pool.zig"); diff --git a/src/surface_mouse.zig b/src/surface_mouse.zig new file mode 100644 index 000000000..f04898d32 --- /dev/null +++ b/src/surface_mouse.zig @@ -0,0 +1,302 @@ +/// SurfaceMouse represents mouse helper functionality for the core surface. +/// +/// It's currently small in scope; its purpose is to isolate mouse logic that +/// has gotten a bit complex (e.g. pointer shape handling for key events), but +/// the intention is to grow it later so that we can better test said logic). +const SurfaceMouse = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const input = @import("input.zig"); +const apprt = @import("apprt.zig"); +const terminal = @import("terminal/main.zig"); +const MouseShape = @import("terminal/mouse_shape.zig").MouseShape; + +/// For processing key events; the key that was physically pressed on the +/// keyboard. +physical_key: input.Key, + +/// The mouse event tracking mode, if any. +mouse_event: terminal.Terminal.MouseEvents, + +/// The current terminal's mouse shape. +mouse_shape: MouseShape, + +/// The last mods state when the last mouse button (whatever it was) was +/// pressed or release. +mods: input.Mods, + +/// True if the mouse position is currently over a link. +over_link: bool, + +/// Translates key state to mouse shape (cursor) state, based on a state +/// machine. +/// +/// There are 4 current states: +/// +/// * text: starting state, displays a text bar. +/// * default: default state when in a mouse tracking mode. (e.g. vim, etc). +/// Displays an arrow pointer. +/// * pointer: default state when over a link. Displays a pointing finger. +/// * crosshair: any above state can transition to this when the rectangle +/// select keys are pressed (ctrl/super+alt). +/// +/// Additionally, default can transition back to text if one of the shift keys +/// are pressed during mouse tracking mode. +/// +/// Any secondary state transitions back to its default state when the +/// appropriate keys are released. +/// +/// null is returned when the mouse shape does not need changing. +pub fn keyToMouseShape(self: SurfaceMouse) ?MouseShape { + // Filter for appropriate key events + if (!eligibleMouseShapeKeyEvent(self.physical_key)) return null; + + // Exception: link hover overrides any other shape processing currently and + // does not change state. + // + // TODO: As we unravel mouse state, we can fix this to be more explicit. + if (self.over_link) { + return null; + } + + // Set our current default state + var current_shape_state: MouseShape = undefined; + if (self.mouse_event != .none) { + // In mouse tracking mode, should be default (arrow pointer) + current_shape_state = .default; + } else { + // Default terminal mode, should be text (text bar) + current_shape_state = .text; + } + + // Transition table. + // + // TODO: This could be updated eventually to be a true transition table if + // we move to a full stateful mouse surface, e.g. very specific inputs + // transitioning state based on previous state, versus flags like "is the + // mouse over a link", etc. + switch (current_shape_state) { + .default => { + if (isMouseModeOverrideState(self.mods) and isRectangleSelectState(self.mods)) { + // Crosshair (rectangle select), only set if we are also + // overriding (e.g. shift+ctrl+alt) + return .crosshair; + } else if (isMouseModeOverrideState(self.mods)) { + // Normal override state + return .text; + } else { + return .default; + } + }, + + .text => { + if (isRectangleSelectState(self.mods)) { + // Crosshair (rectangle select) + return .crosshair; + } else { + return .text; + } + }, + + // Fall back on default state + else => unreachable, + } +} + +fn eligibleMouseShapeKeyEvent(physical_key: input.Key) bool { + return physical_key.ctrlOrSuper() or + physical_key.leftOrRightShift() or + physical_key.leftOrRightAlt(); +} + +fn isRectangleSelectState(mods: input.Mods) bool { + return mods.ctrlOrSuper() and mods.alt; +} + +fn isMouseModeOverrideState(mods: input.Mods) bool { + return mods.shift; +} + +test "keyToMouseShape" { + const testing = std.testing; + + { + // No specific key pressed + const m: SurfaceMouse = .{ + .physical_key = .invalid, + .mouse_event = .none, + .mouse_shape = .progress, + .mods = .{}, + .over_link = false, + }; + + const got = m.keyToMouseShape(); + try testing.expect(got == null); + } + + { + // Over a link. NOTE: This tests that we don't touch the inbound state, + // not necessarily if we're over a link. + const m: SurfaceMouse = .{ + .physical_key = .left_shift, + .mouse_event = .none, + .mouse_shape = .progress, + .mods = .{}, + .over_link = true, + }; + + const got = m.keyToMouseShape(); + try testing.expect(got == null); + } + + { + // default, no mods (mouse tracking) + const m: SurfaceMouse = .{ + .physical_key = .left_shift, + .mouse_event = .x10, + .mouse_shape = .default, + .mods = .{}, + .over_link = false, + }; + + const want: MouseShape = .default; + const got = m.keyToMouseShape(); + try testing.expect(want == got); + } + + { + // default -> crosshair (mouse tracking) + const m: SurfaceMouse = .{ + .physical_key = .left_alt, + .mouse_event = .x10, + .mouse_shape = .default, + .mods = .{ .ctrl = true, .super = true, .alt = true, .shift = true }, + .over_link = false, + }; + + const want: MouseShape = .crosshair; + const got = m.keyToMouseShape(); + try testing.expect(want == got); + } + + { + // default -> text (mouse tracking) + const m: SurfaceMouse = .{ + .physical_key = .left_shift, + .mouse_event = .x10, + .mouse_shape = .default, + .mods = .{ .shift = true }, + .over_link = false, + }; + + const want: MouseShape = .text; + const got = m.keyToMouseShape(); + try testing.expect(want == got); + } + + { + // crosshair -> text (mouse tracking) + const m: SurfaceMouse = .{ + .physical_key = .left_alt, + .mouse_event = .x10, + .mouse_shape = .crosshair, + .mods = .{ .shift = true }, + .over_link = false, + }; + + const want: MouseShape = .text; + const got = m.keyToMouseShape(); + try testing.expect(want == got); + } + + { + // crosshair -> default (mouse tracking) + const m: SurfaceMouse = .{ + .physical_key = .left_alt, + .mouse_event = .x10, + .mouse_shape = .crosshair, + .mods = .{}, + .over_link = false, + }; + + const want: MouseShape = .default; + const got = m.keyToMouseShape(); + try testing.expect(want == got); + } + + { + // text -> crosshair (mouse tracking) + const m: SurfaceMouse = .{ + .physical_key = .left_alt, + .mouse_event = .x10, + .mouse_shape = .text, + .mods = .{ .ctrl = true, .super = true, .alt = true, .shift = true }, + .over_link = false, + }; + + const want: MouseShape = .crosshair; + const got = m.keyToMouseShape(); + try testing.expect(want == got); + } + + { + // text -> default (mouse tracking) + const m: SurfaceMouse = .{ + .physical_key = .left_shift, + .mouse_event = .x10, + .mouse_shape = .text, + .mods = .{}, + .over_link = false, + }; + + const want: MouseShape = .default; + const got = m.keyToMouseShape(); + try testing.expect(want == got); + } + + { + // text, no mods (no mouse tracking) + const m: SurfaceMouse = .{ + .physical_key = .left_shift, + .mouse_event = .none, + .mouse_shape = .text, + .mods = .{}, + .over_link = false, + }; + + const want: MouseShape = .text; + const got = m.keyToMouseShape(); + try testing.expect(want == got); + } + + { + // text -> crosshair (no mouse tracking) + const m: SurfaceMouse = .{ + .physical_key = .left_alt, + .mouse_event = .none, + .mouse_shape = .text, + .mods = .{ .ctrl = true, .super = true, .alt = true }, + .over_link = false, + }; + + const want: MouseShape = .crosshair; + const got = m.keyToMouseShape(); + try testing.expect(want == got); + } + + { + // crosshair -> text (no mouse tracking) + const m: SurfaceMouse = .{ + .physical_key = .left_alt, + .mouse_event = .none, + .mouse_shape = .crosshair, + .mods = .{}, + .over_link = false, + }; + + const want: MouseShape = .text; + const got = m.keyToMouseShape(); + try testing.expect(want == got); + } +}