From 9a32ea515be726865c87dd503a2c3e7c12290f11 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 20 Dec 2023 20:34:34 -0800 Subject: [PATCH 1/7] terminal: Screen.selectPrompt to get the selection of the prompt --- src/terminal/Screen.zig | 183 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 202073c3a..2fe5da85c 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1875,6 +1875,53 @@ 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 }); + switch (pt_row.getSemanticPrompt()) { + .prompt, .prompt_continuation, .input => {}, + .command, .unknown => return null, + } + + // Find the start of the prompt. + 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 => {}, + + // Command output or unknown, definitely not a prompt. + .command, .unknown => break :start y, + } + } else 0; + + // 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 }, + }; +} + /// Scroll behaviors for the scroll function. pub const Scroll = union(enum) { /// Scroll to the top of the scroll buffer. The first line of the @@ -4726,6 +4773,142 @@ 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 + } + + 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 + } + + var row = s.getRow(.{ .screen = 0 }); + row.setSemanticPrompt(.prompt); + row = s.getRow(.{ .screen = 1 }); + row.setSemanticPrompt(.input); + + // Not at a prompt + { + const sel = s.selectPrompt(.{ .x = 0, .y = 2 }); + 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 + } + + 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: scrollRegionUp single" { const testing = std.testing; const alloc = testing.allocator; From 6b1fd2b9eb17e6f7d3244853bba5366738074dbf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 20 Dec 2023 20:50:10 -0800 Subject: [PATCH 2/7] terminal: Screen.promptPath --- src/terminal/Screen.zig | 123 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 122 insertions(+), 1 deletion(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 2fe5da85c..d7fda6094 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1922,6 +1922,39 @@ pub fn selectPrompt(self: *Screen, pt: point.ScreenPoint) ?Selection { }; } +/// 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 @@ -4722,6 +4755,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); @@ -4794,6 +4828,7 @@ test "Screen: selectPrompt basics" { try s.testWriteString("output3\n"); // 8 try s.testWriteString("output3"); // 9 } + // zig fmt: on var row = s.getRow(.{ .screen = 2 }); row.setSemanticPrompt(.prompt); @@ -4850,6 +4885,7 @@ test "Screen: selectPrompt prompt at start" { try s.testWriteString("output2\n"); // 2 try s.testWriteString("output2\n"); // 3 } + // zig fmt: on var row = s.getRow(.{ .screen = 0 }); row.setSemanticPrompt(.prompt); @@ -4887,6 +4923,7 @@ test "Screen: selectPrompt prompt at end" { try s.testWriteString("prompt1\n"); // 2 try s.testWriteString("input1\n"); // 3 } + // zig fmt: on var row = s.getRow(.{ .screen = 2 }); row.setSemanticPrompt(.prompt); @@ -4909,6 +4946,91 @@ test "Screen: selectPrompt prompt at end" { } } +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; @@ -5647,7 +5769,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; From 4a3e1e15e5c7ef4e8a5e9d5b45e6630b03b40dcd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 20 Dec 2023 21:33:13 -0800 Subject: [PATCH 3/7] core: click to move cursor --- src/Surface.zig | 71 +++++++++++++++++++++++++++++++++++---- src/terminal/Screen.zig | 35 +++++++++++++++---- src/terminal/Terminal.zig | 2 +- 3 files changed, 94 insertions(+), 14 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 339b2c5b7..9da95a89c 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1981,6 +1981,16 @@ 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); + } + // For left button clicks we always record some information for // selection/highlighting purposes. if (button == .left and action == .press) { @@ -1988,6 +1998,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 +2014,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 +2040,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 +2089,51 @@ pub fn mouseButtonCallback( } } +fn clickMoveCursor(self: *Surface, to: terminal.point.ScreenPoint) !void { + // Get our path + const from = (terminal.point.Viewport{ + .x = self.io.terminal.screen.cursor.x, + .y = self.io.terminal.screen.cursor.y, + }).toScreen(&self.io.terminal.screen); + const path = self.io.terminal.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 (self.io.terminal.modes.get(.cursor_keys)) "\x1bOA" else "\x1b[A"; + } else arrow: { + break :arrow if (self.io.terminal.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 (self.io.terminal.modes.get(.cursor_keys)) "\x1bOD" else "\x1b[D"; + } else arrow: { + break :arrow if (self.io.terminal.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/terminal/Screen.zig b/src/terminal/Screen.zig index d7fda6094..3f9dadd43 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1886,24 +1886,43 @@ pub fn selectOutput(self: *Screen, pt: point.ScreenPoint) ?Selection { 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 }); - switch (pt_row.getSemanticPrompt()) { - .prompt, .prompt_continuation, .input => {}, - .command, .unknown => return null, - } + 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 => {}, + .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, .unknown => break :start y, + .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 }); @@ -4891,10 +4910,12 @@ test "Screen: selectPrompt prompt at start" { 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 = 2 }); + const sel = s.selectPrompt(.{ .x = 0, .y = 3 }); try testing.expect(sel == null); } 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, From 82e5080cd600742f800437b0b09e56941a4ae132 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 20 Dec 2023 21:38:21 -0800 Subject: [PATCH 4/7] core: click to move cursor only works on primary screen --- src/Surface.zig | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 9da95a89c..9ffd36bcd 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2090,12 +2090,19 @@ pub fn mouseButtonCallback( } fn clickMoveCursor(self: *Surface, to: terminal.point.ScreenPoint) !void { + 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; + // Get our path const from = (terminal.point.Viewport{ - .x = self.io.terminal.screen.cursor.x, - .y = self.io.terminal.screen.cursor.y, - }).toScreen(&self.io.terminal.screen); - const path = self.io.terminal.screen.promptPath(from, to); + .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. @@ -2108,9 +2115,9 @@ fn clickMoveCursor(self: *Surface, to: terminal.point.ScreenPoint) !void { // 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 (self.io.terminal.modes.get(.cursor_keys)) "\x1bOA" else "\x1b[A"; + break :arrow if (t.modes.get(.cursor_keys)) "\x1bOA" else "\x1b[A"; } else arrow: { - break :arrow if (self.io.terminal.modes.get(.cursor_keys)) "\x1bOB" else "\x1b[B"; + break :arrow if (t.modes.get(.cursor_keys)) "\x1bOB" else "\x1b[B"; }; for (0..@abs(path.y)) |_| { _ = self.io_thread.mailbox.push(.{ @@ -2120,9 +2127,9 @@ fn clickMoveCursor(self: *Surface, to: terminal.point.ScreenPoint) !void { } if (path.x != 0) { const arrow = if (path.x < 0) arrow: { - break :arrow if (self.io.terminal.modes.get(.cursor_keys)) "\x1bOD" else "\x1b[D"; + break :arrow if (t.modes.get(.cursor_keys)) "\x1bOD" else "\x1b[D"; } else arrow: { - break :arrow if (self.io.terminal.modes.get(.cursor_keys)) "\x1bOC" else "\x1b[C"; + break :arrow if (t.modes.get(.cursor_keys)) "\x1bOC" else "\x1b[C"; }; for (0..@abs(path.x)) |_| { _ = self.io_thread.mailbox.push(.{ From 3f9c42fd67f1e05a8a2ae3ca584910b50de6de16 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 20 Dec 2023 21:40:02 -0800 Subject: [PATCH 5/7] core: fast path out of click to move if no semantic prompts --- src/Surface.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Surface.zig b/src/Surface.zig index 9ffd36bcd..5cf6fcd0b 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2097,6 +2097,11 @@ fn clickMoveCursor(self: *Surface, to: terminal.point.ScreenPoint) !void { // 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, From 3776b8a77702e4d1d5089175fd82dd20a24edd0f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 20 Dec 2023 21:41:49 -0800 Subject: [PATCH 6/7] core: if click-to-move is done, stop mouse processing --- src/Surface.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Surface.zig b/src/Surface.zig index 5cf6fcd0b..1a4959c17 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1989,6 +1989,7 @@ pub fn mouseButtonCallback( 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 @@ -2089,6 +2090,9 @@ 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 { const t = &self.io.terminal; From 900b127a047e3d8ba9b94e108dc73b17174ae2c7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 20 Dec 2023 22:12:04 -0800 Subject: [PATCH 7/7] core: cursor click to move can be disabled --- README.md | 1 + src/Surface.zig | 5 +++++ src/config/Config.zig | 15 +++++++++++++++ 3 files changed, 21 insertions(+) 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 1a4959c17..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", @@ -2094,6 +2096,9 @@ pub fn mouseButtonCallback( /// 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 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.