mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-07-27 11:06:31 +00:00
Merge remote-tracking branch 'upstream/main' into grapheme-width-changes
This commit is contained in:
182
src/Surface.zig
182
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
|
||||
@@ -4010,25 +4019,6 @@ 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)
|
||||
click_move: {
|
||||
self.renderer_state.mutex.lock();
|
||||
defer self.renderer_state.mutex.unlock();
|
||||
|
||||
// If we have a selection then we do not do click to move because
|
||||
// it means that we moved our cursor while pressing the mouse button.
|
||||
if (self.io.terminal.screens.active.selection != null) break :click_move;
|
||||
|
||||
// Moving always resets the click count so that we don't highlight.
|
||||
self.mouse.left_click_count = 0;
|
||||
const pin = self.mouse.left_click_pin orelse break :click_move;
|
||||
try self.clickMoveCursor(pin.*);
|
||||
return true;
|
||||
}
|
||||
|
||||
// For left button clicks we always record some information for
|
||||
// selection/highlighting purposes.
|
||||
if (button == .left and action == .press) click: {
|
||||
@@ -4188,10 +4178,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,58 +4264,118 @@ pub fn mouseButtonCallback(
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 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.Pin) !void {
|
||||
// If click-to-move is disabled then we're done.
|
||||
if (!self.config.cursor_click_to_move) return;
|
||||
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;
|
||||
|
||||
const t = &self.io.terminal;
|
||||
// If our screen doesn't handle any prompt clicks, then we never
|
||||
// do anything.
|
||||
if (screen.semantic_prompt.click == .none) return false;
|
||||
|
||||
// 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.screens.active_key != .primary) return;
|
||||
// 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;
|
||||
|
||||
// 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 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 our path
|
||||
const from = t.screens.active.cursor.page_pin.*;
|
||||
const path = t.screens.active.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";
|
||||
// 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;
|
||||
};
|
||||
for (0..@abs(path.y)) |_| {
|
||||
self.queueIo(.{ .write_stable = arrow }, .locked);
|
||||
}
|
||||
}
|
||||
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";
|
||||
|
||||
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;
|
||||
};
|
||||
for (0..@abs(path.x)) |_| {
|
||||
self.queueIo(.{ .write_stable = arrow }, .locked);
|
||||
}
|
||||
};
|
||||
|
||||
// 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 => {
|
||||
const left_arrow = if (t.modes.get(.cursor_keys)) "\x1bOD" else "\x1b[D";
|
||||
const right_arrow = if (t.modes.get(.cursor_keys)) "\x1bOC" else "\x1b[C";
|
||||
|
||||
const move = screen.promptClickMove(click_pin);
|
||||
for (0..move.left) |_| {
|
||||
self.queueIo(
|
||||
.{ .write_stable = left_arrow },
|
||||
.locked,
|
||||
);
|
||||
}
|
||||
for (0..move.right) |_| {
|
||||
self.queueIo(
|
||||
.{ .write_stable = right_arrow },
|
||||
.locked,
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const Link = struct {
|
||||
|
||||
@@ -697,6 +697,13 @@ pub const Surface = extern struct {
|
||||
/// Whether primary paste (middle-click paste) is enabled.
|
||||
gtk_enable_primary_paste: bool = true,
|
||||
|
||||
/// How much pending horizontal scroll do we have?
|
||||
pending_horizontal_scroll: f64 = 0.0,
|
||||
|
||||
/// Timer to reset the amount of horizontal scroll if the user
|
||||
/// stops scrolling.
|
||||
pending_horizontal_scroll_reset: ?c_uint = null,
|
||||
|
||||
pub var offset: c_int = 0;
|
||||
};
|
||||
|
||||
@@ -1880,6 +1887,13 @@ pub const Surface = extern struct {
|
||||
priv.idle_rechild = null;
|
||||
}
|
||||
|
||||
if (priv.pending_horizontal_scroll_reset) |v| {
|
||||
if (glib.Source.remove(v) == 0) {
|
||||
log.warn("unable to remove pending horizontal scroll reset source", .{});
|
||||
}
|
||||
priv.pending_horizontal_scroll_reset = null;
|
||||
}
|
||||
|
||||
// This works around a GTK double-free bug where if you bind
|
||||
// to a top-level template child, it frees twice if the widget is
|
||||
// also the root child of the template. By unsetting the child here,
|
||||
@@ -2879,27 +2893,27 @@ pub const Surface = extern struct {
|
||||
}
|
||||
}
|
||||
|
||||
fn ecMouseScrollPrecisionBegin(
|
||||
fn ecMouseScrollVerticalPrecisionBegin(
|
||||
_: *gtk.EventControllerScroll,
|
||||
self: *Self,
|
||||
) callconv(.c) void {
|
||||
self.private().precision_scroll = true;
|
||||
}
|
||||
|
||||
fn ecMouseScrollPrecisionEnd(
|
||||
fn ecMouseScrollVerticalPrecisionEnd(
|
||||
_: *gtk.EventControllerScroll,
|
||||
self: *Self,
|
||||
) callconv(.c) void {
|
||||
self.private().precision_scroll = false;
|
||||
}
|
||||
|
||||
fn ecMouseScroll(
|
||||
fn ecMouseScrollVertical(
|
||||
_: *gtk.EventControllerScroll,
|
||||
x: f64,
|
||||
y: f64,
|
||||
self: *Self,
|
||||
) callconv(.c) c_int {
|
||||
const priv = self.private();
|
||||
const priv: *Private = self.private();
|
||||
const surface = priv.core_surface orelse return 0;
|
||||
|
||||
// Multiply precision scrolls by 10 to get a better response from
|
||||
@@ -2926,6 +2940,57 @@ pub const Surface = extern struct {
|
||||
return 1;
|
||||
}
|
||||
|
||||
fn ecMouseScrollHorizontal(
|
||||
ec: *gtk.EventControllerScroll,
|
||||
x: f64,
|
||||
_: f64,
|
||||
self: *Self,
|
||||
) callconv(.c) c_int {
|
||||
const priv: *Private = self.private();
|
||||
|
||||
switch (ec.getUnit()) {
|
||||
.surface => {},
|
||||
.wheel => return @intFromBool(false),
|
||||
else => return @intFromBool(false),
|
||||
}
|
||||
|
||||
priv.pending_horizontal_scroll += x;
|
||||
|
||||
if (@abs(priv.pending_horizontal_scroll) < 120) {
|
||||
if (priv.pending_horizontal_scroll_reset) |v| {
|
||||
_ = glib.Source.remove(v);
|
||||
priv.pending_horizontal_scroll_reset = null;
|
||||
}
|
||||
priv.pending_horizontal_scroll_reset = glib.timeoutAdd(500, ecMouseScrollHorizontalReset, self);
|
||||
return @intFromBool(true);
|
||||
}
|
||||
|
||||
_ = self.as(gtk.Widget).activateAction(
|
||||
if (priv.pending_horizontal_scroll < 0.0)
|
||||
"tab.next-page"
|
||||
else
|
||||
"tab.previous-page",
|
||||
null,
|
||||
);
|
||||
|
||||
if (priv.pending_horizontal_scroll_reset) |v| {
|
||||
_ = glib.Source.remove(v);
|
||||
priv.pending_horizontal_scroll_reset = null;
|
||||
}
|
||||
|
||||
priv.pending_horizontal_scroll = 0.0;
|
||||
|
||||
return @intFromBool(true);
|
||||
}
|
||||
|
||||
fn ecMouseScrollHorizontalReset(ud: ?*anyopaque) callconv(.c) c_int {
|
||||
const self: *Self = @ptrCast(@alignCast(ud orelse return @intFromBool(glib.SOURCE_REMOVE)));
|
||||
const priv: *Private = self.private();
|
||||
priv.pending_horizontal_scroll = 0.0;
|
||||
priv.pending_horizontal_scroll_reset = null;
|
||||
return @intFromBool(glib.SOURCE_REMOVE);
|
||||
}
|
||||
|
||||
fn imPreeditStart(
|
||||
_: *gtk.IMMulticontext,
|
||||
self: *Self,
|
||||
@@ -3464,9 +3529,10 @@ pub const Surface = extern struct {
|
||||
class.bindTemplateCallback("mouse_up", &gcMouseUp);
|
||||
class.bindTemplateCallback("mouse_motion", &ecMouseMotion);
|
||||
class.bindTemplateCallback("mouse_leave", &ecMouseLeave);
|
||||
class.bindTemplateCallback("scroll", &ecMouseScroll);
|
||||
class.bindTemplateCallback("scroll_begin", &ecMouseScrollPrecisionBegin);
|
||||
class.bindTemplateCallback("scroll_end", &ecMouseScrollPrecisionEnd);
|
||||
class.bindTemplateCallback("scroll_vertical", &ecMouseScrollVertical);
|
||||
class.bindTemplateCallback("scroll_vertical_begin", &ecMouseScrollVerticalPrecisionBegin);
|
||||
class.bindTemplateCallback("scroll_vertical_end", &ecMouseScrollVerticalPrecisionEnd);
|
||||
class.bindTemplateCallback("scroll_horizontal", &ecMouseScrollHorizontal);
|
||||
class.bindTemplateCallback("drop", &dtDrop);
|
||||
class.bindTemplateCallback("gl_realize", &glareaRealize);
|
||||
class.bindTemplateCallback("gl_unrealize", &glareaUnrealize);
|
||||
|
||||
@@ -202,6 +202,8 @@ pub const Tab = extern struct {
|
||||
const actions = [_]ext.actions.Action(Self){
|
||||
.init("close", actionClose, s_param_type),
|
||||
.init("ring-bell", actionRingBell, null),
|
||||
.init("next-page", actionNextPage, null),
|
||||
.init("previous-page", actionPreviousPage, null),
|
||||
};
|
||||
|
||||
_ = ext.actions.addAsGroup(Self, self, "tab", &actions);
|
||||
@@ -235,12 +237,17 @@ pub const Tab = extern struct {
|
||||
return tree.getNeedsConfirmQuit();
|
||||
}
|
||||
|
||||
/// Get the tab page holding this tab, if any.
|
||||
fn getTabPage(self: *Self) ?*adw.TabPage {
|
||||
const tab_view = ext.getAncestor(
|
||||
/// Get the tab view holding this tab, if any.
|
||||
fn getTabView(self: *Self) ?*adw.TabView {
|
||||
return ext.getAncestor(
|
||||
adw.TabView,
|
||||
self.as(gtk.Widget),
|
||||
) orelse return null;
|
||||
);
|
||||
}
|
||||
|
||||
/// Get the tab page holding this tab, if any.
|
||||
fn getTabPage(self: *Self) ?*adw.TabPage {
|
||||
const tab_view = self.getTabView() orelse return null;
|
||||
return tab_view.getPage(self.as(gtk.Widget));
|
||||
}
|
||||
|
||||
@@ -325,11 +332,7 @@ pub const Tab = extern struct {
|
||||
var str: ?[*:0]const u8 = null;
|
||||
param.get("&s", &str);
|
||||
|
||||
const tab_view = ext.getAncestor(
|
||||
adw.TabView,
|
||||
self.as(gtk.Widget),
|
||||
) orelse return;
|
||||
|
||||
const tab_view = self.getTabView() orelse return;
|
||||
const page = tab_view.getPage(self.as(gtk.Widget));
|
||||
|
||||
const mode = std.meta.stringToEnum(
|
||||
@@ -372,6 +375,26 @@ pub const Tab = extern struct {
|
||||
page.setNeedsAttention(@intFromBool(true));
|
||||
}
|
||||
|
||||
/// Select the next tab page.
|
||||
fn actionNextPage(
|
||||
_: *gio.SimpleAction,
|
||||
_: ?*glib.Variant,
|
||||
self: *Self,
|
||||
) callconv(.c) void {
|
||||
const tab_view = self.getTabView() orelse return;
|
||||
_ = tab_view.selectNextPage();
|
||||
}
|
||||
|
||||
/// Select the previous tab page.
|
||||
fn actionPreviousPage(
|
||||
_: *gio.SimpleAction,
|
||||
_: ?*glib.Variant,
|
||||
self: *Self,
|
||||
) callconv(.c) void {
|
||||
const tab_view = self.getTabView() orelse return;
|
||||
_ = tab_view.selectPreviousPage();
|
||||
}
|
||||
|
||||
fn closureComputedTitle(
|
||||
_: *Self,
|
||||
config_: ?*Config,
|
||||
|
||||
@@ -53,10 +53,15 @@ Overlay terminal_page {
|
||||
}
|
||||
|
||||
EventControllerScroll {
|
||||
scroll => $scroll();
|
||||
scroll-begin => $scroll_begin();
|
||||
scroll-end => $scroll_end();
|
||||
flags: both_axes;
|
||||
scroll => $scroll_vertical();
|
||||
scroll-begin => $scroll_vertical_begin();
|
||||
scroll-end => $scroll_vertical_end();
|
||||
flags: vertical;
|
||||
}
|
||||
|
||||
EventControllerScroll {
|
||||
scroll => $scroll_horizontal();
|
||||
flags: horizontal;
|
||||
}
|
||||
|
||||
EventControllerMotion {
|
||||
|
||||
@@ -26,7 +26,7 @@ pub const regex =
|
||||
"(?:" ++ url_schemes ++
|
||||
\\)(?:
|
||||
++ ipv6_url_pattern ++
|
||||
\\|[\w\-.~:/?#@!$&*+,;=%]+(?:[\(\[]\w*[\)\]])?)+(?<![,.])|(?:\.\.\/|\.\/|\/)(?:(?=[\w\-.~:\/?#@!$&*+,;=%]*\.)[\w\-.~:\/?#@!$&*+,;=%]+(?: [\w\-.~:\/?#@!$&*+,;=%]*[\/.])*(?: +(?= *$))?|(?![\w\-.~:\/?#@!$&*+,;=%]*\.)[\w\-.~:\/?#@!$&*+,;=%]+(?: [\w\-.~:\/?#@!$&*+,;=%]+)*(?: +(?= *$))?)
|
||||
\\|[\w\-.~:/?#@!$&*+,;=%]+(?:[\(\[]\w*[\)\]])?)+(?<![,.])|(?:\.\.\/|\.\/|(?<!\w)\/)(?:(?=[\w\-.~:\/?#@!$&*+,;=%]*\.)[\w\-.~:\/?#@!$&*+,;=%]+(?: [\w\-.~:\/?#@!$&*+,;=%]*[\/.])*(?: +(?= *$))?|(?![\w\-.~:\/?#@!$&*+,;=%]*\.)[\w\-.~:\/?#@!$&*+,;=%]+(?: [\w\-.~:\/?#@!$&*+,;=%]+)*(?: +(?= *$))?)|[\w][\w\-.]*\/(?=[\w\-.~:\/?#@!$&*+,;=%]*\.)[\w\-.~:\/?#@!$&*+,;=%]+(?: [\w\-.~:\/?#@!$&*+,;=%]*[\/.])*(?: +(?= *$))?
|
||||
;
|
||||
const url_schemes =
|
||||
\\https?://|mailto:|ftp://|file:|ssh:|git://|ssh://|tel:|magnet:|ipfs://|ipns://|gemini://|gopher://|news:
|
||||
@@ -270,6 +270,27 @@ test "url regex" {
|
||||
.input = "/tmp/test folder/file.txt",
|
||||
.expect = "/tmp/test folder/file.txt",
|
||||
},
|
||||
// Bare relative file paths (no ./ or ../ prefix)
|
||||
.{
|
||||
.input = "src/config/url.zig",
|
||||
.expect = "src/config/url.zig",
|
||||
},
|
||||
.{
|
||||
.input = "app/folder/file.rb:1",
|
||||
.expect = "app/folder/file.rb:1",
|
||||
},
|
||||
.{
|
||||
.input = "modified: src/config/url.zig",
|
||||
.expect = "src/config/url.zig",
|
||||
},
|
||||
.{
|
||||
.input = "lib/ghostty/terminal.zig:42:10",
|
||||
.expect = "lib/ghostty/terminal.zig:42:10",
|
||||
},
|
||||
.{
|
||||
.input = "some-pkg/src/file.txt more text",
|
||||
.expect = "some-pkg/src/file.txt",
|
||||
},
|
||||
};
|
||||
|
||||
for (cases) |case| {
|
||||
@@ -284,4 +305,17 @@ test "url regex" {
|
||||
const match = case.input[@intCast(reg.starts()[0])..@intCast(reg.ends()[0])];
|
||||
try testing.expectEqualStrings(case.expect, match);
|
||||
}
|
||||
|
||||
// Bare relative paths without any dot should not match as file paths
|
||||
const no_match_cases = [_][]const u8{
|
||||
"input/output",
|
||||
"foo/bar",
|
||||
};
|
||||
for (no_match_cases) |input| {
|
||||
var result = re.search(input, .{});
|
||||
if (result) |*reg| {
|
||||
reg.deinit();
|
||||
return error.TestUnexpectedResult;
|
||||
} else |_| {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,4 +53,5 @@ pub const locales = [_][:0]const u8{
|
||||
"zh_TW.UTF-8",
|
||||
"hr_HR.UTF-8",
|
||||
"lt_LT.UTF-8",
|
||||
"lv_LV.UTF-8",
|
||||
};
|
||||
|
||||
@@ -2206,10 +2206,35 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
||||
}
|
||||
|
||||
// If we had a previous overlay, clear it. Otherwise, init.
|
||||
const overlay: *Overlay = if (self.overlay) |*v| overlay: {
|
||||
v.reset();
|
||||
break :overlay v;
|
||||
} else overlay: {
|
||||
const overlay: *Overlay = overlay: {
|
||||
if (self.overlay) |*v| existing: {
|
||||
// Verify that our overlay size matches our screen
|
||||
// size as we know it now. If not, deinit and reinit.
|
||||
// Note: these intCasts are always safe because z2d
|
||||
// stores as i32 but we always init with a u32.
|
||||
const width: u32 = @intCast(v.surface.getWidth());
|
||||
const height: u32 = @intCast(v.surface.getHeight());
|
||||
const term_size = self.size.terminal();
|
||||
if (width != term_size.width or
|
||||
height != term_size.height) break :existing;
|
||||
|
||||
// We also depend on cell size.
|
||||
if (v.cell_size.width != self.size.cell.width or
|
||||
v.cell_size.height != self.size.cell.height) break :existing;
|
||||
|
||||
// Everything matches, so we can just reset the surface
|
||||
// and redraw.
|
||||
v.reset();
|
||||
break :overlay v;
|
||||
}
|
||||
|
||||
// If we reached this point we want to reset our overlay.
|
||||
if (self.overlay) |*v| {
|
||||
v.deinit(alloc);
|
||||
self.overlay = null;
|
||||
}
|
||||
|
||||
assert(self.overlay == null);
|
||||
const new: Overlay = try .init(alloc, self.size);
|
||||
self.overlay = new;
|
||||
break :overlay &self.overlay.?;
|
||||
|
||||
@@ -44,7 +44,7 @@ Elvish, on startup, searches for paths defined in `XDG_DATA_DIRS`
|
||||
variable for `./elvish/lib/*.elv` files and imports them. They are thus
|
||||
made available for use as modules by way of `use <filename>`.
|
||||
|
||||
Ghostty launches Elvish, passing the environment with `XDG_DATA_DIRS`prepended
|
||||
Ghostty launches Elvish, passing the environment with `XDG_DATA_DIRS` prepended
|
||||
with `$GHOSTTY_RESOURCES_DIR/src/shell-integration`. It contains
|
||||
`./elvish/lib/ghostty-integration.elv`. The user can then import it
|
||||
by `use ghostty-integration` every time after shell startup or
|
||||
@@ -57,7 +57,7 @@ of your `rc.elv` file:
|
||||
|
||||
```elvish
|
||||
if (eq $E:TERM "xterm-ghostty") {
|
||||
use ghostty-integration
|
||||
try { use ghostty-integration } catch { }
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -184,9 +184,6 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then
|
||||
}
|
||||
fi
|
||||
|
||||
# Import bash-preexec, safe to do multiple times
|
||||
builtin source "$(dirname -- "${BASH_SOURCE[0]}")/bash-preexec.sh"
|
||||
|
||||
# This is set to 1 when we're executing a command so that we don't
|
||||
# send prompt marks multiple times.
|
||||
_ghostty_executing=""
|
||||
@@ -201,7 +198,7 @@ function __ghostty_precmd() {
|
||||
# Marks. We need to do fresh line (A) at the beginning of the prompt
|
||||
# since if the cursor is not at the beginning of a line, the terminal
|
||||
# will emit a newline.
|
||||
PS1='\[\e]133;A;redraw=last\a\]'$PS1'\[\e]133;B\a\]'
|
||||
PS1='\[\e]133;A;redraw=last;cl=line\a\]'$PS1'\[\e]133;B\a\]'
|
||||
PS2='\[\e]133;A;k=s\a\]'$PS2'\[\e]133;B\a\]'
|
||||
|
||||
# Bash doesn't redraw the leading lines in a multiline prompt so
|
||||
@@ -240,7 +237,7 @@ function __ghostty_precmd() {
|
||||
fi
|
||||
|
||||
# Fresh line and start of prompt.
|
||||
builtin printf "\e]133;A;redraw=last;aid=%s\a" "$BASHPID"
|
||||
builtin printf "\e]133;A;redraw=last;cl=line;aid=%s\a" "$BASHPID"
|
||||
_ghostty_executing=0
|
||||
}
|
||||
|
||||
@@ -260,5 +257,48 @@ function __ghostty_preexec() {
|
||||
_ghostty_executing=1
|
||||
}
|
||||
|
||||
preexec_functions+=(__ghostty_preexec)
|
||||
precmd_functions+=(__ghostty_precmd)
|
||||
if (( BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4) )); then
|
||||
__ghostty_preexec_hook() {
|
||||
builtin local cmd
|
||||
cmd=$(LC_ALL=C HISTTIMEFORMAT='' builtin history 1)
|
||||
cmd="${cmd#*[[:digit:]][* ] }" # remove leading history number
|
||||
[[ -n "$cmd" ]] && __ghostty_preexec "$cmd"
|
||||
}
|
||||
|
||||
# Use function substitution in 5.3+. Otherwise, use command substitution.
|
||||
# Any output (including escape sequences) goes to the terminal.
|
||||
if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 3) )); then
|
||||
# shellcheck disable=SC2016
|
||||
builtin readonly __ghostty_ps0='${ __ghostty_preexec_hook; }'
|
||||
else
|
||||
# shellcheck disable=SC2016
|
||||
builtin readonly __ghostty_ps0='$(__ghostty_preexec_hook >/dev/tty)'
|
||||
fi
|
||||
|
||||
__ghostty_hook() {
|
||||
builtin local ret=$?
|
||||
__ghostty_precmd "$ret"
|
||||
PS0=$__ghostty_ps0
|
||||
}
|
||||
|
||||
# Append our hook to PROMPT_COMMAND, preserving its existing type.
|
||||
if [[ ";${PROMPT_COMMAND[*]:-};" != *";__ghostty_hook;"* ]]; then
|
||||
if [[ -z "${PROMPT_COMMAND[*]}" ]]; then
|
||||
if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) )); then
|
||||
PROMPT_COMMAND=(__ghostty_hook)
|
||||
else
|
||||
# shellcheck disable=SC2178
|
||||
PROMPT_COMMAND="__ghostty_hook"
|
||||
fi
|
||||
elif [[ $(builtin declare -p PROMPT_COMMAND 2>/dev/null) == "declare -a "* ]]; then
|
||||
PROMPT_COMMAND+=(__ghostty_hook)
|
||||
else
|
||||
# shellcheck disable=SC2179
|
||||
PROMPT_COMMAND+="; __ghostty_hook"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
builtin source "$(dirname -- "${BASH_SOURCE[0]}")/bash-preexec.sh"
|
||||
preexec_functions+=(__ghostty_preexec)
|
||||
precmd_functions+=(__ghostty_precmd)
|
||||
fi
|
||||
|
||||
@@ -1,42 +1,12 @@
|
||||
{
|
||||
fn restore-xdg-dirs {
|
||||
use str
|
||||
var integration-dir = $E:GHOSTTY_SHELL_INTEGRATION_XDG_DIR
|
||||
var xdg-dirs = [(str:split ':' $E:XDG_DATA_DIRS)]
|
||||
var len = (count $xdg-dirs)
|
||||
use platform
|
||||
use str
|
||||
|
||||
var index = $nil
|
||||
range $len | each {|dir-index|
|
||||
if (eq $xdg-dirs[$dir-index] $integration-dir) {
|
||||
set index = $dir-index
|
||||
break
|
||||
}
|
||||
}
|
||||
if (eq $nil $index) { return } # will appear as an error
|
||||
|
||||
if (== 0 $index) {
|
||||
set xdg-dirs = $xdg-dirs[1..]
|
||||
} elif (== (- $len 1) $index) {
|
||||
set xdg-dirs = $xdg-dirs[0..(- $len 1)]
|
||||
} else {
|
||||
# no builtin function for this : )
|
||||
set xdg-dirs = [ (take $index $xdg-dirs) (drop (+ 1 $index) $xdg-dirs) ]
|
||||
}
|
||||
|
||||
if (== 0 (count $xdg-dirs)) {
|
||||
unset-env XDG_DATA_DIRS
|
||||
} else {
|
||||
set-env XDG_DATA_DIRS (str:join ':' $xdg-dirs)
|
||||
}
|
||||
# Clean up XDG_DATA_DIRS by removing GHOSTTY_SHELL_INTEGRATION_XDG_DIR
|
||||
if (and (has-env GHOSTTY_SHELL_INTEGRATION_XDG_DIR) (has-env XDG_DATA_DIRS)) {
|
||||
set-env XDG_DATA_DIRS (str:replace $E:GHOSTTY_SHELL_INTEGRATION_XDG_DIR":" "" $E:XDG_DATA_DIRS)
|
||||
unset-env GHOSTTY_SHELL_INTEGRATION_XDG_DIR
|
||||
}
|
||||
if (and (has-env GHOSTTY_SHELL_INTEGRATION_XDG_DIR) (has-env XDG_DATA_DIRS)) {
|
||||
restore-xdg-dirs
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
use str
|
||||
|
||||
# List of enabled shell integration features
|
||||
var features = [(str:split ',' $E:GHOSTTY_SHELL_FEATURES)]
|
||||
@@ -77,24 +47,22 @@
|
||||
printf "\e]133;D;"$exit-status"\a"
|
||||
}
|
||||
|
||||
fn report-pwd {
|
||||
use platform
|
||||
printf "\e]7;kitty-shell-cwd://%s%s\a" (platform:hostname) $pwd
|
||||
}
|
||||
|
||||
fn sudo-with-terminfo {|@args|
|
||||
var sudoedit = $false
|
||||
for arg $args {
|
||||
use str
|
||||
if (str:has-prefix $arg -) {
|
||||
if (has-value [e -edit] $arg[1..]) {
|
||||
if (str:has-prefix $arg --) {
|
||||
if (eq $arg --edit) {
|
||||
set sudoedit = $true
|
||||
break
|
||||
}
|
||||
continue
|
||||
} elif (str:has-prefix $arg -) {
|
||||
if (str:contains (str:trim-prefix $arg -) e) {
|
||||
set sudoedit = $true
|
||||
break
|
||||
}
|
||||
} elif (not (str:contains $arg =)) {
|
||||
break
|
||||
}
|
||||
|
||||
if (not (has-value $arg =)) { break }
|
||||
}
|
||||
|
||||
if (not $sudoedit) { set args = [ --preserve-env=TERMINFO $@args ] }
|
||||
@@ -180,16 +148,12 @@
|
||||
|
||||
defer {
|
||||
mark-prompt-start
|
||||
report-pwd
|
||||
}
|
||||
|
||||
set edit:before-readline = (conj $edit:before-readline $mark-prompt-start~)
|
||||
set edit:after-readline = (conj $edit:after-readline $mark-output-start~)
|
||||
set edit:after-command = (conj $edit:after-command $mark-output-end~)
|
||||
|
||||
if (has-value $features title) {
|
||||
set after-chdir = (conj $after-chdir {|_| report-pwd })
|
||||
}
|
||||
if (has-value $features cursor) {
|
||||
fn beam { printf "\e[5 q" }
|
||||
fn block { printf "\e[0 q" }
|
||||
@@ -207,4 +171,9 @@
|
||||
if (and (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-) (has-external ssh)) {
|
||||
edit:add-var ssh~ $ssh-integration~
|
||||
}
|
||||
|
||||
# Report changes to the current directory.
|
||||
fn report-pwd { printf "\e]7;kitty-shell-cwd://%s%s\a" (platform:hostname) $pwd }
|
||||
set after-chdir = (conj $after-chdir {|_| report-pwd })
|
||||
report-pwd
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -179,14 +200,14 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
|
||||
end
|
||||
|
||||
# Setup prompt marking
|
||||
function __ghostty_mark_prompt_start --on-event fish_prompt --on-event fish_cancel --on-event fish_posterror
|
||||
function __ghostty_mark_prompt_start --on-event fish_prompt --on-event fish_posterror
|
||||
# If we never got the output end event, then we need to send it now.
|
||||
if test "$__ghostty_prompt_state" != prompt-start
|
||||
echo -en "\e]133;D\a"
|
||||
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
|
||||
|
||||
@@ -4,106 +4,83 @@ export module ghostty {
|
||||
$feature in ($env.GHOSTTY_SHELL_FEATURES | default "" | split row ',')
|
||||
}
|
||||
|
||||
# Enables automatic terminfo installation on remote hosts.
|
||||
# Attempts to install Ghostty's terminfo entry using infocmp and tic when
|
||||
# connecting to hosts that lack it.
|
||||
# Requires infocmp to be available locally and tic to be available on remote hosts.
|
||||
# Caches installations to avoid repeat installations.
|
||||
def set_ssh_terminfo [
|
||||
ssh_opts: list<string>
|
||||
ssh_args: list<string>
|
||||
]: [nothing -> record<ssh_term: string, ssh_opts: list<string>>] {
|
||||
let ssh_cfg = ^ssh -G ...($ssh_args)
|
||||
| lines
|
||||
| parse "{key} {value}"
|
||||
| where key in ["user" "hostname"]
|
||||
| select key value
|
||||
| transpose -rd
|
||||
| default {user: $env.USER hostname: "localhost"}
|
||||
|
||||
let ssh_id = $"($ssh_cfg.user)@($ssh_cfg.hostname)"
|
||||
let ghostty_bin = $env.GHOSTTY_BIN_DIR | path join "ghostty"
|
||||
|
||||
let is_cached = (
|
||||
^$ghostty_bin ...(["+ssh-cache" $"--host=($ssh_id)"])
|
||||
| complete
|
||||
| $in.exit_code == 0
|
||||
)
|
||||
|
||||
if not $is_cached {
|
||||
let terminfo_data = try { ^infocmp -0 -x xterm-ghostty } catch {
|
||||
print "Warning: Could not generate terminfo data."
|
||||
return {ssh_term: "xterm-256color" ssh_opts: $ssh_opts}
|
||||
}
|
||||
|
||||
print $"Setting up xterm-ghostty terminfo on ($ssh_cfg.hostname)..."
|
||||
|
||||
let ctrl_path = (
|
||||
mktemp -td $"ghostty-ssh-($ssh_cfg.user).XXXXXX"
|
||||
| path join "socket"
|
||||
)
|
||||
|
||||
let master_parts = $ssh_opts ++ ["-o" "ControlMaster=yes" "-o" $"ControlPath=($ctrl_path)" "-o" "ControlPersist=60s"] ++ $ssh_args
|
||||
|
||||
($terminfo_data) | ^ssh ...(
|
||||
$master_parts ++
|
||||
[
|
||||
'
|
||||
infocmp xterm-ghostty >/dev/null 2>&1 && exit 0
|
||||
command -v tic >/dev/null 2>&1 || exit 1
|
||||
mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0
|
||||
exit 1'
|
||||
]
|
||||
)
|
||||
| complete
|
||||
| if $in.exit_code != 0 {
|
||||
print "Warning: Failed to install terminfo."
|
||||
return {ssh_term: "xterm-256color" ssh_opts: $ssh_opts}
|
||||
}
|
||||
|
||||
^$ghostty_bin ...(["+ssh-cache" $"--add=($ssh_id)"]) o+e>| ignore
|
||||
|
||||
return {ssh_term: "xterm-ghostty" ssh_opts: ($ssh_opts ++ ["-o" $"ControlPath=($ctrl_path)"])}
|
||||
}
|
||||
|
||||
return {ssh_term: "xterm-ghostty" ssh_opts: $ssh_opts}
|
||||
}
|
||||
|
||||
# Wrap `ssh` with Ghostty TERMINFO support
|
||||
export def --wrapped ssh [...ssh_args: string]: any -> any {
|
||||
if ($ssh_args | is-empty) {
|
||||
return (^ssh)
|
||||
}
|
||||
# `ssh-env` enables SSH environment variable compatibility.
|
||||
# Converts TERM from xterm-ghostty to xterm-256color
|
||||
# and propagates COLORTERM, TERM_PROGRAM, and TERM_PROGRAM_VERSION
|
||||
# Check your sshd_config on remote host to see if these variables are accepted
|
||||
let base_ssh_opts = if (has_feature "ssh-env") {
|
||||
["-o" "SetEnv COLORTERM=truecolor" "-o" "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION"]
|
||||
} else {
|
||||
[]
|
||||
}
|
||||
let base_ssh_term = if (has_feature "ssh-env") {
|
||||
"xterm-256color"
|
||||
} else {
|
||||
($env.TERM? | default "")
|
||||
export def --wrapped ssh [...args] {
|
||||
mut ssh_env = {}
|
||||
mut ssh_opts = []
|
||||
|
||||
# `ssh-env`: use xterm-256color and propagate COLORTERM/TERM_PROGRAM vars
|
||||
if (has_feature "ssh-env") {
|
||||
$ssh_env.TERM = "xterm-256color"
|
||||
$ssh_opts = [
|
||||
"-o" "SetEnv COLORTERM=truecolor"
|
||||
"-o" "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION"
|
||||
]
|
||||
}
|
||||
|
||||
let session = if (has_feature "ssh-terminfo") {
|
||||
set_ssh_terminfo $base_ssh_opts $ssh_args
|
||||
} else {
|
||||
{ssh_term: $base_ssh_term ssh_opts: $base_ssh_opts}
|
||||
# `ssh-terminfo`: auto-install xterm-ghostty terminfo on remote hosts
|
||||
if (has_feature "ssh-terminfo") {
|
||||
let ghostty = ($env.GHOSTTY_BIN_DIR? | default "") | path join "ghostty"
|
||||
|
||||
let ssh_cfg = ^ssh -G ...$args
|
||||
| lines
|
||||
| parse "{key} {value}"
|
||||
| where key in ["user" "hostname"]
|
||||
| select key value
|
||||
| transpose -rd
|
||||
| default {user: $env.USER hostname: "localhost"}
|
||||
let ssh_id = $"($ssh_cfg.user)@($ssh_cfg.hostname)"
|
||||
|
||||
if (^$ghostty "+ssh-cache" $"--host=($ssh_id)" | complete | $in.exit_code == 0) {
|
||||
$ssh_env.TERM = "xterm-ghostty"
|
||||
} else {
|
||||
$ssh_env.TERM = "xterm-256color"
|
||||
|
||||
let terminfo = try {
|
||||
^infocmp -0 -x xterm-ghostty
|
||||
} catch {
|
||||
print -e "infocmp failed, using xterm-256color"
|
||||
}
|
||||
|
||||
if ($terminfo | is-not-empty) {
|
||||
print $"Setting up xterm-ghostty terminfo on ($ssh_cfg.hostname)..."
|
||||
|
||||
let ctrl_path = (
|
||||
mktemp -td $"ghostty-ssh-($ssh_cfg.user).XXXXXX"
|
||||
| path join "socket"
|
||||
)
|
||||
|
||||
let remote_args = $ssh_opts ++ [
|
||||
"-o" "ControlMaster=yes"
|
||||
"-o" $"ControlPath=($ctrl_path)"
|
||||
"-o" "ControlPersist=60s"
|
||||
] ++ $args
|
||||
|
||||
$terminfo | ^ssh ...$remote_args '
|
||||
infocmp xterm-ghostty >/dev/null 2>&1 && exit 0
|
||||
command -v tic >/dev/null 2>&1 || exit 1
|
||||
mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0
|
||||
exit 1'
|
||||
| complete
|
||||
| if $in.exit_code == 0 {
|
||||
^$ghostty "+ssh-cache" $"--add=($ssh_id)" e>| print -e
|
||||
$ssh_env.TERM = "xterm-ghostty"
|
||||
$ssh_opts = ($ssh_opts ++ ["-o" $"ControlPath=($ctrl_path)"])
|
||||
} else {
|
||||
print -e "terminfo install failed, using xterm-256color"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with-env {TERM: $session.ssh_term} {
|
||||
^ssh ...($session.ssh_opts ++ $ssh_args)
|
||||
let ssh_args = $ssh_opts ++ $args
|
||||
with-env $ssh_env {
|
||||
^ssh ...$ssh_args
|
||||
}
|
||||
}
|
||||
|
||||
# Wrap `sudo` to preserve Ghostty's TERMINFO environment variable
|
||||
export def --wrapped sudo [
|
||||
...args # Arguments to pass to `sudo`
|
||||
] {
|
||||
export def --wrapped sudo [...args] {
|
||||
mut sudo_args = $args
|
||||
|
||||
if (has_feature "sudo") {
|
||||
|
||||
@@ -121,7 +121,7 @@ _ghostty_deferred_init() {
|
||||
fi
|
||||
fi
|
||||
|
||||
builtin local mark1=$'%{\e]133;A\a%}'
|
||||
builtin local mark1=$'%{\e]133;A;cl=line\a%}'
|
||||
if [[ -o prompt_percent ]]; then
|
||||
builtin typeset -g precmd_functions
|
||||
if [[ ${precmd_functions[-1]} == _ghostty_precmd ]]; then
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1154,9 +1154,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,
|
||||
});
|
||||
|
||||
@@ -1167,17 +1169,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,
|
||||
@@ -11963,6 +11989,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