mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-06-04 02:44:50 +00:00
Support OSC133 click_events Kitty extension (supported by Fish) (#10536)
This adds support for the `OSC 133 A click_events=1` extension introduced by Kitty and supported by Fish.[^1] **What this means:** If the shell advertises `click_events=1` support, Ghostty will _unconditionally_ (no modifier required) send mouse events to the shell for clicks on a prompt line, delegating to the supporting shell to move the cursor as needed. For Fish 4.1+ this means that clicking on the prompt line moves the cursor (see demo video below). This PR also contains: * A minor fix in `cl` parsing but we don't yet implement the logic there * Updated inspector to show the semantic prompt click mode ## Demo https://github.com/user-attachments/assets/03ef8975-7ad9-441f-aaa2-9d0eb5c5e36d ## Implementation Details `click_events` is wildly underspecified, so here are the details the best I understand them. This itself is not a specification (I omit details) but adds some more context to it. The `click_events=1` option can be specified with `OSC 133 A` (Ghostty also allows it on OSC 133 N). When that is specified, it flags for all future prompts that the screen supports click events for semantic prompts. If both `click_events` and `cl` are specified, `click_events` takes priority if true. If `click_events=0` (disable), then any set `cl` will take priority. When a mouse click comes in, we check for the following conditions: 1. The screen supports click events 2. The screen cursor is currently at a prompt 3. The mouse click was at or below the starting prompt line of the current prompt If those are met, we encode an SGR mouse event with: left button, press, coordinates of click. It is up to the shell after that to handle it. Out of prompt bounds SGR events are possible (specifically below). The shell should robustly handle this. [^1]: I don't know any other terminal or shell that supports it at the moment.
This commit is contained in:
115
src/Surface.zig
115
src/Surface.zig
@@ -3969,6 +3969,15 @@ pub fn mouseButtonCallback(
|
||||
log.warn("error processing links err={}", .{err});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle prompt clicking. If we released our mouse on a prompt
|
||||
// and we support some kind of click events, then we need to
|
||||
// move to it.
|
||||
if (self.maybePromptClick()) |handled| {
|
||||
if (handled) return true;
|
||||
} else |err| {
|
||||
log.warn("error processing prompt click err={}", .{err});
|
||||
}
|
||||
}
|
||||
|
||||
// Report mouse events if enabled
|
||||
@@ -4188,10 +4197,6 @@ pub fn mouseButtonCallback(
|
||||
.y = pt_viewport.y,
|
||||
},
|
||||
}) orelse {
|
||||
// Weird... our viewport x/y that we just converted isn't
|
||||
// found in our pages. This is probably a bug but we don't
|
||||
// want to crash in releases because its harmless. So, we
|
||||
// only assert in debug mode.
|
||||
if (comptime std.debug.runtime_safety) unreachable;
|
||||
break :sel;
|
||||
};
|
||||
@@ -4278,6 +4283,106 @@ pub fn mouseButtonCallback(
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// If our screen doesn't handle any prompt clicks, then we never
|
||||
// do anything.
|
||||
if (screen.semantic_prompt.click == .none) return false;
|
||||
|
||||
// If our cursor isn't currently at a prompt then we don't handle
|
||||
// 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 (screen.selection != null) return false;
|
||||
|
||||
// Get the pin for our mouse click.
|
||||
const pos = try self.rt_surface.getCursorPos();
|
||||
const pos_vp = self.posToViewport(pos.x, pos.y);
|
||||
const click_pin: terminal.Pin = pin: {
|
||||
const pin = screen.pages.pin(.{
|
||||
.viewport = .{
|
||||
.x = pos_vp.x,
|
||||
.y = pos_vp.y,
|
||||
},
|
||||
}) orelse {
|
||||
// See mouseButtonCallback for explanation
|
||||
if (comptime std.debug.runtime_safety) unreachable;
|
||||
return false;
|
||||
};
|
||||
|
||||
break :pin pin;
|
||||
};
|
||||
|
||||
// Get our cursor's most current prompt.
|
||||
const prompt_pin: terminal.Pin = prompt_pin: {
|
||||
var it = screen.cursor.page_pin.promptIterator(
|
||||
.left_up,
|
||||
null,
|
||||
);
|
||||
break :prompt_pin it.next() orelse {
|
||||
// This shouldn't be possible because we asserted we're at
|
||||
// a prompt above, so we MUST find some prompt in a left_up search.
|
||||
log.warn("cursor is at prompt but no prompt found", .{});
|
||||
if (comptime std.debug.runtime_safety) unreachable;
|
||||
return false;
|
||||
};
|
||||
};
|
||||
|
||||
// If our mouse click is before the prompt, we don't move.
|
||||
// We DO ALLOW clicks AFTER the prompt, specifically with Kitty's
|
||||
// click_events=1 since we rely on the shell to validate out of
|
||||
// bounds clicks. This matches Kitty's logic as best I can tell.
|
||||
if (click_pin.before(prompt_pin)) return false;
|
||||
|
||||
// At this point we've established:
|
||||
// - Screen supports prompt clicks
|
||||
// - Cursor is at a prompt
|
||||
// - Click is at or below our prompt
|
||||
switch (screen.semantic_prompt.click) {
|
||||
// Guarded at the start of this function
|
||||
.none => unreachable,
|
||||
|
||||
.click_events => {
|
||||
// For the event, we always send a left-click press event.
|
||||
// This matches what Kitty sends.
|
||||
var data: termio.Message.WriteReq.Small.Array = undefined;
|
||||
const resp = try std.fmt.bufPrint(
|
||||
&data,
|
||||
"\x1B[<0;{d};{d}M",
|
||||
.{ pos_vp.x + 1, pos_vp.y + 1 },
|
||||
);
|
||||
|
||||
// Not that noisy since this only happens on prompt clicks.
|
||||
log.debug(
|
||||
"sending click_events=1 event=ESC{s}",
|
||||
.{resp[1..]},
|
||||
);
|
||||
|
||||
// Ask our IO thread to write the data
|
||||
self.queueIo(.{ .write_small = .{
|
||||
.data = data,
|
||||
.len = @intCast(resp.len),
|
||||
} }, .locked);
|
||||
},
|
||||
|
||||
.cl => |cl| {
|
||||
// TODO: Handle these
|
||||
_ = cl;
|
||||
},
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -4295,7 +4400,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void {
|
||||
// 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.screens.active.flags.semantic_content) return;
|
||||
if (!t.screens.active.semantic_prompt.seen) return;
|
||||
|
||||
// Get our path
|
||||
const from = t.screens.active.cursor.page_pin.*;
|
||||
|
||||
@@ -75,6 +75,11 @@ pub const Info = struct {
|
||||
data.modify_other_keys_2,
|
||||
);
|
||||
|
||||
if (cimgui.c.ImGui_CollapsingHeader(
|
||||
"Semantic Prompt",
|
||||
cimgui.c.ImGuiTreeNodeFlags_DefaultOpen,
|
||||
)) semanticPromptTable(&screen.semantic_prompt);
|
||||
|
||||
if (cimgui.c.ImGui_CollapsingHeader(
|
||||
"Kitty Graphics",
|
||||
cimgui.c.ImGuiTreeNodeFlags_DefaultOpen,
|
||||
@@ -355,15 +360,41 @@ pub fn internalStateTable(
|
||||
cimgui.c.ImGui_Text("Viewport Location");
|
||||
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
|
||||
cimgui.c.ImGui_Text("%s", @tagName(pages.viewport).ptr);
|
||||
}
|
||||
|
||||
/// Render semantic prompt state table.
|
||||
pub fn semanticPromptTable(
|
||||
semantic_prompt: *const terminal.Screen.SemanticPrompt,
|
||||
) void {
|
||||
if (!cimgui.c.ImGui_BeginTable(
|
||||
"##semantic_prompt",
|
||||
2,
|
||||
cimgui.c.ImGuiTableFlags_None,
|
||||
)) return;
|
||||
defer cimgui.c.ImGui_EndTable();
|
||||
|
||||
{
|
||||
cimgui.c.ImGui_TableNextRow();
|
||||
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
|
||||
cimgui.c.ImGui_Text("Semantic Content");
|
||||
cimgui.c.ImGui_Text("Seen");
|
||||
cimgui.c.ImGui_SameLine();
|
||||
widgets.helpMarker("Whether semantic prompt markers (OSC 133) have been seen.");
|
||||
widgets.helpMarker("Whether any semantic prompt markers (OSC 133) have been seen in this screen.");
|
||||
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
|
||||
var value: bool = screen.flags.semantic_content;
|
||||
_ = cimgui.c.ImGui_Checkbox("##semantic_content", &value);
|
||||
var value: bool = semantic_prompt.seen;
|
||||
_ = cimgui.c.ImGui_Checkbox("##seen", &value);
|
||||
}
|
||||
|
||||
{
|
||||
cimgui.c.ImGui_TableNextRow();
|
||||
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
|
||||
cimgui.c.ImGui_Text("Click Handling");
|
||||
cimgui.c.ImGui_SameLine();
|
||||
widgets.helpMarker("How click events are handled in prompts. Set via 'cl' or 'click_events' options in OSC 133.");
|
||||
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
|
||||
switch (semantic_prompt.click) {
|
||||
.none => cimgui.c.ImGui_TextDisabled("(none)"),
|
||||
.click_events => cimgui.c.ImGui_Text("click_events"),
|
||||
.cl => |cl| cimgui.c.ImGui_Text("cl=%s", @tagName(cl).ptr),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,27 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
|
||||
|
||||
set --local features (string split , $GHOSTTY_SHELL_FEATURES)
|
||||
|
||||
# Parse the fish version for feature detection.
|
||||
# Default to 0.0 if version is unavailable or malformed.
|
||||
set -l fish_major 0
|
||||
set -l fish_minor 0
|
||||
if set -q version[1]
|
||||
set -l fish_ver (string match -r '(\d+)\.(\d+)' -- $version[1])
|
||||
if set -q fish_ver[2]; and test -n "$fish_ver[2]"
|
||||
set fish_major "$fish_ver[2]"
|
||||
end
|
||||
if set -q fish_ver[3]; and test -n "$fish_ver[3]"
|
||||
set fish_minor "$fish_ver[3]"
|
||||
end
|
||||
end
|
||||
|
||||
# Our OSC133A (prompt start) sequence. If we're using Fish >= 4.1
|
||||
# then it supports click_events so we enable that.
|
||||
set -g __ghostty_prompt_start_mark "\e]133;A\a"
|
||||
if test "$fish_major" -gt 4; or test "$fish_major" -eq 4 -a "$fish_minor" -ge 1
|
||||
set -g __ghostty_prompt_start_mark "\e]133;A;click_events=1\a"
|
||||
end
|
||||
|
||||
if contains cursor $features
|
||||
# Change the cursor to a beam on prompt.
|
||||
function __ghostty_set_cursor_beam --on-event fish_prompt -d "Set cursor shape"
|
||||
@@ -72,14 +93,14 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
|
||||
|
||||
# When using sudo shell integration feature, ensure $TERMINFO is set
|
||||
# and `sudo` is not already a function or alias
|
||||
if contains sudo $features; and test -n "$TERMINFO"; and test "file" = (type -t sudo 2> /dev/null; or echo "x")
|
||||
if contains sudo $features; and test -n "$TERMINFO"; and test file = (type -t sudo 2> /dev/null; or echo "x")
|
||||
# Wrap `sudo` command to ensure Ghostty terminfo is preserved
|
||||
function sudo -d "Wrap sudo to preserve terminfo"
|
||||
set --function sudo_has_sudoedit_flags "no"
|
||||
set --function sudo_has_sudoedit_flags no
|
||||
for arg in $argv
|
||||
# Check if argument is '-e' or '--edit' (sudoedit flags)
|
||||
if string match -q -- "-e" "$arg"; or string match -q -- "--edit" "$arg"
|
||||
set --function sudo_has_sudoedit_flags "yes"
|
||||
if string match -q -- -e "$arg"; or string match -q -- --edit "$arg"
|
||||
set --function sudo_has_sudoedit_flags yes
|
||||
break
|
||||
end
|
||||
# Check if argument is neither an option nor a key-value pair
|
||||
@@ -87,7 +108,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
|
||||
break
|
||||
end
|
||||
end
|
||||
if test "$sudo_has_sudoedit_flags" = "yes"
|
||||
if test "$sudo_has_sudoedit_flags" = yes
|
||||
command sudo $argv
|
||||
else
|
||||
command sudo --preserve-env=TERMINFO $argv
|
||||
@@ -100,7 +121,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
|
||||
if contains ssh-env $features; or contains ssh-terminfo $features
|
||||
function ssh --wraps=ssh --description "SSH wrapper with Ghostty integration"
|
||||
set -l features (string split ',' -- "$GHOSTTY_SHELL_FEATURES")
|
||||
set -l ssh_term "xterm-256color"
|
||||
set -l ssh_term xterm-256color
|
||||
set -l ssh_opts
|
||||
|
||||
# Configure environment variables for remote session
|
||||
@@ -134,7 +155,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
|
||||
|
||||
# Check if terminfo is already cached
|
||||
if test -x "$GHOSTTY_BIN_DIR/ghostty"; and "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --host="$ssh_target" >/dev/null 2>&1
|
||||
set ssh_term "xterm-ghostty"
|
||||
set ssh_term xterm-ghostty
|
||||
else if command -q infocmp
|
||||
set -l ssh_terminfo
|
||||
set -l ssh_cpath_dir
|
||||
@@ -154,7 +175,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
|
||||
mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0
|
||||
exit 1
|
||||
' 2>/dev/null
|
||||
set ssh_term "xterm-ghostty"
|
||||
set ssh_term xterm-ghostty
|
||||
set -a ssh_opts -o "ControlPath=$ssh_cpath"
|
||||
|
||||
# Cache successful installation
|
||||
@@ -186,7 +207,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
|
||||
end
|
||||
|
||||
set --global __ghostty_prompt_state prompt-start
|
||||
echo -en "\e]133;A\a"
|
||||
echo -en $__ghostty_prompt_start_mark
|
||||
end
|
||||
|
||||
function __ghostty_mark_output_start --on-event fish_preexec
|
||||
|
||||
@@ -74,12 +74,12 @@ kitty_images: if (build_options.kitty_graphics)
|
||||
else
|
||||
struct {} = .{},
|
||||
|
||||
/// Semantic prompt (OSC133) state.
|
||||
semantic_prompt: SemanticPrompt = .disabled,
|
||||
|
||||
/// Dirty flags for the renderer.
|
||||
dirty: Dirty = .{},
|
||||
|
||||
/// Packed flags for the screen, internal state.
|
||||
flags: Flags = .{},
|
||||
|
||||
/// See Terminal.Dirty. This behaves the same way.
|
||||
pub const Dirty = packed struct {
|
||||
/// Set when the selection is set or unset, regardless of if the
|
||||
@@ -91,15 +91,30 @@ pub const Dirty = packed struct {
|
||||
hyperlink_hover: bool = false,
|
||||
};
|
||||
|
||||
/// A set of internal state that we pack for memory size.
|
||||
pub const Flags = packed struct {
|
||||
pub const SemanticPrompt = struct {
|
||||
/// This is flipped to true when any sort of semantic content is
|
||||
/// seen. In particular, this is set to true only when a `prompt` type
|
||||
/// is ever set on our cursor.
|
||||
///
|
||||
/// This is used to optimize away semantic content operations if we know
|
||||
/// we've never seen them.
|
||||
semantic_content: bool = false,
|
||||
seen: bool,
|
||||
|
||||
/// This is set on any `cl` or `click_events` option set on the
|
||||
/// most recent OSC 133 commands to specify how click handling in a
|
||||
/// prompt is handling.
|
||||
click: SemanticClick,
|
||||
|
||||
pub const disabled: SemanticPrompt = .{
|
||||
.seen = false,
|
||||
.click = .none,
|
||||
};
|
||||
|
||||
pub const SemanticClick = union(enum) {
|
||||
none,
|
||||
click_events,
|
||||
cl: osc.semantic_prompt.Click,
|
||||
};
|
||||
};
|
||||
|
||||
/// The cursor position and style.
|
||||
@@ -378,7 +393,7 @@ pub fn reset(self: *Screen) void {
|
||||
self.charset = .{};
|
||||
self.kitty_keyboard = .{};
|
||||
self.protected_mode = .off;
|
||||
self.flags = .{};
|
||||
self.semantic_prompt = .disabled;
|
||||
self.clearSelection();
|
||||
}
|
||||
|
||||
@@ -2362,7 +2377,7 @@ pub fn cursorSetSemanticContent(self: *Screen, t: union(enum) {
|
||||
},
|
||||
|
||||
.prompt => |kind| {
|
||||
self.flags.semantic_content = true;
|
||||
self.semantic_prompt.seen = true;
|
||||
cursor.semantic_content = .prompt;
|
||||
cursor.semantic_content_clear_eol = false;
|
||||
cursor.page_row.semantic_prompt = switch (kind) {
|
||||
|
||||
@@ -1087,9 +1087,11 @@ pub fn semanticPrompt(
|
||||
// "First do a fresh-line."
|
||||
try self.semanticPromptFreshLine();
|
||||
|
||||
const screen: *Screen = self.screens.active;
|
||||
|
||||
// "Subsequent text (until a OSC "133;B" or OSC "133;I" command)
|
||||
// is a prompt string (as if followed by OSC 133;P;k=i\007)."
|
||||
self.screens.active.cursorSetSemanticContent(.{
|
||||
screen.cursorSetSemanticContent(.{
|
||||
.prompt = cmd.readOption(.prompt_kind) orelse .initial,
|
||||
});
|
||||
|
||||
@@ -1100,17 +1102,41 @@ pub fn semanticPrompt(
|
||||
self.flags.shell_redraws_prompt = v;
|
||||
}
|
||||
|
||||
click: {
|
||||
// Handle click_events as a priority over cl. click_events
|
||||
// is another Kitty-specific extension that converts clicks
|
||||
// within a prompt area to SGR mouse events and defers to the
|
||||
// shell to handle them.
|
||||
if (cmd.readOption(.click_events)) |v| {
|
||||
if (v) {
|
||||
screen.semantic_prompt.click = .click_events;
|
||||
break :click;
|
||||
}
|
||||
}
|
||||
|
||||
// If click_events was not set or disabled, fallback to `cl`.
|
||||
if (cmd.readOption(.cl)) |v| {
|
||||
screen.semantic_prompt.click = .{ .cl = v };
|
||||
}
|
||||
}
|
||||
|
||||
// The "aid" and "cl" options are also valid for this
|
||||
// command but we don't yet handle these in any meaningful way.
|
||||
},
|
||||
|
||||
.new_command => {
|
||||
// Spec:
|
||||
// Same as OSC "133;A" but may first implicitly terminate a
|
||||
// previous command: if the options specify an aid and there
|
||||
// is an active (open) command with matching aid, finish the
|
||||
// innermost such command (as well as any other commands
|
||||
// nested more deeply). If no aid is specified, treat as an
|
||||
// aid whose value is the empty string.
|
||||
|
||||
// Ghostty:
|
||||
// We don't currently do explicit command tracking in any way
|
||||
// so there is no need to terminate prior commands. We just
|
||||
// perform the `A` action.
|
||||
try self.semanticPrompt(.{
|
||||
.action = .fresh_line_new_prompt,
|
||||
.options_unvalidated = cmd.options_unvalidated,
|
||||
@@ -11629,6 +11655,109 @@ test "Terminal: multiple newlines in prompt mode marks all rows" {
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: OSC133A click_events=1 sets click to click_events" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 10, .rows = 5 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// Verify default state is none
|
||||
try testing.expectEqual(.none, t.screens.active.semantic_prompt.click);
|
||||
|
||||
// OSC 133;A with click_events=1
|
||||
try t.semanticPrompt(.{
|
||||
.action = .fresh_line_new_prompt,
|
||||
.options_unvalidated = "click_events=1",
|
||||
});
|
||||
|
||||
try testing.expectEqual(.click_events, t.screens.active.semantic_prompt.click);
|
||||
}
|
||||
|
||||
test "Terminal: OSC133A click_events=0 does not set click_events" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 10, .rows = 5 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// OSC 133;A with click_events=0
|
||||
try t.semanticPrompt(.{
|
||||
.action = .fresh_line_new_prompt,
|
||||
.options_unvalidated = "click_events=0",
|
||||
});
|
||||
|
||||
// Should remain none since click_events=0 doesn't activate anything
|
||||
try testing.expectEqual(.none, t.screens.active.semantic_prompt.click);
|
||||
}
|
||||
|
||||
test "Terminal: OSC133A cl option sets click to cl value" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 10, .rows = 5 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// OSC 133;A with cl=m (multiple)
|
||||
try t.semanticPrompt(.{
|
||||
.action = .fresh_line_new_prompt,
|
||||
.options_unvalidated = "cl=m",
|
||||
});
|
||||
|
||||
try testing.expectEqual(Screen.SemanticPrompt.SemanticClick{ .cl = .multiple }, t.screens.active.semantic_prompt.click);
|
||||
}
|
||||
|
||||
test "Terminal: OSC133A cl=line sets click to line" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 10, .rows = 5 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
try t.semanticPrompt(.{
|
||||
.action = .fresh_line_new_prompt,
|
||||
.options_unvalidated = "cl=line",
|
||||
});
|
||||
|
||||
try testing.expectEqual(Screen.SemanticPrompt.SemanticClick{ .cl = .line }, t.screens.active.semantic_prompt.click);
|
||||
}
|
||||
|
||||
test "Terminal: OSC133A click_events=1 takes priority over cl" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 10, .rows = 5 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// OSC 133;A with both click_events=1 and cl=m
|
||||
try t.semanticPrompt(.{
|
||||
.action = .fresh_line_new_prompt,
|
||||
.options_unvalidated = "click_events=1;cl=m",
|
||||
});
|
||||
|
||||
// click_events should take priority
|
||||
try testing.expectEqual(.click_events, t.screens.active.semantic_prompt.click);
|
||||
}
|
||||
|
||||
test "Terminal: OSC133A click_events=0 falls back to cl" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 10, .rows = 5 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// OSC 133;A with click_events=0 and cl=v
|
||||
try t.semanticPrompt(.{
|
||||
.action = .fresh_line_new_prompt,
|
||||
.options_unvalidated = "click_events=0;cl=v",
|
||||
});
|
||||
|
||||
// Should fall back to cl since click_events is disabled
|
||||
try testing.expectEqual(Screen.SemanticPrompt.SemanticClick{ .cl = .conservative_vertical }, t.screens.active.semantic_prompt.click);
|
||||
}
|
||||
|
||||
test "Terminal: OSC133A no click options leaves click as none" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 10, .rows = 5 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// OSC 133;A with no click-related options
|
||||
try t.semanticPrompt(.{
|
||||
.action = .fresh_line_new_prompt,
|
||||
.options_unvalidated = "aid=123",
|
||||
});
|
||||
|
||||
try testing.expectEqual(.none, t.screens.active.semantic_prompt.click);
|
||||
}
|
||||
|
||||
test "Terminal: cursorIsAtPrompt" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 10, .rows = 3 });
|
||||
|
||||
@@ -165,7 +165,7 @@ pub const Option = enum {
|
||||
|
||||
return switch (self) {
|
||||
.aid => value,
|
||||
.cl => std.meta.stringToEnum(Click, value),
|
||||
.cl => .init(value),
|
||||
.prompt_kind => if (value.len == 1) PromptKind.init(value[0]) else null,
|
||||
.err => value,
|
||||
.redraw => if (std.mem.eql(u8, value, "0"))
|
||||
@@ -191,11 +191,43 @@ pub const Option = enum {
|
||||
}
|
||||
};
|
||||
|
||||
/// The `cl` option specifies what kind of cursor key sequences are handled
|
||||
/// by the application for click-to-move-cursor functionality.
|
||||
pub const Click = enum {
|
||||
/// Value: "line". Allows motion within a single input line using standard
|
||||
/// left/right arrow escape sequences. Only a single left/right sequence
|
||||
/// should be emitted for double-width characters.
|
||||
line,
|
||||
|
||||
/// Value: "m". Allows movement between different lines in the same group,
|
||||
/// but only using left/right arrow escape sequences.
|
||||
multiple,
|
||||
|
||||
/// Value: "v". Like `multiple` but cursor up/down should be used. The
|
||||
/// terminal should be conservative when moving between lines: move the
|
||||
/// cursor left to the start of line, emit the needed up/down sequences,
|
||||
/// then move the cursor right to the clicked destination.
|
||||
conservative_vertical,
|
||||
|
||||
/// Value: "w". Like `conservative_vertical` but specifies that there are
|
||||
/// no spurious spaces at the end of the line, and the application editor
|
||||
/// handles "smart vertical movement" (moving 2 lines up from position 20,
|
||||
/// where the intermediate line is 15 chars wide and the destination is
|
||||
/// 18 chars wide, ends at position 18).
|
||||
smart_vertical,
|
||||
|
||||
pub fn init(value: []const u8) ?Click {
|
||||
return if (value.len == 1) switch (value[0]) {
|
||||
'm' => .multiple,
|
||||
'v' => .conservative_vertical,
|
||||
'w' => .smart_vertical,
|
||||
else => null,
|
||||
} else if (std.mem.eql(
|
||||
u8,
|
||||
value,
|
||||
"line",
|
||||
)) .line else null;
|
||||
}
|
||||
};
|
||||
|
||||
pub const PromptKind = enum {
|
||||
@@ -447,12 +479,12 @@ test "OSC 133: fresh_line_new_prompt with cl=line" {
|
||||
try testing.expect(cmd.semantic_prompt.readOption(.cl) == .line);
|
||||
}
|
||||
|
||||
test "OSC 133: fresh_line_new_prompt with cl=multiple" {
|
||||
test "OSC 133: fresh_line_new_prompt with cl=m" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(null);
|
||||
|
||||
const input = "133;A;cl=multiple";
|
||||
const input = "133;A;cl=m";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end(null).?.*;
|
||||
@@ -874,9 +906,9 @@ test "Option.read aid" {
|
||||
test "Option.read cl" {
|
||||
const testing = std.testing;
|
||||
try testing.expect(Option.cl.read("cl=line").? == .line);
|
||||
try testing.expect(Option.cl.read("cl=multiple").? == .multiple);
|
||||
try testing.expect(Option.cl.read("cl=conservative_vertical").? == .conservative_vertical);
|
||||
try testing.expect(Option.cl.read("cl=smart_vertical").? == .smart_vertical);
|
||||
try testing.expect(Option.cl.read("cl=m").? == .multiple);
|
||||
try testing.expect(Option.cl.read("cl=v").? == .conservative_vertical);
|
||||
try testing.expect(Option.cl.read("cl=w").? == .smart_vertical);
|
||||
try testing.expect(Option.cl.read("cl=invalid") == null);
|
||||
try testing.expect(Option.cl.read("aid=foo") == null);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user