diff options
Diffstat (limited to 'vendor/libghostty-vt/src/Surface.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/Surface.zig | 1041 |
1 files changed, 185 insertions, 856 deletions
diff --git a/vendor/libghostty-vt/src/Surface.zig b/vendor/libghostty-vt/src/Surface.zig index 5d16f332..410f717b 100644 --- a/vendor/libghostty-vt/src/Surface.zig +++ b/vendor/libghostty-vt/src/Surface.zig @@ -224,22 +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, - - /// 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, @@ -261,6 +247,11 @@ const Mouse = struct { /// The last x/y in the cursor position for links. We use this to /// only process link hover events when the mouse actually moves cells. link_point: ?terminal.point.Coordinate = null, + + /// 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 { + return self.selection_gesture.validatedLeftClickPin(screens); + } }; /// Keyboard state for the surface. @@ -822,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| { @@ -1179,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 screen changed while this is happening, we stop our - // selection scroll. - if (self.mouse.left_click_screen != t.screens.active_key) { + 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(); } @@ -1592,7 +1591,7 @@ fn mouseRefreshLinks( // mouse actions. const left_idx = @intFromEnum(input.MouseButton.left); if (self.mouse.click_state[left_idx] == .press) click: { - const pin = self.mouse.left_click_pin orelse break :click; + const pin = self.mouse.activeLeftClickPin(&self.io.terminal.screens) orelse break :click; const click_pt = self.io.terminal.screens.active.pages.pointFromPin( .viewport, pin.*, @@ -3764,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 @@ -3775,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. @@ -3797,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, ); } @@ -3810,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( @@ -3825,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| { @@ -3863,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(); @@ -3910,109 +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 (t.screens.get(self.mouse.left_click_screen)) |pin_screen| { - pin_screen.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_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, }, + }); + + // 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, select the word under our mouse. - // First try to detect if we're clicking on a URL to select the entire URL. + // 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 @@ -4140,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; @@ -4157,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. @@ -4454,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) @@ -4464,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.left_click_pin 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(); } } @@ -4550,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. @@ -4629,36 +4613,18 @@ 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 terminal screen changed then we don't process this. We don't - // invalidate our pin or mouse state because if the screen switches - // back then we can continue our selection. + // 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 + // because if the same screen switches back then we can continue our + // selection. const t: *terminal.Terminal = self.renderer_state.terminal; - if (self.mouse.left_click_screen != t.screens.active_key) break :select; + if (self.mouse.activeLeftClickPin(&t.screens) == null) break :select; // 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(.{ @@ -4671,250 +4637,40 @@ 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, - } - - return; - } -} - -/// 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.left_click_pin.?.*; - - // 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 = self.mouse.left_click_pin.?.*; - - // 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. - try self.io.terminal.screens.active.select(mouseSelection( - self.mouse.left_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; - } + // 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, + }, + }); - // 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, - }; + // 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, + ); + }, } - 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; + // Update our selection based on the gesture state + try self.io.terminal.screens.active.select(drag_selection); } - - // 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 @@ -6245,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 - ); -} |
