mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-14 07:46:12 +03:00
Merge pull request #1137 from mitchellh/click-cursor
Click to move cursor at prompt
This commit is contained in:
@ -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
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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.
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
|
Reference in New Issue
Block a user