diff --git a/src/Surface.zig b/src/Surface.zig index 525e73a9e..410f717b0 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -224,23 +224,8 @@ const Mouse = struct { /// 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. - left_click_pin: ?*terminal.Pin = null, - left_click_screen: terminal.ScreenSet.Key = .primary, - left_click_screen_generation: usize = 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 surface. - left_click_xpos: f64 = 0, - left_click_ypos: f64 = 0, - - /// 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. This - /// is always set on the first left click. - left_click_count: u8 = 0, - left_click_time: std.time.Instant = undefined, + /// Gesture state for text selection. + selection_gesture: terminal.SelectionGesture = .init, /// The last x/y sent for mouse reports. event_point: ?terminal.point.Coordinate = null, @@ -263,20 +248,9 @@ const Mouse = struct { /// only process link hover events when the mouse actually moves cells. link_point: ?terminal.point.Coordinate = null, - /// Return the PageList that owns the left-click pin, or null if the screen - /// has been removed/reinitialized since the pin was tracked. - fn leftClickPageList(self: *const Mouse, screens: *const terminal.ScreenSet) ?*terminal.PageList { - if (screens.generation(self.left_click_screen) != self.left_click_screen_generation) return null; - const screen = screens.get(self.left_click_screen) orelse return null; - return &screen.pages; - } - /// Return the left-click pin only if it still belongs to the active screen. fn activeLeftClickPin(self: *const Mouse, screens: *const terminal.ScreenSet) ?*terminal.Pin { - const pin = self.left_click_pin orelse return null; - if (self.left_click_screen != screens.active_key) return null; - _ = self.leftClickPageList(screens) orelse return null; - return pin; + return self.selection_gesture.validatedLeftClickPin(screens); } }; @@ -839,6 +813,7 @@ pub fn deinit(self: *Surface) void { self.renderer_thread.deinit(); self.renderer.deinit(); self.io_thread.deinit(); + self.mouse.selection_gesture.deinit(&self.io.terminal); self.io.deinit(); if (self.inspector) |v| { @@ -1196,46 +1171,53 @@ fn selectionScrollTick(self: *Surface) !void { // If we're no longer active then we don't do anything. if (!self.selection_scroll_active) return; - // If we don't have a left mouse button down then we - // don't do anything. - if (self.mouse.left_click_count == 0) return; + // If our gesture doesn't want autoscrolling then disable it. + const was_autoscrolling = self.mouse.selection_gesture.left_drag_autoscroll != .none; + if (!was_autoscrolling) { + self.queueIo( + .{ .selection_scroll = false }, + .unlocked, + ); + return; + } const pos = try self.rt_surface.getCursorPos(); const pos_vp = self.posToViewport(pos.x, pos.y); - const delta: isize = if (pos.y < 0) -1 else 1; // We need our locked state for the remainder self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); const t: *terminal.Terminal = self.renderer_state.terminal; - // If our left-click pin no longer belongs to the active screen, we stop - // our selection scroll. - if (self.mouse.activeLeftClickPin(&t.screens) == null) { + const selection = self.mouse.selection_gesture.autoscrollTick(t, .{ + .viewport = pos_vp, + .xpos = pos.x, + .ypos = pos.y, + .rectangle = SurfaceMouse.isRectangleSelectState(self.mouse.mods), + .word_boundary_codepoints = self.config.selection_word_chars, + .geometry = .{ + .columns = @intCast(self.size.grid().columns), + .cell_width = self.size.cell.width, + .padding_left = self.size.padding.left, + .screen_height = self.size.screen.height, + }, + }); + + // If we're no longer autoscrolling for whatever reason, disable it. + if (self.mouse.selection_gesture.left_drag_autoscroll == .none) { self.queueIo( .{ .selection_scroll = false }, .locked, ); - return; } - // Scroll the viewport as required - t.scrollViewport(.{ .delta = delta }); - - // Next, trigger our drag behavior - const pin = t.screens.active.pages.pin(.{ - .viewport = .{ - .x = pos_vp.x, - .y = pos_vp.y, - }, - }) orelse { - if (comptime std.debug.runtime_safety) unreachable; - return; - }; - try self.dragLeftClickSingle(pin, pos.x); + // If our left click was invalidated, ignore the result. This isn't + // strictly necessary but its a nice to have. + if (self.mouse.selection_gesture.left_click_count == 0) return; // We modified our viewport and selection so we need to queue // a render. + try self.io.terminal.screens.active.select(selection); try self.queueRender(); } @@ -3781,7 +3763,7 @@ pub fn mouseButtonCallback( // We could do all the conditionals in one but I find it more // readable as a human to break this one up. if (mods.shift and - self.mouse.left_click_count > 0 and + self.mouse.selection_gesture.left_click_count > 0 and !shift_capture) extend_selection: { // We split this conditional out on its own because this is the @@ -3792,7 +3774,9 @@ pub fn mouseButtonCallback( // If we are within the interval that the click would register // an increment then we do not extend the selection. if (std.time.Instant.now()) |now| { - const since = now.since(self.mouse.left_click_time); + const click_time = self.mouse.selection_gesture.left_click_time orelse + break :extend_selection; + const since = now.since(click_time); if (since <= self.config.mouse_interval) { // Click interval very short, we may be increasing // click counts so we don't extend the selection. @@ -3814,12 +3798,39 @@ pub fn mouseButtonCallback( } if (button == .left and action == .release) { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + // The selection gesture tracks whether a press became a drag by + // comparing the release cell to the original press cell. Resolve the + // release position and pin before notifying the gesture so later + // release handling can query that state. + const release_pos: ?apprt.CursorPos = self.rt_surface.getCursorPos() catch |err| pos: { + log.warn("error reading cursor position for mouse release err={}", .{err}); + break :pos null; + }; + + // If we can't map the release position to a cell, pass null so the + // gesture can conservatively treat the release as having moved away + // from the pressed cell. + const release_pin: ?terminal.Pin = if (release_pos) |pos| pin: { + const release_vp = self.posToViewport(pos.x, pos.y); + break :pin self.io.terminal.screens.active.pages.pin(.{ .viewport = .{ + .x = release_vp.x, + .y = release_vp.y, + } }); + } else null; + self.mouse.selection_gesture.release( + self.renderer_state.terminal, + .{ .pin = release_pin }, + ); + // Stop selection scrolling when releasing the left mouse button // but only when selection scrolling is active. if (self.selection_scroll_active) { self.queueIo( .{ .selection_scroll = false }, - .unlocked, + .locked, ); } @@ -3827,8 +3838,6 @@ pub fn mouseButtonCallback( // the left button is released. This is to avoid the clipboard // being updated on every mouse move which would be noisy. if (self.config.copy_on_select != .false) { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); const prev_ = self.io.terminal.screens.active.selection; if (prev_) |prev| { try self.setSelection(terminal.Selection.init( @@ -3842,10 +3851,10 @@ pub fn mouseButtonCallback( // Handle link clicking. We want to do this before we do mouse // reporting or any other mouse handling because a successfully // clicked link will swallow the event. - if (self.mouse.over_link) { - const pos = try self.rt_surface.getCursorPos(); - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + if (self.mouse.over_link and !self.mouse.selection_gesture.left_click_dragged) { + // We are holding the renderer lock, but this should just be + // a cached value. + const pos = release_pos orelse try self.rt_surface.getCursorPos(); if (self.processLinks(pos)) |processed| { if (processed) return true; } else |err| { @@ -3880,7 +3889,7 @@ pub fn mouseButtonCallback( // We also set the left click count to 0 so that if mouse reporting // is disabled in the middle of press (before release) we don't // suddenly start selecting text. - self.mouse.left_click_count = 0; + self.mouse.selection_gesture.reset(self.renderer_state.terminal); const pos = try self.rt_surface.getCursorPos(); @@ -3927,108 +3936,67 @@ pub fn mouseButtonCallback( break :click; }; - break :pin try screen.pages.trackPin(pin); + break :pin pin; }; - errdefer screen.pages.untrackPin(pin); - // If we move our cursor too much between clicks then we reset - // the multi-click state. - if (self.mouse.left_click_count > 0) { - const max_distance: f64 = @floatFromInt(self.size.cell.width); - const distance = @sqrt( - std.math.pow(f64, pos.x - self.mouse.left_click_xpos, 2) + - std.math.pow(f64, pos.y - self.mouse.left_click_ypos, 2), - ); - - if (distance > max_distance) self.mouse.left_click_count = 0; - } - - if (self.mouse.left_click_pin) |prev| { - if (self.mouse.leftClickPageList(&t.screens)) |pages| pages.untrackPin(prev); - self.mouse.left_click_pin = null; - } - - // Store it - self.mouse.left_click_pin = pin; - self.mouse.left_click_screen = t.screens.active_key; - self.mouse.left_click_screen_generation = t.screens.generation(t.screens.active_key); - self.mouse.left_click_xpos = pos.x; - self.mouse.left_click_ypos = pos.y; - - // Setup our click counter and timer - if (std.time.Instant.now()) |now| { - // If we have mouse clicks, then we check if the time elapsed - // is less than and our interval and if so, increase the count. - if (self.mouse.left_click_count > 0) { - const since = now.since(self.mouse.left_click_time); - if (since > self.config.mouse_interval) { - self.mouse.left_click_count = 0; - } - } - - self.mouse.left_click_time = now; - self.mouse.left_click_count += 1; - - // We only support up to triple-clicks. - if (self.mouse.left_click_count > 3) self.mouse.left_click_count = 1; - } else |err| { - self.mouse.left_click_count = 1; + const time = std.time.Instant.now() catch |err| time: { log.err("error reading time, mouse multi-click won't work err={}", .{err}); - } - - // In all cases below, we set the selection directly rather than use - // `setSelection` because we want to avoid copying the selection - // to the selection clipboard. For left mouse clicks we only set - // the clipboard on release. - switch (self.mouse.left_click_count) { - // Single click - 1 => { - // If we have a selection, clear it. This always happens. - if (self.io.terminal.screens.active.selection != null) { - try self.io.terminal.screens.active.select(null); - try self.queueRender(); - } + break :time null; + }; + var press_selection = try self.mouse.selection_gesture.press(t, .{ + .time = time, + .pin = pin, + .xpos = pos.x, + .ypos = pos.y, + .max_distance = @floatFromInt(self.size.cell.width), + .repeat_interval = self.config.mouse_interval, + .word_boundary_codepoints = self.config.selection_word_chars, + .behaviors = &.{ + .cell, + .word, + if (mods.ctrlOrSuper()) .output else .line, }, + }); - // Double click, select the word under our mouse. - // First try to detect if we're clicking on a URL to select the entire URL. + // The gesture owns the standard single/double/triple-click selection + // behavior. Surface keeps terminal-surface-specific overrides here. + switch (self.mouse.selection_gesture.left_click_count) { + 1 => {}, + + // Double click on a URL selects the entire URL instead of the + // standard word selection returned by the gesture. 2 => { - const sel_ = sel: { - // Try link detection without requiring modifier keys - if (self.linkAtPin( - pin.*, - null, - )) |result_| { - if (result_) |result| { - break :sel result.selection; - } - } else |_| { - // Ignore any errors, likely regex errors. + // Try link detection without requiring modifier keys. + if (self.linkAtPin( + pin, + null, + )) |result_| { + if (result_) |result| { + press_selection = result.selection; } - - break :sel self.io.terminal.screens.active.selectWord(pin.*, self.config.selection_word_chars); - }; - if (sel_) |sel| { - try self.io.terminal.screens.active.select(sel); - try self.queueRender(); + } else |_| { + // Ignore any errors, likely regex errors. } }, - // Triple click, select the line under our mouse - 3 => { - const sel_ = if (mods.ctrlOrSuper()) - self.io.terminal.screens.active.selectOutput(pin.*) - else - self.io.terminal.screens.active.selectLine(.{ .pin = pin.* }); - if (sel_) |sel| { - try self.io.terminal.screens.active.select(sel); - try self.queueRender(); - } - }, + 3 => {}, // We should be bounded by 1 to 3 else => unreachable, } + + // We set the selection directly rather than use `setSelection` because + // we want to avoid copying the selection to the selection clipboard. + // For left mouse clicks we only set the clipboard on release. + if (press_selection) |selection| { + try self.io.terminal.screens.active.select(selection); + try self.queueRender(); + } else if (self.mouse.selection_gesture.left_click_count == 1 and + self.io.terminal.screens.active.selection != null) + { + try self.io.terminal.screens.active.select(null); + try self.queueRender(); + } } // Middle-click paste source follows copy-on-select: when copy-on-select @@ -4156,9 +4124,8 @@ pub fn mouseButtonCallback( return false; } +/// Requires the renderer state mutex is held. fn maybePromptClick(self: *Surface) !bool { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); const t: *terminal.Terminal = self.renderer_state.terminal; const screen: *terminal.Screen = t.screens.active; @@ -4173,11 +4140,12 @@ fn maybePromptClick(self: *Surface) !bool { // prompt clicks because we can't move if we're not in a prompt! if (!t.cursorIsAtPrompt()) return false; - // If we have a selection currently, then releasing the mouse - // completes the selection and we don't do prompt moving. I don't - // love this logic, I think it should be generalized to "if the - // mouse release was on a different cell than the mouse press" but - // our mouse state at the time of writing this doesn't support that. + // If the left click moved away from its pressed cell then releasing the + // mouse completes the drag gesture and we don't do prompt moving. + if (self.mouse.selection_gesture.left_click_dragged) return false; + + // If we have a selection currently, then releasing the mouse completes + // the selection and we don't do prompt moving. if (screen.selection != null) return false; // Get the pin for our mouse click. @@ -4470,9 +4438,11 @@ pub fn mousePressureCallback( // Update our pressure stage. self.mouse.pressure_stage = stage; - // If our left mouse button is pressed and we're entering a deep - // click then we want to start a selection. We treat this as a - // word selection since that is typical macOS behavior. + // A deep press is pressure-sensitive pointer input, such as macOS force + // click / deep click on a trackpad, that occurs while the left mouse + // button is already down. Treat it as the platform text-selection + // affordance: select the pressed word, then consume the active gesture so + // further cursor motion doesn't drag the selection. const left_idx = @intFromEnum(input.MouseButton.left); if (self.mouse.click_state[left_idx] == .press and stage == .deep) @@ -4480,14 +4450,21 @@ pub fn mousePressureCallback( self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - // This should always be set in this state but we don't want - // to handle state inconsistency here. - const pin = self.mouse.activeLeftClickPin(&self.io.terminal.screens) orelse break :select; - const sel = self.io.terminal.screens.active.selectWord( - pin.*, - self.config.selection_word_chars, - ) orelse break :select; - try self.io.terminal.screens.active.select(sel); + const sel = self.mouse.selection_gesture.deepPress( + self.renderer_state.terminal, + .{ .word_boundary_codepoints = self.config.selection_word_chars }, + ); + + // Deep press consumes the active drag gesture, so stop any pending + // selection autoscroll timer that may have been started by the drag. + if (self.selection_scroll_active) { + self.queueIo( + .{ .selection_scroll = false }, + .locked, + ); + } + + try self.io.terminal.screens.active.select(sel orelse break :select); try self.queueRender(); } } @@ -4566,15 +4543,6 @@ pub fn cursorPosCallback( self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - // Stop selection scrolling when inside the viewport within a 1px buffer - // for fullscreen windows, but only when selection scrolling is active. - if (pos.y >= 1 and self.selection_scroll_active) { - self.queueIo( - .{ .selection_scroll = false }, - .locked, - ); - } - // Update our mouse state. We set this to null initially because we only // want to set it when we're not selecting or doing any other mouse // event. @@ -4645,7 +4613,7 @@ pub fn cursorPosCallback( // In this scenario, we mark the click state because we need that to // properly make some mouse reports, but we don't keep track of the // count because we don't want to handle selection. - if (self.mouse.left_click_count == 0) break :select; + if (self.mouse.selection_gesture.left_click_count == 0) break :select; // If our left-click pin no longer belongs to the active screen then we // don't process this. We don't invalidate our pin or mouse state @@ -4657,25 +4625,6 @@ pub fn cursorPosCallback( // All roads lead to requiring a re-render at this point. try self.queueRender(); - // If our y is negative, we're above the window. In this case, we scroll - // up. The amount we scroll up is dependent on how negative we are. - // We allow for a 1 pixel buffer at the top and bottom to detect - // scroll even in full screen windows. - // Note: one day, we can change this from distance to time based if we want. - //log.warn("CURSOR POS: {} {}", .{ pos, self.size.screen }); - const max_y: f32 = @floatFromInt(self.size.screen.height); - - // If the mouse is outside the viewport and we have the left - // mouse button pressed then we need to start the scroll timer. - if ((pos.y <= 1 or pos.y > max_y - 1) and - !self.selection_scroll_active) - { - self.queueIo( - .{ .selection_scroll = true }, - .locked, - ); - } - // Convert to points const screen: *terminal.Screen = t.screens.active; const pin = screen.pages.pin(.{ @@ -4688,261 +4637,42 @@ pub fn cursorPosCallback( return; }; - // Handle dragging depending on click count - switch (self.mouse.left_click_count) { - 1 => try self.dragLeftClickSingle(pin, pos.x), - 2 => try self.dragLeftClickDouble(pin), - 3 => try self.dragLeftClickTriple(pin), - 0 => unreachable, // handled above - else => unreachable, + // Perform our drag behavior in our gesture handler. + const drag_selection = self.mouse.selection_gesture.drag(t, .{ + .pin = pin, + .xpos = pos.x, + .ypos = pos.y, + .rectangle = SurfaceMouse.isRectangleSelectState(self.mouse.mods), + .word_boundary_codepoints = self.config.selection_word_chars, + .geometry = .{ + .columns = @intCast(self.size.grid().columns), + .cell_width = self.size.cell.width, + .padding_left = self.size.padding.left, + .screen_height = self.size.screen.height, + }, + }); + + // Update our autoscroll timer based on the gesture state + switch (self.mouse.selection_gesture.left_drag_autoscroll) { + .none => if (self.selection_scroll_active) { + self.queueIo( + .{ .selection_scroll = false }, + .locked, + ); + }, + .up, .down => if (!self.selection_scroll_active) { + self.queueIo( + .{ .selection_scroll = true }, + .locked, + ); + }, } - return; + // Update our selection based on the gesture state + try self.io.terminal.screens.active.select(drag_selection); } } -/// Double-click dragging moves the selection one "word" at a time. -fn dragLeftClickDouble( - self: *Surface, - drag_pin: terminal.Pin, -) !void { - const screen: *terminal.Screen = self.io.terminal.screens.active; - const click_pin = (self.mouse.activeLeftClickPin(&self.io.terminal.screens) orelse return).*; - - // Get the word closest to our starting click. - const word_start = screen.selectWordBetween( - click_pin, - drag_pin, - self.config.selection_word_chars, - ) orelse { - try self.setSelection(null); - return; - }; - - // Get the word closest to our current point. - const word_current = screen.selectWordBetween( - drag_pin, - click_pin, - self.config.selection_word_chars, - ) orelse { - try self.setSelection(null); - return; - }; - - // If our current mouse position is before the starting position, - // then the selection start is the word nearest our current position. - if (drag_pin.before(click_pin)) { - try self.io.terminal.screens.active.select(.init( - word_current.start(), - word_start.end(), - false, - )); - } else { - try self.io.terminal.screens.active.select(.init( - word_start.start(), - word_current.end(), - false, - )); - } -} - -/// Triple-click dragging moves the selection one "line" at a time. -fn dragLeftClickTriple( - self: *Surface, - drag_pin: terminal.Pin, -) !void { - const screen: *terminal.Screen = self.io.terminal.screens.active; - const click_pin: terminal.Pin = pin: { - const set: *terminal.ScreenSet = &self.io.terminal.screens; - const tracked = self.mouse.activeLeftClickPin(set) orelse return; - break :pin tracked.*; - }; - - // Get the line selection under our current drag point. If there isn't a - // line, do nothing. - const line = screen.selectLine(.{ .pin = drag_pin }) orelse return; - - // Get the selection under our click point. We first try to trim - // whitespace if we've selected a word. But if no word exists then - // we select the blank line. - const sel_ = screen.selectLine(.{ .pin = click_pin }) orelse - screen.selectLine(.{ .pin = click_pin, .whitespace = null }); - - var sel = sel_ orelse return; - if (drag_pin.before(click_pin)) { - sel.startPtr().* = line.start(); - } else { - sel.endPtr().* = line.end(); - } - try self.io.terminal.screens.active.select(sel); -} - -fn dragLeftClickSingle( - self: *Surface, - drag_pin: terminal.Pin, - drag_x: f64, -) !void { - // This logic is in a separate function so that it can be unit tested. - const click_pin: terminal.Pin = pin: { - const set: *terminal.ScreenSet = &self.io.terminal.screens; - const tracked = self.mouse.activeLeftClickPin(set) orelse return; - break :pin tracked.*; - }; - try self.io.terminal.screens.active.select(mouseSelection( - click_pin, - drag_pin, - @intFromFloat(@max(0.0, self.mouse.left_click_xpos)), - @intFromFloat(@max(0.0, drag_x)), - self.mouse.mods, - self.size, - )); -} - -/// Calculates the appropriate selection given pins and pixel x positions for -/// the click point and the drag point, as well as mouse mods and screen size. -fn mouseSelection( - click_pin: terminal.Pin, - drag_pin: terminal.Pin, - click_x: u32, - drag_x: u32, - mods: input.Mods, - size: rendererpkg.Size, -) ?terminal.Selection { - // Explanation: - // - // # Normal selections - // - // ## Left-to-right selections - // - The clicked cell is included if it was clicked to the left of its - // threshold point and the drag location is right of the threshold point. - // - The cell under the cursor (the "drag cell") is included if the drag - // location is right of its threshold point. - // - // ## Right-to-left selections - // - The clicked cell is included if it was clicked to the right of its - // threshold point and the drag location is left of the threshold point. - // - The cell under the cursor (the "drag cell") is included if the drag - // location is left of its threshold point. - // - // # Rectangular selections - // - // Rectangular selections are handled similarly, except that - // entire columns are considered rather than individual cells. - - // We only include cells in the selection if the threshold point lies - // between the start and end points of the selection. A threshold of - // 60% of the cell width was chosen empirically because it felt good. - const threshold_point: u32 = @intFromFloat(@round( - @as(f64, @floatFromInt(size.cell.width)) * 0.6, - )); - - // We use this to clamp the pixel positions below. - const max_x = size.grid().columns * size.cell.width - 1; - - // We need to know how far across in the cell the drag pos is, so - // we subtract the padding and then take it modulo the cell width. - const drag_x_frac = @min(max_x, drag_x -| size.padding.left) % size.cell.width; - - // We figure out the fractional part of the click x position similarly. - const click_x_frac = @min(max_x, click_x -| size.padding.left) % size.cell.width; - - // Whether or not this is a rectangular selection. - const rectangle_selection = SurfaceMouse.isRectangleSelectState(mods); - - // Whether the click pin and drag pin are equal. - const same_pin = drag_pin.eql(click_pin); - - // Whether or not the end point of our selection is before the start point. - const end_before_start = ebs: { - if (same_pin) { - break :ebs drag_x_frac < click_x_frac; - } - - // Special handling for rectangular selections, we only use x position. - if (rectangle_selection) { - break :ebs switch (std.math.order(drag_pin.x, click_pin.x)) { - .eq => drag_x_frac < click_x_frac, - .lt => true, - .gt => false, - }; - } - - break :ebs drag_pin.before(click_pin); - }; - - // Whether or not the click pin cell - // should be included in the selection. - const include_click_cell = if (end_before_start) - click_x_frac >= threshold_point - else - click_x_frac < threshold_point; - - // Whether or not the drag pin cell - // should be included in the selection. - const include_drag_cell = if (end_before_start) - drag_x_frac < threshold_point - else - drag_x_frac >= threshold_point; - - // If the click cell should be included in the selection then it's the - // start, otherwise we get the previous or next cell to it depending on - // the type and direction of the selection. - const start_pin = - if (include_click_cell) - click_pin - else if (end_before_start) - if (rectangle_selection) - click_pin.leftClamp(1) - else - click_pin.leftWrap(1) orelse click_pin - else if (rectangle_selection) - click_pin.rightClamp(1) - else - click_pin.rightWrap(1) orelse click_pin; - - // Likewise for the end pin with the drag cell. - const end_pin = - if (include_drag_cell) - drag_pin - else if (end_before_start) - if (rectangle_selection) - drag_pin.rightClamp(1) - else - drag_pin.rightWrap(1) orelse drag_pin - else if (rectangle_selection) - drag_pin.leftClamp(1) - else - drag_pin.leftWrap(1) orelse drag_pin; - - // If the click cell is the same as the drag cell and the click cell - // shouldn't be included, or if the cells are adjacent such that the - // start or end pin becomes the other cell, and that cell should not - // be included, then we have no selection, so we set it to null. - // - // If in rectangular selection mode, we compare columns as well. - // - // TODO(qwerasd): this can/should probably be refactored, it's a bit - // repetitive and does excess work in rectangle mode. - if ((!include_click_cell and same_pin) or - (!include_click_cell and rectangle_selection and click_pin.x == drag_pin.x) or - (!include_click_cell and end_pin.eql(click_pin)) or - (!include_click_cell and rectangle_selection and end_pin.x == click_pin.x) or - (!include_drag_cell and start_pin.eql(drag_pin)) or - (!include_drag_cell and rectangle_selection and start_pin.x == drag_pin.x)) - { - return null; - } - - // TODO: Clamp selection to the screen area, don't - // let it extend past the last written row. - - return .init( - start_pin, - end_pin, - rectangle_selection, - ); -} - /// Call to notify Ghostty that the color scheme for the terminal has /// changed. pub fn colorSchemeCallback(self: *Surface, scheme: apprt.ColorScheme) !void { @@ -6271,436 +6001,9 @@ fn presentSurface(self: *Surface) !void { ); } -/// Utility function for the unit tests for mouse selection logic. -/// -/// Tests a click and drag on a 10x5 cell grid, x positions are given in -/// fractional cells, e.g. 3.1 would be 10% through the cell at x = 3. -/// -/// NOTE: The size tested with has 10px wide cells, meaning only one digit -/// after the decimal place has any meaning, e.g. 3.14 is equal to 3.1. -/// -/// The provided start_x/y and end_x/y are the expected start and end points -/// of the resulting selection. -fn testMouseSelection( - click_x: f64, - click_y: u32, - drag_x: f64, - drag_y: u32, - start_x: terminal.size.CellCountInt, - start_y: u32, - end_x: terminal.size.CellCountInt, - end_y: u32, - rect: bool, -) !void { - assert(builtin.is_test); - - // Our screen size is 10x5 cells that are - // 10x20 px, with 5px padding on all sides. - const size: rendererpkg.Size = .{ - .cell = .{ .width = 10, .height = 20 }, - .padding = .{ .left = 5, .top = 5, .right = 5, .bottom = 5 }, - .screen = .{ .width = 110, .height = 110 }, - }; - var screen = try terminal.Screen.init(std.testing.allocator, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); - defer screen.deinit(); - - // We hold both ctrl and alt for rectangular - // select so that this test is platform agnostic. - const mods: input.Mods = .{ - .ctrl = rect, - .alt = rect, - }; - - try std.testing.expectEqual(rect, SurfaceMouse.isRectangleSelectState(mods)); - - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y }, - }) orelse unreachable; - - const cell_width_f64: f64 = @floatFromInt(size.cell.width); - const click_x_pos: u32 = - @as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) + - size.padding.left; - const drag_x_pos: u32 = - @as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) + - size.padding.left; - - const start_pin = screen.pages.pin(.{ - .viewport = .{ .x = start_x, .y = start_y }, - }) orelse unreachable; - const end_pin = screen.pages.pin(.{ - .viewport = .{ .x = end_x, .y = end_y }, - }) orelse unreachable; - - try std.testing.expectEqualDeep(terminal.Selection{ - .bounds = .{ .untracked = .{ - .start = start_pin, - .end = end_pin, - } }, - .rectangle = rect, - }, mouseSelection( - click_pin, - drag_pin, - click_x_pos, - drag_x_pos, - mods, - size, - )); -} - -/// Like `testMouseSelection` but checks that the resulting selection is null. -/// -/// See `testMouseSelection` for more details. -fn testMouseSelectionIsNull( - click_x: f64, - click_y: u32, - drag_x: f64, - drag_y: u32, - rect: bool, -) !void { - assert(builtin.is_test); - - // Our screen size is 10x5 cells that are - // 10x20 px, with 5px padding on all sides. - const size: rendererpkg.Size = .{ - .cell = .{ .width = 10, .height = 20 }, - .padding = .{ .left = 5, .top = 5, .right = 5, .bottom = 5 }, - .screen = .{ .width = 110, .height = 110 }, - }; - var screen = try terminal.Screen.init(std.testing.allocator, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); - defer screen.deinit(); - - // We hold both ctrl and alt for rectangular - // select so that this test is platform agnostic. - const mods: input.Mods = .{ - .ctrl = rect, - .alt = rect, - }; - - try std.testing.expectEqual(rect, SurfaceMouse.isRectangleSelectState(mods)); - - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y }, - }) orelse unreachable; - - const cell_width_f64: f64 = @floatFromInt(size.cell.width); - const click_x_pos: u32 = - @as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) + - size.padding.left; - const drag_x_pos: u32 = - @as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) + - size.padding.left; - - try std.testing.expectEqual( - null, - mouseSelection( - click_pin, - drag_pin, - click_x_pos, - drag_x_pos, - mods, - size, - ), - ); -} - /// Get information about the process(es) running within the surface. Returns /// `null` if there was an error getting the information or the information is /// not available on a particular platform. pub fn getProcessInfo(self: *Surface, comptime info: ProcessInfo) ?ProcessInfo.Type(info) { return self.io.getProcessInfo(info); } - -test "Surface: selection logic" { - // We disable format to make these easier to - // read by pairing sets of coordinates per line. - // zig fmt: off - - // -- LTR - // single cell selection - try testMouseSelection( - 3.0, 3, // click - 3.9, 3, // drag - 3, 3, // expected start - 3, 3, // expected end - false, // regular selection - ); - // including click and drag pin cells - try testMouseSelection( - 3.0, 3, // click - 5.9, 3, // drag - 3, 3, // expected start - 5, 3, // expected end - false, // regular selection - ); - // including click pin cell but not drag pin cell - try testMouseSelection( - 3.0, 3, // click - 5.0, 3, // drag - 3, 3, // expected start - 4, 3, // expected end - false, // regular selection - ); - // including drag pin cell but not click pin cell - try testMouseSelection( - 3.9, 3, // click - 5.9, 3, // drag - 4, 3, // expected start - 5, 3, // expected end - false, // regular selection - ); - // including neither click nor drag pin cells - try testMouseSelection( - 3.9, 3, // click - 5.0, 3, // drag - 4, 3, // expected start - 4, 3, // expected end - false, // regular selection - ); - // empty selection (single cell on only left half) - try testMouseSelectionIsNull( - 3.0, 3, // click - 3.1, 3, // drag - false, // regular selection - ); - // empty selection (single cell on only right half) - try testMouseSelectionIsNull( - 3.8, 3, // click - 3.9, 3, // drag - false, // regular selection - ); - // empty selection (between two cells, not crossing threshold) - try testMouseSelectionIsNull( - 3.9, 3, // click - 4.0, 3, // drag - false, // regular selection - ); - - // -- RTL - // single cell selection - try testMouseSelection( - 3.9, 3, // click - 3.0, 3, // drag - 3, 3, // expected start - 3, 3, // expected end - false, // regular selection - ); - // including click and drag pin cells - try testMouseSelection( - 5.9, 3, // click - 3.0, 3, // drag - 5, 3, // expected start - 3, 3, // expected end - false, // regular selection - ); - // including click pin cell but not drag pin cell - try testMouseSelection( - 5.9, 3, // click - 3.9, 3, // drag - 5, 3, // expected start - 4, 3, // expected end - false, // regular selection - ); - // including drag pin cell but not click pin cell - try testMouseSelection( - 5.0, 3, // click - 3.0, 3, // drag - 4, 3, // expected start - 3, 3, // expected end - false, // regular selection - ); - // including neither click nor drag pin cells - try testMouseSelection( - 5.0, 3, // click - 3.9, 3, // drag - 4, 3, // expected start - 4, 3, // expected end - false, // regular selection - ); - // empty selection (single cell on only left half) - try testMouseSelectionIsNull( - 3.1, 3, // click - 3.0, 3, // drag - false, // regular selection - ); - // empty selection (single cell on only right half) - try testMouseSelectionIsNull( - 3.9, 3, // click - 3.8, 3, // drag - false, // regular selection - ); - // empty selection (between two cells, not crossing threshold) - try testMouseSelectionIsNull( - 4.0, 3, // click - 3.9, 3, // drag - false, // regular selection - ); - - // -- Wrapping - // LTR, wrap excluded cells - try testMouseSelection( - 9.9, 2, // click - 0.0, 4, // drag - 0, 3, // expected start - 9, 3, // expected end - false, // regular selection - ); - // RTL, wrap excluded cells - try testMouseSelection( - 0.0, 4, // click - 9.9, 2, // drag - 9, 3, // expected start - 0, 3, // expected end - false, // regular selection - ); -} - -test "Surface: rectangle selection logic" { - // We disable format to make these easier to - // read by pairing sets of coordinates per line. - // zig fmt: off - - // -- LTR - // single column selection - try testMouseSelection( - 3.0, 2, // click - 3.9, 4, // drag - 3, 2, // expected start - 3, 4, // expected end - true, //rectangle selection - ); - // including click and drag pin columns - try testMouseSelection( - 3.0, 2, // click - 5.9, 4, // drag - 3, 2, // expected start - 5, 4, // expected end - true, //rectangle selection - ); - // including click pin column but not drag pin column - try testMouseSelection( - 3.0, 2, // click - 5.0, 4, // drag - 3, 2, // expected start - 4, 4, // expected end - true, //rectangle selection - ); - // including drag pin column but not click pin column - try testMouseSelection( - 3.9, 2, // click - 5.9, 4, // drag - 4, 2, // expected start - 5, 4, // expected end - true, //rectangle selection - ); - // including neither click nor drag pin columns - try testMouseSelection( - 3.9, 2, // click - 5.0, 4, // drag - 4, 2, // expected start - 4, 4, // expected end - true, //rectangle selection - ); - // empty selection (single column on only left half) - try testMouseSelectionIsNull( - 3.0, 2, // click - 3.1, 4, // drag - true, //rectangle selection - ); - // empty selection (single column on only right half) - try testMouseSelectionIsNull( - 3.8, 2, // click - 3.9, 4, // drag - true, //rectangle selection - ); - // empty selection (between two columns, not crossing threshold) - try testMouseSelectionIsNull( - 3.9, 2, // click - 4.0, 4, // drag - true, //rectangle selection - ); - - // -- RTL - // single column selection - try testMouseSelection( - 3.9, 2, // click - 3.0, 4, // drag - 3, 2, // expected start - 3, 4, // expected end - true, //rectangle selection - ); - // including click and drag pin columns - try testMouseSelection( - 5.9, 2, // click - 3.0, 4, // drag - 5, 2, // expected start - 3, 4, // expected end - true, //rectangle selection - ); - // including click pin column but not drag pin column - try testMouseSelection( - 5.9, 2, // click - 3.9, 4, // drag - 5, 2, // expected start - 4, 4, // expected end - true, //rectangle selection - ); - // including drag pin column but not click pin column - try testMouseSelection( - 5.0, 2, // click - 3.0, 4, // drag - 4, 2, // expected start - 3, 4, // expected end - true, //rectangle selection - ); - // including neither click nor drag pin columns - try testMouseSelection( - 5.0, 2, // click - 3.9, 4, // drag - 4, 2, // expected start - 4, 4, // expected end - true, //rectangle selection - ); - // empty selection (single column on only left half) - try testMouseSelectionIsNull( - 3.1, 2, // click - 3.0, 4, // drag - true, //rectangle selection - ); - // empty selection (single column on only right half) - try testMouseSelectionIsNull( - 3.9, 2, // click - 3.8, 4, // drag - true, //rectangle selection - ); - // empty selection (between two columns, not crossing threshold) - try testMouseSelectionIsNull( - 4.0, 2, // click - 3.9, 4, // drag - true, //rectangle selection - ); - - // -- Wrapping - // LTR, do not wrap - try testMouseSelection( - 9.9, 2, // click - 0.0, 4, // drag - 9, 2, // expected start - 0, 4, // expected end - true, //rectangle selection - ); - // RTL, do not wrap - try testMouseSelection( - 0.0, 4, // click - 9.9, 2, // drag - 0, 4, // expected start - 9, 2, // expected end - true, //rectangle selection - ); -} diff --git a/src/inspector/widgets/surface.zig b/src/inspector/widgets/surface.zig index d73e784ce..a630bad87 100644 --- a/src/inspector/widgets/surface.zig +++ b/src/inspector/widgets/surface.zig @@ -462,7 +462,8 @@ fn mouseTable( { const left_click_point: terminal.point.Coordinate = pt: { - const p = surface_mouse.left_click_pin orelse break :pt .{}; + const p = surface_mouse.selection_gesture.validatedLeftClickPin(&t.screens) orelse + break :pt .{}; const pt = t.screens.active.pages.pointFromPin( .active, p.*, @@ -495,8 +496,8 @@ fn mouseTable( _ = cimgui.c.ImGui_TableSetColumnIndex(1); cimgui.c.ImGui_Text( "(%dpx, %dpx)", - @as(u32, @intFromFloat(surface_mouse.left_click_xpos)), - @as(u32, @intFromFloat(surface_mouse.left_click_ypos)), + @as(u32, @intFromFloat(surface_mouse.selection_gesture.left_click_xpos)), + @as(u32, @intFromFloat(surface_mouse.selection_gesture.left_click_ypos)), ); } } diff --git a/src/terminal/SelectionGesture.zig b/src/terminal/SelectionGesture.zig new file mode 100644 index 000000000..4b1edac88 --- /dev/null +++ b/src/terminal/SelectionGesture.zig @@ -0,0 +1,2003 @@ +/// SelectionGesture manages gesture-based terminal text selection for one +/// pointer stream: press, drag, release, autoscroll, and pressure/deep-press +/// selection. +/// +/// This type owns only the state required to interpret a gesture. It does not +/// modify the terminal selection directly, except for scrolling the viewport +/// during `autoscrollTick`. The caller feeds platform events into this type and +/// applies the returned `Selection` to the active screen when appropriate. +/// +/// A typical single-click drag flow looks like this: +/// +/// ```zig +/// const selection = try gesture.press(terminal, .{ ... }); +/// try terminal.screens.active.select(selection); +/// if (gesture.drag(terminal, .{ ... })) |selection| { +/// try terminal.screens.active.select(selection); +/// } +/// gesture.release(terminal, .{ ... }); +/// ``` +/// +/// Double- and triple-click gestures use the same event flow. Repeated presses +/// inside `Press.repeat_interval` and within `Press.max_distance` increment the +/// internal click count up to three. `Press.behaviors` maps single-, double-, +/// and triple-clicks to behavior. By default, a single press returns null to +/// clear any existing selection, a double-click returns a word selection, and a +/// triple-click returns a line selection. Drags use the behavior selected by the +/// corresponding press. A new press that is too late, too far away, or on +/// another active screen starts a new single-click gesture. +/// +/// # Resetting and lifetime +/// +/// `release` ends the active drag/autoscroll phase but intentionally preserves +/// enough state for a subsequent press to become a double- or triple-click. +/// Call `reset` when the gesture is cancelled rather than released normally, or +/// when another subsystem takes ownership of pointer input. Examples include +/// enabling mouse reporting for an application, losing pointer/button state, +/// destroying the surface, switching to a mode that must not continue text +/// selection, or otherwise abandoning the current click sequence. Call `deinit` +/// once before discarding the gesture object so any tracked click pin is +/// released. +/// +/// # Terminal and screen changes +/// +/// The initial press pin is tracked in the active screen's `PageList`, so normal +/// terminal output and viewport scrolling can move rows without making the +/// gesture immediately stale. Selection results are computed against the current +/// terminal contents at the time of each call. For example, a double-click drag +/// selects word boundaries from the screen as it exists during `drag`, not from a +/// snapshot captured at `press`. +/// +/// The tracked pin is tied to both a `ScreenSet.Key` and that screen's +/// generation. If the active screen changes, or a screen is removed/recycled, +/// `validatedLeftClickPin` returns null and drag-style operations stop producing +/// selections. `autoscrollTick` treats this as cancellation and calls `reset` so +/// callers can stop their timers. This avoids exposing pins from inactive or +/// freed screens, but it does not make a historical snapshot of terminal data. +/// +/// # Concurrency +/// +/// SelectionGesture is not concurrency safe. It has mutable gesture state and +/// mutates/tracks pins inside the terminal page list without taking locks. The +/// caller must serialize all calls that touch the same gesture and terminal, +/// typically by holding the same terminal/renderer mutex used for other screen +/// mutations. Do not call `press`, `drag`, `release`, `reset`, `deinit`, or +/// `autoscrollTick` concurrently with each other or with unrelated terminal +/// mutations unless the caller provides that synchronization. +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 Screen = @import("Screen.zig"); +const ScreenSet = @import("ScreenSet.zig"); +const Selection = @import("Selection.zig"); +const Terminal = @import("Terminal.zig"); +const point = @import("point.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 selection behavior chosen for the active left-click gesture. +left_click_behavior: Behavior, + +/// 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, + +/// True once the active left-click gesture has moved away from the initially +/// pressed cell. This is reset on every press that starts or continues a +/// multi-click sequence, and is left available for callers to inspect while +/// handling the corresponding release. +left_click_dragged: bool, + +/// The current autoscroll state for the active left-click drag gesture. +left_drag_autoscroll: Autoscroll, + +/// The direction that selection dragging should autoscroll the viewport. +/// This is derived from the most recent drag position relative to the +/// surface bounds and reset whenever there is no active drag gesture. +/// +/// When autoscroll is non-none, the caller should setup a timer +/// to periodically call autoscrollTick. The timer interval is up to the +/// caller but reasonable defaults are approximately every 15 milliseconds. +/// +/// This is used to implement selection above/below the viewport that +/// wants to drag the viewport. +pub const Autoscroll = enum { none, up, down }; + +/// The selection behavior for a click and subsequent drag. +pub const Behavior = enum { + /// Cell-granular drag selection. Press returns null to clear selection. + cell, + + /// Word selection on press and word-granular drag selection. + word, + + /// Line selection on press and line-granular drag selection. + line, + + /// Semantic command output selection on press and drag. + output, +}; + +/// Standard terminal selection behavior for single-, double-, and triple-clicks. +/// +/// A single click uses cell behavior, which returns null on press so callers can +/// clear any existing selection and then drag by cell. A double-click selects and +/// drags by word. A triple-click selects and drags by line. +pub const default_behaviors: [3]Behavior = .{ .cell, .word, .line }; + +/// Distance from the top or bottom surface edge, in pixels, where dragging +/// should request autoscroll. This preserves the historical 1px buffer used +/// so fullscreen-edge drags can still trigger autoscroll. +const autoscroll_buffer: f64 = 1; + +pub const init: SelectionGesture = .{ + .left_click_pin = null, + .left_click_count = 0, + .left_click_time = null, + .left_click_behavior = .cell, + .left_click_screen = .primary, + .left_click_screen_generation = 0, + .left_click_xpos = 0, + .left_click_ypos = 0, + .left_click_dragged = false, + .left_drag_autoscroll = .none, +}; + +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. +/// +/// Use this for cancellation/abandonment, not for the ordinary left-button +/// release path. `release` deliberately keeps the last press time/count so a +/// following press can become a double- or triple-click; `reset` clears that +/// sequence and makes the next press a fresh single click. +/// +/// Examples of reset-worthy events are: mouse reporting taking over, pointer +/// capture being lost, a surface/window being torn down, or another interaction +/// mode deciding that text selection must stop immediately. If the active screen +/// was already removed or recycled, this safely drops the stale reference without +/// trying to untrack a pin from the wrong screen generation. +pub fn reset(self: *SelectionGesture, t: *Terminal) void { + self.left_click_count = 0; + self.left_click_time = null; + self.left_click_behavior = .cell; + self.left_click_dragged = false; + self.left_drag_autoscroll = .none; + self.untrackPin(t); +} + +/// Return the tracked left-click pin only if it still belongs to the current +/// active screen instance. +/// +/// This validates both the screen key and generation so a pin from a removed, +/// recycled, or inactive screen is never exposed to callers. A null result means +/// callers should treat the in-progress gesture as temporarily or permanently +/// unable to produce a selection. For a normal drag this usually means "do +/// nothing for this event"; for autoscroll it is treated as cancellation because +/// a timer should not continue firing for a gesture that no longer has a valid +/// anchor. +pub fn validatedLeftClickPin( + self: *const SelectionGesture, + screens: *const ScreenSet, +) ?*Pin { + const pin = self.left_click_pin orelse return null; + if (self.left_click_screen != screens.active_key) return null; + if (screens.generation(self.left_click_screen) != self.left_click_screen_generation) return null; + _ = screens.get(self.left_click_screen) orelse return null; + return pin; +} + +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. + /// + /// `press` stores a tracked copy of this pin. The caller does not need to + /// keep `p.pin` alive after the call returns, but the pin must belong to the + /// terminal's active screen when passed in. + 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, + + /// The codepoints that delimit words for double-click selection. + word_boundary_codepoints: []const u21, + + /// Selection behaviors for single-, double-, and triple-clicks. + behaviors: *const [3]Behavior = &default_behaviors, +}; + +/// Record a press event and return the standard selection for this click. +/// +/// If this press continues the existing click sequence, the click count is +/// incremented up to three and the original anchor pin is kept. Otherwise, the +/// previous gesture state is cleared and this press becomes the new anchor. +/// The returned selection is untracked and represents the standard terminal +/// click behavior for the resulting click count. The caller is responsible for +/// applying it to the screen, usually with `Screen.select`, and for arranging +/// any copy-on-select behavior. +/// +/// Examples: +/// +/// * first press: `left_click_count == 1`, defaults to cell behavior; +/// * second nearby press within the repeat interval: `left_click_count == 2`, +/// defaults to word behavior; +/// * third nearby press within the repeat interval: `left_click_count == 3`, +/// defaults to line behavior; +/// * press after the interval, too far away, or after a screen generation +/// change: starts over at `left_click_count == 1` and returns null. +pub fn press( + self: *SelectionGesture, + t: *Terminal, + p: Press, +) Allocator.Error!?Selection { + if (self.left_click_count > 0) { + if (self.pressRepeat(t, p)) { + // Successful repeat. + return self.pressSelection(t.screens.active, p); + } 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); + return self.pressSelection(t.screens.active, p); +} + +pub const Drag = struct { + /// The cell where the current drag position is. This is used + /// synchronously to calculate the selection and is not tracked. + pin: Pin, + + /// The x/y value of the drag relative to the surface with (0,0) being + /// top-left. + xpos: f64, + ypos: f64, + + /// True if the current drag should produce a rectangular selection. + rectangle: bool, + + /// The codepoints that delimit words for double-click drag selection. + word_boundary_codepoints: []const u21, + + /// Geometry required for selection threshold and autoscroll calculations. + geometry: Geometry, + + /// Display geometry needed to translate surface-relative pointer positions + /// into selection behavior. + pub const Geometry = struct { + /// The number of columns in the rendered terminal grid. + columns: u32, + + /// The width of one terminal cell in surface pixels. + cell_width: u32, + + /// The left padding before the terminal grid begins, in surface pixels. + padding_left: u32, + + /// The height of the rendered terminal surface in surface pixels. + screen_height: u32, + }; +}; + +/// Record a drag event and return the current untracked drag selection. +/// +/// The returned selection is untracked and represents the best selection for the +/// terminal contents at the time of this call. The caller is responsible for +/// applying it to the screen, usually with `Screen.select`, and for arranging any +/// copy-on-select behavior. A null result means either there is no active +/// selection gesture, the original press is no longer valid for the active +/// screen, or the drag has not crossed the threshold required to select a cell. +/// +/// This method also updates `left_click_dragged` and `left_drag_autoscroll`. +/// If `left_drag_autoscroll` becomes `.up` or `.down`, the caller should start or +/// keep a timer that calls `autoscrollTick` while the button remains pressed. If +/// it becomes `.none`, the caller should stop that timer. +/// +/// Normal terminal output and viewport movement between drag events are allowed: +/// the tracked press pin follows the page list, and the drag pin is used only +/// synchronously. Content-sensitive selections such as word and line selection +/// are recalculated from the current active screen every time. +pub fn drag( + self: *SelectionGesture, + t: *Terminal, + d: Drag, +) ?Selection { + // If we aren't currently clicked then we don't do any dragging + // behavior. + if (self.left_click_count == 0) { + assert(self.left_drag_autoscroll == .none); + return null; + } + + // Get our click pin. We get a validated pin because if our + // screen changed out from under us then we aren't actually + // clicking anymore. + const click_pin = self.validatedLeftClickPin(&t.screens) orelse return null; + if (!d.pin.eql(click_pin.*)) self.left_click_dragged = true; + + // Determine if we should autoscroll. If our drag position is above + // the top, we go up. If its below the bottom we go down. Easy. + const max_y: f64 = @floatFromInt(d.geometry.screen_height); + self.left_drag_autoscroll = if (d.ypos <= autoscroll_buffer) + .up + else if (d.ypos > max_y - autoscroll_buffer) + .down + else + .none; + + const selection = switch (self.left_click_behavior) { + .cell => dragSelection( + click_pin.*, + d.pin, + @intFromFloat(@max(0, self.left_click_xpos)), + @intFromFloat(@max(0, d.xpos)), + d.rectangle, + d.geometry, + ), + + .word => dragSelectionWord( + t.screens.active, + click_pin.*, + d.pin, + d.word_boundary_codepoints, + ), + + .line => dragSelectionLine( + t.screens.active, + click_pin.*, + d.pin, + ), + + .output => dragSelectionOutput( + t.screens.active, + click_pin.*, + d.pin, + ), + }; + + // Same-cell cell selections can still become real selections when the drag + // crosses the within-cell threshold. Treat those as drags so callers don't + // also process click-only actions such as opening links. + if (self.left_click_behavior == .cell and selection != null) { + self.left_click_dragged = true; + } + + return selection; +} + +pub const AutoscrollTick = struct { + /// The viewport cell where the current drag position is. This is resolved + /// after the viewport is scrolled so the selection tracks the newly visible + /// row under the pointer. + viewport: point.Coordinate, + + /// The x/y value of the drag relative to the surface with (0,0) being + /// top-left. + xpos: f64, + ypos: f64, + + /// True if the current drag should produce a rectangular selection. + rectangle: bool, + + /// The codepoints that delimit words for double-click drag selection. + word_boundary_codepoints: []const u21, + + /// Geometry required for selection threshold and autoscroll calculations. + geometry: Drag.Geometry, +}; + +/// Record a selection autoscroll tick for the active left-click drag gesture. +/// +/// This scrolls the viewport in the active autoscroll direction and then +/// continues the drag at the provided viewport position. The viewport position +/// is resolved to a pin after scrolling so the drag applies to the row now under +/// the pointer. +/// +/// This always scrolls the viewport by exactly one row in the current +/// autoscroll direction. If you want to scroll by more, increase your +/// tick rate. +/// +/// If the original press pin no longer belongs to the active screen, this calls +/// `reset` and returns null. That is a signal for the caller to stop its +/// autoscroll timer and leave any existing terminal selection alone unless some +/// other event says otherwise. +pub fn autoscrollTick( + self: *SelectionGesture, + t: *Terminal, + tick: AutoscrollTick, +) ?Selection { + if (self.left_click_count == 0) { + assert(self.left_drag_autoscroll == .none); + return null; + } + + const delta: isize = switch (self.left_drag_autoscroll) { + .none => return null, + .up => -1, + .down => 1, + }; + + // If our click pin no longer belongs to the active screen, the gesture is + // no longer valid. Stop it so callers can stop their autoscroll timer + // without clearing the current selection as if this were a real drag. + _ = self.validatedLeftClickPin(&t.screens) orelse { + self.reset(t); + return null; + }; + + t.scrollViewport(.{ .delta = delta }); + + const pin = t.screens.active.pages.pin(.{ .viewport = tick.viewport }) orelse return null; + return self.drag(t, .{ + .pin = pin, + .xpos = tick.xpos, + .ypos = tick.ypos, + .rectangle = tick.rectangle, + .word_boundary_codepoints = tick.word_boundary_codepoints, + .geometry = tick.geometry, + }); +} + +/// A pressure-based activation during an existing left-click gesture. +/// +/// This is the terminal gesture model for platform features such as macOS +/// force click / deep click on pressure-sensitive trackpads. It is not a +/// distinct mouse button and it is not part of the normal single/double/triple +/// click count sequence; it can only occur after a left press is already +/// active. +pub const DeepPress = struct { + /// The codepoints that delimit words for the word selection produced by + /// the deep press. + word_boundary_codepoints: []const u21, +}; + +/// Record a deep press event for the active left-click gesture. +/// +/// A deep press is a force/pressure activation while the primary pointer is +/// already down. Ghostty treats it like the platform text-selection affordance: +/// select the word under the original press, then consume the gesture so +/// further cursor movement while the button remains pressed does not drag or +/// autoscroll the selection. +/// +/// After a successful deep press, the click sequence is cleared and the tracked +/// pin is untracked. The returned selection should be applied by the caller. A +/// null result means there was no valid active left-click anchor, commonly +/// because the screen changed or the gesture had already been cancelled. +pub fn deepPress( + self: *SelectionGesture, + t: *Terminal, + p: DeepPress, +) ?Selection { + const click_pin = self.validatedLeftClickPin(&t.screens) orelse return null; + const sel = t.screens.active.selectWord( + click_pin.*, + p.word_boundary_codepoints, + ); + + self.left_click_count = 0; + self.left_click_time = null; + self.left_click_behavior = .cell; + self.left_click_dragged = true; + self.left_drag_autoscroll = .none; + self.untrackPin(t); + + return sel; +} + +pub const Release = struct { + /// The cell where the release occurred, if the release position mapped to + /// a valid cell. This is used synchronously to update gesture state and is + /// not tracked. + pin: ?Pin, +}; + +/// Record a release event for the active left-click gesture. +/// +/// This stops autoscroll and updates `left_click_dragged`, but it does not clear +/// the click count or time. Keeping that state is what lets the next nearby press +/// become a double- or triple-click. Call `reset` instead if the release should +/// cancel the click sequence entirely. +/// +/// Pass the release pin when the pointer position maps to a valid terminal cell. +/// If it does not, pass null; the gesture then conservatively records that the +/// pointer moved away from the original pressed cell. This is useful for callers +/// that use `left_click_dragged` after release to decide whether a click should +/// activate links or other hit targets. +pub fn release( + self: *SelectionGesture, + t: *Terminal, + r: Release, +) void { + if (self.left_click_count == 0) { + assert(self.left_drag_autoscroll == .none); + return; + } + + if (r.pin) |release_pin| { + if (self.validatedLeftClickPin(&t.screens)) |click_pin| { + if (!release_pin.eql(click_pin.*)) self.left_click_dragged = true; + } else { + // If the original anchor is no longer valid, conservatively treat + // this as a drag/cancelled click so callers don't perform click-only + // actions on a different or recycled screen. + self.left_click_dragged = true; + } + } else { + self.left_click_dragged = true; + } + self.left_drag_autoscroll = .none; +} + +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_behavior = p.behaviors[0]; + self.left_click_xpos = p.xpos; + self.left_click_ypos = p.ypos; + self.left_click_time = p.time; + self.left_click_dragged = false; + self.left_drag_autoscroll = .none; +} + +fn pressRepeat( + self: *SelectionGesture, + t: *Terminal, + p: Press, +) error{PressRequiresReset}!void { + errdefer { + self.left_click_count = 0; + self.left_click_behavior = .cell; + 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_dragged = false; + self.left_drag_autoscroll = .none; + self.left_click_count = @min( + self.left_click_count + 1, + 3, // We only support triple clicks max + ); + self.left_click_behavior = p.behaviors[self.left_click_count - 1]; +} + +fn pressSelection( + self: *const SelectionGesture, + screen: *Screen, + p: Press, +) ?Selection { + return switch (self.left_click_behavior) { + .cell => null, + .word => screen.selectWord(p.pin, p.word_boundary_codepoints), + .line => screen.selectLine(.{ .pin = p.pin }), + .output => screen.selectOutput(p.pin), + }; +} + +/// Calculates the appropriate selection given pins and pixel x positions for +/// the click point and the drag point, as well as selection mode and geometry. +fn dragSelection( + click_pin: Pin, + drag_pin: Pin, + click_x: u32, + drag_x: u32, + rectangle_selection: bool, + geometry: Drag.Geometry, +) ?Selection { + // Explanation: + // + // # Normal selections + // + // ## Left-to-right selections + // - The clicked cell is included if it was clicked to the left of its + // threshold point and the drag location is right of the threshold point. + // - The cell under the cursor (the "drag cell") is included if the drag + // location is right of its threshold point. + // + // ## Right-to-left selections + // - The clicked cell is included if it was clicked to the right of its + // threshold point and the drag location is left of the threshold point. + // - The cell under the cursor (the "drag cell") is included if the drag + // location is left of its threshold point. + // + // # Rectangular selections + // + // Rectangular selections are handled similarly, except that + // entire columns are considered rather than individual cells. + + // We only include cells in the selection if the threshold point lies + // between the start and end points of the selection. A threshold of + // 60% of the cell width was chosen empirically because it felt good. + const threshold_point: u32 = @intFromFloat(@round( + @as(f64, @floatFromInt(geometry.cell_width)) * 0.6, + )); + + // We use this to clamp the pixel positions below. + const max_x = geometry.columns * geometry.cell_width - 1; + + // We need to know how far across in the cell the drag pos is, so + // we subtract the padding and then take it modulo the cell width. + const drag_x_frac = @min(max_x, drag_x -| geometry.padding_left) % geometry.cell_width; + + // We figure out the fractional part of the click x position similarly. + const click_x_frac = @min(max_x, click_x -| geometry.padding_left) % geometry.cell_width; + + // Whether the click pin and drag pin are equal. + const same_pin = drag_pin.eql(click_pin); + + // Whether or not the end point of our selection is before the start point. + const end_before_start = ebs: { + if (same_pin) { + break :ebs drag_x_frac < click_x_frac; + } + + // Special handling for rectangular selections, we only use x position. + if (rectangle_selection) { + break :ebs switch (std.math.order(drag_pin.x, click_pin.x)) { + .eq => drag_x_frac < click_x_frac, + .lt => true, + .gt => false, + }; + } + + break :ebs drag_pin.before(click_pin); + }; + + // Whether or not the click pin cell + // should be included in the selection. + const include_click_cell = if (end_before_start) + click_x_frac >= threshold_point + else + click_x_frac < threshold_point; + + // Whether or not the drag pin cell + // should be included in the selection. + const include_drag_cell = if (end_before_start) + drag_x_frac < threshold_point + else + drag_x_frac >= threshold_point; + + // If the click cell should be included in the selection then it's the + // start, otherwise we get the previous or next cell to it depending on + // the type and direction of the selection. + const start_pin = + if (include_click_cell) + click_pin + else if (end_before_start) + if (rectangle_selection) + click_pin.leftClamp(1) + else + click_pin.leftWrap(1) orelse click_pin + else if (rectangle_selection) + click_pin.rightClamp(1) + else + click_pin.rightWrap(1) orelse click_pin; + + // Likewise for the end pin with the drag cell. + const end_pin = + if (include_drag_cell) + drag_pin + else if (end_before_start) + if (rectangle_selection) + drag_pin.rightClamp(1) + else + drag_pin.rightWrap(1) orelse drag_pin + else if (rectangle_selection) + drag_pin.leftClamp(1) + else + drag_pin.leftWrap(1) orelse drag_pin; + + // If the click cell is the same as the drag cell and the click cell + // shouldn't be included, or if the cells are adjacent such that the + // start or end pin becomes the other cell, and that cell should not + // be included, then we have no selection, so we set it to null. + // + // If in rectangular selection mode, we compare columns as well. + // + // TODO(qwerasd): this can/should probably be refactored, it's a bit + // repetitive and does excess work in rectangle mode. + if ((!include_click_cell and same_pin) or + (!include_click_cell and rectangle_selection and click_pin.x == drag_pin.x) or + (!include_click_cell and end_pin.eql(click_pin)) or + (!include_click_cell and rectangle_selection and end_pin.x == click_pin.x) or + (!include_drag_cell and start_pin.eql(drag_pin)) or + (!include_drag_cell and rectangle_selection and start_pin.x == drag_pin.x)) + { + return null; + } + + // TODO: Clamp selection to the screen area, don't + // let it extend past the last written row. + + return .init( + start_pin, + end_pin, + rectangle_selection, + ); +} + +/// Calculates the appropriate word-wise selection for a double-click drag. +fn dragSelectionWord( + screen: *Screen, + click_pin: Pin, + drag_pin: Pin, + boundary_codepoints: []const u21, +) ?Selection { + // Get the word closest to our starting click. + const word_start = screen.selectWordBetween( + click_pin, + drag_pin, + boundary_codepoints, + ) orelse return null; + + // Get the word closest to our current point. + const word_current = screen.selectWordBetween( + drag_pin, + click_pin, + boundary_codepoints, + ) orelse return null; + + // If our current mouse position is before the starting position, + // then the selection start is the word nearest our current position. + return if (drag_pin.before(click_pin)) + .init( + word_current.start(), + word_start.end(), + false, + ) + else + .init( + word_start.start(), + word_current.end(), + false, + ); +} + +/// Calculates the appropriate line-wise selection for a triple-click drag. +fn dragSelectionLine( + screen: *Screen, + click_pin: Pin, + drag_pin: Pin, +) ?Selection { + // Get the line selection under our current drag point. If there isn't a + // line, do nothing. + const line = screen.selectLine(.{ .pin = drag_pin }) orelse return null; + + // Get the selection under our click point. We first try to trim + // whitespace if we've selected a word. But if no word exists then + // we select the blank line. + const sel_ = screen.selectLine(.{ .pin = click_pin }) orelse + screen.selectLine(.{ .pin = click_pin, .whitespace = null }); + + var sel = sel_ orelse return null; + if (drag_pin.before(click_pin)) { + sel.startPtr().* = line.start(); + } else { + sel.endPtr().* = line.end(); + } + return sel; +} + +/// Calculates the appropriate semantic-output-wise selection for an output +/// drag. This expands from the output block under the click point to the output +/// block under the current drag point. If the drag point is not output, keep the +/// original output selection. +fn dragSelectionOutput( + screen: *Screen, + click_pin: Pin, + drag_pin: Pin, +) ?Selection { + var sel = screen.selectOutput(click_pin) orelse return null; + const current = screen.selectOutput(drag_pin) orelse return sel; + + if (drag_pin.before(click_pin)) { + sel.startPtr().* = current.start(); + } else { + sel.endPtr().* = current.end(); + } + return sel; +} + +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), + .word_boundary_codepoints = &.{}, + }; +} + +fn testDrag(t: *Terminal, x: u16, y: u32, xpos: f64, ypos: f64) Drag { + return .{ + .pin = t.screens.active.pages.pin(.{ .active = .{ + .x = x, + .y = y, + } }).?, + .xpos = xpos, + .ypos = ypos, + .rectangle = false, + .word_boundary_codepoints = &.{}, + .geometry = .{ + .columns = 5, + .cell_width = 10, + .padding_left = 0, + .screen_height = 100, + }, + }; +} + +fn testAutoscrollTick( + viewport: point.Coordinate, + xpos: f64, + ypos: f64, +) AutoscrollTick { + return .{ + .viewport = viewport, + .xpos = xpos, + .ypos = ypos, + .rectangle = false, + .word_boundary_codepoints = &.{}, + .geometry = .{ + .columns = 5, + .cell_width = 10, + .padding_left = 0, + .screen_height = 100, + }, + }; +} + +fn testPin(t: *Terminal, x: u16, y: u32) Pin { + return t.screens.active.pages.pin(.{ .active = .{ + .x = x, + .y = y, + } }).?; +} + +/// Utility function for the unit tests for drag selection logic. +/// +/// Tests a click and drag on a 10x5 cell grid, x positions are given in +/// fractional cells, e.g. 3.1 would be 10% through the cell at x = 3. +/// +/// NOTE: The geometry tested with has 10px wide cells, meaning only one digit +/// after the decimal place has any meaning, e.g. 3.14 is equal to 3.1. +/// +/// The provided start_x/y and end_x/y are the expected start and end points +/// of the resulting selection. +fn testDragSelection( + click_x: f64, + click_y: u32, + drag_x: f64, + drag_y: u32, + start_x: u16, + start_y: u32, + end_x: u16, + end_y: u32, + rect: bool, +) !void { + assert(@import("builtin").is_test); + + // Our screen size is 10x5 cells that are + // 10x20 px, with 5px padding on all sides. + const geometry: Drag.Geometry = .{ + .columns = 10, + .cell_width = 10, + .padding_left = 5, + .screen_height = 110, + }; + var screen = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); + defer screen.deinit(); + + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y }, + }) orelse unreachable; + + const cell_width_f64: f64 = @floatFromInt(geometry.cell_width); + const click_x_pos: u32 = + @as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) + + geometry.padding_left; + const drag_x_pos: u32 = + @as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) + + geometry.padding_left; + + const start_pin = screen.pages.pin(.{ + .viewport = .{ .x = start_x, .y = start_y }, + }) orelse unreachable; + const end_pin = screen.pages.pin(.{ + .viewport = .{ .x = end_x, .y = end_y }, + }) orelse unreachable; + + try testing.expectEqualDeep(Selection{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = rect, + }, dragSelection( + click_pin, + drag_pin, + click_x_pos, + drag_x_pos, + rect, + geometry, + )); +} + +/// Like `testDragSelection` but checks that the resulting selection is null. +/// +/// See `testDragSelection` for more details. +fn testDragSelectionIsNull( + click_x: f64, + click_y: u32, + drag_x: f64, + drag_y: u32, + rect: bool, +) !void { + assert(@import("builtin").is_test); + + // Our screen size is 10x5 cells that are + // 10x20 px, with 5px padding on all sides. + const geometry: Drag.Geometry = .{ + .columns = 10, + .cell_width = 10, + .padding_left = 5, + .screen_height = 110, + }; + var screen = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); + defer screen.deinit(); + + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y }, + }) orelse unreachable; + + const cell_width_f64: f64 = @floatFromInt(geometry.cell_width); + const click_x_pos: u32 = + @as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) + + geometry.padding_left; + const drag_x_pos: u32 = + @as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) + + geometry.padding_left; + + try testing.expectEqual( + null, + dragSelection( + click_pin, + drag_pin, + click_x_pos, + drag_x_pos, + rect, + geometry, + ), + ); +} + +test "SelectionGesture drag selection logic" { + // We disable format to make these easier to + // read by pairing sets of coordinates per line. + // zig fmt: off + + // -- LTR + // single cell selection + try testDragSelection( + 3.0, 3, // click + 3.9, 3, // drag + 3, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including click and drag pin cells + try testDragSelection( + 3.0, 3, // click + 5.9, 3, // drag + 3, 3, // expected start + 5, 3, // expected end + false, // regular selection + ); + // including click pin cell but not drag pin cell + try testDragSelection( + 3.0, 3, // click + 5.0, 3, // drag + 3, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // including drag pin cell but not click pin cell + try testDragSelection( + 3.9, 3, // click + 5.9, 3, // drag + 4, 3, // expected start + 5, 3, // expected end + false, // regular selection + ); + // including neither click nor drag pin cells + try testDragSelection( + 3.9, 3, // click + 5.0, 3, // drag + 4, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // empty selection (single cell on only left half) + try testDragSelectionIsNull( + 3.0, 3, // click + 3.1, 3, // drag + false, // regular selection + ); + // empty selection (single cell on only right half) + try testDragSelectionIsNull( + 3.8, 3, // click + 3.9, 3, // drag + false, // regular selection + ); + // empty selection (between two cells, not crossing threshold) + try testDragSelectionIsNull( + 3.9, 3, // click + 4.0, 3, // drag + false, // regular selection + ); + + // -- RTL + // single cell selection + try testDragSelection( + 3.9, 3, // click + 3.0, 3, // drag + 3, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including click and drag pin cells + try testDragSelection( + 5.9, 3, // click + 3.0, 3, // drag + 5, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including click pin cell but not drag pin cell + try testDragSelection( + 5.9, 3, // click + 3.9, 3, // drag + 5, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // including drag pin cell but not click pin cell + try testDragSelection( + 5.0, 3, // click + 3.0, 3, // drag + 4, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including neither click nor drag pin cells + try testDragSelection( + 5.0, 3, // click + 3.9, 3, // drag + 4, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // empty selection (single cell on only left half) + try testDragSelectionIsNull( + 3.1, 3, // click + 3.0, 3, // drag + false, // regular selection + ); + // empty selection (single cell on only right half) + try testDragSelectionIsNull( + 3.9, 3, // click + 3.8, 3, // drag + false, // regular selection + ); + // empty selection (between two cells, not crossing threshold) + try testDragSelectionIsNull( + 4.0, 3, // click + 3.9, 3, // drag + false, // regular selection + ); + + // -- Wrapping + // LTR, wrap excluded cells + try testDragSelection( + 9.9, 2, // click + 0.0, 4, // drag + 0, 3, // expected start + 9, 3, // expected end + false, // regular selection + ); + // RTL, wrap excluded cells + try testDragSelection( + 0.0, 4, // click + 9.9, 2, // drag + 9, 3, // expected start + 0, 3, // expected end + false, // regular selection + ); +} + +test "SelectionGesture rectangle drag selection logic" { + // We disable format to make these easier to + // read by pairing sets of coordinates per line. + // zig fmt: off + + // -- LTR + // single column selection + try testDragSelection( + 3.0, 2, // click + 3.9, 4, // drag + 3, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including click and drag pin columns + try testDragSelection( + 3.0, 2, // click + 5.9, 4, // drag + 3, 2, // expected start + 5, 4, // expected end + true, //rectangle selection + ); + // including click pin column but not drag pin column + try testDragSelection( + 3.0, 2, // click + 5.0, 4, // drag + 3, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // including drag pin column but not click pin column + try testDragSelection( + 3.9, 2, // click + 5.9, 4, // drag + 4, 2, // expected start + 5, 4, // expected end + true, //rectangle selection + ); + // including neither click nor drag pin columns + try testDragSelection( + 3.9, 2, // click + 5.0, 4, // drag + 4, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // empty selection (single column on only left half) + try testDragSelectionIsNull( + 3.0, 2, // click + 3.1, 4, // drag + true, //rectangle selection + ); + // empty selection (single column on only right half) + try testDragSelectionIsNull( + 3.8, 2, // click + 3.9, 4, // drag + true, //rectangle selection + ); + // empty selection (between two columns, not crossing threshold) + try testDragSelectionIsNull( + 3.9, 2, // click + 4.0, 4, // drag + true, //rectangle selection + ); + + // -- RTL + // single column selection + try testDragSelection( + 3.9, 2, // click + 3.0, 4, // drag + 3, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including click and drag pin columns + try testDragSelection( + 5.9, 2, // click + 3.0, 4, // drag + 5, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including click pin column but not drag pin column + try testDragSelection( + 5.9, 2, // click + 3.9, 4, // drag + 5, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // including drag pin column but not click pin column + try testDragSelection( + 5.0, 2, // click + 3.0, 4, // drag + 4, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including neither click nor drag pin columns + try testDragSelection( + 5.0, 2, // click + 3.9, 4, // drag + 4, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // empty selection (single column on only left half) + try testDragSelectionIsNull( + 3.1, 2, // click + 3.0, 4, // drag + true, //rectangle selection + ); + // empty selection (single column on only right half) + try testDragSelectionIsNull( + 3.9, 2, // click + 3.8, 4, // drag + true, //rectangle selection + ); + // empty selection (between two columns, not crossing threshold) + try testDragSelectionIsNull( + 4.0, 2, // click + 3.9, 4, // drag + true, //rectangle selection + ); + + // -- Wrapping + // LTR, do not wrap + try testDragSelection( + 9.9, 2, // click + 0.0, 4, // drag + 9, 2, // expected start + 0, 4, // expected end + true, //rectangle selection + ); + // RTL, do not wrap + try testDragSelection( + 0.0, 4, // click + 9.9, 2, // drag + 0, 4, // expected start + 9, 2, // expected end + true, //rectangle selection + ); +} + +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); + try testing.expectEqual(false, gesture.left_click_dragged); +} + +test "SelectionGesture press returns standard click selections" { + var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 }); + defer t.deinit(testing.allocator); + try t.printString("alpha beta\none two"); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + var event = testPress(&t, 1, 0, time); + event.word_boundary_codepoints = &.{ ' ' }; + + try testing.expectEqual(null, try gesture.press(&t, event)); + + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 4, 0), + false, + ), (try gesture.press(&t, event)).?); + + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 9, 0), + false, + ), (try gesture.press(&t, event)).?); +} + +test "SelectionGesture press behaviors choose press and drag behavior" { + var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 }); + defer t.deinit(testing.allocator); + try t.printString("alpha beta\none two\nthree four"); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + var event = testPress(&t, 1, 0, time); + event.behaviors = &.{ .cell, .line, .word }; + event.word_boundary_codepoints = &.{ ' ' }; + + _ = try gesture.press(&t, event); + try testing.expectEqual(.cell, gesture.left_click_behavior); + + const double_click = (try gesture.press(&t, event)).?; + try testing.expectEqual(.line, gesture.left_click_behavior); + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 9, 0), + false, + ), double_click); + + const line_drag = gesture.drag(&t, testDrag(&t, 2, 2, 20, 50)).?; + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 9, 2), + false, + ), line_drag); +} + +test "SelectionGesture output behavior selects and drags semantic output" { + var t = try Terminal.init(testing.allocator, .{ .cols = 10, .rows = 6 }); + defer t.deinit(testing.allocator); + + const screen = t.screens.active; + screen.cursorSetSemanticContent(.output); + try screen.testWriteString("out1\n"); + screen.cursorSetSemanticContent(.{ .prompt = .initial }); + try screen.testWriteString("$ "); + screen.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try screen.testWriteString("cmd\n"); + screen.cursorSetSemanticContent(.output); + try screen.testWriteString("out2"); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + var event = testPress(&t, 1, 0, try std.time.Instant.now()); + event.behaviors = &.{ .output, .word, .line }; + + const press_selection = (try gesture.press(&t, event)).?; + try testing.expectEqual(.output, gesture.left_click_behavior); + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 3, 0), + false, + ), press_selection); + + const output_drag = gesture.drag(&t, testDrag(&t, 1, 2, 10, 50)).?; + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 3, 2), + false, + ), output_drag); +} + +test "SelectionGesture drag returns selection and records autoscroll" { + 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 press_event = testPress(&t, 1, 1, try std.time.Instant.now()); + press_event.xpos = 10; + _ = try gesture.press(&t, press_event); + + const sel = gesture.drag(&t, testDrag(&t, 3, 1, 39, 50)).?; + try testing.expectEqual(.none, gesture.left_drag_autoscroll); + try testing.expectEqual(true, gesture.left_click_dragged); + + try testing.expectEqualDeep(Selection.init( + t.screens.active.pages.pin(.{ .active = .{ .x = 1, .y = 1 } }).?, + t.screens.active.pages.pin(.{ .active = .{ .x = 3, .y = 1 } }).?, + false, + ), sel); + + _ = gesture.drag(&t, testDrag(&t, 3, 1, 39, 1)); + try testing.expectEqual(.up, gesture.left_drag_autoscroll); + + _ = gesture.drag(&t, testDrag(&t, 3, 1, 39, 100)); + try testing.expectEqual(.down, gesture.left_drag_autoscroll); +} + +test "SelectionGesture release clears autoscroll and records drag" { + 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 testing.expectEqual(false, gesture.left_click_dragged); + + _ = gesture.drag(&t, testDrag(&t, 1, 1, 10, 1)); + try testing.expectEqual(.up, gesture.left_drag_autoscroll); + try testing.expectEqual(false, gesture.left_click_dragged); + + gesture.release(&t, .{ + .pin = testPin(&t, 2, 1), + }); + try testing.expectEqual(.none, gesture.left_drag_autoscroll); + try testing.expectEqual(true, gesture.left_click_dragged); +} + +test "SelectionGesture release with invalidated click records drag" { + 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 testing.expectEqual(false, gesture.left_click_dragged); + + _ = try t.screens.getInit(testing.allocator, .alternate, .{ + .cols = t.cols, + .rows = t.rows, + }); + t.screens.switchTo(.alternate); + + gesture.release(&t, .{ .pin = testPin(&t, 1, 1) }); + try testing.expectEqual(true, gesture.left_click_dragged); + try testing.expectEqual(.none, gesture.left_drag_autoscroll); +} + +test "SelectionGesture same-cell threshold selection records drag" { + 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 press_event = testPress(&t, 1, 1, try std.time.Instant.now()); + press_event.xpos = 10; + _ = try gesture.press(&t, press_event); + try testing.expectEqual(false, gesture.left_click_dragged); + + const sel = gesture.drag(&t, testDrag(&t, 1, 1, 19, 50)).?; + try testing.expectEqual(true, gesture.left_click_dragged); + try testing.expectEqualDeep(Selection.init( + testPin(&t, 1, 1), + testPin(&t, 1, 1), + false, + ), sel); +} + +test "SelectionGesture drag without press returns null" { + 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 testing.expectEqual(null, gesture.drag(&t, testDrag(&t, 1, 1, 10, 50))); + try testing.expectEqual(.none, gesture.left_drag_autoscroll); +} + +test "SelectionGesture drag autoscroll edge boundaries" { + 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 press_event = testPress(&t, 1, 1, try std.time.Instant.now()); + press_event.xpos = 10; + _ = try gesture.press(&t, press_event); + + _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 1)); + try testing.expectEqual(.up, gesture.left_drag_autoscroll); + + _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 1.1)); + try testing.expectEqual(.none, gesture.left_drag_autoscroll); + + _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 99)); + try testing.expectEqual(.none, gesture.left_drag_autoscroll); + + _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 99.1)); + try testing.expectEqual(.down, gesture.left_drag_autoscroll); +} + +test "SelectionGesture autoscroll tick scrolls and continues drag" { + 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 press_event = testPress(&t, 1, 1, try std.time.Instant.now()); + press_event.xpos = 10; + _ = try gesture.press(&t, press_event); + + _ = gesture.drag(&t, testDrag(&t, 3, 1, 39, 100)); + try testing.expectEqual(.down, gesture.left_drag_autoscroll); + + const sel = gesture.autoscrollTick(&t, testAutoscrollTick(.{ .x = 3, .y = 2 }, 39, 100)).?; + try testing.expectEqual(.down, gesture.left_drag_autoscroll); + try testing.expectEqual(true, gesture.left_click_dragged); + try testing.expectEqualDeep(Selection.init( + testPin(&t, 1, 1), + testPin(&t, 3, 2), + false, + ), sel); +} + +test "SelectionGesture autoscroll tick resolves drag pin after scrolling" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 3, .max_scrollback = 10 }); + defer t.deinit(testing.allocator); + try t.printString("1111\n2222\n3333\n4444\n5555"); + t.scrollViewport(.{ .delta = -2 }); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + var press_event = testPress(&t, 1, 1, try std.time.Instant.now()); + press_event.xpos = 10; + _ = try gesture.press(&t, press_event); + + _ = gesture.drag(&t, testDrag(&t, 3, 2, 39, 100)); + try testing.expectEqual(.down, gesture.left_drag_autoscroll); + + const viewport: point.Coordinate = .{ .x = 3, .y = 2 }; + const pre_scroll_pin = t.screens.active.pages.pin(.{ .viewport = viewport }).?; + const sel = gesture.autoscrollTick(&t, testAutoscrollTick(viewport, 39, 100)).?; + const post_scroll_pin = t.screens.active.pages.pin(.{ .viewport = viewport }).?; + + try testing.expect(!pre_scroll_pin.eql(post_scroll_pin)); + try testing.expectEqualDeep(Selection.init( + testPin(&t, 1, 1), + post_scroll_pin, + false, + ), sel); +} + +test "SelectionGesture autoscroll tick stops with invalidated 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); + + var press_event = testPress(&t, 1, 1, try std.time.Instant.now()); + press_event.xpos = 10; + _ = try gesture.press(&t, press_event); + + _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 1)); + try testing.expectEqual(.up, gesture.left_drag_autoscroll); + + _ = try t.screens.getInit(testing.allocator, .alternate, .{ + .cols = t.cols, + .rows = t.rows, + }); + t.screens.switchTo(.alternate); + + try testing.expectEqual(null, gesture.autoscrollTick(&t, testAutoscrollTick(.{ .x = 2, .y = 1 }, 20, 1))); + try testing.expectEqual(.none, gesture.left_drag_autoscroll); + try testing.expectEqual(@as(u3, 0), gesture.left_click_count); +} + +test "SelectionGesture deep press selects word and consumes drag" { + var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 }); + defer t.deinit(testing.allocator); + try t.printString("alpha beta"); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + _ = try gesture.press(&t, testPress(&t, 1, 0, try std.time.Instant.now())); + _ = gesture.drag(&t, testDrag(&t, 1, 0, 10, 1)); + try testing.expectEqual(.up, gesture.left_drag_autoscroll); + + const sel = gesture.deepPress(&t, .{ + .word_boundary_codepoints = &.{ ' ' }, + }).?; + + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 4, 0), + false, + ), sel); + try testing.expectEqual(@as(u3, 0), gesture.left_click_count); + try testing.expectEqual(@as(?std.time.Instant, null), gesture.left_click_time); + try testing.expectEqual(true, gesture.left_click_dragged); + try testing.expectEqual(.none, gesture.left_drag_autoscroll); + try testing.expect(gesture.left_click_pin == null); + + try testing.expectEqual(null, gesture.drag(&t, testDrag(&t, 7, 0, 70, 50))); + gesture.release(&t, .{ .pin = testPin(&t, 7, 0) }); + try testing.expectEqual(true, gesture.left_click_dragged); +} + +test "SelectionGesture drag with invalidated click returns null" { + 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 press_event = testPress(&t, 1, 1, try std.time.Instant.now()); + press_event.xpos = 10; + _ = try gesture.press(&t, press_event); + + _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 1)); + try testing.expectEqual(.up, gesture.left_drag_autoscroll); + + _ = try t.screens.getInit(testing.allocator, .alternate, .{ + .cols = t.cols, + .rows = t.rows, + }); + t.screens.switchTo(.alternate); + + try testing.expectEqual(null, gesture.drag(&t, testDrag(&t, 2, 1, 20, 50))); + try testing.expectEqual(.up, gesture.left_drag_autoscroll); +} + +test "SelectionGesture double-click drag selects by word" { + var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 }); + defer t.deinit(testing.allocator); + try t.printString("alpha beta gamma"); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + _ = try gesture.press(&t, testPress(&t, 1, 0, time)); + _ = try gesture.press(&t, testPress(&t, 1, 0, time)); + + var drag_event = testDrag(&t, 7, 0, 70, 50); + drag_event.word_boundary_codepoints = &.{ ' ' }; + const sel = gesture.drag(&t, drag_event).?; + + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 9, 0), + false, + ), sel); +} + +test "SelectionGesture double-click drag selects by word backwards" { + var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 }); + defer t.deinit(testing.allocator); + try t.printString("alpha beta gamma"); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + _ = try gesture.press(&t, testPress(&t, 7, 0, time)); + _ = try gesture.press(&t, testPress(&t, 7, 0, time)); + + var drag_event = testDrag(&t, 1, 0, 10, 50); + drag_event.word_boundary_codepoints = &.{ ' ' }; + const sel = gesture.drag(&t, drag_event).?; + + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 9, 0), + false, + ), sel); +} + +test "SelectionGesture double-click drag on empty cell selects nearest word" { + var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 }); + defer t.deinit(testing.allocator); + try t.printString("alpha beta"); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + _ = try gesture.press(&t, testPress(&t, 1, 0, time)); + _ = try gesture.press(&t, testPress(&t, 1, 0, time)); + + var drag_event = testDrag(&t, 15, 0, 150, 50); + drag_event.word_boundary_codepoints = &.{ ' ' }; + const sel = gesture.drag(&t, drag_event).?; + + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 9, 0), + false, + ), sel); +} + +test "SelectionGesture triple-click drag selects by line" { + var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 }); + defer t.deinit(testing.allocator); + try t.printString("alpha beta\none two\nthree four"); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + _ = try gesture.press(&t, testPress(&t, 1, 0, time)); + _ = try gesture.press(&t, testPress(&t, 1, 0, time)); + _ = try gesture.press(&t, testPress(&t, 1, 0, time)); + + const sel = gesture.drag(&t, testDrag(&t, 2, 2, 20, 50)).?; + + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 9, 2), + false, + ), sel); +} + +test "SelectionGesture triple-click drag selects by line backwards" { + var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 }); + defer t.deinit(testing.allocator); + try t.printString("alpha beta\none two\nthree four"); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const time = try std.time.Instant.now(); + _ = try gesture.press(&t, testPress(&t, 2, 2, time)); + _ = try gesture.press(&t, testPress(&t, 2, 2, time)); + _ = try gesture.press(&t, testPress(&t, 2, 2, time)); + + const sel = gesture.drag(&t, testDrag(&t, 1, 0, 10, 50)).?; + + try testing.expectEqualDeep(Selection.init( + testPin(&t, 0, 0), + testPin(&t, 9, 2), + false, + ), sel); +} + +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;