diff --git a/README.md b/README.md index 68da271fa..28ff78e8d 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,7 @@ The currently supported shell integration features in Ghostty: - The cursor at the prompt is turned into a bar. - The `jump_to_prompt` keybinding can be used to scroll the terminal window forward and back through prompts. +- Alt+click (option+click on macOS) to move the cursor at the prompt. #### Shell Integration Installation and Verification diff --git a/src/Surface.zig b/src/Surface.zig index 339b2c5b7..40f3b3f5a 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -182,6 +182,7 @@ const DerivedConfig = struct { clipboard_paste_bracketed_safe: bool, copy_on_select: configpkg.CopyOnSelect, confirm_close_surface: bool, + cursor_click_to_move: bool, desktop_notifications: bool, mouse_interval: u64, mouse_hide_while_typing: bool, @@ -235,6 +236,7 @@ const DerivedConfig = struct { .clipboard_paste_bracketed_safe = config.@"clipboard-paste-bracketed-safe", .copy_on_select = config.@"copy-on-select", .confirm_close_surface = config.@"confirm-close-surface", + .cursor_click_to_move = config.@"cursor-click-to-move", .desktop_notifications = config.@"desktop-notifications", .mouse_interval = config.@"click-repeat-interval" * 1_000_000, // 500ms .mouse_hide_while_typing = config.@"mouse-hide-while-typing", @@ -1981,6 +1983,17 @@ pub fn mouseButtonCallback( } } + // For left button click release we check if we are moving our cursor. + if (button == .left and action == .release and mods.alt) { + // Moving always resets the click count so that we don't highlight. + self.mouse.left_click_count = 0; + + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + try self.clickMoveCursor(self.mouse.left_click_point); + return; + } + // For left button clicks we always record some information for // selection/highlighting purposes. if (button == .left and action == .press) { @@ -1988,6 +2001,8 @@ pub fn mouseButtonCallback( defer self.renderer_state.mutex.unlock(); const pos = try self.rt_surface.getCursorPos(); + const pt_viewport = self.posToViewport(pos.x, pos.y); + const pt_screen = pt_viewport.toScreen(&self.io.terminal.screen); // If we move our cursor too much between clicks then we reset // the multi-click state. @@ -2002,8 +2017,7 @@ pub fn mouseButtonCallback( } // Store it - const point = self.posToViewport(pos.x, pos.y); - self.mouse.left_click_point = point.toScreen(&self.io.terminal.screen); + self.mouse.left_click_point = pt_screen; self.mouse.left_click_xpos = pos.x; self.mouse.left_click_ypos = pos.y; @@ -2029,10 +2043,13 @@ pub fn mouseButtonCallback( } switch (self.mouse.left_click_count) { - // First mouse click, clear selection - 1 => if (self.io.terminal.screen.selection != null) { - self.setSelection(null); - try self.queueRender(); + // Single click + 1 => { + // If we have a selection, clear it. This always happens. + if (self.io.terminal.screen.selection != null) { + self.setSelection(null); + try self.queueRender(); + } }, // Double click, select the word under our mouse @@ -2075,6 +2092,69 @@ pub fn mouseButtonCallback( } } +/// Performs the "click-to-move" logic to move the cursor to the given +/// screen point if possible. This works by converting the path to the +/// given point into a series of arrow key inputs. +fn clickMoveCursor(self: *Surface, to: terminal.point.ScreenPoint) !void { + // If click-to-move is disabled then we're done. + if (!self.config.cursor_click_to_move) return; + + const t = &self.io.terminal; + + // Click to move cursor only works on the primary screen where prompts + // exist. This means that alt screen multiplexers like tmux will not + // support this feature. It is just too messy. + if (t.active_screen != .primary) return; + + // This flag is only set if we've seen at least one semantic prompt + // OSC sequence. If we've never seen that sequence, we can't possibly + // move the cursor so we can fast path out of here. + if (!t.flags.shell_redraws_prompt) return; + + // Get our path + const from = (terminal.point.Viewport{ + .x = t.screen.cursor.x, + .y = t.screen.cursor.y, + }).toScreen(&t.screen); + const path = t.screen.promptPath(from, to); + log.debug("click-to-move-cursor from={} to={} path={}", .{ from, to, path }); + + // If we aren't moving at all, fast path out of here. + if (path.x == 0 and path.y == 0) return; + + // Convert our path to arrow key inputs. Yes, that is how this works. + // Yes, that is pretty sad. Yes, this could backfire in various ways. + // But its the best we can do. + + // We do Y first because it prevents any weird wrap behavior. + if (path.y != 0) { + const arrow = if (path.y < 0) arrow: { + break :arrow if (t.modes.get(.cursor_keys)) "\x1bOA" else "\x1b[A"; + } else arrow: { + break :arrow if (t.modes.get(.cursor_keys)) "\x1bOB" else "\x1b[B"; + }; + for (0..@abs(path.y)) |_| { + _ = self.io_thread.mailbox.push(.{ + .write_stable = arrow, + }, .{ .instant = {} }); + } + } + if (path.x != 0) { + const arrow = if (path.x < 0) arrow: { + break :arrow if (t.modes.get(.cursor_keys)) "\x1bOD" else "\x1b[D"; + } else arrow: { + break :arrow if (t.modes.get(.cursor_keys)) "\x1bOC" else "\x1b[C"; + }; + for (0..@abs(path.x)) |_| { + _ = self.io_thread.mailbox.push(.{ + .write_stable = arrow, + }, .{ .instant = {} }); + } + } + + try self.io_thread.wakeup.notify(); +} + /// Returns the link at the given cursor position, if any. fn linkAtPos( self: *Surface, diff --git a/src/config/Config.zig b/src/config/Config.zig index 2ca1c4a18..0a019b70d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -264,6 +264,21 @@ palette: Palette = .{}, /// will be chosen. @"cursor-text": ?Color = null, +/// Enables the ability to move the cursor at prompts by using alt+click +/// on Linux and option+click on macOS. +/// +/// This feature requires shell integration (specifically prompt marking +/// via OSC 133) and only works in primary screen mode. Alternate screen +/// applications like vim usually have their own version of this feature +/// but this configuration doesn't control that. +/// +/// It should be noted that this feature works by translating your desired +/// position into a series of synthetic arrow key movements, so some weird +/// behavior around edge cases are to be expected. This is unfortunately +/// how this feature is implemented across terminals because there isn't +/// any other way to implement it. +@"cursor-click-to-move": bool = true, + /// Hide the mouse immediately when typing. The mouse becomes visible /// again when the mouse is used. The mouse is only hidden if the mouse /// cursor is over the active terminal surface. diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 202073c3a..3f9dadd43 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1875,6 +1875,105 @@ pub fn selectOutput(self: *Screen, pt: point.ScreenPoint) ?Selection { }; } +/// Returns the selection bounds for the prompt at the given point. If the +/// point is not on a prompt line, this returns null. Note that due to +/// the underlying protocol, this will only return the y-coordinates of +/// the prompt. The x-coordinates of the start will always be zero and +/// the x-coordinates of the end will always be the last column. +/// +/// Note that this feature requires shell integration. If shell integration +/// is not enabled, this will always return null. +pub fn selectPrompt(self: *Screen, pt: point.ScreenPoint) ?Selection { + // Ensure that the line the point is on is a prompt. + const pt_row = self.getRow(.{ .screen = pt.y }); + const is_known = switch (pt_row.getSemanticPrompt()) { + .prompt, .prompt_continuation, .input => true, + .command => return null, + + // We allow unknown to continue because not all shells output any + // semantic prompt information for continuation lines. This has the + // possibility of making this function VERY slow (we look at all + // scrollback) so we should try to avoid this in the future by + // setting a flag or something if we have EVER seen a semantic + // prompt sequence. + .unknown => false, + }; + + // Find the start of the prompt. + var saw_semantic_prompt = is_known; + const start: usize = start: for (0..pt.y) |offset| { + const y = pt.y - offset; + const row = self.getRow(.{ .screen = y - 1 }); + switch (row.getSemanticPrompt()) { + // A prompt, we continue searching. + .prompt, .prompt_continuation, .input => saw_semantic_prompt = true, + + // See comment about "unknown" a few lines above. If we have + // previously seen a semantic prompt then if we see an unknown + // we treat it as a boundary. + .unknown => if (saw_semantic_prompt) break :start y, + + // Command output or unknown, definitely not a prompt. + .command => break :start y, + } + } else 0; + + // If we never saw a semantic prompt flag, then we can't trust our + // start value and we return null. This scenario usually means that + // semantic prompts aren't enabled via the shell. + if (!saw_semantic_prompt) return null; + + // Find the end of the prompt. + const end: usize = end: for (pt.y..self.rowsWritten()) |y| { + const row = self.getRow(.{ .screen = y }); + switch (row.getSemanticPrompt()) { + // A prompt, we continue searching. + .prompt, .prompt_continuation, .input => {}, + + // Command output or unknown, definitely not a prompt. + .command, .unknown => break :end y - 1, + } + } else self.rowsWritten() - 1; + + return .{ + .start = .{ .x = 0, .y = start }, + .end = .{ .x = self.cols - 1, .y = end }, + }; +} + +/// Returns the change in x/y that is needed to reach "to" from "from" +/// within a prompt. If "to" is before or after the prompt bounds then +/// the result will be bounded to the prompt. +/// +/// This feature requires shell integration. If shell integration is not +/// enabled, this will always return zero for both x and y (no path). +pub fn promptPath( + self: *Screen, + from: point.ScreenPoint, + to: point.ScreenPoint, +) struct { + x: isize, + y: isize, +} { + // Get our prompt bounds assuming "from" is at a prompt. + const bounds = self.selectPrompt(from) orelse return .{ .x = 0, .y = 0 }; + + // Get our actual "to" point clamped to the bounds of the prompt. + const to_clamped = if (bounds.contains(to)) + to + else if (to.before(bounds.start)) + bounds.start + else + bounds.end; + + // Basic math to calculate our path. + const from_x: isize = @intCast(from.x); + const from_y: isize = @intCast(from.y); + const to_x: isize = @intCast(to_clamped.x); + const to_y: isize = @intCast(to_clamped.y); + return .{ .x = to_x - from_x, .y = to_y - from_y }; +} + /// Scroll behaviors for the scroll function. pub const Scroll = union(enum) { /// Scroll to the top of the scroll buffer. The first line of the @@ -4675,6 +4774,7 @@ test "Screen: selectOutput" { try s.testWriteString("output3\n"); // 8 try s.testWriteString("output3"); // 9 } + // zig fmt: on var row = s.getRow(.{ .screen = 2 }); row.setSemanticPrompt(.prompt); @@ -4726,6 +4826,232 @@ test "Screen: selectOutput" { } } +test "Screen: selectPrompt basics" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 15, 10, 0); + defer s.deinit(); + + // zig fmt: off + { + // line number: + try s.testWriteString("output1\n"); // 0 + try s.testWriteString("output1\n"); // 1 + try s.testWriteString("prompt2\n"); // 2 + try s.testWriteString("input2\n"); // 3 + try s.testWriteString("output2\n"); // 4 + try s.testWriteString("output2\n"); // 5 + try s.testWriteString("prompt3$ input3\n"); // 6 + try s.testWriteString("output3\n"); // 7 + try s.testWriteString("output3\n"); // 8 + try s.testWriteString("output3"); // 9 + } + // zig fmt: on + + var row = s.getRow(.{ .screen = 2 }); + row.setSemanticPrompt(.prompt); + row = s.getRow(.{ .screen = 3 }); + row.setSemanticPrompt(.input); + row = s.getRow(.{ .screen = 4 }); + row.setSemanticPrompt(.command); + row = s.getRow(.{ .screen = 6 }); + row.setSemanticPrompt(.input); + row = s.getRow(.{ .screen = 7 }); + row.setSemanticPrompt(.command); + + // Not at a prompt + { + const sel = s.selectPrompt(.{ .x = 0, .y = 1 }); + try testing.expect(sel == null); + } + { + const sel = s.selectPrompt(.{ .x = 0, .y = 8 }); + try testing.expect(sel == null); + } + + // Single line prompt + { + const sel = s.selectPrompt(.{ .x = 1, .y = 6 }).?; + try testing.expectEqual(Selection{ + .start = .{ .x = 0, .y = 6 }, + .end = .{ .x = 9, .y = 6 }, + }, sel); + } + + // Multi line prompt + { + const sel = s.selectPrompt(.{ .x = 1, .y = 3 }).?; + try testing.expectEqual(Selection{ + .start = .{ .x = 0, .y = 2 }, + .end = .{ .x = 9, .y = 3 }, + }, sel); + } +} + +test "Screen: selectPrompt prompt at start" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 15, 10, 0); + defer s.deinit(); + + // zig fmt: off + { + // line number: + try s.testWriteString("prompt1\n"); // 0 + try s.testWriteString("input1\n"); // 1 + try s.testWriteString("output2\n"); // 2 + try s.testWriteString("output2\n"); // 3 + } + // zig fmt: on + + var row = s.getRow(.{ .screen = 0 }); + row.setSemanticPrompt(.prompt); + row = s.getRow(.{ .screen = 1 }); + row.setSemanticPrompt(.input); + row = s.getRow(.{ .screen = 2 }); + row.setSemanticPrompt(.command); + + // Not at a prompt + { + const sel = s.selectPrompt(.{ .x = 0, .y = 3 }); + try testing.expect(sel == null); + } + + // Multi line prompt + { + const sel = s.selectPrompt(.{ .x = 1, .y = 1 }).?; + try testing.expectEqual(Selection{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = 9, .y = 1 }, + }, sel); + } +} + +test "Screen: selectPrompt prompt at end" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 15, 10, 0); + defer s.deinit(); + + // zig fmt: off + { + // line number: + try s.testWriteString("output2\n"); // 0 + try s.testWriteString("output2\n"); // 1 + try s.testWriteString("prompt1\n"); // 2 + try s.testWriteString("input1\n"); // 3 + } + // zig fmt: on + + var row = s.getRow(.{ .screen = 2 }); + row.setSemanticPrompt(.prompt); + row = s.getRow(.{ .screen = 3 }); + row.setSemanticPrompt(.input); + + // Not at a prompt + { + const sel = s.selectPrompt(.{ .x = 0, .y = 1 }); + try testing.expect(sel == null); + } + + // Multi line prompt + { + const sel = s.selectPrompt(.{ .x = 1, .y = 2 }).?; + try testing.expectEqual(Selection{ + .start = .{ .x = 0, .y = 2 }, + .end = .{ .x = 9, .y = 3 }, + }, sel); + } +} + +test "Screen: promtpPath" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 15, 10, 0); + defer s.deinit(); + + // zig fmt: off + { + // line number: + try s.testWriteString("output1\n"); // 0 + try s.testWriteString("output1\n"); // 1 + try s.testWriteString("prompt2\n"); // 2 + try s.testWriteString("input2\n"); // 3 + try s.testWriteString("output2\n"); // 4 + try s.testWriteString("output2\n"); // 5 + try s.testWriteString("prompt3$ input3\n"); // 6 + try s.testWriteString("output3\n"); // 7 + try s.testWriteString("output3\n"); // 8 + try s.testWriteString("output3"); // 9 + } + // zig fmt: on + + var row = s.getRow(.{ .screen = 2 }); + row.setSemanticPrompt(.prompt); + row = s.getRow(.{ .screen = 3 }); + row.setSemanticPrompt(.input); + row = s.getRow(.{ .screen = 4 }); + row.setSemanticPrompt(.command); + row = s.getRow(.{ .screen = 6 }); + row.setSemanticPrompt(.input); + row = s.getRow(.{ .screen = 7 }); + row.setSemanticPrompt(.command); + + // From is not in the prompt + { + const path = s.promptPath( + .{ .x = 0, .y = 1 }, + .{ .x = 0, .y = 2 }, + ); + try testing.expectEqual(@as(isize, 0), path.x); + try testing.expectEqual(@as(isize, 0), path.y); + } + + // Same line + { + const path = s.promptPath( + .{ .x = 6, .y = 2 }, + .{ .x = 3, .y = 2 }, + ); + try testing.expectEqual(@as(isize, -3), path.x); + try testing.expectEqual(@as(isize, 0), path.y); + } + + // Different lines + { + const path = s.promptPath( + .{ .x = 6, .y = 2 }, + .{ .x = 3, .y = 3 }, + ); + try testing.expectEqual(@as(isize, -3), path.x); + try testing.expectEqual(@as(isize, 1), path.y); + } + + // To is out of bounds before + { + const path = s.promptPath( + .{ .x = 6, .y = 2 }, + .{ .x = 3, .y = 1 }, + ); + try testing.expectEqual(@as(isize, -6), path.x); + try testing.expectEqual(@as(isize, 0), path.y); + } + + // To is out of bounds after + { + const path = s.promptPath( + .{ .x = 6, .y = 2 }, + .{ .x = 3, .y = 9 }, + ); + try testing.expectEqual(@as(isize, 3), path.x); + try testing.expectEqual(@as(isize, 1), path.y); + } +} + test "Screen: scrollRegionUp single" { const testing = std.testing; const alloc = testing.allocator; @@ -5464,7 +5790,6 @@ test "Screen: selectionString, rectangle, more complex w/breaks" { try testing.expectEqualStrings(expected, contents); } - test "Screen: dirty with getCellPtr" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 54086c903..9b6b1f42f 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2115,7 +2115,7 @@ pub fn setLeftAndRightMargin(self: *Terminal, left_req: usize, right_req: usize) /// (OSC 133) only allow setting this for wherever the current active cursor /// is located. pub fn markSemanticPrompt(self: *Terminal, p: SemanticPrompt) void { - //log.warn("semantic_prompt y={} p={}", .{ self.screen.cursor.y, p }); + //log.debug("semantic_prompt y={} p={}", .{ self.screen.cursor.y, p }); const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); row.setSemanticPrompt(switch (p) { .prompt => .prompt,