diff --git a/src/terminal/SelectionGesture.zig b/src/terminal/SelectionGesture.zig new file mode 100644 index 000000000..a85e22a2a --- /dev/null +++ b/src/terminal/SelectionGesture.zig @@ -0,0 +1,363 @@ +/// SelectionGesture manages gesture-based selection logic (mouse press, drag, +/// etc.). Callers setup initial state, make calls for various external +/// events, and react to the requested effects. +const SelectionGesture = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const testing = std.testing; +const Allocator = std.mem.Allocator; +const PageList = @import("PageList.zig"); +const Pin = PageList.Pin; +const ScreenSet = @import("ScreenSet.zig"); +const Terminal = @import("Terminal.zig"); + +/// The tracked pin of the initial left click along with the screen +/// that the pin is part of. +left_click_pin: ?*Pin, +left_click_screen: ScreenSet.Key, +left_click_screen_generation: usize, + +/// The count of clicks to count double and triple clicks and so on. +/// The left click time was the last time the left click was done, if the +/// caller could provide one. If this is null then we only support single clicks. +left_click_count: u3, +left_click_time: ?std.time.Instant, + +/// 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 surface. +left_click_xpos: f64, +left_click_ypos: f64, + +pub const init: SelectionGesture = .{ + .left_click_pin = null, + .left_click_count = 0, + .left_click_time = null, + .left_click_screen = .primary, + .left_click_screen_generation = 0, + .left_click_xpos = 0, + .left_click_ypos = 0, +}; + +pub fn deinit(self: *SelectionGesture, t: *Terminal) void { + // Grab our pagelist that is associated with the pin. If it doesn't + // exist anymore then our tracked pin is already free. + const pin = self.left_click_pin orelse return; + if (t.screens.generation(self.left_click_screen) != self.left_click_screen_generation) return; + const screen = t.screens.get(self.left_click_screen) orelse return; + screen.pages.untrackPin(pin); +} + +/// Reset any active gesture state and untrack the tracked click pin. +pub fn reset(self: *SelectionGesture, t: *Terminal) void { + self.left_click_count = 0; + self.left_click_time = null; + self.untrackPin(t); +} + +pub const Press = struct { + /// The time when the press event occurred. Use a monotonic timer. + /// This can be null if you're on a system that doesn't support + /// time for some reason. In that case, we only support single clicks. + time: ?std.time.Instant, + + /// The cell where the click was. + pin: Pin, + + /// The x/y value of the click relative to the surface with (0,0) being + /// top-left. This is used for distance detection for multi-clicks so + /// double/triple clicks too far away from each other will reset the click + /// count as well more accurate drag behaviors. + xpos: f64, + ypos: f64, + + /// Maximum distance a click can be from the original click to register + /// as a repeat. If uncertain, set this to cell width. + max_distance: f64, + + /// The maximum interval in nanoseconds that a press is considered + /// a repeat e.g. to record double/triple clicks. + repeat_interval: u64, +}; + +/// Record a press event. +pub fn press( + self: *SelectionGesture, + t: *Terminal, + p: Press, +) Allocator.Error!void { + if (self.left_click_count > 0) { + if (self.pressRepeat(t, p)) { + // Successful repeat, return. + return; + } else |err| switch (err) { + error.PressRequiresReset => {}, + } + } + + // Initial click or the repeat failed for some reason such as + // the subsequent click being too far away. + try self.pressInitial(t, p); +} + +fn pressInitial( + self: *SelectionGesture, + t: *Terminal, + p: Press, +) Allocator.Error!void { + // Setup our pin first, reusing our existing pin if we can. + if (self.left_click_pin) |pin| { + if (comptime std.debug.runtime_safety) { + assert(self.left_click_screen == t.screens.active_key); + assert(self.left_click_screen_generation == t.screens.generation(t.screens.active_key)); + } + pin.* = p.pin; + } else { + const screens: *const ScreenSet = &t.screens; + self.left_click_pin = try screens.active.pages.trackPin(p.pin); + errdefer comptime unreachable; + self.left_click_screen = screens.active_key; + self.left_click_screen_generation = screens.generation(screens.active_key); + } + errdefer comptime unreachable; + self.left_click_count = 1; + self.left_click_xpos = p.xpos; + self.left_click_ypos = p.ypos; + self.left_click_time = p.time; +} + +fn pressRepeat( + self: *SelectionGesture, + t: *Terminal, + p: Press, +) error{PressRequiresReset}!void { + errdefer { + self.left_click_count = 0; + self.untrackPin(t); + } + + // If too much time has passed then we always reset. + const time = p.time orelse return error.PressRequiresReset; + { + const prev_time = self.left_click_time orelse return error.PressRequiresReset; + const since = time.since(prev_time); + if (since > p.repeat_interval) return error.PressRequiresReset; + } + + // If the click is too far away from the initial click we can't continue. + const distance = @sqrt( + std.math.pow(f64, p.xpos - self.left_click_xpos, 2) + + std.math.pow(f64, p.ypos - self.left_click_ypos, 2), + ); + if (distance > p.max_distance) return error.PressRequiresReset; + + // If our prior click was on another screen then free and reset. "Another screen" + // doesn't just mean alt vs primary, it could mean an alt screen that was + // recycled since we free tracked pins on recycle. + const screens: *const ScreenSet = &t.screens; + if (self.left_click_screen != screens.active_key or + screens.generation(self.left_click_screen) != + self.left_click_screen_generation) + { + // The error return will trigger the top-level errdefer which + // will reset our pin. + return error.PressRequiresReset; + } + + self.left_click_time = time; + self.left_click_count = @min( + self.left_click_count + 1, + 3, // We only support triple clicks max + ); +} + +fn untrackPin(self: *SelectionGesture, t: *Terminal) void { + // Can't untrack unless we have a pin. + const pin = self.left_click_pin orelse return; + self.left_click_pin = null; + + // If the generation changed our pin is already invalid. + const screens: *const ScreenSet = &t.screens; + if (screens.generation(self.left_click_screen) != self.left_click_screen_generation) return; + + // If we can't get a screen then its already freed. + const screen = screens.get(self.left_click_screen) orelse return; + screen.pages.untrackPin(pin); +} + +fn testPress(t: *Terminal, x: u16, y: u32, time: ?std.time.Instant) Press { + return .{ + .time = time, + .pin = t.screens.active.pages.pin(.{ .active = .{ + .x = x, + .y = y, + } }).?, + .xpos = @floatFromInt(x), + .ypos = @floatFromInt(y), + .max_distance = 1, + .repeat_interval = std.math.maxInt(u64), + }; +} + +test "SelectionGesture press records initial click" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + try gesture.press(&t, testPress(&t, 1, 2, time)); + + try testing.expectEqual(@as(u3, 1), gesture.left_click_count); + try testing.expectEqual(time, gesture.left_click_time.?); + try testing.expectEqual(@as(f64, 1), gesture.left_click_xpos); + try testing.expectEqual(@as(f64, 2), gesture.left_click_ypos); +} + +test "SelectionGesture repeat increments click count" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + try gesture.press(&t, testPress(&t, 1, 1, time)); + try gesture.press(&t, testPress(&t, 1, 1, time)); + + try testing.expectEqual(@as(u3, 2), gesture.left_click_count); +} + +test "SelectionGesture repeat clamps at triple click" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + for (0..4) |_| try gesture.press(&t, testPress(&t, 1, 1, time)); + + try testing.expectEqual(@as(u3, 3), gesture.left_click_count); +} + +test "SelectionGesture null initial time stays single click" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + try gesture.press(&t, testPress(&t, 1, 1, null)); + try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + + try testing.expectEqual(@as(u3, 1), gesture.left_click_count); + try testing.expect(gesture.left_click_time != null); +} + +test "SelectionGesture null repeat time stays single click" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + try gesture.press(&t, testPress(&t, 1, 1, null)); + + try testing.expectEqual(@as(u3, 1), gesture.left_click_count); + try testing.expectEqual(@as(?std.time.Instant, null), gesture.left_click_time); +} + +test "SelectionGesture distant press resets click count" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + try gesture.press(&t, testPress(&t, 1, 1, time)); + try gesture.press(&t, testPress(&t, 4, 1, time)); + + try testing.expectEqual(@as(u3, 1), gesture.left_click_count); + try testing.expectEqual(@as(f64, 4), gesture.left_click_xpos); +} + +test "SelectionGesture expired repeat resets click count" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + var event = testPress(&t, 1, 1, try std.time.Instant.now()); + event.repeat_interval = 0; + try gesture.press(&t, event); + + std.Thread.sleep(std.time.ns_per_ms); + event.time = try std.time.Instant.now(); + try gesture.press(&t, event); + + try testing.expectEqual(@as(u3, 1), gesture.left_click_count); +} + +test "SelectionGesture screen switch resets click count" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + const primary_tracked = t.screens.active.pages.countTrackedPins(); + try gesture.press(&t, testPress(&t, 1, 1, time)); + + _ = try t.screens.getInit(testing.allocator, .alternate, .{ + .cols = t.cols, + .rows = t.rows, + }); + t.screens.switchTo(.alternate); + try gesture.press(&t, testPress(&t, 1, 1, time)); + + try testing.expectEqual(@as(u3, 1), gesture.left_click_count); + try testing.expectEqual(.alternate, gesture.left_click_screen); + try testing.expectEqual(primary_tracked, t.screens.get(.primary).?.pages.countTrackedPins()); +} + +test "SelectionGesture removed screen resets without untracking stale pin" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + _ = try t.screens.getInit(testing.allocator, .alternate, .{ + .cols = t.cols, + .rows = t.rows, + }); + t.screens.switchTo(.alternate); + try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + + t.screens.switchTo(.primary); + t.screens.remove(testing.allocator, .alternate); + try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + + try testing.expectEqual(@as(u3, 1), gesture.left_click_count); + try testing.expectEqual(.primary, gesture.left_click_screen); +} + +test "SelectionGesture deinit untracks pin" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + const tracked = t.screens.active.pages.countTrackedPins(); + try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); + + gesture.deinit(&t); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); +} diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 87a9aded9..53491a009 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -49,6 +49,7 @@ pub const Screen = @import("Screen.zig"); pub const ScreenSet = @import("ScreenSet.zig"); pub const Scrollbar = PageList.Scrollbar; pub const Selection = @import("Selection.zig"); +pub const SelectionGesture = @import("SelectionGesture.zig"); pub const SizeReportStyle = csi.SizeReportStyle; pub const StringMap = @import("StringMap.zig"); pub const Style = style.Style;