Merge remote-tracking branch 'origin/main' into shaping-positions

This commit is contained in:
Jacob Sandlund
2025-12-17 09:18:10 -05:00
81 changed files with 3021 additions and 405 deletions

View File

@@ -145,6 +145,12 @@ focused: bool = true,
/// Used to determine whether to continuously scroll.
selection_scroll_active: bool = false,
/// True if the surface is in read-only mode. When read-only, no input
/// is sent to the PTY but terminal-level operations like selections,
/// (native) scrolling, and copy keybinds still work. Warn before quit is
/// always enabled in this state.
readonly: bool = false,
/// Used to send notifications that long running commands have finished.
/// Requires that shell integration be active. Should represent a nanosecond
/// precision timestamp. It does not necessarily need to correspond to the
@@ -601,6 +607,9 @@ pub fn init(
};
errdefer env.deinit();
// don't leak GHOSTTY_LOG to any subprocesses
env.remove("GHOSTTY_LOG");
// Initialize our IO backend
var io_exec = try termio.Exec.init(alloc, .{
.command = command,
@@ -812,6 +821,30 @@ inline fn surfaceMailbox(self: *Surface) Mailbox {
};
}
/// Queue a message for the IO thread.
///
/// We centralize all our logic into this spot so we can intercept
/// messages for example in readonly mode.
fn queueIo(
self: *Surface,
msg: termio.Message,
mutex: termio.Termio.MutexState,
) void {
// In readonly mode, we don't allow any writes through to the pty.
if (self.readonly) {
switch (msg) {
.write_small,
.write_stable,
.write_alloc,
=> return,
else => {},
}
}
self.io.queueMessage(msg, mutex);
}
/// Forces the surface to render. This is useful for when the surface
/// is in the middle of animation (such as a resize, etc.) or when
/// the render timer is managed manually by the apprt.
@@ -843,7 +876,7 @@ pub fn activateInspector(self: *Surface) !void {
// Notify our components we have an inspector active
_ = self.renderer_thread.mailbox.push(.{ .inspector = true }, .{ .forever = {} });
self.io.queueMessage(.{ .inspector = true }, .unlocked);
self.queueIo(.{ .inspector = true }, .unlocked);
}
/// Deactivate the inspector and stop collecting any information.
@@ -860,7 +893,7 @@ pub fn deactivateInspector(self: *Surface) void {
// Notify our components we have deactivated inspector
_ = self.renderer_thread.mailbox.push(.{ .inspector = false }, .{ .forever = {} });
self.io.queueMessage(.{ .inspector = false }, .unlocked);
self.queueIo(.{ .inspector = false }, .unlocked);
// Deinit the inspector
insp.deinit();
@@ -871,6 +904,9 @@ pub fn deactivateInspector(self: *Surface) void {
/// True if the surface requires confirmation to quit. This should be called
/// by apprt to determine if the surface should confirm before quitting.
pub fn needsConfirmQuit(self: *Surface) bool {
// If the surface is in read-only mode, always require confirmation
if (self.readonly) return true;
// If the child has exited, then our process is certainly not alive.
// We check this first to avoid the locking overhead below.
if (self.child_exited) return false;
@@ -929,7 +965,7 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
// We always use an allocating message because we don't know
// the length of the title and this isn't a performance critical
// path.
self.io.queueMessage(.{
self.queueIo(.{
.write_alloc = .{
.alloc = self.alloc,
.data = data,
@@ -1121,7 +1157,7 @@ fn selectionScrollTick(self: *Surface) !void {
// If our screen changed while this is happening, we stop our
// selection scroll.
if (self.mouse.left_click_screen != t.screens.active_key) {
self.io.queueMessage(
self.queueIo(
.{ .selection_scroll = false },
.locked,
);
@@ -1353,7 +1389,7 @@ fn reportColorScheme(self: *Surface, force: bool) void {
.dark => "\x1B[?997;1n",
};
self.io.queueMessage(.{ .write_stable = output }, .unlocked);
self.queueIo(.{ .write_stable = output }, .unlocked);
}
fn searchCallback(event: terminal.search.Thread.Event, ud: ?*anyopaque) void {
@@ -1726,7 +1762,7 @@ pub fn updateConfig(
errdefer termio_config_ptr.deinit();
_ = self.renderer_thread.mailbox.push(renderer_message, .{ .forever = {} });
self.io.queueMessage(.{
self.queueIo(.{
.change_config = .{
.alloc = self.alloc,
.ptr = termio_config_ptr,
@@ -2001,6 +2037,23 @@ pub fn pwd(
return try alloc.dupe(u8, terminal_pwd);
}
/// Resolves a relative file path to an absolute path using the terminal's pwd.
fn resolvePathForOpening(
self: *Surface,
path: []const u8,
) Allocator.Error!?[]const u8 {
if (!std.fs.path.isAbsolute(path)) {
const terminal_pwd = self.io.terminal.getPwd() orelse {
return null;
};
const resolved = try std.fs.path.resolve(self.alloc, &.{ terminal_pwd, path });
return resolved;
}
return null;
}
/// Returns the x/y coordinate of where the IME (Input Method Editor)
/// keyboard should be rendered.
pub fn imePoint(self: *const Surface) apprt.IMEPos {
@@ -2292,7 +2345,7 @@ fn setCellSize(self: *Surface, size: rendererpkg.CellSize) !void {
self.balancePaddingIfNeeded();
// Notify the terminal
self.io.queueMessage(.{ .resize = self.size }, .unlocked);
self.queueIo(.{ .resize = self.size }, .unlocked);
// Update our terminal default size if necessary.
self.recomputeInitialSize() catch |err| {
@@ -2395,7 +2448,7 @@ fn resize(self: *Surface, size: rendererpkg.ScreenSize) !void {
}
// Mail the IO thread
self.io.queueMessage(.{ .resize = self.size }, .unlocked);
self.queueIo(.{ .resize = self.size }, .unlocked);
}
/// Recalculate the balanced padding if needed.
@@ -2671,7 +2724,7 @@ pub fn keyCallback(
}
errdefer write_req.deinit();
self.io.queueMessage(switch (write_req) {
self.queueIo(switch (write_req) {
.small => |v| .{ .write_small = v },
.stable => |v| .{ .write_stable = v },
.alloc => |v| .{ .write_alloc = v },
@@ -2900,7 +2953,7 @@ fn endKeySequence(
if (self.keyboard.queued.items.len > 0) {
switch (action) {
.flush => for (self.keyboard.queued.items) |write_req| {
self.io.queueMessage(switch (write_req) {
self.queueIo(switch (write_req) {
.small => |v| .{ .write_small = v },
.stable => |v| .{ .write_stable = v },
.alloc => |v| .{ .write_alloc = v },
@@ -3126,7 +3179,7 @@ pub fn focusCallback(self: *Surface, focused: bool) !void {
self.renderer_state.mutex.lock();
self.io.terminal.flags.focused = focused;
self.renderer_state.mutex.unlock();
self.io.queueMessage(.{ .focused = focused }, .unlocked);
self.queueIo(.{ .focused = focused }, .unlocked);
}
}
@@ -3290,7 +3343,7 @@ pub fn scrollCallback(
};
};
for (0..y.magnitude()) |_| {
self.io.queueMessage(.{ .write_stable = seq }, .locked);
self.queueIo(.{ .write_stable = seq }, .locked);
}
}
@@ -3511,7 +3564,7 @@ fn mouseReport(
data[5] = 32 + @as(u8, @intCast(viewport_point.y)) + 1;
// Ask our IO thread to write the data
self.io.queueMessage(.{ .write_small = .{
self.queueIo(.{ .write_small = .{
.data = data,
.len = 6,
} }, .locked);
@@ -3534,7 +3587,7 @@ fn mouseReport(
i += try std.unicode.utf8Encode(@intCast(32 + viewport_point.y + 1), data[i..]);
// Ask our IO thread to write the data
self.io.queueMessage(.{ .write_small = .{
self.queueIo(.{ .write_small = .{
.data = data,
.len = @intCast(i),
} }, .locked);
@@ -3555,7 +3608,7 @@ fn mouseReport(
});
// Ask our IO thread to write the data
self.io.queueMessage(.{ .write_small = .{
self.queueIo(.{ .write_small = .{
.data = data,
.len = @intCast(resp.len),
} }, .locked);
@@ -3572,7 +3625,7 @@ fn mouseReport(
});
// Ask our IO thread to write the data
self.io.queueMessage(.{ .write_small = .{
self.queueIo(.{ .write_small = .{
.data = data,
.len = @intCast(resp.len),
} }, .locked);
@@ -3601,7 +3654,7 @@ fn mouseReport(
});
// Ask our IO thread to write the data
self.io.queueMessage(.{ .write_small = .{
self.queueIo(.{ .write_small = .{
.data = data,
.len = @intCast(resp.len),
} }, .locked);
@@ -3753,7 +3806,7 @@ pub fn mouseButtonCallback(
// Stop selection scrolling when releasing the left mouse button
// but only when selection scrolling is active.
if (self.selection_scroll_active) {
self.io.queueMessage(
self.queueIo(
.{ .selection_scroll = false },
.unlocked,
);
@@ -4110,7 +4163,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void {
break :arrow if (t.modes.get(.cursor_keys)) "\x1bOB" else "\x1b[B";
};
for (0..@abs(path.y)) |_| {
self.io.queueMessage(.{ .write_stable = arrow }, .locked);
self.queueIo(.{ .write_stable = arrow }, .locked);
}
}
if (path.x != 0) {
@@ -4120,7 +4173,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void {
break :arrow if (t.modes.get(.cursor_keys)) "\x1bOC" else "\x1b[C";
};
for (0..@abs(path.x)) |_| {
self.io.queueMessage(.{ .write_stable = arrow }, .locked);
self.queueIo(.{ .write_stable = arrow }, .locked);
}
}
}
@@ -4229,7 +4282,12 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool {
.trim = false,
});
defer self.alloc.free(str);
try self.openUrl(.{ .kind = .unknown, .url = str });
const resolved_path = try self.resolvePathForOpening(str);
defer if (resolved_path) |p| self.alloc.free(p);
const url_to_open = resolved_path orelse str;
try self.openUrl(.{ .kind = .unknown, .url = url_to_open });
},
._open_osc8 => {
@@ -4393,7 +4451,7 @@ pub fn cursorPosCallback(
// Stop selection scrolling when inside the viewport within a 1px buffer
// for fullscreen windows, but only when selection scrolling is active.
if (pos.y >= 1 and self.selection_scroll_active) {
self.io.queueMessage(
self.queueIo(
.{ .selection_scroll = false },
.locked,
);
@@ -4493,7 +4551,7 @@ pub fn cursorPosCallback(
if ((pos.y <= 1 or pos.y > max_y - 1) and
!self.selection_scroll_active)
{
self.io.queueMessage(
self.queueIo(
.{ .selection_scroll = true },
.locked,
);
@@ -4869,7 +4927,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
.esc => try std.fmt.bufPrint(&buf, "\x1b{s}", .{data}),
else => unreachable,
};
self.io.queueMessage(try termio.Message.writeReq(
self.queueIo(try termio.Message.writeReq(
self.alloc,
full_data,
), .unlocked);
@@ -4896,7 +4954,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
);
return true;
};
self.io.queueMessage(try termio.Message.writeReq(
self.queueIo(try termio.Message.writeReq(
self.alloc,
text,
), .unlocked);
@@ -4929,9 +4987,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
};
if (normal) {
self.io.queueMessage(.{ .write_stable = ck.normal }, .unlocked);
self.queueIo(.{ .write_stable = ck.normal }, .unlocked);
} else {
self.io.queueMessage(.{ .write_stable = ck.application }, .unlocked);
self.queueIo(.{ .write_stable = ck.application }, .unlocked);
}
},
@@ -5204,19 +5262,19 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
if (self.io.terminal.screens.active_key == .alternate) return false;
}
self.io.queueMessage(.{
self.queueIo(.{
.clear_screen = .{ .history = true },
}, .unlocked);
},
.scroll_to_top => {
self.io.queueMessage(.{
self.queueIo(.{
.scroll_viewport = .{ .top = {} },
}, .unlocked);
},
.scroll_to_bottom => {
self.io.queueMessage(.{
self.queueIo(.{
.scroll_viewport = .{ .bottom = {} },
}, .unlocked);
},
@@ -5246,14 +5304,14 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
.scroll_page_up => {
const rows: isize = @intCast(self.size.grid().rows);
self.io.queueMessage(.{
self.queueIo(.{
.scroll_viewport = .{ .delta = -1 * rows },
}, .unlocked);
},
.scroll_page_down => {
const rows: isize = @intCast(self.size.grid().rows);
self.io.queueMessage(.{
self.queueIo(.{
.scroll_viewport = .{ .delta = rows },
}, .unlocked);
},
@@ -5261,19 +5319,19 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
.scroll_page_fractional => |fraction| {
const rows: f32 = @floatFromInt(self.size.grid().rows);
const delta: isize = @intFromFloat(@trunc(fraction * rows));
self.io.queueMessage(.{
self.queueIo(.{
.scroll_viewport = .{ .delta = delta },
}, .unlocked);
},
.scroll_page_lines => |lines| {
self.io.queueMessage(.{
self.queueIo(.{
.scroll_viewport = .{ .delta = lines },
}, .unlocked);
},
.jump_to_prompt => |delta| {
self.io.queueMessage(.{
self.queueIo(.{
.jump_to_prompt = @intCast(delta),
}, .unlocked);
},
@@ -5357,6 +5415,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
},
),
.goto_window => |direction| return try self.rt_app.performAction(
.{ .surface = self },
.goto_window,
switch (direction) {
.previous => .previous,
.next => .next,
},
),
.resize_split => |value| return try self.rt_app.performAction(
.{ .surface = self },
.resize_split,
@@ -5383,6 +5450,16 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
{},
),
.toggle_readonly => {
self.readonly = !self.readonly;
_ = try self.rt_app.performAction(
.{ .surface = self },
.readonly,
if (self.readonly) .on else .off,
);
return true;
},
.reset_window_size => return try self.rt_app.performAction(
.{ .surface = self },
.reset_window_size,
@@ -5441,6 +5518,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
{},
),
.toggle_background_opacity => return try self.rt_app.performAction(
.{ .surface = self },
.toggle_background_opacity,
{},
),
.show_on_screen_keyboard => return try self.rt_app.performAction(
.{ .surface = self },
.show_on_screen_keyboard,
@@ -5488,7 +5571,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
};
},
.io => self.io.queueMessage(.{ .crash = {} }, .unlocked),
.io => self.queueIo(.{ .crash = {} }, .unlocked),
},
.adjust_selection => |direction| {
@@ -5686,7 +5769,7 @@ fn writeScreenFile(
},
.url = path,
}),
.paste => self.io.queueMessage(try termio.Message.writeReq(
.paste => self.queueIo(try termio.Message.writeReq(
self.alloc,
path,
), .unlocked),
@@ -5826,7 +5909,7 @@ fn completeClipboardPaste(
};
for (vecs) |vec| if (vec.len > 0) {
self.io.queueMessage(try termio.Message.writeReq(
self.queueIo(try termio.Message.writeReq(
self.alloc,
vec,
), .unlocked);
@@ -5872,7 +5955,7 @@ fn completeClipboardReadOSC52(
const encoded = enc.encode(buf[prefix.len..], data);
assert(encoded.len == size);
self.io.queueMessage(try termio.Message.writeReq(
self.queueIo(try termio.Message.writeReq(
self.alloc,
buf,
), .unlocked);

View File

@@ -115,6 +115,11 @@ pub const Action = union(Key) {
/// Toggle the visibility of all Ghostty terminal windows.
toggle_visibility,
/// Toggle the window background opacity. This only has an effect
/// if the window started as transparent (non-opaque), and toggles
/// it between fully opaque and the configured background opacity.
toggle_background_opacity,
/// Moves a tab by a relative offset.
///
/// Adjusts the tab position based on `offset` (e.g., -1 for left, +1
@@ -129,6 +134,9 @@ pub const Action = union(Key) {
/// Jump to a specific split.
goto_split: GotoSplit,
/// Jump to next/previous window.
goto_window: GotoWindow,
/// Resize the split in the given direction.
resize_split: ResizeSplit,
@@ -314,6 +322,9 @@ pub const Action = union(Key) {
/// The currently selected search match index (1-based).
search_selected: SearchSelected,
/// The readonly state of the surface has changed.
readonly: Readonly,
/// Sync with: ghostty_action_tag_e
pub const Key = enum(c_int) {
quit,
@@ -329,9 +340,11 @@ pub const Action = union(Key) {
toggle_quick_terminal,
toggle_command_palette,
toggle_visibility,
toggle_background_opacity,
move_tab,
goto_tab,
goto_split,
goto_window,
resize_split,
equalize_splits,
toggle_split_zoom,
@@ -375,6 +388,7 @@ pub const Action = union(Key) {
end_search,
search_total,
search_selected,
readonly,
};
/// Sync with: ghostty_action_u
@@ -470,6 +484,13 @@ pub const GotoSplit = enum(c_int) {
right,
};
// This is made extern (c_int) to make interop easier with our embedded
// runtime. The small size cost doesn't make a difference in our union.
pub const GotoWindow = enum(c_int) {
previous,
next,
};
/// The amount to resize the split by and the direction to resize it in.
pub const ResizeSplit = extern struct {
amount: u16,
@@ -532,6 +553,11 @@ pub const QuitTimer = enum(c_int) {
stop,
};
pub const Readonly = enum(c_int) {
off,
on,
};
pub const MouseVisibility = enum(c_int) {
visible,
hidden,

View File

@@ -8,6 +8,8 @@ const glib = @import("glib");
const gobject = @import("gobject");
const gtk = @import("gtk");
const build_config = @import("../../../build_config.zig");
const state = &@import("../../../global.zig").state;
const i18n = @import("../../../os/main.zig").i18n;
const apprt = @import("../../../apprt.zig");
const cgroup = @import("../cgroup.zig");
@@ -659,6 +661,8 @@ pub const Application = extern struct {
.goto_split => return Action.gotoSplit(target, value),
.goto_window => return Action.gotoWindow(value),
.goto_tab => return Action.gotoTab(target, value),
.initial_size => return Action.initialSize(target, value),
@@ -737,6 +741,7 @@ pub const Application = extern struct {
.close_all_windows,
.float_window,
.toggle_visibility,
.toggle_background_opacity,
.cell_size,
.key_sequence,
.render_inspector,
@@ -746,6 +751,7 @@ pub const Application = extern struct {
.check_for_updates,
.undo,
.redo,
.readonly,
=> {
log.warn("unimplemented action={}", .{action});
return false;
@@ -2013,6 +2019,69 @@ const Action = struct {
}
}
pub fn gotoWindow(direction: apprt.action.GotoWindow) bool {
const glist = gtk.Window.listToplevels();
defer glist.free();
// The window we're starting from is typically our active window.
const starting: *glib.List = @as(?*glib.List, glist.findCustom(
null,
findActiveWindow,
)) orelse glist;
// Go forward or backwards in the list until we find a valid
// window that is visible.
var current_: ?*glib.List = starting;
while (current_) |node| : (current_ = switch (direction) {
.next => node.f_next,
.previous => node.f_prev,
}) {
const data = node.f_data orelse continue;
const gtk_window: *gtk.Window = @ptrCast(@alignCast(data));
if (gotoWindowMaybe(gtk_window)) return true;
}
// If we reached here, we didn't find a valid window to focus.
// Wrap around.
current_ = switch (direction) {
.next => glist,
.previous => last: {
var end: *glib.List = glist;
while (end.f_next) |next| end = next;
break :last end;
},
};
while (current_) |node| : (current_ = switch (direction) {
.next => node.f_next,
.previous => node.f_prev,
}) {
if (current_ == starting) break;
const data = node.f_data orelse continue;
const gtk_window: *gtk.Window = @ptrCast(@alignCast(data));
if (gotoWindowMaybe(gtk_window)) return true;
}
return false;
}
fn gotoWindowMaybe(gtk_window: *gtk.Window) bool {
// If it is already active skip it.
if (gtk_window.isActive() != 0) return false;
// If it is hidden, skip it.
if (gtk_window.as(gtk.Widget).isVisible() == 0) return false;
// If it isn't a Ghostty window, skip it.
const window = gobject.ext.cast(
Window,
gtk_window,
) orelse return false;
// Focus our active surface
const surface = window.getActiveSurface() orelse return false;
gtk.Window.present(gtk_window);
surface.grabFocus();
return true;
}
pub fn initialSize(
target: apprt.Target,
value: apprt.action.InitialSize,
@@ -2611,7 +2680,9 @@ fn setGtkEnv(config: *const CoreConfig) error{NoSpaceLeft}!void {
/// disable it.
@"vulkan-disable": bool = false,
} = .{
.opengl = config.@"gtk-opengl-debug",
// `gtk-opengl-debug` dumps logs directly to stderr so both must be true
// to enable OpenGL debugging.
.opengl = state.logging.stderr and config.@"gtk-opengl-debug",
};
var gdk_disable: struct {

View File

@@ -340,6 +340,35 @@ pub const SplitTree = extern struct {
const surface = tree.nodes[target.idx()].leaf;
surface.grabFocus();
// We also need to setup our last_focused to this because if we
// trigger a tree change like below, the grab focus above never
// actually triggers in time to set this and this ensures we
// grab focus to the right thing.
const old_last_focused = self.private().last_focused.get();
defer if (old_last_focused) |v| v.unref(); // unref strong ref from get
self.private().last_focused.set(surface);
errdefer self.private().last_focused.set(old_last_focused);
if (tree.zoomed != null) {
const app = Application.default();
const config_obj = app.getConfig();
defer config_obj.unref();
const config = config_obj.get();
if (!config.@"split-preserve-zoom".navigation) {
tree.zoomed = null;
} else {
tree.zoom(target);
}
// When the zoom state changes our tree state changes and
// we need to send the proper notifications to trigger
// relayout.
const object = self.as(gobject.Object);
object.notifyByPspec(properties.tree.impl.param_spec);
object.notifyByPspec(properties.@"is-zoomed".impl.param_spec);
}
return true;
}

View File

@@ -1658,13 +1658,7 @@ pub const Surface = extern struct {
};
priv.drop_target.setGtypes(&drop_target_types, drop_target_types.len);
// Initialize our GLArea. We only set the values we can't set
// in our blueprint file.
const gl_area = priv.gl_area;
gl_area.setRequiredVersion(
renderer.OpenGL.MIN_VERSION_MAJOR,
renderer.OpenGL.MIN_VERSION_MINOR,
);
// Setup properties we can't set from our Blueprint file.
self.as(gtk.Widget).setCursorFromName("text");
// Initialize our config

View File

@@ -793,7 +793,7 @@ pub const Window = extern struct {
/// Get the currently active surface. See the "active-surface" property.
/// This does not ref the value.
fn getActiveSurface(self: *Self) ?*Surface {
pub fn getActiveSurface(self: *Self) ?*Surface {
const tab = self.getSelectedTab() orelse return null;
return tab.getActiveSurface();
}

View File

@@ -173,6 +173,12 @@ pub const Window = struct {
blur_region: Region = .{},
// Cache last applied values to avoid redundant X11 property updates.
// Redundant property updates seem to cause some visual glitches
// with some window managers: https://github.com/ghostty-org/ghostty/pull/8075
last_applied_blur_region: ?Region = null,
last_applied_decoration_hints: ?MotifWMHints = null,
pub fn init(
alloc: Allocator,
app: *App,
@@ -255,30 +261,42 @@ pub const Window = struct {
const gtk_widget = self.apprt_window.as(gtk.Widget);
const config = if (self.apprt_window.getConfig()) |v| v.get() else return;
// When blur is disabled, remove the property if it was previously set
const blur = config.@"background-blur";
if (!blur.enabled()) {
if (self.last_applied_blur_region != null) {
try self.deleteProperty(self.app.atoms.kde_blur);
self.last_applied_blur_region = null;
}
return;
}
// Transform surface coordinates to device coordinates.
const scale = gtk_widget.getScaleFactor();
self.blur_region.width = gtk_widget.getWidth() * scale;
self.blur_region.height = gtk_widget.getHeight() * scale;
const blur = config.@"background-blur";
// Only update X11 properties when the blur region actually changes
if (self.last_applied_blur_region) |last| {
if (std.meta.eql(self.blur_region, last)) return;
}
log.debug("set blur={}, window xid={}, region={}", .{
blur,
self.x11_surface.getXid(),
self.blur_region,
});
if (blur.enabled()) {
try self.changeProperty(
Region,
self.app.atoms.kde_blur,
c.XA_CARDINAL,
._32,
.{ .mode = .replace },
&self.blur_region,
);
} else {
try self.deleteProperty(self.app.atoms.kde_blur);
}
try self.changeProperty(
Region,
self.app.atoms.kde_blur,
c.XA_CARDINAL,
._32,
.{ .mode = .replace },
&self.blur_region,
);
self.last_applied_blur_region = self.blur_region;
}
fn syncDecorations(self: *Window) !void {
@@ -307,6 +325,11 @@ pub const Window = struct {
.auto, .client, .none => false,
};
// Only update decoration hints when they actually change
if (self.last_applied_decoration_hints) |last| {
if (std.meta.eql(hints, last)) return;
}
try self.changeProperty(
MotifWMHints,
self.app.atoms.motif_wm_hints,
@@ -315,6 +338,7 @@ pub const Window = struct {
.{ .mode = .replace },
&hints,
);
self.last_applied_decoration_hints = hints;
}
pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void {

118
src/benchmark/OscParser.zig Normal file
View File

@@ -0,0 +1,118 @@
//! This benchmark tests the throughput of the OSC parser.
const OscParser = @This();
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const Benchmark = @import("Benchmark.zig");
const options = @import("options.zig");
const Parser = @import("../terminal/osc.zig").Parser;
const log = std.log.scoped(.@"osc-parser-bench");
opts: Options,
/// The file, opened in the setup function.
data_f: ?std.fs.File = null,
parser: Parser,
pub const Options = struct {
/// The data to read as a filepath. If this is "-" then
/// we will read stdin. If this is unset, then we will
/// do nothing (benchmark is a noop). It'd be more unixy to
/// use stdin by default but I find that a hanging CLI command
/// with no interaction is a bit annoying.
data: ?[]const u8 = null,
};
/// Create a new terminal stream handler for the given arguments.
pub fn create(
alloc: Allocator,
opts: Options,
) !*OscParser {
const ptr = try alloc.create(OscParser);
errdefer alloc.destroy(ptr);
ptr.* = .{
.opts = opts,
.data_f = null,
.parser = .init(alloc),
};
return ptr;
}
pub fn destroy(self: *OscParser, alloc: Allocator) void {
self.parser.deinit();
alloc.destroy(self);
}
pub fn benchmark(self: *OscParser) Benchmark {
return .init(self, .{
.stepFn = step,
.setupFn = setup,
.teardownFn = teardown,
});
}
fn setup(ptr: *anyopaque) Benchmark.Error!void {
const self: *OscParser = @ptrCast(@alignCast(ptr));
// Open our data file to prepare for reading. We can do more
// validation here eventually.
assert(self.data_f == null);
self.data_f = options.dataFile(self.opts.data) catch |err| {
log.warn("error opening data file err={}", .{err});
return error.BenchmarkFailed;
};
self.parser.reset();
}
fn teardown(ptr: *anyopaque) void {
const self: *OscParser = @ptrCast(@alignCast(ptr));
if (self.data_f) |f| {
f.close();
self.data_f = null;
}
}
fn step(ptr: *anyopaque) Benchmark.Error!void {
const self: *OscParser = @ptrCast(@alignCast(ptr));
const f = self.data_f orelse return;
var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
var r = f.reader(&read_buf);
var osc_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
while (true) {
r.interface.fill(@bitSizeOf(usize) / 8) catch |err| switch (err) {
error.EndOfStream => return,
error.ReadFailed => return error.BenchmarkFailed,
};
const len = r.interface.takeInt(usize, .little) catch |err| switch (err) {
error.EndOfStream => return,
error.ReadFailed => return error.BenchmarkFailed,
};
if (len > osc_buf.len) return error.BenchmarkFailed;
r.interface.readSliceAll(osc_buf[0..len]) catch |err| switch (err) {
error.EndOfStream => return,
error.ReadFailed => return error.BenchmarkFailed,
};
for (osc_buf[0..len]) |c| self.parser.next(c);
_ = self.parser.end(std.ascii.control_code.bel);
self.parser.reset();
}
}
test OscParser {
const testing = std.testing;
const alloc = testing.allocator;
const impl: *OscParser = try .create(alloc, .{});
defer impl.destroy(alloc);
const bench = impl.benchmark();
_ = try bench.run(.once);
}

View File

@@ -12,6 +12,7 @@ pub const Action = enum {
@"terminal-parser",
@"terminal-stream",
@"is-symbol",
@"osc-parser",
/// Returns the struct associated with the action. The struct
/// should have a few decls:
@@ -29,6 +30,7 @@ pub const Action = enum {
.@"grapheme-break" => @import("GraphemeBreak.zig"),
.@"terminal-parser" => @import("TerminalParser.zig"),
.@"is-symbol" => @import("IsSymbol.zig"),
.@"osc-parser" => @import("OscParser.zig"),
};
}
};

View File

@@ -219,20 +219,14 @@ pub fn init(b: *std.Build, appVersion: []const u8) !Config {
else version: {
const app_version = try std.SemanticVersion.parse(appVersion);
// Detect if ghostty is being built as a dependency by checking if the
// build root has our marker. When used as a dependency, we skip git
// detection entirely to avoid reading the downstream project's git state.
const is_dependency = !@hasDecl(
@import("root"),
"_ghostty_build_root",
);
if (is_dependency) {
break :version .{
.major = app_version.major,
.minor = app_version.minor,
.patch = app_version.patch,
};
}
// Is ghostty a dependency? If so, skip git detection.
// @src().file won't resolve from b.build_root unless ghostty
// is the project being built.
b.build_root.handle.access(@src().file, .{}) catch break :version .{
.major = app_version.major,
.minor = app_version.minor,
.patch = app_version.patch,
};
// If no explicit version is given, we try to detect it from git.
const vsn = GitVersion.detect(b) catch |err| switch (err) {

View File

@@ -151,7 +151,7 @@ pub fn init(
// This overrides our default behavior and forces logs to show
// up on stderr (in addition to the centralized macOS log).
open.setEnvironmentVariable("GHOSTTY_LOG", "1");
open.setEnvironmentVariable("GHOSTTY_LOG", "stderr,macos");
// Configure how we're launching
open.setEnvironmentVariable("GHOSTTY_MAC_LAUNCH_SOURCE", "zig_run");

View File

@@ -37,6 +37,19 @@ precedence over the XDG environment locations.
: **WINDOWS ONLY:** alternate location to search for configuration files.
**GHOSTTY_LOG**
: The `GHOSTTY_LOG` environment variable can be used to control which
destinations receive logs. Ghostty currently defines two destinations:
: - `stderr` - logging to `stderr`.
: - `macos` - logging to macOS's unified log (has no effect on non-macOS platforms).
: Combine values with a comma to enable multiple destinations. Prefix a
destination with `no-` to disable it. Enabling and disabling destinations
can be done at the same time. Setting `GHOSTTY_LOG` to `true` will enable all
destinations. Setting `GHOSTTY_LOG` to `false` will disable all destinations.
# BUGS
See GitHub issues: <https://github.com/ghostty-org/ghostty/issues>

View File

@@ -73,16 +73,12 @@ the public config files of many Ghostty users for examples and inspiration.
## Configuration Errors
If your configuration file has any errors, Ghostty does its best to ignore
them and move on. Configuration errors currently show up in the log. The log
is written directly to stderr, so it is up to you to figure out how to access
that for your system (for now). On macOS, you can also use the system `log` CLI
utility with `log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"'`.
them and move on. Configuration errors will be logged.
## Debugging Configuration
You can verify that configuration is being properly loaded by looking at the
debug output of Ghostty. Documentation for how to view the debug output is in
the "building Ghostty" section at the end of the README.
debug output of Ghostty.
In the debug output, you should see in the first 20 lines or so messages about
loading (or not loading) a configuration file, as well as any errors it may have
@@ -93,3 +89,34 @@ will fall back to default values for erroneous keys.
You can also view the full configuration Ghostty is loading using `ghostty
+show-config` from the command-line. Use the `--help` flag to additional options
for that command.
## Logging
Ghostty can write logs to a number of destinations. On all platforms, logging to
`stderr` is available. Depending on the platform and how Ghostty was launched,
logs sent to `stderr` may be stored by the system and made available for later
retrieval.
On Linux if Ghostty is launched by the default `systemd` user service, you can use
`journald` to see Ghostty's logs: `journalctl --user --unit app-com.mitchellh.ghostty.service`.
On macOS logging to the macOS unified log is available and enabled by default.
--Use the system `log` CLI to view Ghostty's logs: `sudo log stream --level debug
--predicate 'subsystem=="com.mitchellh.ghostty"'`.
Ghostty's logging can be configured in two ways. The first is by what
optimization level Ghostty is compiled with. If Ghostty is compiled with `Debug`
optimizations debug logs will be output to `stderr`. If Ghostty is compiled with
any other optimization the debug logs will not be output to `stderr`.
Ghostty also checks the `GHOSTTY_LOG` environment variable. It can be used
to control which destinations receive logs. Ghostty currently defines two
destinations:
- `stderr` - logging to `stderr`.
- `macos` - logging to macOS's unified log (has no effect on non-macOS platforms).
Combine values with a comma to enable multiple destinations. Prefix a
destination with `no-` to disable it. Enabling and disabling destinations
can be done at the same time. Setting `GHOSTTY_LOG` to `true` will enable all
destinations. Setting `GHOSTTY_LOG` to `false` will disable all destinations.

View File

@@ -604,7 +604,7 @@ pub fn parseAutoStruct(
return result;
}
fn parsePackedStruct(comptime T: type, v: []const u8) !T {
pub fn parsePackedStruct(comptime T: type, v: []const u8) !T {
const info = @typeInfo(T).@"struct";
comptime assert(info.layout == .@"packed");

View File

@@ -2,6 +2,7 @@ const std = @import("std");
const args = @import("args.zig");
const Action = @import("ghostty.zig").Action;
const Config = @import("../config/Config.zig");
const configpkg = @import("../config.zig");
const themepkg = @import("../config/theme.zig");
const tui = @import("tui.zig");
const global_state = &@import("../global.zig").state;
@@ -196,6 +197,31 @@ pub fn run(gpa_alloc: std.mem.Allocator) !u8 {
return 0;
}
fn resolveAutoThemePath(alloc: std.mem.Allocator) ![]u8 {
const main_cfg_path = try configpkg.preferredDefaultFilePath(alloc);
defer alloc.free(main_cfg_path);
const base_dir = std.fs.path.dirname(main_cfg_path) orelse return error.BadPathName;
return try std.fs.path.join(alloc, &.{ base_dir, "auto", "theme.ghostty" });
}
fn writeAutoThemeFile(alloc: std.mem.Allocator, theme_name: []const u8) !void {
const auto_path = try resolveAutoThemePath(alloc);
defer alloc.free(auto_path);
if (std.fs.path.dirname(auto_path)) |dir| {
try std.fs.cwd().makePath(dir);
}
var f = try std.fs.createFileAbsolute(auto_path, .{ .truncate = true });
defer f.close();
var buf: [128]u8 = undefined;
var w = f.writer(&buf);
try w.interface.print("theme = {s}\n", .{theme_name});
try w.interface.flush();
}
const Event = union(enum) {
key_press: vaxis.Key,
mouse: vaxis.Mouse,
@@ -487,6 +513,9 @@ const Preview = struct {
self.should_quit = true;
if (key.matchesAny(&.{ vaxis.Key.escape, vaxis.Key.enter, vaxis.Key.kp_enter }, .{}))
self.mode = .normal;
if (key.matches('w', .{})) {
self.saveSelectedTheme();
}
},
}
},
@@ -698,7 +727,7 @@ const Preview = struct {
.help => {
win.hideCursor();
const width = 60;
const height = 20;
const height = 22;
const child = win.child(
.{
.x_off = win.width / 2 -| width / 2,
@@ -733,6 +762,7 @@ const Preview = struct {
.{ .keys = "/", .help = "Start search." },
.{ .keys = "^X, ^/", .help = "Clear search." },
.{ .keys = "", .help = "Save theme or close search window." },
.{ .keys = "w", .help = "Write theme to auto config file." },
};
for (key_help, 0..) |help, captured_i| {
@@ -786,8 +816,8 @@ const Preview = struct {
.save => {
const theme = self.themes[self.filtered.items[self.current]];
const width = 90;
const height = 12;
const width = 92;
const height = 17;
const child = win.child(
.{
.x_off = win.width / 2 -| width / 2,
@@ -809,6 +839,12 @@ const Preview = struct {
try std.fmt.allocPrint(alloc, "theme = {s}", .{theme.theme}),
"",
"Save the configuration file and then reload it to apply the new theme.",
"",
"Or press 'w' to write an auto theme file to your system's preferred default config path.",
"Then add the following line to your Ghostty configuration and reload:",
"",
"config-file = ?auto/theme.ghostty",
"",
"For more details on configuration and themes, visit the Ghostty documentation:",
"",
"https://ghostty.org/docs/config/reference",
@@ -1657,6 +1693,18 @@ const Preview = struct {
});
}
}
fn saveSelectedTheme(self: *Preview) void {
if (self.filtered.items.len == 0)
return;
const idx = self.filtered.items[self.current];
const theme = self.themes[idx];
writeAutoThemeFile(self.allocator, theme.theme) catch {
return;
};
}
};
fn color(config: Config, palette: usize) vaxis.Color {

View File

@@ -86,6 +86,10 @@ pub const compatibility = std.StaticStringMap(
// Ghostty 1.2 removed the "desktop" option and renamed it to "detect".
// The semantics also changed slightly but this is the correct mapping.
.{ "gtk-single-instance", compatGtkSingleInstance },
// Ghostty 1.3 rename the "window" option to "new-window".
// See: https://github.com/ghostty-org/ghostty/pull/9764
.{ "macos-dock-drop-behavior", compatMacOSDockDropBehavior },
});
/// The font families to use.
@@ -927,6 +931,15 @@ palette: Palette = .{},
/// reasonable for a good looking blur. Higher blur intensities may
/// cause strange rendering and performance issues.
///
/// On macOS 26.0 and later, there are additional special values that
/// can be set to use the native macOS glass effects:
///
/// * `macos-glass-regular` - Standard glass effect with some opacity
/// * `macos-glass-clear` - Highly transparent glass effect
///
/// If the macOS values are set, then this implies `background-blur = true`
/// on non-macOS platforms.
///
/// Supported on macOS and on some Linux desktop environments, including:
///
/// * KDE Plasma (Wayland and X11)
@@ -976,6 +989,22 @@ palette: Palette = .{},
/// Available since: 1.1.0
@"split-divider-color": ?Color = null,
/// Control when Ghostty preserves a zoomed split. Under normal circumstances,
/// any operation that changes focus or layout of the split tree in a window
/// will unzoom any zoomed split. This configuration allows you to control
/// this behavior.
///
/// This can be set to `navigation` to preserve the zoomed split state
/// when navigating to another split (e.g. via `goto_split`). This will
/// change the zoomed split to the newly focused split instead of unzooming.
///
/// Any options can also be prefixed with `no-` to disable that option.
///
/// Example: `split-preserve-zoom = navigation`
///
/// Available since: 1.3.0
@"split-preserve-zoom": SplitPreserveZoom = .{},
/// The foreground and background color for search matches. This only applies
/// to non-focused search matches, also known as candidate matches.
///
@@ -1329,7 +1358,7 @@ maximize: bool = false,
/// new windows, not just the first one.
///
/// On macOS, this setting does not work if window-decoration is set to
/// "false", because native fullscreen on macOS requires window decorations
/// "none", because native fullscreen on macOS requires window decorations
/// to be set.
fullscreen: bool = false,
@@ -2825,7 +2854,7 @@ keybind: Keybinds = .{},
/// also known as the traffic lights, that allow you to close, miniaturize, and
/// zoom the window.
///
/// This setting has no effect when `window-decoration = false` or
/// This setting has no effect when `window-decoration = none` or
/// `macos-titlebar-style = hidden`, as the window buttons are always hidden in
/// these modes.
///
@@ -2866,7 +2895,7 @@ keybind: Keybinds = .{},
/// macOS 14 does not have this issue and any other macOS version has not
/// been tested.
///
/// The "hidden" style hides the titlebar. Unlike `window-decoration = false`,
/// The "hidden" style hides the titlebar. Unlike `window-decoration = none`,
/// however, it does not remove the frame from the window or cause it to have
/// squared corners. Changing to or from this option at run-time may affect
/// existing windows in buggy ways.
@@ -2911,7 +2940,7 @@ keybind: Keybinds = .{},
///
/// * `new-tab` - Create a new tab in the current window, or open
/// a new window if none exist.
/// * `window` - Create a new window unconditionally.
/// * `new-window` - Create a new window unconditionally.
///
/// The default value is `new-tab`.
///
@@ -3205,7 +3234,7 @@ else
/// manager's simple titlebar. The behavior of this option will vary with your
/// window manager.
///
/// This option does nothing when `window-decoration` is false or when running
/// This option does nothing when `window-decoration` is none or when running
/// under macOS.
@"gtk-titlebar": bool = true,
@@ -4445,6 +4474,23 @@ fn compatBoldIsBright(
return true;
}
fn compatMacOSDockDropBehavior(
self: *Config,
alloc: Allocator,
key: []const u8,
value: ?[]const u8,
) bool {
_ = alloc;
assert(std.mem.eql(u8, key, "macos-dock-drop-behavior"));
if (std.mem.eql(u8, value orelse "", "window")) {
self.@"macos-dock-drop-behavior" = .@"new-window";
return true;
}
return false;
}
/// Add a diagnostic message to the config with the given string.
/// This is always added with a location of "none".
pub fn addDiagnosticFmt(
@@ -7414,6 +7460,10 @@ pub const ShellIntegrationFeatures = packed struct {
path: bool = true,
};
pub const SplitPreserveZoom = packed struct {
navigation: bool = false,
};
pub const RepeatableCommand = struct {
value: std.ArrayListUnmanaged(inputpkg.Command) = .empty,
@@ -7875,7 +7925,7 @@ pub const WindowNewTabPosition = enum {
/// See macos-dock-drop-behavior
pub const MacOSDockDropBehavior = enum {
@"new-tab",
window,
@"new-window",
};
/// See window-show-tab-bar
@@ -8315,6 +8365,8 @@ pub const AutoUpdate = enum {
pub const BackgroundBlur = union(enum) {
false,
true,
@"macos-glass-regular",
@"macos-glass-clear",
radius: u8,
pub fn parseCLI(self: *BackgroundBlur, input: ?[]const u8) !void {
@@ -8324,14 +8376,35 @@ pub const BackgroundBlur = union(enum) {
return;
};
self.* = if (cli.args.parseBool(input_)) |b|
if (b) .true else .false
else |_|
.{ .radius = std.fmt.parseInt(
u8,
input_,
0,
) catch return error.InvalidValue };
// Try to parse normal bools
if (cli.args.parseBool(input_)) |b| {
self.* = if (b) .true else .false;
return;
} else |_| {}
// Try to parse enums
if (std.meta.stringToEnum(
std.meta.Tag(BackgroundBlur),
input_,
)) |v| switch (v) {
inline else => |tag| tag: {
// We can only parse void types
const info = std.meta.fieldInfo(BackgroundBlur, tag);
if (info.type != void) break :tag;
self.* = @unionInit(
BackgroundBlur,
@tagName(tag),
{},
);
return;
},
};
self.* = .{ .radius = std.fmt.parseInt(
u8,
input_,
0,
) catch return error.InvalidValue };
}
pub fn enabled(self: BackgroundBlur) bool {
@@ -8339,14 +8412,24 @@ pub const BackgroundBlur = union(enum) {
.false => false,
.true => true,
.radius => |v| v > 0,
// We treat these as true because they both imply some blur!
// This has the effect of making the standard blur happen on
// Linux.
.@"macos-glass-regular", .@"macos-glass-clear" => true,
};
}
pub fn cval(self: BackgroundBlur) u8 {
pub fn cval(self: BackgroundBlur) i16 {
return switch (self) {
.false => 0,
.true => 20,
.radius => |v| v,
// I hate sentinel values like this but this is only for
// our macOS application currently. We can switch to a proper
// tagged union if we ever need to.
.@"macos-glass-regular" => -1,
.@"macos-glass-clear" => -2,
};
}
@@ -8358,6 +8441,8 @@ pub const BackgroundBlur = union(enum) {
.false => try formatter.formatEntry(bool, false),
.true => try formatter.formatEntry(bool, true),
.radius => |v| try formatter.formatEntry(u8, v),
.@"macos-glass-regular" => try formatter.formatEntry([]const u8, "macos-glass-regular"),
.@"macos-glass-clear" => try formatter.formatEntry([]const u8, "macos-glass-clear"),
}
}
@@ -8377,6 +8462,12 @@ pub const BackgroundBlur = union(enum) {
try v.parseCLI("42");
try testing.expectEqual(42, v.radius);
try v.parseCLI("macos-glass-regular");
try testing.expectEqual(.@"macos-glass-regular", v);
try v.parseCLI("macos-glass-clear");
try testing.expectEqual(.@"macos-glass-clear", v);
try testing.expectError(error.InvalidValue, v.parseCLI(""));
try testing.expectError(error.InvalidValue, v.parseCLI("aaaa"));
try testing.expectError(error.InvalidValue, v.parseCLI("420"));
@@ -9491,3 +9582,22 @@ test "compatibility: removed bold-is-bright" {
);
}
}
test "compatibility: window new-window" {
const testing = std.testing;
const alloc = testing.allocator;
{
var cfg = try Config.default(alloc);
defer cfg.deinit();
var it: TestIterator = .{ .data = &.{
"--macos-dock-drop-behavior=window",
} };
try cfg.loadIter(alloc, &it);
try cfg.finalize();
try testing.expectEqual(
MacOSDockDropBehavior.@"new-window",
cfg.@"macos-dock-drop-behavior",
);
}
}

View File

@@ -193,20 +193,48 @@ test "c_get: background-blur" {
{
c.@"background-blur" = .false;
var cval: u8 = undefined;
var cval: i16 = undefined;
try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval)));
try testing.expectEqual(0, cval);
}
{
c.@"background-blur" = .true;
var cval: u8 = undefined;
var cval: i16 = undefined;
try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval)));
try testing.expectEqual(20, cval);
}
{
c.@"background-blur" = .{ .radius = 42 };
var cval: u8 = undefined;
var cval: i16 = undefined;
try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval)));
try testing.expectEqual(42, cval);
}
{
c.@"background-blur" = .@"macos-glass-regular";
var cval: i16 = undefined;
try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval)));
try testing.expectEqual(-1, cval);
}
{
c.@"background-blur" = .@"macos-glass-clear";
var cval: i16 = undefined;
try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval)));
try testing.expectEqual(-2, cval);
}
}
test "c_get: split-preserve-zoom" {
const testing = std.testing;
const alloc = testing.allocator;
var c = try Config.default(alloc);
defer c.deinit();
var bits: c_uint = undefined;
try testing.expect(get(&c, .@"split-preserve-zoom", @ptrCast(&bits)));
try testing.expectEqual(@as(c_uint, 0), bits);
c.@"split-preserve-zoom".navigation = true;
try testing.expect(get(&c, .@"split-preserve-zoom", @ptrCast(&bits)));
try testing.expectEqual(@as(c_uint, 1), bits);
}

View File

@@ -26,7 +26,7 @@ pub const regex =
"(?:" ++ url_schemes ++
\\)(?:
++ ipv6_url_pattern ++
\\|[\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:
@@ -194,7 +194,7 @@ test "url regex" {
},
.{
.input = "../example.py ",
.expect = "../example.py",
.expect = "../example.py ",
},
.{
.input = "first time ../example.py contributor ",
@@ -253,6 +253,23 @@ test "url regex" {
.input = "IPv6 in markdown [link](http://[2001:db8::1]/docs)",
.expect = "http://[2001:db8::1]/docs",
},
// File paths with spaces
.{
.input = "./spaces-end. ",
.expect = "./spaces-end. ",
},
.{
.input = "./space middle",
.expect = "./space middle",
},
.{
.input = "../test folder/file.txt",
.expect = "../test folder/file.txt",
},
.{
.input = "/tmp/test folder/file.txt",
.expect = "/tmp/test folder/file.txt",
},
};
for (cases) |case| {

View File

@@ -39,9 +39,13 @@ pub const GlobalState = struct {
resources_dir: internal_os.ResourcesDir,
/// Where logging should go
pub const Logging = union(enum) {
disabled: void,
stderr: void,
pub const Logging = packed struct {
/// Whether to log to stderr. For lib mode we always disable stderr
/// logging by default. Otherwise it's enabled by default.
stderr: bool = build_config.app_runtime != .none,
/// Whether to log to macOS's unified logging. Enabled by default
/// on macOS.
macos: bool = builtin.os.tag.isDarwin(),
};
/// Initialize the global state.
@@ -61,7 +65,7 @@ pub const GlobalState = struct {
.gpa = null,
.alloc = undefined,
.action = null,
.logging = .{ .stderr = {} },
.logging = .{},
.rlimits = .{},
.resources_dir = .{},
};
@@ -100,12 +104,7 @@ pub const GlobalState = struct {
// If we have an action executing, we disable logging by default
// since we write to stderr we don't want logs messing up our
// output.
if (self.action != null) self.logging = .{ .disabled = {} };
// For lib mode we always disable stderr logging by default.
if (comptime build_config.app_runtime == .none) {
self.logging = .{ .disabled = {} };
}
if (self.action != null) self.logging.stderr = false;
// I don't love the env var name but I don't have it in my heart
// to parse CLI args 3 times (once for actions, once for config,
@@ -114,9 +113,7 @@ pub const GlobalState = struct {
// easy to set.
if ((try internal_os.getenv(self.alloc, "GHOSTTY_LOG"))) |v| {
defer v.deinit(self.alloc);
if (v.value.len > 0) {
self.logging = .{ .stderr = {} };
}
self.logging = cli.args.parsePackedStruct(Logging, v.value) catch .{};
}
// Setup our signal handlers before logging

View File

@@ -545,6 +545,9 @@ pub const Action = union(enum) {
/// (`previous` and `next`).
goto_split: SplitFocusDirection,
/// Focus on either the previous window or the next one ('previous', 'next')
goto_window: GotoWindow,
/// Zoom in or out of the current split.
///
/// When a split is zoomed into, it will take up the entire space in
@@ -552,6 +555,16 @@ pub const Action = union(enum) {
/// reflect this by displaying an icon indicating the zoomed state.
toggle_split_zoom,
/// Toggle read-only mode for the current surface.
///
/// When a surface is in read-only mode:
/// - No input is sent to the PTY (mouse events, key encoding)
/// - Input can still be used at the terminal level to make selections,
/// copy/paste (keybinds), scroll, etc.
/// - Warn before quit is always enabled in this state even if an active
/// process is not running
toggle_readonly,
/// Resize the current split in the specified direction and amount in
/// pixels. The two arguments should be joined with a comma (`,`),
/// like in `resize_split:up,10`.
@@ -742,6 +755,16 @@ pub const Action = union(enum) {
/// Only implemented on macOS.
toggle_visibility,
/// Toggle the window background opacity between transparent and opaque.
///
/// This does nothing when `background-opacity` is set to 1 or above.
///
/// When `background-opacity` is less than 1, this action will either make
/// the window transparent or not depending on its current transparency state.
///
/// Only implemented on macOS.
toggle_background_opacity,
/// Check for updates.
///
/// Only implemented on macOS.
@@ -921,6 +944,11 @@ pub const Action = union(enum) {
right,
};
pub const GotoWindow = enum {
previous,
next,
};
pub const SplitResizeParameter = struct {
SplitResizeDirection,
u16,
@@ -1222,6 +1250,7 @@ pub const Action = union(enum) {
.toggle_secure_input,
.toggle_mouse_reporting,
.toggle_command_palette,
.toggle_background_opacity,
.show_on_screen_keyboard,
.reset_window_size,
.crash,
@@ -1240,7 +1269,9 @@ pub const Action = union(enum) {
.toggle_tab_overview,
.new_split,
.goto_split,
.goto_window,
.toggle_split_zoom,
.toggle_readonly,
.resize_split,
.equalize_splits,
.inspector,

View File

@@ -479,12 +479,31 @@ fn actionCommands(action: Action.Key) []const Command {
},
},
.goto_window => comptime &.{
.{
.action = .{ .goto_window = .previous },
.title = "Focus Window: Previous",
.description = "Focus the previous window, if any.",
},
.{
.action = .{ .goto_window = .next },
.title = "Focus Window: Next",
.description = "Focus the next window, if any.",
},
},
.toggle_split_zoom => comptime &.{.{
.action = .toggle_split_zoom,
.title = "Toggle Split Zoom",
.description = "Toggle the zoom state of the current split.",
}},
.toggle_readonly => comptime &.{.{
.action = .toggle_readonly,
.title = "Toggle Read-Only Mode",
.description = "Toggle read-only mode for the current surface.",
}},
.equalize_splits => comptime &.{.{
.action = .equalize_splits,
.title = "Equalize Splits",
@@ -599,6 +618,12 @@ fn actionCommands(action: Action.Key) []const Command {
.description = "Toggle whether mouse events are reported to terminal applications.",
}},
.toggle_background_opacity => comptime &.{.{
.action = .toggle_background_opacity,
.title = "Toggle Background Opacity",
.description = "Toggle the background opacity of a window that started transparent.",
}},
.check_for_updates => comptime &.{.{
.action = .check_for_updates,
.title = "Check for Updates",

View File

@@ -178,7 +178,7 @@ fn kitty(
// Quote ("report all" mode):
// Note that all keys are reported as escape codes, including Enter,
// Tab, Backspace etc.
if (effective_mods.empty()) {
if (binding_mods.empty()) {
switch (event.key) {
.enter => return try writer.writeByte('\r'),
.tab => return try writer.writeByte('\t'),
@@ -1311,7 +1311,48 @@ test "kitty: enter, backspace, tab" {
try testing.expectEqualStrings("\x1b[9;1:3u", writer.buffered());
}
}
//
test "kitty: shift+backspace emits CSI u" {
// Backspace with shift modifier should emit CSI u sequence, not raw 0x7F.
// This is important for programs that want to distinguish shift+backspace.
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try kitty(&writer, .{
.key = .backspace,
.mods = .{ .shift = true },
.utf8 = "",
}, .{
.kitty_flags = .{ .disambiguate = true },
});
try testing.expectEqualStrings("\x1b[127;2u", writer.buffered());
}
test "kitty: shift+enter emits CSI u" {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try kitty(&writer, .{
.key = .enter,
.mods = .{ .shift = true },
.utf8 = "",
}, .{
.kitty_flags = .{ .disambiguate = true },
});
try testing.expectEqualStrings("\x1b[13;2u", writer.buffered());
}
test "kitty: shift+tab emits CSI u" {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try kitty(&writer, .{
.key = .tab,
.mods = .{ .shift = true },
.utf8 = "",
}, .{
.kitty_flags = .{ .disambiguate = true },
});
try testing.expectEqualStrings("\x1b[9;2u", writer.buffered());
}
test "kitty: enter with all flags" {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);

View File

@@ -118,19 +118,17 @@ fn logFn(
comptime format: []const u8,
args: anytype,
) void {
// Stuff we can do before the lock
const level_txt = comptime level.asText();
const prefix = if (scope == .default) ": " else "(" ++ @tagName(scope) ++ "): ";
// Lock so we are thread-safe
std.debug.lockStdErr();
defer std.debug.unlockStdErr();
// On Mac, we use unified logging. To view this:
//
// sudo log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"'
//
if (builtin.target.os.tag.isDarwin()) {
// macOS logging is thread safe so no need for locks/mutexes
macos: {
if (comptime !builtin.target.os.tag.isDarwin()) break :macos;
if (!state.logging.macos) break :macos;
const prefix = if (scope == .default) "" else @tagName(scope) ++ ": ";
// Convert our levels to Mac levels
const mac_level: macos.os.LogType = switch (level) {
.debug => .debug,
@@ -143,26 +141,35 @@ fn logFn(
// but we shouldn't be logging too much.
const logger = macos.os.Log.create(build_config.bundle_id, @tagName(scope));
defer logger.release();
logger.log(std.heap.c_allocator, mac_level, format, args);
logger.log(std.heap.c_allocator, mac_level, prefix ++ format, args);
}
switch (state.logging) {
.disabled => {},
stderr: {
// don't log debug messages to stderr unless we are a debug build
if (comptime builtin.mode != .Debug and level == .debug) break :stderr;
.stderr => {
// Always try default to send to stderr
var buffer: [1024]u8 = undefined;
var stderr = std.fs.File.stderr().writer(&buffer);
const writer = &stderr.interface;
nosuspend writer.print(level_txt ++ prefix ++ format ++ "\n", args) catch return;
// TODO: Do we want to use flushless stderr in the future?
writer.flush() catch {};
},
// skip if we are not logging to stderr
if (!state.logging.stderr) break :stderr;
// Lock so we are thread-safe
var buf: [64]u8 = undefined;
const stderr = std.debug.lockStderrWriter(&buf);
defer std.debug.unlockStderrWriter();
const level_txt = comptime level.asText();
const prefix = if (scope == .default) ": " else "(" ++ @tagName(scope) ++ "): ";
nosuspend stderr.print(level_txt ++ prefix ++ format ++ "\n", args) catch break :stderr;
nosuspend stderr.flush() catch break :stderr;
}
}
pub const std_options: std.Options = .{
// Our log level is always at least info in every build mode.
//
// Note, we don't lower this to debug even with conditional logging
// via GHOSTTY_LOG because our debug logs are very expensive to
// calculate and we want to make sure they're optimized out in
// builds.
.log_level = switch (builtin.mode) {
.Debug => .debug,
else => .info,

View File

@@ -1,7 +1,84 @@
const std = @import("std");
const testing = std.testing;
const Allocator = std.mem.Allocator;
const Writer = std.Io.Writer;
/// Builder for constructing space-separated shell command strings.
/// Uses a caller-provided allocator (typically with stackFallback).
pub const ShellCommandBuilder = struct {
buffer: std.Io.Writer.Allocating,
pub fn init(allocator: Allocator) ShellCommandBuilder {
return .{ .buffer = .init(allocator) };
}
pub fn deinit(self: *ShellCommandBuilder) void {
self.buffer.deinit();
}
/// Append an argument to the command with automatic space separation.
pub fn appendArg(self: *ShellCommandBuilder, arg: []const u8) (Allocator.Error || Writer.Error)!void {
if (arg.len == 0) return;
if (self.buffer.written().len > 0) {
try self.buffer.writer.writeByte(' ');
}
try self.buffer.writer.writeAll(arg);
}
/// Get the final null-terminated command string, transferring ownership to caller.
/// Calling deinit() after this is safe but unnecessary.
pub fn toOwnedSlice(self: *ShellCommandBuilder) Allocator.Error![:0]const u8 {
return try self.buffer.toOwnedSliceSentinel(0);
}
};
test ShellCommandBuilder {
// Empty command
{
var cmd = ShellCommandBuilder.init(testing.allocator);
defer cmd.deinit();
try testing.expectEqualStrings("", cmd.buffer.written());
}
// Single arg
{
var cmd = ShellCommandBuilder.init(testing.allocator);
defer cmd.deinit();
try cmd.appendArg("bash");
try testing.expectEqualStrings("bash", cmd.buffer.written());
}
// Multiple args
{
var cmd = ShellCommandBuilder.init(testing.allocator);
defer cmd.deinit();
try cmd.appendArg("bash");
try cmd.appendArg("--posix");
try cmd.appendArg("-l");
try testing.expectEqualStrings("bash --posix -l", cmd.buffer.written());
}
// Empty arg
{
var cmd = ShellCommandBuilder.init(testing.allocator);
defer cmd.deinit();
try cmd.appendArg("bash");
try cmd.appendArg("");
try testing.expectEqualStrings("bash", cmd.buffer.written());
}
// toOwnedSlice
{
var cmd = ShellCommandBuilder.init(testing.allocator);
try cmd.appendArg("bash");
try cmd.appendArg("--posix");
const result = try cmd.toOwnedSlice();
defer testing.allocator.free(result);
try testing.expectEqualStrings("bash --posix", result);
try testing.expectEqual(@as(u8, 0), result[result.len]);
}
}
/// Writer that escapes characters that shells treat specially to reduce the
/// risk of injection attacks or other such weirdness. Specifically excludes
/// linefeeds so that they can be used to delineate lists of file paths.

View File

@@ -265,3 +265,16 @@ test "percent 7" {
@memcpy(&src, s);
try std.testing.expectError(error.DecodeError, urlPercentDecode(&src));
}
/// Is the given character valid in URI percent encoding?
fn isValidChar(c: u8) bool {
return switch (c) {
' ', ';', '=' => false,
else => return std.ascii.isPrint(c),
};
}
/// Write data to the writer after URI percent encoding.
pub fn urlPercentEncode(writer: *std.Io.Writer, data: []const u8) std.Io.Writer.Error!void {
try std.Uri.Component.percentEncode(writer, data, isValidChar);
}

View File

@@ -137,15 +137,7 @@ fn prepareContext(getProcAddress: anytype) !void {
errdefer gl.glad.unload();
log.info("loaded OpenGL {}.{}", .{ major, minor });
// Enable debug output for the context.
try gl.enable(gl.c.GL_DEBUG_OUTPUT);
// Register our debug message callback with the OpenGL context.
gl.glad.context.DebugMessageCallback.?(glDebugMessageCallback, null);
// Enable SRGB framebuffer for linear blending support.
try gl.enable(gl.c.GL_FRAMEBUFFER_SRGB);
// Need to check version before trying to enable it
if (major < MIN_VERSION_MAJOR or
(major == MIN_VERSION_MAJOR and minor < MIN_VERSION_MINOR))
{
@@ -155,6 +147,15 @@ fn prepareContext(getProcAddress: anytype) !void {
);
return error.OpenGLOutdated;
}
// Enable debug output for the context.
try gl.enable(gl.c.GL_DEBUG_OUTPUT);
// Register our debug message callback with the OpenGL context.
gl.glad.context.DebugMessageCallback.?(glDebugMessageCallback, null);
// Enable SRGB framebuffer for linear blending support.
try gl.enable(gl.c.GL_FRAMEBUFFER_SRGB);
}
/// This is called early right after surface creation.

View File

@@ -561,6 +561,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
vsync: bool,
colorspace: configpkg.Config.WindowColorspace,
blending: configpkg.Config.AlphaBlending,
background_blur: configpkg.Config.BackgroundBlur,
pub fn init(
alloc_gpa: Allocator,
@@ -633,6 +634,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
.vsync = config.@"window-vsync",
.colorspace = config.@"window-colorspace",
.blending = config.@"alpha-blending",
.background_blur = config.@"background-blur",
.arena = arena,
};
}
@@ -716,6 +718,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
options.config.background.r,
options.config.background.g,
options.config.background.b,
// Note that if we're on macOS with glass effects
// we'll disable background opacity but we handle
// that in updateFrame.
@intFromFloat(@round(options.config.background_opacity * 255.0)),
},
.bools = .{
@@ -1295,6 +1300,17 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.terminal_state.colors.background.b,
@intFromFloat(@round(self.config.background_opacity * 255.0)),
};
// If we're on macOS and have glass styles, we remove
// the background opacity because the glass effect handles
// it.
if (comptime builtin.os.tag == .macos) switch (self.config.background_blur) {
.@"macos-glass-regular",
.@"macos-glass-clear",
=> self.uniforms.bg_color[3] = 0,
else => {},
};
}
}

View File

@@ -44,6 +44,15 @@ pub const Size = struct {
self.grid(),
self.cell,
);
// The top/bottom padding is interesting. Subjectively, lots of padding
// at the top looks bad. So instead of always being equal (like left/right),
// we force the top padding to be at most equal to the maximum left padding,
// which is the balanced explicit horizontal padding plus half a cell width.
const max_padding_left = (explicit.left + explicit.right + self.cell.width) / 2;
const vshift = self.padding.top -| max_padding_left;
self.padding.top -= vshift;
self.padding.bottom += vshift;
}
};
@@ -258,16 +267,12 @@ pub const Padding = struct {
const space_right = @as(f32, @floatFromInt(screen.width)) - grid_width;
const space_bot = @as(f32, @floatFromInt(screen.height)) - grid_height;
// The left/right padding is just an equal split.
// The padding is split equally along both axes.
const padding_right = @floor(space_right / 2);
const padding_left = padding_right;
// The top/bottom padding is interesting. Subjectively, lots of padding
// at the top looks bad. So instead of always being equal (like left/right),
// we force the top padding to be at most equal to the left, and the bottom
// padding is the difference thereafter.
const padding_top = @min(padding_left, @floor(space_bot / 2));
const padding_bot = space_bot - padding_top;
const padding_bot = @floor(space_bot / 2);
const padding_top = padding_bot;
const zero = @as(f32, 0);
return .{

View File

@@ -78,10 +78,16 @@ on the Fish startup process, see the
### Zsh
For `zsh`, Ghostty sets `ZDOTDIR` so that it loads our configuration
from the `zsh` directory. The existing `ZDOTDIR` is retained so that
after loading the Ghostty shell integration the normal Zsh loading
sequence occurs.
Automatic [Zsh](https://www.zsh.org/) integration works by temporarily setting
`ZDOTDIR` to our `zsh` directory. An existing `ZDOTDIR` environment variable
value will be retained and restored after our shell integration scripts are
run.
However, if `ZDOTDIR` is set in a system-wide file like `/etc/zshenv`, it will
override Ghostty's `ZDOTDIR` value, preventing the shell integration from being
loaded. In this case, the shell integration needs to be loaded manually.
To load the Zsh shell integration manually:
```zsh
if [[ -n $GHOSTTY_RESOURCES_DIR ]]; then

View File

@@ -103,7 +103,7 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then
if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then
builtin command sudo "$@";
else
builtin command sudo TERMINFO="$TERMINFO" "$@";
builtin command sudo --preserve-env=TERMINFO "$@";
fi
}
fi

View File

@@ -97,7 +97,7 @@
if (not (has-value $arg =)) { break }
}
if (not $sudoedit) { set args = [ TERMINFO=$E:TERMINFO $@args ] }
if (not $sudoedit) { set args = [ --preserve-env=TERMINFO $@args ] }
(external sudo) $@args
}

View File

@@ -90,7 +90,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
if test "$sudo_has_sudoedit_flags" = "yes"
command sudo $argv
else
command sudo TERMINFO="$TERMINFO" $argv
command sudo --preserve-env=TERMINFO $argv
end
end
end

View File

@@ -93,9 +93,6 @@ _entrypoint() {
_ghostty_deferred_init() {
builtin emulate -L zsh -o no_warn_create_global -o no_aliases
# The directory where ghostty-integration is located: /../shell-integration/zsh.
builtin local self_dir="${functions_source[_ghostty_deferred_init]:A:h}"
# Enable semantic markup with OSC 133.
_ghostty_precmd() {
builtin local -i cmd_status=$?
@@ -255,7 +252,7 @@ _ghostty_deferred_init() {
if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then
builtin command sudo "$@";
else
builtin command sudo TERMINFO="$TERMINFO" "$@";
builtin command sudo --preserve-env=TERMINFO "$@";
fi
}
fi

View File

@@ -5,12 +5,23 @@ const std = @import("std");
const assert = std.debug.assert;
const Generator = @import("Generator.zig");
const Bytes = @import("Bytes.zig");
const urlPercentEncode = @import("../os/string_encoding.zig").urlPercentEncode;
/// Valid OSC request kinds that can be generated.
pub const ValidKind = enum {
change_window_title,
prompt_start,
prompt_end,
end_of_input,
end_of_command,
rxvt_notify,
mouse_shape,
clipboard_operation,
report_pwd,
hyperlink_start,
hyperlink_end,
conemu_progress,
iterm2_notification,
};
/// Invalid OSC request kinds that can be generated.
@@ -55,6 +66,9 @@ fn checkOscAlphabet(c: u8) bool {
/// The alphabet for random bytes in OSCs (omitting 0x1B and 0x07).
pub const osc_alphabet = Bytes.generateAlphabet(checkOscAlphabet);
pub const ascii_alphabet = Bytes.generateAlphabet(std.ascii.isPrint);
pub const alphabetic_alphabet = Bytes.generateAlphabet(std.ascii.isAlphabetic);
pub const alphanumeric_alphabet = Bytes.generateAlphabet(std.ascii.isAlphanumeric);
pub fn generator(self: *Osc) Generator {
return .init(self, next);
@@ -143,6 +157,115 @@ fn nextUnwrappedValidExact(self: *const Osc, writer: *std.Io.Writer, k: ValidKin
if (max_len < 4) break :prompt_end;
try writer.writeAll("133;B"); // End prompt
},
.end_of_input => end_of_input: {
if (max_len < 5) break :end_of_input;
var remaining = max_len;
try writer.writeAll("133;C"); // End prompt
remaining -= 5;
if (self.rand.boolean()) cmdline: {
const prefix = ";cmdline_url=";
if (remaining < prefix.len + 1) break :cmdline;
try writer.writeAll(prefix);
remaining -= prefix.len;
var buf: [128]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try self.bytes().newAlphabet(ascii_alphabet).atMost(@min(remaining, buf.len)).format(&w);
try urlPercentEncode(writer, w.buffered());
remaining -= w.buffered().len;
}
},
.end_of_command => end_of_command: {
if (max_len < 4) break :end_of_command;
try writer.writeAll("133;D"); // End prompt
if (self.rand.boolean()) exit_code: {
if (max_len < 7) break :exit_code;
try writer.print(";{d}", .{self.rand.int(u8)});
}
},
.mouse_shape => mouse_shape: {
if (max_len < 4) break :mouse_shape;
try writer.print("22;{f}", .{self.bytes().newAlphabet(alphabetic_alphabet).atMost(@min(32, max_len - 3))}); // Start prompt
},
.rxvt_notify => rxvt_notify: {
const prefix = "777;notify;";
if (max_len < prefix.len) break :rxvt_notify;
var remaining = max_len;
try writer.writeAll(prefix);
remaining -= prefix.len;
remaining -= try self.bytes().newAlphabet(kv_alphabet).atMost(@min(remaining - 2, 32)).write(writer);
try writer.writeByte(';');
remaining -= 1;
remaining -= try self.bytes().newAlphabet(osc_alphabet).atMost(remaining).write(writer);
},
.clipboard_operation => {
try writer.writeAll("52;");
var remaining = max_len - 3;
if (self.rand.boolean()) {
remaining -= try self.bytes().newAlphabet(alphabetic_alphabet).atMost(1).write(writer);
}
try writer.writeByte(';');
remaining -= 1;
if (self.rand.boolean()) {
remaining -= try self.bytes().newAlphabet(osc_alphabet).atMost(remaining).write(writer);
}
},
.report_pwd => report_pwd: {
const prefix = "7;file://localhost";
if (max_len < prefix.len) break :report_pwd;
var remaining = max_len;
try writer.writeAll(prefix);
remaining -= prefix.len;
for (0..self.rand.intRangeAtMost(usize, 2, 5)) |_| {
try writer.writeByte('/');
remaining -= 1;
remaining -= try self.bytes().newAlphabet(alphanumeric_alphabet).atMost(@min(16, remaining)).write(writer);
}
},
.hyperlink_start => {
try writer.writeAll("8;");
if (self.rand.boolean()) {
try writer.print("id={f}", .{self.bytes().newAlphabet(alphanumeric_alphabet).atMost(16)});
}
try writer.writeAll(";https://localhost");
for (0..self.rand.intRangeAtMost(usize, 2, 5)) |_| {
try writer.print("/{f}", .{self.bytes().newAlphabet(alphanumeric_alphabet).atMost(16)});
}
},
.hyperlink_end => hyperlink_end: {
if (max_len < 3) break :hyperlink_end;
try writer.writeAll("8;;");
},
.conemu_progress => {
try writer.writeAll("9;");
switch (self.rand.intRangeAtMost(u3, 0, 4)) {
0, 3 => |c| {
try writer.print(";{d}", .{c});
},
1, 2, 4 => |c| {
if (self.rand.boolean()) {
try writer.print(";{d}", .{c});
} else {
try writer.print(";{d};{d}", .{ c, self.rand.intRangeAtMost(u8, 0, 100) });
}
},
else => unreachable,
}
},
.iterm2_notification => iterm2_notification: {
if (max_len < 3) break :iterm2_notification;
// add a prefix to ensure that this is not interpreted as a ConEmu OSC
try writer.print("9;_{f}", .{self.bytes().newAlphabet(ascii_alphabet).atMost(max_len - 3)});
},
}
}

View File

@@ -36,10 +36,12 @@ pub fn run(_: *Ascii, writer: *std.Io.Writer, rand: std.Random) !void {
var gen: Bytes = .{
.rand = rand,
.alphabet = ascii,
.min_len = 1024,
.max_len = 1024,
};
while (true) {
gen.next(writer, 1024) catch |err| {
_ = gen.write(writer) catch |err| {
const Error = error{ WriteFailed, BrokenPipe } || @TypeOf(err);
switch (@as(Error, err)) {
error.BrokenPipe => return, // stdout closed

View File

@@ -10,6 +10,14 @@ const log = std.log.scoped(.@"terminal-stream-bench");
pub const Options = struct {
/// Probability of generating a valid value.
@"p-valid": f64 = 0.5,
style: enum {
/// Write all OSC data, including ESC ] and ST for end-to-end tests
streaming,
/// Only write data, prefixed with a length, used for testing just the
/// OSC parser.
parser,
} = .streaming,
};
opts: Options,
@@ -40,9 +48,21 @@ pub fn run(self: *Osc, writer: *std.Io.Writer, rand: std.Random) !void {
var fixed: std.Io.Writer = .fixed(&buf);
try gen.next(&fixed, buf.len);
const data = fixed.buffered();
writer.writeAll(data) catch |err| switch (err) {
error.WriteFailed => return,
};
switch (self.opts.style) {
.streaming => {
writer.writeAll(data) catch |err| switch (err) {
error.WriteFailed => return,
};
},
.parser => {
writer.writeInt(usize, data.len - 3, .little) catch |err| switch (err) {
error.WriteFailed => return,
};
writer.writeAll(data[2 .. data.len - 1]) catch |err| switch (err) {
error.WriteFailed => return,
};
},
}
}
}

View File

@@ -1219,7 +1219,7 @@ pub fn index(self: *Terminal) !void {
// this check.
!self.screens.active.blankCell().isZero())
{
self.scrollUp(1);
try self.scrollUp(1);
return;
}
@@ -1398,7 +1398,7 @@ pub fn scrollDown(self: *Terminal, count: usize) void {
/// The new lines are created according to the current SGR state.
///
/// Does not change the (absolute) cursor position.
pub fn scrollUp(self: *Terminal, count: usize) void {
pub fn scrollUp(self: *Terminal, count: usize) !void {
// Preserve our x/y to restore.
const old_x = self.screens.active.cursor.x;
const old_y = self.screens.active.cursor.y;
@@ -1408,6 +1408,32 @@ pub fn scrollUp(self: *Terminal, count: usize) void {
self.screens.active.cursor.pending_wrap = old_wrap;
}
// If our scroll region is at the top and we have no left/right
// margins then we move the scrolled out text into the scrollback.
if (self.scrolling_region.top == 0 and
self.scrolling_region.left == 0 and
self.scrolling_region.right == self.cols - 1)
{
// Scrolling dirties the images because it updates their placements pins.
if (comptime build_options.kitty_graphics) {
self.screens.active.kitty_images.dirty = true;
}
// Clamp count to the scroll region height.
const region_height = self.scrolling_region.bottom + 1;
const adjusted_count = @min(count, region_height);
// TODO: Create an optimized version that can scroll N times
// This isn't critical because in most cases, scrollUp is used
// with count=1, but it's still a big optimization opportunity.
// Move our cursor to the bottom of the scroll region so we can
// use the cursorScrollAbove function to create scrollback
self.screens.active.cursorAbsolute(0, self.scrolling_region.bottom);
for (0..adjusted_count) |_| try self.screens.active.cursorScrollAbove();
return;
}
// Move to the top of the scroll region
self.screens.active.cursorAbsolute(self.scrolling_region.left, self.scrolling_region.top);
self.deleteLines(count);
@@ -5635,14 +5661,16 @@ test "Terminal: scrollUp simple" {
t.setCursorPos(2, 2);
const cursor = t.screens.active.cursor;
t.clearDirty();
t.scrollUp(1);
const viewport_before = t.screens.active.pages.getTopLeft(.viewport);
try t.scrollUp(1);
try testing.expectEqual(cursor.x, t.screens.active.cursor.x);
try testing.expectEqual(cursor.y, t.screens.active.cursor.y);
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } }));
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } }));
// Viewport should have moved. Our entire page should've scrolled!
// The viewport moving will cause our render state to make the full
// frame as dirty.
const viewport_after = t.screens.active.pages.getTopLeft(.viewport);
try testing.expect(!viewport_before.eql(viewport_after));
{
const str = try t.plainString(testing.allocator);
@@ -5666,7 +5694,7 @@ test "Terminal: scrollUp moves hyperlink" {
try t.linefeed();
try t.printString("GHI");
t.setCursorPos(2, 2);
t.scrollUp(1);
try t.scrollUp(1);
{
const str = try t.plainString(testing.allocator);
@@ -5717,7 +5745,7 @@ test "Terminal: scrollUp clears hyperlink" {
try t.linefeed();
try t.printString("GHI");
t.setCursorPos(2, 2);
t.scrollUp(1);
try t.scrollUp(1);
{
const str = try t.plainString(testing.allocator);
@@ -5755,7 +5783,7 @@ test "Terminal: scrollUp top/bottom scroll region" {
t.setCursorPos(1, 1);
t.clearDirty();
t.scrollUp(1);
try t.scrollUp(1);
// This is dirty because the cursor moves from this row
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
@@ -5787,7 +5815,7 @@ test "Terminal: scrollUp left/right scroll region" {
const cursor = t.screens.active.cursor;
t.clearDirty();
t.scrollUp(1);
try t.scrollUp(1);
try testing.expectEqual(cursor.x, t.screens.active.cursor.x);
try testing.expectEqual(cursor.y, t.screens.active.cursor.y);
@@ -5819,7 +5847,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" {
t.scrolling_region.left = 1;
t.scrolling_region.right = 3;
t.setCursorPos(2, 2);
t.scrollUp(1);
try t.scrollUp(1);
{
const str = try t.plainString(testing.allocator);
@@ -5919,7 +5947,7 @@ test "Terminal: scrollUp preserves pending wrap" {
try t.print('B');
t.setCursorPos(3, 5);
try t.print('C');
t.scrollUp(1);
try t.scrollUp(1);
try t.print('X');
{
@@ -5940,7 +5968,7 @@ test "Terminal: scrollUp full top/bottom region" {
t.setTopAndBottomMargin(2, 5);
t.clearDirty();
t.scrollUp(4);
try t.scrollUp(4);
// This is dirty because the cursor moves from this row
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
@@ -5966,7 +5994,7 @@ test "Terminal: scrollUp full top/bottomleft/right scroll region" {
t.setLeftAndRightMargin(2, 4);
t.clearDirty();
t.scrollUp(4);
try t.scrollUp(4);
// This is dirty because the cursor moves from this row
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
@@ -5982,6 +6010,143 @@ test "Terminal: scrollUp full top/bottomleft/right scroll region" {
}
}
test "Terminal: scrollUp creates scrollback in primary screen" {
// When in primary screen with full-width scroll region at top,
// scrollUp (CSI S) should push lines into scrollback like xterm.
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 10 });
defer t.deinit(alloc);
// Fill the screen with content
try t.printString("AAAAA");
t.carriageReturn();
try t.linefeed();
try t.printString("BBBBB");
t.carriageReturn();
try t.linefeed();
try t.printString("CCCCC");
t.carriageReturn();
try t.linefeed();
try t.printString("DDDDD");
t.carriageReturn();
try t.linefeed();
try t.printString("EEEEE");
t.clearDirty();
// Scroll up by 1, which should push "AAAAA" into scrollback
try t.scrollUp(1);
// The cursor row (new empty row) should be dirty
try testing.expect(t.screens.active.cursor.page_row.dirty);
// The active screen should now show BBBBB through EEEEE plus one blank line
{
const str = try t.plainString(alloc);
defer alloc.free(str);
try testing.expectEqualStrings("BBBBB\nCCCCC\nDDDDD\nEEEEE", str);
}
// Now scroll to the top to see scrollback - AAAAA should be there
t.screens.active.scroll(.{ .top = {} });
{
const str = try t.plainString(alloc);
defer alloc.free(str);
// Should see AAAAA in scrollback
try testing.expectEqualStrings("AAAAA\nBBBBB\nCCCCC\nDDDDD\nEEEEE", str);
}
}
test "Terminal: scrollUp with max_scrollback zero" {
// When max_scrollback is 0, scrollUp should still work but not retain history
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 0 });
defer t.deinit(alloc);
try t.printString("AAAAA");
t.carriageReturn();
try t.linefeed();
try t.printString("BBBBB");
t.carriageReturn();
try t.linefeed();
try t.printString("CCCCC");
try t.scrollUp(1);
// Active screen should show scrolled content
{
const str = try t.plainString(alloc);
defer alloc.free(str);
try testing.expectEqualStrings("BBBBB\nCCCCC", str);
}
// Scroll to top - should be same as active since no scrollback
t.screens.active.scroll(.{ .top = {} });
{
const str = try t.plainString(alloc);
defer alloc.free(str);
try testing.expectEqualStrings("BBBBB\nCCCCC", str);
}
}
test "Terminal: scrollUp with max_scrollback zero and top margin" {
// When max_scrollback is 0 and top margin is set, should use deleteLines path
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 0 });
defer t.deinit(alloc);
try t.printString("AAAAA");
t.carriageReturn();
try t.linefeed();
try t.printString("BBBBB");
t.carriageReturn();
try t.linefeed();
try t.printString("CCCCC");
t.carriageReturn();
try t.linefeed();
try t.printString("DDDDD");
// Set top margin (not at row 0)
t.setTopAndBottomMargin(2, 5);
try t.scrollUp(1);
{
const str = try t.plainString(alloc);
defer alloc.free(str);
// First row preserved, rest scrolled
try testing.expectEqualStrings("AAAAA\nCCCCC\nDDDDD", str);
}
}
test "Terminal: scrollUp with max_scrollback zero and left/right margin" {
// When max_scrollback is 0 with left/right margins, uses deleteLines path
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 5, .cols = 10, .max_scrollback = 0 });
defer t.deinit(alloc);
try t.printString("AAAAABBBBB");
t.carriageReturn();
try t.linefeed();
try t.printString("CCCCCDDDDD");
t.carriageReturn();
try t.linefeed();
try t.printString("EEEEEFFFFF");
// Set left/right margins (columns 2-6, 1-indexed = indices 1-5)
t.modes.set(.enable_left_and_right_margin, true);
t.setLeftAndRightMargin(2, 6);
try t.scrollUp(1);
{
const str = try t.plainString(alloc);
defer alloc.free(str);
// cols 1-5 scroll, col 0 and cols 6+ preserved
try testing.expectEqualStrings("ACCCCDBBBB\nCEEEEFDDDD\nE FFFF", str);
}
}
test "Terminal: scrollDown simple" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 5, .cols = 5 });

View File

@@ -100,7 +100,7 @@ pub const Handler = struct {
.insert_lines => self.terminal.insertLines(value),
.insert_blanks => self.terminal.insertBlanks(value),
.delete_lines => self.terminal.deleteLines(value),
.scroll_up => self.terminal.scrollUp(value),
.scroll_up => try self.terminal.scrollUp(value),
.scroll_down => self.terminal.scrollDown(value),
.horizontal_tab => try self.horizontalTab(value),
.horizontal_tab_back => try self.horizontalTabBack(value),

View File

@@ -22,6 +22,9 @@ const configpkg = @import("../config.zig");
const log = std.log.scoped(.io_exec);
/// Mutex state argument for queueMessage.
pub const MutexState = enum { locked, unlocked };
/// Allocator
alloc: Allocator,
@@ -380,7 +383,7 @@ pub fn threadExit(self: *Termio, data: *ThreadData) void {
pub fn queueMessage(
self: *Termio,
msg: termio.Message,
mutex: enum { locked, unlocked },
mutex: MutexState,
) void {
self.mailbox.send(msg, switch (mutex) {
.locked => self.renderer_state.mutex,

View File

@@ -259,8 +259,9 @@ fn setupBash(
resource_dir: []const u8,
env: *EnvMap,
) !?config.Command {
var args: std.ArrayList([:0]const u8) = try .initCapacity(alloc, 2);
defer args.deinit(alloc);
var stack_fallback = std.heap.stackFallback(4096, alloc);
var cmd = internal_os.shell.ShellCommandBuilder.init(stack_fallback.get());
defer cmd.deinit();
// Iterator that yields each argument in the original command line.
// This will allocate once proportionate to the command line length.
@@ -269,9 +270,9 @@ fn setupBash(
// Start accumulating arguments with the executable and initial flags.
if (iter.next()) |exe| {
try args.append(alloc, try alloc.dupeZ(u8, exe));
try cmd.appendArg(exe);
} else return null;
try args.append(alloc, "--posix");
try cmd.appendArg("--posix");
// Stores the list of intercepted command line flags that will be passed
// to our shell integration script: --norc --noprofile
@@ -304,17 +305,17 @@ fn setupBash(
if (std.mem.indexOfScalar(u8, arg, 'c') != null) {
return null;
}
try args.append(alloc, try alloc.dupeZ(u8, arg));
try cmd.appendArg(arg);
} else if (std.mem.eql(u8, arg, "-") or std.mem.eql(u8, arg, "--")) {
// All remaining arguments should be passed directly to the shell
// command. We shouldn't perform any further option processing.
try args.append(alloc, try alloc.dupeZ(u8, arg));
try cmd.appendArg(arg);
while (iter.next()) |remaining_arg| {
try args.append(alloc, try alloc.dupeZ(u8, remaining_arg));
try cmd.appendArg(remaining_arg);
}
break;
} else {
try args.append(alloc, try alloc.dupeZ(u8, arg));
try cmd.appendArg(arg);
}
}
try env.put("GHOSTTY_BASH_INJECT", buf[0..inject.end]);
@@ -352,8 +353,11 @@ fn setupBash(
);
try env.put("ENV", integ_dir);
// Join the accumulated arguments to form the final command string.
return .{ .shell = try std.mem.joinZ(alloc, " ", args.items) };
// Get the command string from the builder, then copy it to the arena
// allocator. The stackFallback allocator's memory becomes invalid after
// this function returns, so we must copy to the arena.
const cmd_str = try cmd.toOwnedSlice();
return .{ .shell = try alloc.dupeZ(u8, cmd_str) };
}
test "bash" {

View File

@@ -246,7 +246,7 @@ pub const StreamHandler = struct {
.insert_lines => self.terminal.insertLines(value),
.insert_blanks => self.terminal.insertBlanks(value),
.delete_lines => self.terminal.deleteLines(value),
.scroll_up => self.terminal.scrollUp(value),
.scroll_up => try self.terminal.scrollUp(value),
.scroll_down => self.terminal.scrollDown(value),
.tab_clear_current => self.terminal.tabClear(.current),
.tab_clear_all => self.terminal.tabClear(.all),