mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-14 11:35:48 +00:00
Merge remote-tracking branch 'upstream/main' into grapheme-width-changes
This commit is contained in:
225
src/Command.zig
225
src/Command.zig
@@ -18,6 +18,7 @@ const Command = @This();
|
||||
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const configpkg = @import("config.zig");
|
||||
const global_state = &@import("global.zig").state;
|
||||
const internal_os = @import("os/main.zig");
|
||||
const windows = internal_os.windows;
|
||||
@@ -30,8 +31,20 @@ const testing = std.testing;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const File = std.fs.File;
|
||||
const EnvMap = std.process.EnvMap;
|
||||
const apprt = @import("apprt.zig");
|
||||
|
||||
const PreExecFn = fn (*Command) void;
|
||||
/// Function prototype for a function executed /in the child process/ after the
|
||||
/// fork, but before exec'ing the command. If the function returns a u8, the
|
||||
/// child process will be exited with that error code.
|
||||
const PreExecFn = fn (*Command) ?u8;
|
||||
|
||||
/// Allowable set of errors that can be returned by a post fork function. Any
|
||||
/// errors will result in the failure to create the surface.
|
||||
pub const PostForkError = error{PostForkError};
|
||||
|
||||
/// Function prototype for a function executed /in the parent process/
|
||||
/// after the fork.
|
||||
const PostForkFn = fn (*Command) PostForkError!void;
|
||||
|
||||
/// Path to the command to run. This doesn't have to be an absolute path,
|
||||
/// because use exec functions that search the PATH, if necessary.
|
||||
@@ -63,9 +76,25 @@ stderr: ?File = null,
|
||||
/// If set, this will be executed /in the child process/ after fork but
|
||||
/// before exec. This is useful to setup some state in the child before the
|
||||
/// exec process takes over, such as signal handlers, setsid, setuid, etc.
|
||||
pre_exec: ?*const PreExecFn = null,
|
||||
os_pre_exec: ?*const PreExecFn,
|
||||
|
||||
linux_cgroup: LinuxCgroup = linux_cgroup_default,
|
||||
/// If set, this will be executed /in the child process/ after fork but
|
||||
/// before exec. This is useful to setup some state in the child before the
|
||||
/// exec process takes over, such as signal handlers, setsid, setuid, etc.
|
||||
rt_pre_exec: ?*const PreExecFn,
|
||||
|
||||
/// Configuration information needed by the apprt pre exec function. Note
|
||||
/// that this should be a trivially copyable struct and not require any
|
||||
/// allocation/deallocation.
|
||||
rt_pre_exec_info: RtPreExecInfo,
|
||||
|
||||
/// If set, this will be executed in the /in the parent process/ after the fork.
|
||||
rt_post_fork: ?*const PostForkFn,
|
||||
|
||||
/// Configuration information needed by the apprt post fork function. Note
|
||||
/// that this should be a trivially copyable struct and not require any
|
||||
/// allocation/deallocation.
|
||||
rt_post_fork_info: RtPostForkInfo,
|
||||
|
||||
/// If set, then the process will be created attached to this pseudo console.
|
||||
/// `stdin`, `stdout`, and `stderr` will be ignored if set.
|
||||
@@ -79,11 +108,6 @@ data: ?*anyopaque = null,
|
||||
/// Process ID is set after start is called.
|
||||
pid: ?posix.pid_t = null,
|
||||
|
||||
/// LinuxCGroup type depends on our target OS
|
||||
pub const LinuxCgroup = if (builtin.os.tag == .linux) ?[]const u8 else void;
|
||||
pub const linux_cgroup_default = if (LinuxCgroup == void)
|
||||
{} else null;
|
||||
|
||||
/// The various methods a process may exit.
|
||||
pub const Exit = if (builtin.os.tag == .windows) union(enum) {
|
||||
Exited: u32,
|
||||
@@ -112,6 +136,24 @@ pub const Exit = if (builtin.os.tag == .windows) union(enum) {
|
||||
}
|
||||
};
|
||||
|
||||
/// Configuration information needed by the apprt pre exec function. Note
|
||||
/// that this should be a trivially copyable struct and not require any
|
||||
/// allocation/deallocation.
|
||||
pub const RtPreExecInfo = if (@hasDecl(apprt.runtime, "pre_exec")) apprt.runtime.pre_exec.PreExecInfo else struct {
|
||||
pub inline fn init(_: *const configpkg.Config) @This() {
|
||||
return .{};
|
||||
}
|
||||
};
|
||||
|
||||
/// Configuration information needed by the apprt post fork function. Note
|
||||
/// that this should be a trivially copyable struct and not require any
|
||||
/// allocation/deallocation.
|
||||
pub const RtPostForkInfo = if (@hasDecl(apprt.runtime, "post_fork")) apprt.runtime.post_fork.PostForkInfo else struct {
|
||||
pub inline fn init(_: *const configpkg.Config) @This() {
|
||||
return .{};
|
||||
}
|
||||
};
|
||||
|
||||
/// Start the subprocess. This returns immediately once the child is started.
|
||||
///
|
||||
/// After this is successful, self.pid is available.
|
||||
@@ -143,19 +185,13 @@ fn startPosix(self: *Command, arena: Allocator) !void {
|
||||
else
|
||||
@compileError("missing env vars");
|
||||
|
||||
// Fork. If we have a cgroup specified on Linxu then we use clone
|
||||
const pid: posix.pid_t = switch (builtin.os.tag) {
|
||||
.linux => if (self.linux_cgroup) |cgroup|
|
||||
try internal_os.cgroup.cloneInto(cgroup)
|
||||
else
|
||||
try posix.fork(),
|
||||
|
||||
else => try posix.fork(),
|
||||
};
|
||||
// Fork.
|
||||
const pid = try posix.fork();
|
||||
|
||||
if (pid != 0) {
|
||||
// Parent, return immediately.
|
||||
self.pid = @intCast(pid);
|
||||
if (self.rt_post_fork) |f| try f(self);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -182,8 +218,9 @@ fn startPosix(self: *Command, arena: Allocator) !void {
|
||||
// any failures are ignored (its best effort).
|
||||
global_state.rlimits.restore();
|
||||
|
||||
// If the user requested a pre exec callback, call it now.
|
||||
if (self.pre_exec) |f| f(self);
|
||||
// If there are pre exec callbacks, call them now.
|
||||
if (self.os_pre_exec) |f| if (f(self)) |exitcode| posix.exit(exitcode);
|
||||
if (self.rt_pre_exec) |f| if (f(self)) |exitcode| posix.exit(exitcode);
|
||||
|
||||
// Finally, replace our process.
|
||||
// Note: we must use the "p"-variant of exec here because we
|
||||
@@ -533,18 +570,22 @@ test "createNullDelimitedEnvMap" {
|
||||
}
|
||||
}
|
||||
|
||||
test "Command: pre exec" {
|
||||
test "Command: os pre exec 1" {
|
||||
if (builtin.os.tag == .windows) return error.SkipZigTest;
|
||||
var cmd: Command = .{
|
||||
.path = "/bin/sh",
|
||||
.args = &.{ "/bin/sh", "-v" },
|
||||
.pre_exec = (struct {
|
||||
fn do(_: *Command) void {
|
||||
.os_pre_exec = (struct {
|
||||
fn do(_: *Command) ?u8 {
|
||||
// This runs in the child, so we can exit and it won't
|
||||
// kill the test runner.
|
||||
posix.exit(42);
|
||||
}
|
||||
}).do,
|
||||
.rt_pre_exec = null,
|
||||
.rt_post_fork = null,
|
||||
.rt_pre_exec_info = undefined,
|
||||
.rt_post_fork_info = undefined,
|
||||
};
|
||||
|
||||
try cmd.testingStart();
|
||||
@@ -554,6 +595,100 @@ test "Command: pre exec" {
|
||||
try testing.expect(exit.Exited == 42);
|
||||
}
|
||||
|
||||
test "Command: os pre exec 2" {
|
||||
if (builtin.os.tag == .windows) return error.SkipZigTest;
|
||||
var cmd: Command = .{
|
||||
.path = "/bin/sh",
|
||||
.args = &.{ "/bin/sh", "-v" },
|
||||
.os_pre_exec = (struct {
|
||||
fn do(_: *Command) ?u8 {
|
||||
// This runs in the child, so we can exit and it won't
|
||||
// kill the test runner.
|
||||
return 42;
|
||||
}
|
||||
}).do,
|
||||
.rt_pre_exec = null,
|
||||
.rt_post_fork = null,
|
||||
.rt_pre_exec_info = undefined,
|
||||
.rt_post_fork_info = undefined,
|
||||
};
|
||||
|
||||
try cmd.testingStart();
|
||||
try testing.expect(cmd.pid != null);
|
||||
const exit = try cmd.wait(true);
|
||||
try testing.expect(exit == .Exited);
|
||||
try testing.expect(exit.Exited == 42);
|
||||
}
|
||||
|
||||
test "Command: rt pre exec 1" {
|
||||
if (builtin.os.tag == .windows) return error.SkipZigTest;
|
||||
var cmd: Command = .{
|
||||
.path = "/bin/sh",
|
||||
.args = &.{ "/bin/sh", "-v" },
|
||||
.os_pre_exec = null,
|
||||
.rt_pre_exec = (struct {
|
||||
fn do(_: *Command) ?u8 {
|
||||
// This runs in the child, so we can exit and it won't
|
||||
// kill the test runner.
|
||||
posix.exit(42);
|
||||
}
|
||||
}).do,
|
||||
.rt_post_fork = null,
|
||||
.rt_pre_exec_info = undefined,
|
||||
.rt_post_fork_info = undefined,
|
||||
};
|
||||
|
||||
try cmd.testingStart();
|
||||
try testing.expect(cmd.pid != null);
|
||||
const exit = try cmd.wait(true);
|
||||
try testing.expect(exit == .Exited);
|
||||
try testing.expect(exit.Exited == 42);
|
||||
}
|
||||
|
||||
test "Command: rt pre exec 2" {
|
||||
if (builtin.os.tag == .windows) return error.SkipZigTest;
|
||||
var cmd: Command = .{
|
||||
.path = "/bin/sh",
|
||||
.args = &.{ "/bin/sh", "-v" },
|
||||
.os_pre_exec = null,
|
||||
.rt_pre_exec = (struct {
|
||||
fn do(_: *Command) ?u8 {
|
||||
// This runs in the child, so we can exit and it won't
|
||||
// kill the test runner.
|
||||
return 42;
|
||||
}
|
||||
}).do,
|
||||
.rt_post_fork = null,
|
||||
.rt_pre_exec_info = undefined,
|
||||
.rt_post_fork_info = undefined,
|
||||
};
|
||||
|
||||
try cmd.testingStart();
|
||||
try testing.expect(cmd.pid != null);
|
||||
const exit = try cmd.wait(true);
|
||||
try testing.expect(exit == .Exited);
|
||||
try testing.expect(exit.Exited == 42);
|
||||
}
|
||||
|
||||
test "Command: rt post fork 1" {
|
||||
if (builtin.os.tag == .windows) return error.SkipZigTest;
|
||||
var cmd: Command = .{
|
||||
.path = "/bin/sh",
|
||||
.args = &.{ "/bin/sh", "-c", "sleep 1" },
|
||||
.os_pre_exec = null,
|
||||
.rt_pre_exec = null,
|
||||
.rt_post_fork = (struct {
|
||||
fn do(_: *Command) PostForkError!void {
|
||||
return error.PostForkError;
|
||||
}
|
||||
}).do,
|
||||
.rt_pre_exec_info = undefined,
|
||||
.rt_post_fork_info = undefined,
|
||||
};
|
||||
|
||||
try testing.expectError(error.PostForkError, cmd.testingStart());
|
||||
}
|
||||
|
||||
fn createTestStdout(dir: std.fs.Dir) !File {
|
||||
const file = try dir.createFile("stdout.txt", .{ .read = true });
|
||||
if (builtin.os.tag == .windows) {
|
||||
@@ -567,6 +702,19 @@ fn createTestStdout(dir: std.fs.Dir) !File {
|
||||
return file;
|
||||
}
|
||||
|
||||
fn createTestStderr(dir: std.fs.Dir) !File {
|
||||
const file = try dir.createFile("stderr.txt", .{ .read = true });
|
||||
if (builtin.os.tag == .windows) {
|
||||
try windows.SetHandleInformation(
|
||||
file.handle,
|
||||
windows.HANDLE_FLAG_INHERIT,
|
||||
windows.HANDLE_FLAG_INHERIT,
|
||||
);
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
test "Command: redirect stdout to file" {
|
||||
var td = try TempDir.init();
|
||||
defer td.deinit();
|
||||
@@ -581,6 +729,11 @@ test "Command: redirect stdout to file" {
|
||||
.path = "/bin/sh",
|
||||
.args = &.{ "/bin/sh", "-c", "echo hello" },
|
||||
.stdout = stdout,
|
||||
.os_pre_exec = null,
|
||||
.rt_pre_exec = null,
|
||||
.rt_post_fork = null,
|
||||
.rt_pre_exec_info = undefined,
|
||||
.rt_post_fork_info = undefined,
|
||||
};
|
||||
|
||||
try cmd.testingStart();
|
||||
@@ -611,11 +764,21 @@ test "Command: custom env vars" {
|
||||
.args = &.{ "C:\\Windows\\System32\\cmd.exe", "/C", "echo %VALUE%" },
|
||||
.stdout = stdout,
|
||||
.env = &env,
|
||||
.os_pre_exec = null,
|
||||
.rt_pre_exec = null,
|
||||
.rt_post_fork = null,
|
||||
.rt_pre_exec_info = undefined,
|
||||
.rt_post_fork_info = undefined,
|
||||
} else .{
|
||||
.path = "/bin/sh",
|
||||
.args = &.{ "/bin/sh", "-c", "echo $VALUE" },
|
||||
.stdout = stdout,
|
||||
.env = &env,
|
||||
.os_pre_exec = null,
|
||||
.rt_pre_exec = null,
|
||||
.rt_post_fork = null,
|
||||
.rt_pre_exec_info = undefined,
|
||||
.rt_post_fork_info = undefined,
|
||||
};
|
||||
|
||||
try cmd.testingStart();
|
||||
@@ -647,11 +810,21 @@ test "Command: custom working directory" {
|
||||
.args = &.{ "C:\\Windows\\System32\\cmd.exe", "/C", "cd" },
|
||||
.stdout = stdout,
|
||||
.cwd = "C:\\Windows\\System32",
|
||||
.os_pre_exec = null,
|
||||
.rt_pre_exec = null,
|
||||
.rt_post_fork = null,
|
||||
.rt_pre_exec_info = undefined,
|
||||
.rt_post_fork_info = undefined,
|
||||
} else .{
|
||||
.path = "/bin/sh",
|
||||
.args = &.{ "/bin/sh", "-c", "pwd" },
|
||||
.stdout = stdout,
|
||||
.cwd = "/tmp",
|
||||
.os_pre_exec = null,
|
||||
.rt_pre_exec = null,
|
||||
.rt_post_fork = null,
|
||||
.rt_pre_exec_info = undefined,
|
||||
.rt_post_fork_info = undefined,
|
||||
};
|
||||
|
||||
try cmd.testingStart();
|
||||
@@ -688,12 +861,20 @@ test "Command: posix fork handles execveZ failure" {
|
||||
defer td.deinit();
|
||||
var stdout = try createTestStdout(td.dir);
|
||||
defer stdout.close();
|
||||
var stderr = try createTestStderr(td.dir);
|
||||
defer stderr.close();
|
||||
|
||||
var cmd: Command = .{
|
||||
.path = "/not/a/binary",
|
||||
.args = &.{ "/not/a/binary", "" },
|
||||
.stdout = stdout,
|
||||
.stderr = stderr,
|
||||
.cwd = "/bin",
|
||||
.os_pre_exec = null,
|
||||
.rt_pre_exec = null,
|
||||
.rt_post_fork = null,
|
||||
.rt_pre_exec_info = undefined,
|
||||
.rt_post_fork_info = undefined,
|
||||
};
|
||||
|
||||
try cmd.testingStart();
|
||||
|
||||
@@ -632,19 +632,12 @@ pub fn init(
|
||||
.env_override = config.env,
|
||||
.shell_integration = config.@"shell-integration",
|
||||
.shell_integration_features = config.@"shell-integration-features",
|
||||
.cursor_blink = config.@"cursor-style-blink",
|
||||
.working_directory = config.@"working-directory",
|
||||
.resources_dir = global_state.resources_dir.host(),
|
||||
.term = config.term,
|
||||
|
||||
// Get the cgroup if we're on linux and have the decl. I'd love
|
||||
// to change this from a decl to a surface options struct because
|
||||
// then we can do memory management better (don't need to retain
|
||||
// the string around).
|
||||
.linux_cgroup = if (comptime builtin.os.tag == .linux and
|
||||
@hasDecl(apprt.runtime.Surface, "cgroup"))
|
||||
rt_surface.cgroup()
|
||||
else
|
||||
Command.linux_cgroup_default,
|
||||
.rt_pre_exec_info = .init(config),
|
||||
.rt_post_fork_info = .init(config),
|
||||
});
|
||||
errdefer io_exec.deinit();
|
||||
|
||||
@@ -5398,20 +5391,11 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
|
||||
return false;
|
||||
},
|
||||
|
||||
.copy_title_to_clipboard => {
|
||||
const title = self.rt_surface.getTitle() orelse return false;
|
||||
if (title.len == 0) return false;
|
||||
|
||||
self.rt_surface.setClipboard(.standard, &.{.{
|
||||
.mime = "text/plain",
|
||||
.data = title,
|
||||
}}, false) catch |err| {
|
||||
log.err("error copying title to clipboard err={}", .{err});
|
||||
return true;
|
||||
};
|
||||
|
||||
return true;
|
||||
},
|
||||
.copy_title_to_clipboard => return try self.rt_app.performAction(
|
||||
.{ .surface = self },
|
||||
.copy_title_to_clipboard,
|
||||
{},
|
||||
),
|
||||
|
||||
.paste_from_clipboard => return try self.startClipboardRequest(
|
||||
.standard,
|
||||
|
||||
@@ -330,6 +330,11 @@ pub const Action = union(Key) {
|
||||
/// The readonly state of the surface has changed.
|
||||
readonly: Readonly,
|
||||
|
||||
/// Copy the effective title of the surface to the clipboard.
|
||||
/// The effective title is the user-overridden title if set,
|
||||
/// otherwise the terminal-set title.
|
||||
copy_title_to_clipboard,
|
||||
|
||||
/// Sync with: ghostty_action_tag_e
|
||||
pub const Key = enum(c_int) {
|
||||
quit,
|
||||
@@ -395,6 +400,7 @@ pub const Action = union(Key) {
|
||||
search_total,
|
||||
search_selected,
|
||||
readonly,
|
||||
copy_title_to_clipboard,
|
||||
};
|
||||
|
||||
/// Sync with: ghostty_action_u
|
||||
|
||||
@@ -6,6 +6,8 @@ pub const resourcesDir = @import("gtk/flatpak.zig").resourcesDir;
|
||||
// The exported API, custom for the apprt.
|
||||
pub const class = @import("gtk/class.zig");
|
||||
pub const WeakRef = @import("gtk/weak_ref.zig").WeakRef;
|
||||
pub const pre_exec = @import("gtk/pre_exec.zig");
|
||||
pub const post_fork = @import("gtk/post_fork.zig");
|
||||
|
||||
test {
|
||||
@import("std").testing.refAllDecls(@This());
|
||||
|
||||
@@ -49,9 +49,9 @@ pub const blueprints: []const Blueprint = &.{
|
||||
.{ .major = 1, .minor = 5, .name = "split-tree-split" },
|
||||
.{ .major = 1, .minor = 2, .name = "surface" },
|
||||
.{ .major = 1, .minor = 5, .name = "surface-scrolled-window" },
|
||||
.{ .major = 1, .minor = 5, .name = "surface-title-dialog" },
|
||||
.{ .major = 1, .minor = 3, .name = "surface-child-exited" },
|
||||
.{ .major = 1, .minor = 5, .name = "tab" },
|
||||
.{ .major = 1, .minor = 5, .name = "title-dialog" },
|
||||
.{ .major = 1, .minor = 5, .name = "window" },
|
||||
.{ .major = 1, .minor = 5, .name = "command-palette" },
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/// Contains all the logic for putting the Ghostty process and
|
||||
/// each individual surface into its own cgroup.
|
||||
/// Contains all the logic for putting individual surfaces into
|
||||
/// transient systemd scopes.
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const assert = @import("../../quirks.zig").inlineAssert;
|
||||
|
||||
const gio = @import("gio");
|
||||
const glib = @import("glib");
|
||||
@@ -12,125 +13,27 @@ const log = std.log.scoped(.gtk_systemd_cgroup);
|
||||
|
||||
pub const Options = struct {
|
||||
memory_high: ?u64 = null,
|
||||
pids_max: ?u64 = null,
|
||||
tasks_max: ?u64 = null,
|
||||
};
|
||||
|
||||
/// Initialize the cgroup for the app. This will create our
|
||||
/// transient scope, initialize the cgroups we use for the app,
|
||||
/// configure them, and return the cgroup path for the app.
|
||||
///
|
||||
/// Returns the path of the current cgroup for the app, which is
|
||||
/// allocated with the given allocator.
|
||||
pub fn init(
|
||||
alloc: Allocator,
|
||||
dbus: *gio.DBusConnection,
|
||||
opts: Options,
|
||||
) ![]const u8 {
|
||||
const pid = std.os.linux.getpid();
|
||||
pub fn fmtScope(buf: []u8, pid: u32) [:0]const u8 {
|
||||
const fmt = "app-ghostty-surface-transient-{}.scope";
|
||||
|
||||
// Get our initial cgroup. We need this so we can compare
|
||||
// and detect when we've switched to our transient group.
|
||||
const original = try internal_os.cgroup.current(
|
||||
alloc,
|
||||
pid,
|
||||
) orelse "";
|
||||
defer alloc.free(original);
|
||||
assert(buf.len >= fmt.len - 2 + std.math.log10_int(@as(usize, std.math.maxInt(@TypeOf(pid)))) + 1);
|
||||
|
||||
// Create our transient scope. If this succeeds then the unit
|
||||
// was created, but we may not have moved into it yet, so we need
|
||||
// to do a dumb busy loop to wait for the move to complete.
|
||||
try createScope(dbus, pid);
|
||||
const transient = transient: while (true) {
|
||||
const current = try internal_os.cgroup.current(
|
||||
alloc,
|
||||
pid,
|
||||
) orelse "";
|
||||
if (!std.mem.eql(u8, original, current)) break :transient current;
|
||||
alloc.free(current);
|
||||
std.Thread.sleep(25 * std.time.ns_per_ms);
|
||||
};
|
||||
errdefer alloc.free(transient);
|
||||
log.info("transient scope created cgroup={s}", .{transient});
|
||||
|
||||
// Create the app cgroup and put ourselves in it. This is
|
||||
// required because controllers can't be configured while a
|
||||
// process is in a cgroup.
|
||||
try internal_os.cgroup.create(transient, "app", pid);
|
||||
|
||||
// Create a cgroup that will contain all our surfaces. We will
|
||||
// enable the controllers and configure resource limits for surfaces
|
||||
// only on this cgroup so that it doesn't affect our main app.
|
||||
try internal_os.cgroup.create(transient, "surfaces", null);
|
||||
const surfaces = try std.fmt.allocPrint(alloc, "{s}/surfaces", .{transient});
|
||||
defer alloc.free(surfaces);
|
||||
|
||||
// Enable all of our cgroup controllers. If these fail then
|
||||
// we just log. We can't reasonably undo what we've done above
|
||||
// so we log the warning and still return the transient group.
|
||||
// I don't know a scenario where this fails yet.
|
||||
try enableControllers(alloc, transient);
|
||||
try enableControllers(alloc, surfaces);
|
||||
|
||||
// Configure the "high" memory limit. This limit is used instead
|
||||
// of "max" because it's a soft limit that can be exceeded and
|
||||
// can be monitored by things like systemd-oomd to kill if needed,
|
||||
// versus an instant hard kill.
|
||||
if (opts.memory_high) |limit| {
|
||||
try internal_os.cgroup.configureLimit(surfaces, .{
|
||||
.memory_high = limit,
|
||||
});
|
||||
}
|
||||
|
||||
// Configure the "max" pids limit. This is a hard limit and cannot be
|
||||
// exceeded.
|
||||
if (opts.pids_max) |limit| {
|
||||
try internal_os.cgroup.configureLimit(surfaces, .{
|
||||
.pids_max = limit,
|
||||
});
|
||||
}
|
||||
|
||||
return transient;
|
||||
return std.fmt.bufPrintZ(buf, fmt, .{pid}) catch unreachable;
|
||||
}
|
||||
|
||||
/// Enable all the cgroup controllers for the given cgroup.
|
||||
fn enableControllers(alloc: Allocator, cgroup: []const u8) !void {
|
||||
const raw = try internal_os.cgroup.controllers(alloc, cgroup);
|
||||
defer alloc.free(raw);
|
||||
|
||||
// Build our string builder for enabling all controllers
|
||||
var builder: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer builder.deinit();
|
||||
|
||||
// Controllers are space-separated
|
||||
var it = std.mem.splitScalar(u8, raw, ' ');
|
||||
while (it.next()) |controller| {
|
||||
try builder.writer.writeByte('+');
|
||||
try builder.writer.writeAll(controller);
|
||||
if (it.rest().len > 0) try builder.writer.writeByte(' ');
|
||||
}
|
||||
|
||||
// Enable them all
|
||||
try internal_os.cgroup.configureControllers(
|
||||
cgroup,
|
||||
builder.written(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a transient systemd scope unit for the current process and
|
||||
/// move our process into it.
|
||||
fn createScope(
|
||||
/// Create a transient systemd scope unit for the given process and
|
||||
/// move the process into it.
|
||||
pub fn createScope(
|
||||
dbus: *gio.DBusConnection,
|
||||
pid_: std.os.linux.pid_t,
|
||||
) !void {
|
||||
const pid: u32 = @intCast(pid_);
|
||||
|
||||
// The unit name needs to be unique. We use the pid for this.
|
||||
pid: u32,
|
||||
options: Options,
|
||||
) error{DbusCallFailed}!void {
|
||||
// The unit name needs to be unique. We use the PID for this.
|
||||
var name_buf: [256]u8 = undefined;
|
||||
const name = std.fmt.bufPrintZ(
|
||||
&name_buf,
|
||||
"app-ghostty-transient-{}.scope",
|
||||
.{pid},
|
||||
) catch unreachable;
|
||||
const name = fmtScope(&name_buf, pid);
|
||||
|
||||
const builder_type = glib.VariantType.new("(ssa(sv)a(sa(sv)))");
|
||||
defer glib.free(builder_type);
|
||||
@@ -150,16 +53,18 @@ fn createScope(
|
||||
builder.open(properties_type);
|
||||
defer builder.close();
|
||||
|
||||
if (options.memory_high) |value| {
|
||||
builder.add("(sv)", "MemoryHigh", glib.Variant.newUint64(value));
|
||||
}
|
||||
|
||||
if (options.tasks_max) |value| {
|
||||
builder.add("(sv)", "TasksMax", glib.Variant.newUint64(value));
|
||||
}
|
||||
|
||||
// https://www.freedesktop.org/software/systemd/man/latest/systemd-oomd.service.html
|
||||
const pressure_value = glib.Variant.newString("kill");
|
||||
builder.add("(sv)", "ManagedOOMMemoryPressure", glib.Variant.newString("kill"));
|
||||
|
||||
builder.add("(sv)", "ManagedOOMMemoryPressure", pressure_value);
|
||||
|
||||
// Delegate
|
||||
const delegate_value = glib.Variant.newBoolean(1);
|
||||
builder.add("(sv)", "Delegate", delegate_value);
|
||||
|
||||
// Pid to move into the unit
|
||||
// PID to move into the unit
|
||||
const pids_value_type = glib.VariantType.new("u");
|
||||
defer glib.free(pids_value_type);
|
||||
|
||||
@@ -169,7 +74,7 @@ fn createScope(
|
||||
}
|
||||
|
||||
{
|
||||
// Aux
|
||||
// Aux - unused but must be present
|
||||
const aux_type = glib.VariantType.new("a(sa(sv))");
|
||||
defer glib.free(aux_type);
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ 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");
|
||||
const CoreApp = @import("../../../App.zig");
|
||||
const configpkg = @import("../../../config.zig");
|
||||
const input = @import("../../../input.zig");
|
||||
@@ -36,6 +35,7 @@ const Config = @import("config.zig").Config;
|
||||
const Surface = @import("surface.zig").Surface;
|
||||
const SplitTree = @import("split_tree.zig").SplitTree;
|
||||
const Window = @import("window.zig").Window;
|
||||
const Tab = @import("tab.zig").Tab;
|
||||
const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog;
|
||||
const ConfigErrorsDialog = @import("config_errors_dialog.zig").ConfigErrorsDialog;
|
||||
const GlobalShortcuts = @import("global_shortcuts.zig").GlobalShortcuts;
|
||||
@@ -175,11 +175,6 @@ pub const Application = extern struct {
|
||||
/// The global shortcut logic.
|
||||
global_shortcuts: *GlobalShortcuts,
|
||||
|
||||
/// The base path of the transient cgroup used to put all surfaces
|
||||
/// into their own cgroup. This is only set if cgroups are enabled
|
||||
/// and initialization was successful.
|
||||
transient_cgroup_base: ?[]const u8 = null,
|
||||
|
||||
/// This is set to true so long as we request a window exactly
|
||||
/// once. This prevents quitting the app before we've shown one
|
||||
/// window.
|
||||
@@ -213,6 +208,11 @@ pub const Application = extern struct {
|
||||
/// Providers for loading custom stylesheets defined by user
|
||||
custom_css_providers: std.ArrayListUnmanaged(*gtk.CssProvider) = .empty,
|
||||
|
||||
/// A copy of the LANG environment variable that was provided to Ghostty
|
||||
/// by the system. If this is null, the LANG environment variable did
|
||||
/// not exist in Ghostty's environment variable.
|
||||
saved_language: ?[:0]const u8 = null,
|
||||
|
||||
pub var offset: c_int = 0;
|
||||
};
|
||||
|
||||
@@ -249,15 +249,6 @@ pub const Application = extern struct {
|
||||
gtk_version.logVersion();
|
||||
adw_version.logVersion();
|
||||
|
||||
// Set gettext global domain to be our app so that our unqualified
|
||||
// translations map to our translations.
|
||||
internal_os.i18n.initGlobalDomain() catch |err| {
|
||||
// Failures shuldn't stop application startup. Our app may
|
||||
// not translate correctly but it should still work. In the
|
||||
// future we may want to add this to the GUI to show.
|
||||
log.warn("i18n initialization failed error={}", .{err});
|
||||
};
|
||||
|
||||
// Load our configuration.
|
||||
var config = CoreConfig.load(alloc) catch |err| err: {
|
||||
// If we fail to load the configuration, then we should log
|
||||
@@ -275,6 +266,27 @@ pub const Application = extern struct {
|
||||
};
|
||||
defer config.deinit();
|
||||
|
||||
const saved_language: ?[:0]const u8 = saved_language: {
|
||||
const old_language = old_language: {
|
||||
const result = (internal_os.getenv(alloc, "LANG") catch break :old_language null) orelse break :old_language null;
|
||||
defer result.deinit(alloc);
|
||||
break :old_language alloc.dupeZ(u8, result.value) catch break :old_language null;
|
||||
};
|
||||
|
||||
if (config.language) |language| _ = internal_os.setenv("LANG", language);
|
||||
|
||||
break :saved_language old_language;
|
||||
};
|
||||
|
||||
// Set gettext global domain to be our app so that our unqualified
|
||||
// translations map to our translations.
|
||||
internal_os.i18n.initGlobalDomain() catch |err| {
|
||||
// Failures shuldn't stop application startup. Our app may
|
||||
// not translate correctly but it should still work. In the
|
||||
// future we may want to add this to the GUI to show.
|
||||
log.warn("i18n initialization failed error={}", .{err});
|
||||
};
|
||||
|
||||
// Setup our GTK init env vars
|
||||
setGtkEnv(&config) catch |err| switch (err) {
|
||||
error.NoSpaceLeft => {
|
||||
@@ -374,7 +386,7 @@ pub const Application = extern struct {
|
||||
// Setup our private state. More setup is done in the init
|
||||
// callback that GObject calls, but we can't pass this data through
|
||||
// to there (and we don't need it there directly) so this is here.
|
||||
const priv = self.private();
|
||||
const priv: *Private = self.private();
|
||||
priv.* = .{
|
||||
.rt_app = rt_app,
|
||||
.core_app = core_app,
|
||||
@@ -383,6 +395,7 @@ pub const Application = extern struct {
|
||||
.css_provider = css_provider,
|
||||
.custom_css_providers = .empty,
|
||||
.global_shortcuts = gobject.ext.newInstance(GlobalShortcuts, .{}),
|
||||
.saved_language = saved_language,
|
||||
};
|
||||
|
||||
// Signals
|
||||
@@ -415,11 +428,11 @@ pub const Application = extern struct {
|
||||
/// ensures that our memory is cleaned up properly.
|
||||
pub fn deinit(self: *Self) void {
|
||||
const alloc = self.allocator();
|
||||
const priv = self.private();
|
||||
const priv: *Private = self.private();
|
||||
priv.config.unref();
|
||||
priv.winproto.deinit(alloc);
|
||||
priv.global_shortcuts.unref();
|
||||
if (priv.transient_cgroup_base) |base| alloc.free(base);
|
||||
if (priv.saved_language) |language| alloc.free(language);
|
||||
if (gdk.Display.getDefault()) |display| {
|
||||
gtk.StyleContext.removeProviderForDisplay(
|
||||
display,
|
||||
@@ -445,6 +458,12 @@ pub const Application = extern struct {
|
||||
return self.private().core_app.alloc;
|
||||
}
|
||||
|
||||
/// Get the original language that Ghostty was launched with. This returns a
|
||||
/// pointer to internal memory so it must be copied by callers.
|
||||
pub fn savedLanguage(self: *Self) ?[:0]const u8 {
|
||||
return self.private().saved_language;
|
||||
}
|
||||
|
||||
/// Run the application. This is a replacement for `gio.Application.run`
|
||||
/// because we want more tight control over our event loop so we can
|
||||
/// integrate it with libghostty.
|
||||
@@ -649,6 +668,8 @@ pub const Application = extern struct {
|
||||
.close_tab => return Action.closeTab(target, value),
|
||||
.close_window => return Action.closeWindow(target),
|
||||
|
||||
.copy_title_to_clipboard => return Action.copyTitleToClipboard(target),
|
||||
|
||||
.config_change => try Action.configChange(
|
||||
self,
|
||||
target,
|
||||
@@ -781,11 +802,6 @@ pub const Application = extern struct {
|
||||
return &self.private().winproto;
|
||||
}
|
||||
|
||||
/// Returns the cgroup base (if any).
|
||||
pub fn cgroupBase(self: *Self) ?[]const u8 {
|
||||
return self.private().transient_cgroup_base;
|
||||
}
|
||||
|
||||
/// This will get called when there are no more open surfaces.
|
||||
fn startQuitTimer(self: *Self) void {
|
||||
const priv = self.private();
|
||||
@@ -1284,22 +1300,6 @@ pub const Application = extern struct {
|
||||
// Setup our global shortcuts
|
||||
self.startupGlobalShortcuts();
|
||||
|
||||
// Setup our cgroup for the application.
|
||||
self.startupCgroup() catch |err| {
|
||||
log.warn("cgroup initialization failed err={}", .{err});
|
||||
|
||||
// Add it to our config diagnostics so it shows up in a GUI dialog.
|
||||
// Admittedly this has two issues: (1) we shuldn't be using the
|
||||
// config errors dialog for this long term and (2) using a mut
|
||||
// ref to the config wouldn't propagate changes to UI properly,
|
||||
// but we're in startup mode so its okay.
|
||||
const config = self.private().config.getMut();
|
||||
config.addDiagnosticFmt(
|
||||
"cgroup initialization failed: {}",
|
||||
.{err},
|
||||
) catch {};
|
||||
};
|
||||
|
||||
// If we have any config diagnostics from loading, then we
|
||||
// show the diagnostics dialog. We show this one as a general
|
||||
// modal (not to any specific window) because we don't even
|
||||
@@ -1433,72 +1433,6 @@ pub const Application = extern struct {
|
||||
);
|
||||
}
|
||||
|
||||
const CgroupError = error{
|
||||
DbusConnectionFailed,
|
||||
CgroupInitFailed,
|
||||
};
|
||||
|
||||
/// Setup our cgroup for the application, if enabled.
|
||||
///
|
||||
/// The setup for cgroups involves creating the cgroup for our
|
||||
/// application, moving ourselves into it, and storing the base path
|
||||
/// so that created surfaces can also have their own cgroups.
|
||||
fn startupCgroup(self: *Self) CgroupError!void {
|
||||
const priv = self.private();
|
||||
const config = priv.config.get();
|
||||
|
||||
// If cgroup isolation isn't enabled then we don't do this.
|
||||
if (!switch (config.@"linux-cgroup") {
|
||||
.never => false,
|
||||
.always => true,
|
||||
.@"single-instance" => single: {
|
||||
const flags = self.as(gio.Application).getFlags();
|
||||
break :single !flags.non_unique;
|
||||
},
|
||||
}) {
|
||||
log.info(
|
||||
"cgroup isolation disabled via config={}",
|
||||
.{config.@"linux-cgroup"},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// We need a dbus connection to do anything else
|
||||
const dbus = self.as(gio.Application).getDbusConnection() orelse {
|
||||
if (config.@"linux-cgroup-hard-fail") {
|
||||
log.err("dbus connection required for cgroup isolation, exiting", .{});
|
||||
return error.DbusConnectionFailed;
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
const alloc = priv.core_app.alloc;
|
||||
const path = cgroup.init(alloc, dbus, .{
|
||||
.memory_high = config.@"linux-cgroup-memory-limit",
|
||||
.pids_max = config.@"linux-cgroup-processes-limit",
|
||||
}) catch |err| {
|
||||
// If we can't initialize cgroups then that's okay. We
|
||||
// want to continue to run so we just won't isolate surfaces.
|
||||
// NOTE(mitchellh): do we want a config to force it?
|
||||
log.warn(
|
||||
"failed to initialize cgroups, terminals will not be isolated err={}",
|
||||
.{err},
|
||||
);
|
||||
|
||||
// If we have hard fail enabled then we exit now.
|
||||
if (config.@"linux-cgroup-hard-fail") {
|
||||
log.err("linux-cgroup-hard-fail enabled, exiting", .{});
|
||||
return error.CgroupInitFailed;
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
log.info("cgroup isolation enabled base={s}", .{path});
|
||||
priv.transient_cgroup_base = path;
|
||||
}
|
||||
|
||||
fn activate(self: *Self) callconv(.c) void {
|
||||
log.debug("activate", .{});
|
||||
|
||||
@@ -1896,6 +1830,13 @@ const Action = struct {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn copyTitleToClipboard(target: apprt.Target) bool {
|
||||
return switch (target) {
|
||||
.app => false,
|
||||
.surface => |v| v.rt_surface.gobj().copyTitleToClipboard(),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn configChange(
|
||||
self: *Application,
|
||||
target: apprt.Target,
|
||||
@@ -2331,8 +2272,21 @@ const Action = struct {
|
||||
},
|
||||
},
|
||||
.tab => {
|
||||
// GTK does not yet support tab title prompting
|
||||
return false;
|
||||
switch (target) {
|
||||
.app => return false,
|
||||
.surface => |v| {
|
||||
const surface = v.rt_surface.surface;
|
||||
const tab = ext.getAncestor(
|
||||
Tab,
|
||||
surface.as(gtk.Widget),
|
||||
) orelse {
|
||||
log.warn("surface is not in a tab, ignoring prompt_tab_title", .{});
|
||||
return false;
|
||||
};
|
||||
tab.promptTabTitle();
|
||||
return true;
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ const SearchOverlay = @import("search_overlay.zig").SearchOverlay;
|
||||
const KeyStateOverlay = @import("key_state_overlay.zig").KeyStateOverlay;
|
||||
const ChildExited = @import("surface_child_exited.zig").SurfaceChildExited;
|
||||
const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog;
|
||||
const TitleDialog = @import("surface_title_dialog.zig").SurfaceTitleDialog;
|
||||
const TitleDialog = @import("title_dialog.zig").TitleDialog;
|
||||
const Window = @import("window.zig").Window;
|
||||
const InspectorWindow = @import("inspector_window.zig").InspectorWindow;
|
||||
const i18n = @import("../../../os/i18n.zig");
|
||||
@@ -551,10 +551,6 @@ pub const Surface = extern struct {
|
||||
/// The configuration that this surface is using.
|
||||
config: ?*Config = null,
|
||||
|
||||
/// The cgroup created for this surface. This will be created
|
||||
/// if `Application.transient_cgroup_base` is set.
|
||||
cgroup_path: ?[]const u8 = null,
|
||||
|
||||
/// The default size for a window that embeds this surface.
|
||||
default_size: ?*Size = null,
|
||||
|
||||
@@ -1404,12 +1400,7 @@ pub const Surface = extern struct {
|
||||
/// Prompt for a manual title change for the surface.
|
||||
pub fn promptTitle(self: *Self) void {
|
||||
const priv = self.private();
|
||||
const dialog = gobject.ext.newInstance(
|
||||
TitleDialog,
|
||||
.{
|
||||
.@"initial-value" = priv.title_override orelse priv.title,
|
||||
},
|
||||
);
|
||||
const dialog = TitleDialog.new(.surface, priv.title_override orelse priv.title);
|
||||
_ = TitleDialog.signals.set.connect(
|
||||
dialog,
|
||||
*Self,
|
||||
@@ -1438,63 +1429,6 @@ pub const Surface = extern struct {
|
||||
};
|
||||
}
|
||||
|
||||
/// Initialize the cgroup for this surface if it hasn't been
|
||||
/// already. While this is `init`-prefixed, we prefer to call this
|
||||
/// in the realize function because we don't need to create a cgroup
|
||||
/// if we don't init a surface.
|
||||
fn initCgroup(self: *Self) void {
|
||||
const priv = self.private();
|
||||
|
||||
// If we already have a cgroup path then we don't do it again.
|
||||
if (priv.cgroup_path != null) return;
|
||||
|
||||
const app = Application.default();
|
||||
const alloc = app.allocator();
|
||||
const base = app.cgroupBase() orelse return;
|
||||
|
||||
// For the unique group name we use the self pointer. This may
|
||||
// not be a good idea for security reasons but not sure yet. We
|
||||
// may want to change this to something else eventually to be safe.
|
||||
var buf: [256]u8 = undefined;
|
||||
const name = std.fmt.bufPrint(
|
||||
&buf,
|
||||
"surfaces/{X}.scope",
|
||||
.{@intFromPtr(self)},
|
||||
) catch unreachable;
|
||||
|
||||
// Create the cgroup. If it fails, no big deal... just ignore.
|
||||
internal_os.cgroup.create(base, name, null) catch |err| {
|
||||
log.warn("failed to create surface cgroup err={}", .{err});
|
||||
return;
|
||||
};
|
||||
|
||||
// Success, save the cgroup path.
|
||||
priv.cgroup_path = std.fmt.allocPrint(
|
||||
alloc,
|
||||
"{s}/{s}",
|
||||
.{ base, name },
|
||||
) catch null;
|
||||
}
|
||||
|
||||
/// Deletes the cgroup if set.
|
||||
fn clearCgroup(self: *Self) void {
|
||||
const priv = self.private();
|
||||
const path = priv.cgroup_path orelse return;
|
||||
|
||||
internal_os.cgroup.remove(path) catch |err| {
|
||||
// We don't want this to be fatal in any way so we just log
|
||||
// and continue. A dangling empty cgroup is not a big deal
|
||||
// and this should be rare.
|
||||
log.warn(
|
||||
"failed to remove cgroup for surface path={s} err={}",
|
||||
.{ path, err },
|
||||
);
|
||||
};
|
||||
|
||||
Application.default().allocator().free(path);
|
||||
priv.cgroup_path = null;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------
|
||||
// Libghostty Callbacks
|
||||
|
||||
@@ -1530,10 +1464,6 @@ pub const Surface = extern struct {
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn cgroupPath(self: *Self) ?[]const u8 {
|
||||
return self.private().cgroup_path;
|
||||
}
|
||||
|
||||
pub fn getContentScale(self: *Self) apprt.ContentScale {
|
||||
const priv = self.private();
|
||||
const gl_area = priv.gl_area;
|
||||
@@ -1595,10 +1525,17 @@ pub const Surface = extern struct {
|
||||
}
|
||||
|
||||
pub fn defaultTermioEnv(self: *Self) !std.process.EnvMap {
|
||||
const alloc = Application.default().allocator();
|
||||
const app = Application.default();
|
||||
const alloc = app.allocator();
|
||||
var env = try internal_os.getEnvMap(alloc);
|
||||
errdefer env.deinit();
|
||||
|
||||
if (app.savedLanguage()) |language| {
|
||||
try env.put("LANG", language);
|
||||
} else {
|
||||
env.remove("LANG");
|
||||
}
|
||||
|
||||
// Don't leak these GTK environment variables to child processes.
|
||||
env.remove("GDK_DEBUG");
|
||||
env.remove("GDK_DISABLE");
|
||||
@@ -1966,8 +1903,6 @@ pub const Surface = extern struct {
|
||||
for (priv.key_tables.items) |s| alloc.free(s);
|
||||
priv.key_tables.deinit(alloc);
|
||||
|
||||
self.clearCgroup();
|
||||
|
||||
gobject.Object.virtual_methods.finalize.call(
|
||||
Class.parent,
|
||||
self.as(Parent),
|
||||
@@ -1982,6 +1917,24 @@ pub const Surface = extern struct {
|
||||
return self.private().title;
|
||||
}
|
||||
|
||||
/// Returns the effective title: the user-overridden title if set,
|
||||
/// otherwise the terminal-set title.
|
||||
pub fn getEffectiveTitle(self: *Self) ?[:0]const u8 {
|
||||
const priv = self.private();
|
||||
return priv.title_override orelse priv.title;
|
||||
}
|
||||
|
||||
/// Copies the effective title to the clipboard.
|
||||
pub fn copyTitleToClipboard(self: *Self) bool {
|
||||
const title = self.getEffectiveTitle() orelse return false;
|
||||
if (title.len == 0) return false;
|
||||
self.setClipboard(.standard, &.{.{
|
||||
.mime = "text/plain",
|
||||
.data = title,
|
||||
}}, false);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Set the title for this surface, copies the value. This should always
|
||||
/// be the title as set by the terminal program, not any manually set
|
||||
/// title. For manually set titles see `setTitleOverride`.
|
||||
@@ -3311,10 +3264,6 @@ pub const Surface = extern struct {
|
||||
const app = Application.default();
|
||||
const alloc = app.allocator();
|
||||
|
||||
// Initialize our cgroup if we can.
|
||||
self.initCgroup();
|
||||
errdefer self.clearCgroup();
|
||||
|
||||
// Make our pointer to store our surface
|
||||
const surface = try alloc.create(CoreSurface);
|
||||
errdefer alloc.destroy(surface);
|
||||
|
||||
@@ -14,6 +14,7 @@ const Config = @import("config.zig").Config;
|
||||
const Application = @import("application.zig").Application;
|
||||
const SplitTree = @import("split_tree.zig").SplitTree;
|
||||
const Surface = @import("surface.zig").Surface;
|
||||
const TitleDialog = @import("title_dialog.zig").TitleDialog;
|
||||
|
||||
const log = std.log.scoped(.gtk_ghostty_window);
|
||||
|
||||
@@ -125,6 +126,18 @@ pub const Tab = extern struct {
|
||||
},
|
||||
);
|
||||
};
|
||||
pub const @"title-override" = struct {
|
||||
pub const name = "title-override";
|
||||
const impl = gobject.ext.defineProperty(
|
||||
name,
|
||||
Self,
|
||||
?[:0]const u8,
|
||||
.{
|
||||
.default = null,
|
||||
.accessor = C.privateStringFieldAccessor("title_override"),
|
||||
},
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
pub const signals = struct {
|
||||
@@ -148,6 +161,9 @@ pub const Tab = extern struct {
|
||||
/// The title of this tab. This is usually bound to the active surface.
|
||||
title: ?[:0]const u8 = null,
|
||||
|
||||
/// The manually overridden title from `promptTabTitle`.
|
||||
title_override: ?[:0]const u8 = null,
|
||||
|
||||
/// The tooltip of this tab. This is usually bound to the active surface.
|
||||
tooltip: ?[:0]const u8 = null,
|
||||
|
||||
@@ -204,6 +220,7 @@ pub const Tab = extern struct {
|
||||
.init("ring-bell", actionRingBell, null),
|
||||
.init("next-page", actionNextPage, null),
|
||||
.init("previous-page", actionPreviousPage, null),
|
||||
.init("prompt-tab-title", actionPromptTabTitle, null),
|
||||
};
|
||||
|
||||
_ = ext.actions.addAsGroup(Self, self, "tab", &actions);
|
||||
@@ -212,6 +229,37 @@ pub const Tab = extern struct {
|
||||
//---------------------------------------------------------------
|
||||
// Properties
|
||||
|
||||
/// Overridden title. This will be generally be shown over the title
|
||||
/// unless this is unset (null).
|
||||
pub fn setTitleOverride(self: *Self, title: ?[:0]const u8) void {
|
||||
const priv = self.private();
|
||||
if (priv.title_override) |v| glib.free(@ptrCast(@constCast(v)));
|
||||
priv.title_override = null;
|
||||
if (title) |v| priv.title_override = glib.ext.dupeZ(u8, v);
|
||||
self.as(gobject.Object).notifyByPspec(properties.@"title-override".impl.param_spec);
|
||||
}
|
||||
fn titleDialogSet(
|
||||
_: *TitleDialog,
|
||||
title_ptr: [*:0]const u8,
|
||||
self: *Self,
|
||||
) callconv(.c) void {
|
||||
const title = std.mem.span(title_ptr);
|
||||
self.setTitleOverride(if (title.len == 0) null else title);
|
||||
}
|
||||
pub fn promptTabTitle(self: *Self) void {
|
||||
const priv = self.private();
|
||||
const dialog = TitleDialog.new(.tab, priv.title_override orelse priv.title);
|
||||
_ = TitleDialog.signals.set.connect(
|
||||
dialog,
|
||||
*Self,
|
||||
titleDialogSet,
|
||||
self,
|
||||
.{},
|
||||
);
|
||||
|
||||
dialog.present(self.as(gtk.Widget));
|
||||
}
|
||||
|
||||
/// Get the currently active surface. See the "active-surface" property.
|
||||
/// This does not ref the value.
|
||||
pub fn getActiveSurface(self: *Self) ?*Surface {
|
||||
@@ -358,6 +406,14 @@ pub const Tab = extern struct {
|
||||
}
|
||||
}
|
||||
|
||||
fn actionPromptTabTitle(
|
||||
_: *gio.SimpleAction,
|
||||
_: ?*glib.Variant,
|
||||
self: *Self,
|
||||
) callconv(.c) void {
|
||||
self.promptTabTitle();
|
||||
}
|
||||
|
||||
fn actionRingBell(
|
||||
_: *gio.SimpleAction,
|
||||
_: ?*glib.Variant,
|
||||
@@ -399,7 +455,8 @@ pub const Tab = extern struct {
|
||||
_: *Self,
|
||||
config_: ?*Config,
|
||||
terminal_: ?[*:0]const u8,
|
||||
override_: ?[*:0]const u8,
|
||||
surface_override_: ?[*:0]const u8,
|
||||
tab_override_: ?[*:0]const u8,
|
||||
zoomed_: c_int,
|
||||
bell_ringing_: c_int,
|
||||
_: *gobject.ParamSpec,
|
||||
@@ -407,7 +464,8 @@ pub const Tab = extern struct {
|
||||
const zoomed = zoomed_ != 0;
|
||||
const bell_ringing = bell_ringing_ != 0;
|
||||
|
||||
// Our plain title is the overridden title if it exists, otherwise
|
||||
// Our plain title is the manually tab overridden title if it exists,
|
||||
// otherwise the overridden title if it exists, otherwise
|
||||
// the terminal title if it exists, otherwise a default string.
|
||||
const plain = plain: {
|
||||
const default = "Ghostty";
|
||||
@@ -416,7 +474,8 @@ pub const Tab = extern struct {
|
||||
break :title config.get().title orelse null;
|
||||
};
|
||||
|
||||
const plain = override_ orelse
|
||||
const plain = tab_override_ orelse
|
||||
surface_override_ orelse
|
||||
terminal_ orelse
|
||||
config_title orelse
|
||||
break :plain default;
|
||||
@@ -480,6 +539,7 @@ pub const Tab = extern struct {
|
||||
properties.@"split-tree".impl,
|
||||
properties.@"surface-tree".impl,
|
||||
properties.title.impl,
|
||||
properties.@"title-override".impl,
|
||||
properties.tooltip.impl,
|
||||
});
|
||||
|
||||
|
||||
@@ -6,17 +6,19 @@ const gobject = @import("gobject");
|
||||
const gtk = @import("gtk");
|
||||
|
||||
const gresource = @import("../build/gresource.zig");
|
||||
const i18n = @import("../../../os/main.zig").i18n;
|
||||
const ext = @import("../ext.zig");
|
||||
const Common = @import("../class.zig").Common;
|
||||
const Dialog = @import("dialog.zig").Dialog;
|
||||
|
||||
const log = std.log.scoped(.gtk_ghostty_surface_title_dialog);
|
||||
const log = std.log.scoped(.gtk_ghostty_title_dialog);
|
||||
|
||||
pub const SurfaceTitleDialog = extern struct {
|
||||
pub const TitleDialog = extern struct {
|
||||
const Self = @This();
|
||||
parent_instance: Parent,
|
||||
pub const Parent = adw.AlertDialog;
|
||||
pub const getGObjectType = gobject.ext.defineClass(Self, .{
|
||||
.name = "GhosttySurfaceTitleDialog",
|
||||
.name = "GhosttyTitleDialog",
|
||||
.instanceInit = &init,
|
||||
.classInit = &Class.init,
|
||||
.parent_class = &Class.parent,
|
||||
@@ -24,6 +26,24 @@ pub const SurfaceTitleDialog = extern struct {
|
||||
});
|
||||
|
||||
pub const properties = struct {
|
||||
pub const target = struct {
|
||||
pub const name = "target";
|
||||
const impl = gobject.ext.defineProperty(
|
||||
name,
|
||||
Self,
|
||||
Target,
|
||||
.{
|
||||
.default = .surface,
|
||||
.accessor = gobject.ext
|
||||
.privateFieldAccessor(
|
||||
Self,
|
||||
Private,
|
||||
&Private.offset,
|
||||
"target",
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
pub const @"initial-value" = struct {
|
||||
pub const name = "initial-value";
|
||||
pub const get = impl.get;
|
||||
@@ -59,6 +79,7 @@ pub const SurfaceTitleDialog = extern struct {
|
||||
initial_value: ?[:0]const u8 = null,
|
||||
|
||||
// Template bindings
|
||||
target: Target,
|
||||
entry: *gtk.Entry,
|
||||
|
||||
pub var offset: c_int = 0;
|
||||
@@ -68,6 +89,10 @@ pub const SurfaceTitleDialog = extern struct {
|
||||
gtk.Widget.initTemplate(self.as(gtk.Widget));
|
||||
}
|
||||
|
||||
pub fn new(target: Target, initial_value: ?[:0]const u8) *Self {
|
||||
return gobject.ext.newInstance(Self, .{ .target = target, .@"initial-value" = initial_value });
|
||||
}
|
||||
|
||||
pub fn present(self: *Self, parent_: *gtk.Widget) void {
|
||||
// If we have a window we can attach to, we prefer that.
|
||||
const parent: *gtk.Widget = if (ext.getAncestor(
|
||||
@@ -89,6 +114,9 @@ pub const SurfaceTitleDialog = extern struct {
|
||||
priv.entry.getBuffer().setText(v, -1);
|
||||
}
|
||||
|
||||
// Set the title for the dialog
|
||||
self.as(Dialog.Parent).setHeading(priv.target.title());
|
||||
|
||||
// Show it. We could also just use virtual methods to bind to
|
||||
// response but this is pretty simple.
|
||||
self.as(adw.AlertDialog).choose(
|
||||
@@ -162,7 +190,7 @@ pub const SurfaceTitleDialog = extern struct {
|
||||
comptime gresource.blueprint(.{
|
||||
.major = 1,
|
||||
.minor = 5,
|
||||
.name = "surface-title-dialog",
|
||||
.name = "title-dialog",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -175,6 +203,7 @@ pub const SurfaceTitleDialog = extern struct {
|
||||
// Properties
|
||||
gobject.ext.registerProperties(class, &.{
|
||||
properties.@"initial-value".impl,
|
||||
properties.target.impl,
|
||||
});
|
||||
|
||||
// Virtual methods
|
||||
@@ -187,3 +216,19 @@ pub const SurfaceTitleDialog = extern struct {
|
||||
pub const bindTemplateCallback = C.Class.bindTemplateCallback;
|
||||
};
|
||||
};
|
||||
|
||||
pub const Target = enum(c_int) {
|
||||
surface,
|
||||
tab,
|
||||
pub fn title(self: Target) [*:0]const u8 {
|
||||
return switch (self) {
|
||||
.surface => i18n._("Change Terminal Title"),
|
||||
.tab => i18n._("Change Tab Title"),
|
||||
};
|
||||
}
|
||||
|
||||
pub const getGObjectType = gobject.ext.defineEnum(
|
||||
Target,
|
||||
.{ .name = "GhosttyTitleDialogTarget" },
|
||||
);
|
||||
};
|
||||
@@ -252,6 +252,10 @@ pub const Window = extern struct {
|
||||
/// A weak reference to a command palette.
|
||||
command_palette: WeakRef(CommandPalette) = .empty,
|
||||
|
||||
/// Tab page that the context menu was opened for.
|
||||
/// setup by `setup-menu`.
|
||||
context_menu_page: ?*adw.TabPage = null,
|
||||
|
||||
// Template bindings
|
||||
tab_overview: *adw.TabOverview,
|
||||
tab_bar: *adw.TabBar,
|
||||
@@ -335,6 +339,8 @@ pub const Window = extern struct {
|
||||
.init("close-tab", actionCloseTab, s_variant_type),
|
||||
.init("new-tab", actionNewTab, null),
|
||||
.init("new-window", actionNewWindow, null),
|
||||
.init("prompt-tab-title", actionPromptTabTitle, null),
|
||||
.init("prompt-context-tab-title", actionPromptContextTabTitle, null),
|
||||
.init("ring-bell", actionRingBell, null),
|
||||
.init("split-right", actionSplitRight, null),
|
||||
.init("split-left", actionSplitLeft, null),
|
||||
@@ -1531,6 +1537,13 @@ pub const Window = extern struct {
|
||||
self.as(gtk.Window).close();
|
||||
}
|
||||
}
|
||||
fn setupTabMenu(
|
||||
_: *adw.TabView,
|
||||
page: ?*adw.TabPage,
|
||||
self: *Self,
|
||||
) callconv(.c) void {
|
||||
self.private().context_menu_page = page;
|
||||
}
|
||||
|
||||
fn surfaceClipboardWrite(
|
||||
_: *Surface,
|
||||
@@ -1774,6 +1787,26 @@ pub const Window = extern struct {
|
||||
self.performBindingAction(.new_tab);
|
||||
}
|
||||
|
||||
fn actionPromptContextTabTitle(
|
||||
_: *gio.SimpleAction,
|
||||
_: ?*glib.Variant,
|
||||
self: *Self,
|
||||
) callconv(.c) void {
|
||||
const priv = self.private();
|
||||
const page = priv.context_menu_page orelse return;
|
||||
const child = page.getChild();
|
||||
const tab = gobject.ext.cast(Tab, child) orelse return;
|
||||
tab.promptTabTitle();
|
||||
}
|
||||
|
||||
fn actionPromptTabTitle(
|
||||
_: *gio.SimpleAction,
|
||||
_: ?*glib.Variant,
|
||||
self: *Window,
|
||||
) callconv(.c) void {
|
||||
self.performBindingAction(.prompt_tab_title);
|
||||
}
|
||||
|
||||
fn actionSplitRight(
|
||||
_: *gio.SimpleAction,
|
||||
_: ?*glib.Variant,
|
||||
@@ -1999,6 +2032,7 @@ pub const Window = extern struct {
|
||||
class.bindTemplateCallback("close_page", &tabViewClosePage);
|
||||
class.bindTemplateCallback("page_attached", &tabViewPageAttached);
|
||||
class.bindTemplateCallback("page_detached", &tabViewPageDetached);
|
||||
class.bindTemplateCallback("setup_tab_menu", &setupTabMenu);
|
||||
class.bindTemplateCallback("tab_create_window", &tabViewCreateWindow);
|
||||
class.bindTemplateCallback("notify_n_pages", &tabViewNPages);
|
||||
class.bindTemplateCallback("notify_selected_page", &tabViewSelectedPage);
|
||||
|
||||
121
src/apprt/gtk/post_fork.zig
Normal file
121
src/apprt/gtk/post_fork.zig
Normal file
@@ -0,0 +1,121 @@
|
||||
const std = @import("std");
|
||||
|
||||
const gio = @import("gio");
|
||||
const glib = @import("glib");
|
||||
|
||||
const log = std.log.scoped(.gtk_post_fork);
|
||||
|
||||
const configpkg = @import("../../config.zig");
|
||||
const internal_os = @import("../../os/main.zig");
|
||||
const Command = @import("../../Command.zig");
|
||||
const cgroup = @import("./cgroup.zig");
|
||||
|
||||
const Application = @import("class/application.zig").Application;
|
||||
|
||||
pub const PostForkInfo = struct {
|
||||
gtk_single_instance: configpkg.Config.GtkSingleInstance,
|
||||
linux_cgroup: configpkg.Config.LinuxCgroup,
|
||||
linux_cgroup_hard_fail: bool,
|
||||
linux_cgroup_memory_limit: ?u64,
|
||||
linux_cgroup_processes_limit: ?u64,
|
||||
|
||||
pub fn init(cfg: *const configpkg.Config) PostForkInfo {
|
||||
return .{
|
||||
.gtk_single_instance = cfg.@"gtk-single-instance",
|
||||
.linux_cgroup = cfg.@"linux-cgroup",
|
||||
.linux_cgroup_hard_fail = cfg.@"linux-cgroup-hard-fail",
|
||||
.linux_cgroup_memory_limit = cfg.@"linux-cgroup-memory-limit",
|
||||
.linux_cgroup_processes_limit = cfg.@"linux-cgroup-processes-limit",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// If we are configured to do so, tell `systemd` to move the new child PID into
|
||||
/// a transient `systemd` scope with the configured resource limits.
|
||||
///
|
||||
/// If we are configured to hard fail, log an error message and return an error
|
||||
/// code if we don't detect the move in time.
|
||||
pub fn postFork(cmd: *Command) Command.PostForkError!void {
|
||||
switch (cmd.rt_post_fork_info.linux_cgroup) {
|
||||
.always => {},
|
||||
.never => return,
|
||||
.@"single-instance" => switch (cmd.rt_post_fork_info.gtk_single_instance) {
|
||||
.true => {},
|
||||
.false => return,
|
||||
.detect => {
|
||||
log.err("gtk-single-instance is set to detect which should be impossible!", .{});
|
||||
return error.PostForkError;
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const pid: u32 = @intCast(cmd.pid orelse {
|
||||
log.err("PID of child not known!", .{});
|
||||
return error.PostForkError;
|
||||
});
|
||||
|
||||
var expected_cgroup_buf: [256]u8 = undefined;
|
||||
const expected_cgroup = cgroup.fmtScope(&expected_cgroup_buf, pid);
|
||||
|
||||
log.debug("beginning transition to transient systemd scope {s}", .{expected_cgroup});
|
||||
|
||||
const app = Application.default();
|
||||
|
||||
const dbus = app.as(gio.Application).getDbusConnection() orelse {
|
||||
if (cmd.rt_post_fork_info.linux_cgroup_hard_fail) {
|
||||
log.err("dbus connection required for cgroup isolation, exiting", .{});
|
||||
return error.PostForkError;
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
cgroup.createScope(
|
||||
dbus,
|
||||
pid,
|
||||
.{
|
||||
.memory_high = cmd.rt_post_fork_info.linux_cgroup_memory_limit,
|
||||
.tasks_max = cmd.rt_post_fork_info.linux_cgroup_processes_limit,
|
||||
},
|
||||
) catch |err| {
|
||||
if (cmd.rt_post_fork_info.linux_cgroup_hard_fail) {
|
||||
log.err("unable to create transient systemd scope {s}: {t}", .{ expected_cgroup, err });
|
||||
return error.PostForkError;
|
||||
}
|
||||
log.warn("unable to create transient systemd scope {s}: {t}", .{ expected_cgroup, err });
|
||||
return;
|
||||
};
|
||||
|
||||
const start = std.time.Instant.now() catch unreachable;
|
||||
|
||||
loop: while (true) {
|
||||
const now = std.time.Instant.now() catch unreachable;
|
||||
|
||||
if (now.since(start) > 250 * std.time.ns_per_ms) {
|
||||
if (cmd.rt_pre_exec_info.linux_cgroup_hard_fail) {
|
||||
log.err("transition to new transient systemd scope {s} took too long", .{expected_cgroup});
|
||||
return error.PostForkError;
|
||||
}
|
||||
log.warn("transition to transient systemd scope {s} took too long", .{expected_cgroup});
|
||||
break :loop;
|
||||
}
|
||||
|
||||
not_found: {
|
||||
var current_cgroup_buf: [4096]u8 = undefined;
|
||||
|
||||
const current_cgroup_raw = internal_os.cgroup.current(
|
||||
¤t_cgroup_buf,
|
||||
@intCast(pid),
|
||||
) orelse break :not_found;
|
||||
|
||||
const index = std.mem.lastIndexOfScalar(u8, current_cgroup_raw, '/') orelse break :not_found;
|
||||
const current_cgroup = current_cgroup_raw[index + 1 ..];
|
||||
|
||||
if (std.mem.eql(u8, current_cgroup, expected_cgroup)) {
|
||||
log.debug("transition to transient systemd scope {s} complete", .{expected_cgroup});
|
||||
break :loop;
|
||||
}
|
||||
}
|
||||
|
||||
std.Thread.sleep(25 * std.time.ns_per_ms);
|
||||
}
|
||||
}
|
||||
81
src/apprt/gtk/pre_exec.zig
Normal file
81
src/apprt/gtk/pre_exec.zig
Normal file
@@ -0,0 +1,81 @@
|
||||
const std = @import("std");
|
||||
|
||||
const log = std.log.scoped(.gtk_pre_exec);
|
||||
|
||||
const configpkg = @import("../../config.zig");
|
||||
|
||||
const internal_os = @import("../../os/main.zig");
|
||||
const Command = @import("../../Command.zig");
|
||||
const cgroup = @import("./cgroup.zig");
|
||||
|
||||
pub const PreExecInfo = struct {
|
||||
gtk_single_instance: configpkg.Config.GtkSingleInstance,
|
||||
linux_cgroup: configpkg.Config.LinuxCgroup,
|
||||
linux_cgroup_hard_fail: bool,
|
||||
|
||||
pub fn init(cfg: *const configpkg.Config) PreExecInfo {
|
||||
return .{
|
||||
.gtk_single_instance = cfg.@"gtk-single-instance",
|
||||
.linux_cgroup = cfg.@"linux-cgroup",
|
||||
.linux_cgroup_hard_fail = cfg.@"linux-cgroup-hard-fail",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// If we are expecting to be moved to a transient systemd scope, wait to see if
|
||||
/// that happens by checking for the correct name of the current cgroup. Wait at
|
||||
/// most 250ms so that we don't overly delay the soft-fail scenario.
|
||||
///
|
||||
/// If we are configured to hard fail, log an error message and return an error
|
||||
/// code if we don't detect the move in time.
|
||||
pub fn preExec(cmd: *Command) ?u8 {
|
||||
switch (cmd.rt_pre_exec_info.linux_cgroup) {
|
||||
.always => {},
|
||||
.never => return null,
|
||||
.@"single-instance" => switch (cmd.rt_pre_exec_info.gtk_single_instance) {
|
||||
.true => {},
|
||||
.false => return null,
|
||||
.detect => {
|
||||
log.err("gtk-single-instance is set to detect", .{});
|
||||
return 127;
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const pid: u32 = @intCast(std.os.linux.getpid());
|
||||
|
||||
var expected_cgroup_buf: [256]u8 = undefined;
|
||||
const expected_cgroup = cgroup.fmtScope(&expected_cgroup_buf, pid);
|
||||
|
||||
const start = std.time.Instant.now() catch unreachable;
|
||||
|
||||
while (true) {
|
||||
const now = std.time.Instant.now() catch unreachable;
|
||||
|
||||
if (now.since(start) > 250 * std.time.ns_per_ms) {
|
||||
if (cmd.rt_pre_exec_info.linux_cgroup_hard_fail) {
|
||||
log.err("transition to new transient systemd scope took too long", .{});
|
||||
return 127;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
not_found: {
|
||||
var current_cgroup_buf: [4096]u8 = undefined;
|
||||
|
||||
const current_cgroup_raw = internal_os.cgroup.current(
|
||||
¤t_cgroup_buf,
|
||||
@intCast(pid),
|
||||
) orelse break :not_found;
|
||||
|
||||
const index = std.mem.lastIndexOfScalar(u8, current_cgroup_raw, '/') orelse break :not_found;
|
||||
const current_cgroup = current_cgroup_raw[index + 1 ..];
|
||||
|
||||
if (std.mem.eql(u8, current_cgroup, expected_cgroup)) return null;
|
||||
}
|
||||
|
||||
std.Thread.sleep(25 * std.time.ns_per_ms);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -321,6 +321,11 @@ menu context_menu_model {
|
||||
submenu {
|
||||
label: _("Tab");
|
||||
|
||||
item {
|
||||
label: _("Change Tab Title…");
|
||||
action: "tab.prompt-tab-title";
|
||||
}
|
||||
|
||||
item {
|
||||
label: _("New Tab");
|
||||
action: "win.new-tab";
|
||||
|
||||
@@ -8,7 +8,7 @@ template $GhosttyTab: Box {
|
||||
orientation: vertical;
|
||||
hexpand: true;
|
||||
vexpand: true;
|
||||
title: bind $computed_title(template.config, split_tree.active-surface as <$GhosttySurface>.title, split_tree.active-surface as <$GhosttySurface>.title-override, split_tree.is-zoomed, split_tree.active-surface as <$GhosttySurface>.bell-ringing) as <string>;
|
||||
title: bind $computed_title(template.config, split_tree.active-surface as <$GhosttySurface>.title, split_tree.active-surface as <$GhosttySurface>.title-override, template.title-override, split_tree.is-zoomed, split_tree.active-surface as <$GhosttySurface>.bell-ringing) as <string>;
|
||||
tooltip: bind split_tree.active-surface as <$GhosttySurface>.pwd;
|
||||
|
||||
$GhosttySplitTree split_tree {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
using Gtk 4.0;
|
||||
using Adw 1;
|
||||
|
||||
template $GhosttySurfaceTitleDialog: Adw.AlertDialog {
|
||||
heading: _("Change Terminal Title");
|
||||
template $GhosttyTitleDialog: Adw.AlertDialog {
|
||||
body: _("Leave blank to restore the default title.");
|
||||
|
||||
responses [
|
||||
@@ -162,6 +162,8 @@ template $GhosttyWindow: Adw.ApplicationWindow {
|
||||
page-attached => $page_attached();
|
||||
page-detached => $page_detached();
|
||||
create-window => $tab_create_window();
|
||||
setup-menu => $setup_tab_menu();
|
||||
menu-model: tab_context_menu;
|
||||
shortcuts: none;
|
||||
}
|
||||
}
|
||||
@@ -218,6 +220,11 @@ menu main_menu {
|
||||
}
|
||||
|
||||
section {
|
||||
item {
|
||||
label: _("Change Tab Title…");
|
||||
action: "win.prompt-tab-title";
|
||||
}
|
||||
|
||||
item {
|
||||
label: _("New Tab");
|
||||
action: "win.new-tab";
|
||||
@@ -307,3 +314,10 @@ menu main_menu {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
menu tab_context_menu {
|
||||
item {
|
||||
label: _("Change Tab Title…");
|
||||
action: "win.prompt-context-tab-title";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,16 +65,14 @@ fn createUpdateStep(b: *std.Build) !*std.Build.Step {
|
||||
"xgettext",
|
||||
"--language=C", // Silence the "unknown extension" errors
|
||||
"--from-code=UTF-8",
|
||||
"--add-comments=Translators",
|
||||
"--keyword=_",
|
||||
"--keyword=C_:1c,2",
|
||||
"--package-name=" ++ domain,
|
||||
"--msgid-bugs-address=m@mitchellh.com",
|
||||
"--copyright-holder=\"Mitchell Hashimoto, Ghostty contributors\"",
|
||||
"-o",
|
||||
"-",
|
||||
});
|
||||
|
||||
// Collect to intermediate .pot file
|
||||
xgettext.addArg("-o");
|
||||
const gtk_pot = xgettext.addOutputFileArg("gtk.pot");
|
||||
|
||||
// Not cacheable due to the gresource files
|
||||
xgettext.has_side_effects = true;
|
||||
|
||||
@@ -149,16 +147,45 @@ fn createUpdateStep(b: *std.Build) !*std.Build.Step {
|
||||
}
|
||||
}
|
||||
|
||||
// Add support for localizing our `nautilus` integration
|
||||
const xgettext_py = b.addSystemCommand(&.{
|
||||
"xgettext",
|
||||
"--language=Python",
|
||||
"--from-code=UTF-8",
|
||||
});
|
||||
|
||||
// Collect to intermediate .pot file
|
||||
xgettext_py.addArg("-o");
|
||||
const py_pot = xgettext_py.addOutputFileArg("py.pot");
|
||||
|
||||
const nautilus_script_path = "dist/linux/ghostty_nautilus.py";
|
||||
xgettext_py.addArg(nautilus_script_path);
|
||||
xgettext_py.addFileInput(b.path(nautilus_script_path));
|
||||
|
||||
// Merge pot files
|
||||
const xgettext_merge = b.addSystemCommand(&.{
|
||||
"xgettext",
|
||||
"--add-comments=Translators",
|
||||
"--package-name=" ++ domain,
|
||||
"--msgid-bugs-address=m@mitchellh.com",
|
||||
"--copyright-holder=\"Mitchell Hashimoto, Ghostty contributors\"",
|
||||
"-o",
|
||||
"-",
|
||||
});
|
||||
// py_pot needs to be first on merge order because of `xgettext` behavior around
|
||||
// charset when merging the two `.pot` files
|
||||
xgettext_merge.addFileArg(py_pot);
|
||||
xgettext_merge.addFileArg(gtk_pot);
|
||||
const usf = b.addUpdateSourceFiles();
|
||||
usf.addCopyFileToSource(
|
||||
xgettext.captureStdOut(),
|
||||
xgettext_merge.captureStdOut(),
|
||||
"po/" ++ domain ++ ".pot",
|
||||
);
|
||||
|
||||
inline for (locales) |locale| {
|
||||
const msgmerge = b.addSystemCommand(&.{ "msgmerge", "--quiet", "--no-fuzzy-matching" });
|
||||
msgmerge.addFileArg(b.path("po/" ++ locale ++ ".po"));
|
||||
msgmerge.addFileArg(xgettext.captureStdOut());
|
||||
msgmerge.addFileArg(xgettext_merge.captureStdOut());
|
||||
usf.addCopyFileToSource(msgmerge.captureStdOut(), "po/" ++ locale ++ ".po");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const GhosttyLibVt = @This();
|
||||
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const assert = std.debug.assert;
|
||||
const RunStep = std.Build.Step.Run;
|
||||
const GhosttyZig = @import("GhosttyZig.zig");
|
||||
@@ -61,6 +62,19 @@ pub fn initShared(
|
||||
.{ .include_extensions = &.{".h"} },
|
||||
);
|
||||
|
||||
if (lib.rootModuleTarget().os.tag.isDarwin()) {
|
||||
// Self-hosted x86_64 doesn't work for darwin. It may not work
|
||||
// for other platforms too but definitely darwin.
|
||||
lib.use_llvm = true;
|
||||
|
||||
// This is required for codesign and dynamic linking to work.
|
||||
lib.headerpad_max_install_names = true;
|
||||
|
||||
// If we're not cross compiling then we try to find the Apple
|
||||
// SDK using standard Apple tooling.
|
||||
if (builtin.os.tag.isDarwin()) try @import("apple_sdk").addPaths(b, lib);
|
||||
}
|
||||
|
||||
// Get our debug symbols
|
||||
const dsymutil: ?std.Build.LazyPath = dsymutil: {
|
||||
if (!target.result.os.tag.isDarwin()) {
|
||||
|
||||
@@ -94,6 +94,27 @@ pub const compatibility = std.StaticStringMap(
|
||||
.{ "macos-dock-drop-behavior", compatMacOSDockDropBehavior },
|
||||
});
|
||||
|
||||
/// Set Ghostty's graphical user interface language to a language other than the
|
||||
/// system default language. The language must be fully specified, including the
|
||||
/// encoding. For example:
|
||||
///
|
||||
/// language = de_DE.UTF-8
|
||||
///
|
||||
/// will force the strings in Ghostty's graphical user interface to be in German
|
||||
/// rather than the system default.
|
||||
///
|
||||
/// This will not affect the language used by programs run _within_ Ghostty.
|
||||
/// Those will continue to use the default system language. There are also many
|
||||
/// non-GUI elements in Ghostty that are not translated - this setting will have
|
||||
/// no effect on those.
|
||||
///
|
||||
/// Warning: This setting cannot be reloaded at runtime. To change the language
|
||||
/// you must fully restart Ghostty.
|
||||
///
|
||||
/// GTK only.
|
||||
/// Available since 1.3.0.
|
||||
language: ?[:0]const u8 = null,
|
||||
|
||||
/// The font families to use.
|
||||
///
|
||||
/// You can generate the list of valid values using the CLI:
|
||||
@@ -763,8 +784,30 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF },
|
||||
///
|
||||
/// For definitions on the color indices and what they canonically map to,
|
||||
/// [see this cheat sheet](https://www.ditig.com/256-colors-cheat-sheet).
|
||||
///
|
||||
/// For most themes, you only need to set the first 16 colors (0–15) since the
|
||||
/// rest of the palette (16–255) will be automatically generated by
|
||||
/// default (see `palette-generate` for more details).
|
||||
palette: Palette = .{},
|
||||
|
||||
/// Whether to automatically generate the extended 256 color palette
|
||||
/// (indices 16–255) from the base 16 ANSI colors.
|
||||
///
|
||||
/// This lets theme authors specify only the base 16 colors and have the
|
||||
/// rest of the palette be automatically generated in a consistent and
|
||||
/// aesthetic way.
|
||||
///
|
||||
/// When enabled, the 6×6×6 color cube and 24-step grayscale ramp are
|
||||
/// derived from interpolations of the base palette, giving a more cohesive
|
||||
/// look. Colors that have been explicitly set via `palette` are never
|
||||
/// overwritten.
|
||||
///
|
||||
/// For more information on how the generation works, see here:
|
||||
/// https://gist.github.com/jake-stewart/0a8ea46159a7da2c808e5be2177e1783
|
||||
///
|
||||
/// Available since: 1.3.0
|
||||
@"palette-generate": bool = true,
|
||||
|
||||
/// The color of the cursor. If this is not set, a default will be chosen.
|
||||
///
|
||||
/// Direct colors can be specified as either hex (`#RRGGBB` or `RRGGBB`)
|
||||
@@ -2710,7 +2753,7 @@ keybind: Keybinds = .{},
|
||||
///
|
||||
/// Available features:
|
||||
///
|
||||
/// * `cursor` - Set the cursor to a blinking bar at the prompt.
|
||||
/// * `cursor` - Set the cursor to a bar at the prompt.
|
||||
///
|
||||
/// * `sudo` - Set sudo wrapper to preserve terminfo.
|
||||
///
|
||||
@@ -3349,13 +3392,12 @@ keybind: Keybinds = .{},
|
||||
/// Available since: 1.2.0
|
||||
@"macos-shortcuts": MacShortcuts = .ask,
|
||||
|
||||
/// Put every surface (tab, split, window) into a dedicated Linux cgroup.
|
||||
/// Put every surface (tab, split, window) into a transient `systemd` scope.
|
||||
///
|
||||
/// This makes it so that resource management can be done on a per-surface
|
||||
/// granularity. For example, if a shell program is using too much memory,
|
||||
/// only that shell will be killed by the oom monitor instead of the entire
|
||||
/// Ghostty process. Similarly, if a shell program is using too much CPU,
|
||||
/// only that surface will be CPU-throttled.
|
||||
/// This allows per-surface resource management. For example, if a shell program
|
||||
/// is using too much memory, only that shell will be killed by the oom monitor
|
||||
/// instead of the entire Ghostty process. Similarly, if a shell program is
|
||||
/// using too much CPU, only that surface will be CPU-throttled.
|
||||
///
|
||||
/// This will cause startup times to be slower (a hundred milliseconds or so),
|
||||
/// so the default value is "single-instance." In single-instance mode, only
|
||||
@@ -3364,9 +3406,12 @@ keybind: Keybinds = .{},
|
||||
/// more likely to have many windows, tabs, etc. so cgroup isolation is a
|
||||
/// big benefit.
|
||||
///
|
||||
/// This feature requires systemd. If systemd is unavailable, cgroup
|
||||
/// initialization will fail. By default, this will not prevent Ghostty
|
||||
/// from working (see linux-cgroup-hard-fail).
|
||||
/// This feature requires `systemd`. If `systemd` is unavailable, cgroup
|
||||
/// initialization will fail. By default, this will not prevent Ghostty from
|
||||
/// working (see `linux-cgroup-hard-fail`).
|
||||
///
|
||||
/// Changing this value and reloading the config will not affect existing
|
||||
/// surfaces.
|
||||
///
|
||||
/// Valid values are:
|
||||
///
|
||||
@@ -3382,30 +3427,42 @@ else
|
||||
/// Memory limit for any individual terminal process (tab, split, window,
|
||||
/// etc.) in bytes. If this is unset then no memory limit will be set.
|
||||
///
|
||||
/// Note that this sets the "memory.high" configuration for the memory
|
||||
/// controller, which is a soft limit. You should configure something like
|
||||
/// systemd-oom to handle killing processes that have too much memory
|
||||
/// Note that this sets the `MemoryHigh` setting on the transient `systemd`
|
||||
/// scope, which is a soft limit. You should configure something like
|
||||
/// `systemd-oom` to handle killing processes that have too much memory
|
||||
/// pressure.
|
||||
///
|
||||
/// Changing this value and reloading the config will not affect existing
|
||||
/// surfaces.
|
||||
///
|
||||
/// See the `systemd.resource-control` manual page for more information:
|
||||
/// https://www.freedesktop.org/software/systemd/man/latest/systemd.resource-control.html
|
||||
@"linux-cgroup-memory-limit": ?u64 = null,
|
||||
|
||||
/// Number of processes limit for any individual terminal process (tab, split,
|
||||
/// window, etc.). If this is unset then no limit will be set.
|
||||
///
|
||||
/// Note that this sets the "pids.max" configuration for the process number
|
||||
/// controller, which is a hard limit.
|
||||
/// Note that this sets the `TasksMax` setting on the transient `systemd` scope,
|
||||
/// which is a hard limit.
|
||||
///
|
||||
/// Changing this value and reloading the config will not affect existing
|
||||
/// surfaces.
|
||||
///
|
||||
/// See the `systemd.resource-control` manual page for more information:
|
||||
/// https://www.freedesktop.org/software/systemd/man/latest/systemd.resource-control.html
|
||||
@"linux-cgroup-processes-limit": ?u64 = null,
|
||||
|
||||
/// If this is false, then any cgroup initialization (for linux-cgroup)
|
||||
/// will be allowed to fail and the failure is ignored. This is useful if
|
||||
/// you view cgroup isolation as a "nice to have" and not a critical resource
|
||||
/// management feature, because Ghostty startup will not fail if cgroup APIs
|
||||
/// fail.
|
||||
/// If this is false, then creating a transient `systemd` scope (for
|
||||
/// `linux-cgroup`) will be allowed to fail and the failure is ignored. This is
|
||||
/// useful if you view cgroup isolation as a "nice to have" and not a critical
|
||||
/// resource management feature, because surface creation will not fail if
|
||||
/// `systemd` APIs fail.
|
||||
///
|
||||
/// If this is true, then any cgroup initialization failure will cause
|
||||
/// Ghostty to exit or new surfaces to not be created.
|
||||
/// If this is true, then any transient `systemd` scope creation failure will
|
||||
/// cause surface creation to fail.
|
||||
///
|
||||
/// Note: This currently only affects cgroup initialization. Subprocesses
|
||||
/// must always be able to move themselves into an isolated cgroup.
|
||||
/// Changing this value and reloading the config will not affect existing
|
||||
/// surfaces.
|
||||
@"linux-cgroup-hard-fail": bool = false,
|
||||
|
||||
/// Enable or disable GTK's OpenGL debugging logs. The default is `true` for
|
||||
@@ -5509,14 +5566,16 @@ pub const ColorList = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// Palette is the 256 color palette for 256-color mode. This is still
|
||||
/// used by many terminal applications.
|
||||
/// Palette is the 256 color palette for 256-color mode.
|
||||
pub const Palette = struct {
|
||||
const Self = @This();
|
||||
|
||||
/// The actual value that is updated as we parse.
|
||||
value: terminal.color.Palette = terminal.color.default,
|
||||
|
||||
/// Keep track of which indexes were manually set by the user.
|
||||
mask: terminal.color.PaletteMask = .initEmpty(),
|
||||
|
||||
/// ghostty_config_palette_s
|
||||
pub const C = extern struct {
|
||||
colors: [265]Color.C,
|
||||
@@ -5553,6 +5612,7 @@ pub const Palette = struct {
|
||||
// Parse the color part (Color.parseCLI will handle whitespace)
|
||||
const rgb = try Color.parseCLI(value[eqlIdx + 1 ..]);
|
||||
self.value[key] = .{ .r = rgb.r, .g = rgb.g, .b = rgb.b };
|
||||
self.mask.set(key);
|
||||
}
|
||||
|
||||
/// Deep copy of the struct. Required by Config.
|
||||
@@ -5588,6 +5648,8 @@ pub const Palette = struct {
|
||||
try testing.expect(p.value[0].r == 0xAA);
|
||||
try testing.expect(p.value[0].g == 0xBB);
|
||||
try testing.expect(p.value[0].b == 0xCC);
|
||||
try testing.expect(p.mask.isSet(0));
|
||||
try testing.expect(!p.mask.isSet(1));
|
||||
}
|
||||
|
||||
test "parseCLI base" {
|
||||
@@ -5610,6 +5672,12 @@ pub const Palette = struct {
|
||||
try testing.expect(p.value[0xF].r == 0xAB);
|
||||
try testing.expect(p.value[0xF].g == 0xCD);
|
||||
try testing.expect(p.value[0xF].b == 0xEF);
|
||||
|
||||
try testing.expect(p.mask.isSet(0b1));
|
||||
try testing.expect(p.mask.isSet(0o7));
|
||||
try testing.expect(p.mask.isSet(0xF));
|
||||
try testing.expect(!p.mask.isSet(0));
|
||||
try testing.expect(!p.mask.isSet(2));
|
||||
}
|
||||
|
||||
test "parseCLI overflow" {
|
||||
@@ -5617,6 +5685,8 @@ pub const Palette = struct {
|
||||
|
||||
var p: Self = .{};
|
||||
try testing.expectError(error.Overflow, p.parseCLI("256=#AABBCC"));
|
||||
// Mask should remain empty since parsing failed.
|
||||
try testing.expectEqual(@as(usize, 0), p.mask.count());
|
||||
}
|
||||
|
||||
test "formatConfig" {
|
||||
@@ -5648,6 +5718,11 @@ pub const Palette = struct {
|
||||
try testing.expect(p.value[2].r == 0x12);
|
||||
try testing.expect(p.value[2].g == 0x34);
|
||||
try testing.expect(p.value[2].b == 0x56);
|
||||
|
||||
try testing.expect(p.mask.isSet(0));
|
||||
try testing.expect(p.mask.isSet(1));
|
||||
try testing.expect(p.mask.isSet(2));
|
||||
try testing.expect(!p.mask.isSet(3));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
const std = @import("std");
|
||||
const oni = @import("oniguruma");
|
||||
|
||||
/// Default URL regex. This is used to detect URLs in terminal output.
|
||||
/// Default URL/path regex. This is used to detect URLs and file paths in
|
||||
/// terminal output.
|
||||
///
|
||||
/// This is here in the config package because one day the matchers will be
|
||||
/// configurable and this will be a default.
|
||||
///
|
||||
/// This regex is liberal in what it accepts after the scheme, with exceptions
|
||||
/// for URLs ending with . or ). Although such URLs are perfectly valid, it is
|
||||
/// common for text to contain URLs surrounded by parentheses (such as in
|
||||
/// Markdown links) or at the end of sentences. Therefore, this regex excludes
|
||||
/// them as follows:
|
||||
/// For scheme URLs, this regex is liberal in what it accepts after the scheme,
|
||||
/// with exceptions for URLs ending with . or ). Although such URLs are
|
||||
/// perfectly valid, it is common for text to contain URLs surrounded by
|
||||
/// parentheses (such as in Markdown links) or at the end of sentences.
|
||||
/// Therefore, this regex excludes them as follows:
|
||||
///
|
||||
/// 1. Do not match regexes ending with .
|
||||
/// 2. Do not match regexes ending with ), except for ones which contain a (
|
||||
@@ -22,12 +24,6 @@ const oni = @import("oniguruma");
|
||||
///
|
||||
/// There are many complicated cases where these heuristics break down, but
|
||||
/// handling them well requires a non-regex approach.
|
||||
pub const regex =
|
||||
"(?:" ++ url_schemes ++
|
||||
\\)(?:
|
||||
++ ipv6_url_pattern ++
|
||||
\\|[\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:
|
||||
;
|
||||
@@ -36,6 +32,95 @@ const ipv6_url_pattern =
|
||||
\\(?:\[[:0-9a-fA-F]+(?:[:0-9a-fA-F]*)+\](?::[0-9]+)?)
|
||||
;
|
||||
|
||||
const scheme_url_chars =
|
||||
\\[\w\-.~:/?#@!$&*+,;=%]
|
||||
;
|
||||
|
||||
const path_chars =
|
||||
\\[\w\-.~:\/?#@!$&*+;=%]
|
||||
;
|
||||
|
||||
const optional_bracketed_word_suffix =
|
||||
\\(?:[\(\[]\w*[\)\]])?
|
||||
;
|
||||
|
||||
const no_trailing_punctuation =
|
||||
\\(?<![,.])
|
||||
;
|
||||
|
||||
const no_trailing_colon =
|
||||
\\(?<!:)
|
||||
;
|
||||
|
||||
const trailing_spaces_at_eol =
|
||||
\\(?: +(?= *$))?
|
||||
;
|
||||
|
||||
const dotted_path_lookahead =
|
||||
\\(?=[\w\-.~:\/?#@!$&*+;=%]*\.)
|
||||
;
|
||||
|
||||
const non_dotted_path_lookahead =
|
||||
\\(?![\w\-.~:\/?#@!$&*+;=%]*\.)
|
||||
;
|
||||
|
||||
const dotted_path_space_segments =
|
||||
\\(?:(?<!:) (?!\w+:\/\/)[\w\-.~:\/?#@!$&*+;=%]*[\/.])*
|
||||
;
|
||||
|
||||
const any_path_space_segments =
|
||||
\\(?:(?<!:) (?!\w+:\/\/)[\w\-.~:\/?#@!$&*+;=%]+)*
|
||||
;
|
||||
|
||||
// Branch 1: URLs with explicit schemes (http, mailto, ftp, etc.).
|
||||
const scheme_url_branch =
|
||||
"(?:" ++ url_schemes ++ ")" ++
|
||||
"(?:" ++ ipv6_url_pattern ++ "|" ++ scheme_url_chars ++ "+" ++ optional_bracketed_word_suffix ++ ")+" ++
|
||||
no_trailing_punctuation;
|
||||
|
||||
const rooted_or_relative_path_prefix =
|
||||
\\(?:\.\.\/|\.\/|(?<!\w)~\/|(?:[\w][\w\-.]*\/)*(?<!\w)\$[A-Za-z_]\w*\/|\.[\w][\w\-.]*\/|(?<![\w~\/])\/(?!\/))
|
||||
;
|
||||
|
||||
// Branch 2: Absolute paths and dot-relative paths (/, ./, ../).
|
||||
// A dotted segment is treated as file-like, while the undotted case stays
|
||||
// broad to capture directory-like paths with spaces.
|
||||
const rooted_or_relative_path_branch =
|
||||
rooted_or_relative_path_prefix ++
|
||||
"(?:" ++
|
||||
dotted_path_lookahead ++
|
||||
path_chars ++ "+" ++
|
||||
dotted_path_space_segments ++
|
||||
no_trailing_colon ++
|
||||
trailing_spaces_at_eol ++
|
||||
"|" ++
|
||||
non_dotted_path_lookahead ++
|
||||
path_chars ++ "+" ++
|
||||
any_path_space_segments ++
|
||||
no_trailing_colon ++
|
||||
trailing_spaces_at_eol ++
|
||||
")";
|
||||
|
||||
// Branch 3: Bare relative paths such as src/config/url.zig.
|
||||
const bare_relative_path_prefix =
|
||||
\\(?<!\$\d*)(?<!\w)[\w][\w\-.]*\/
|
||||
;
|
||||
|
||||
const bare_relative_path_branch =
|
||||
dotted_path_lookahead ++
|
||||
bare_relative_path_prefix ++
|
||||
path_chars ++ "+" ++
|
||||
dotted_path_space_segments ++
|
||||
no_trailing_colon ++
|
||||
trailing_spaces_at_eol;
|
||||
|
||||
pub const regex =
|
||||
scheme_url_branch ++
|
||||
"|" ++
|
||||
rooted_or_relative_path_branch ++
|
||||
"|" ++
|
||||
bare_relative_path_branch;
|
||||
|
||||
test "url regex" {
|
||||
const testing = std.testing;
|
||||
|
||||
@@ -77,7 +162,7 @@ test "url regex" {
|
||||
.expect = "https://example.com",
|
||||
},
|
||||
.{
|
||||
.input = "Link trailing colon https://example.com, more text.",
|
||||
.input = "Link trailing comma https://example.com, more text.",
|
||||
.expect = "https://example.com",
|
||||
},
|
||||
.{
|
||||
@@ -148,6 +233,10 @@ test "url regex" {
|
||||
.input = "match git://example.com git links",
|
||||
.expect = "git://example.com",
|
||||
},
|
||||
.{
|
||||
.input = "/tmp/test.txt http://www.google.com",
|
||||
.expect = "/tmp/test.txt",
|
||||
},
|
||||
.{
|
||||
.input = "match tel:+18005551234 tel links",
|
||||
.expect = "tel:+18005551234",
|
||||
@@ -291,6 +380,89 @@ test "url regex" {
|
||||
.input = "some-pkg/src/file.txt more text",
|
||||
.expect = "some-pkg/src/file.txt",
|
||||
},
|
||||
// comma should match substrings
|
||||
.{
|
||||
.input = "src/foo.c,baz.txt",
|
||||
.expect = "src/foo.c",
|
||||
},
|
||||
.{
|
||||
.input = "~/foo/bar.txt",
|
||||
.expect = "~/foo/bar.txt",
|
||||
},
|
||||
.{
|
||||
.input = "open ~/Documents/notes.md please",
|
||||
.expect = "~/Documents/notes.md",
|
||||
},
|
||||
.{
|
||||
.input = "~/.config/ghostty/config",
|
||||
.expect = "~/.config/ghostty/config",
|
||||
},
|
||||
.{
|
||||
.input = "directory: ~/src/ghostty-org/ghostty",
|
||||
.expect = "~/src/ghostty-org/ghostty",
|
||||
},
|
||||
.{
|
||||
.input = "$HOME/src/config/url.zig",
|
||||
.expect = "$HOME/src/config/url.zig",
|
||||
},
|
||||
.{
|
||||
.input = "project dir: $PWD/src/ghostty/main.zig",
|
||||
.expect = "$PWD/src/ghostty/main.zig",
|
||||
},
|
||||
// $VAR mid-path should match fully, not partially from the $
|
||||
.{
|
||||
.input = "foo/$BAR/baz",
|
||||
.expect = "foo/$BAR/baz",
|
||||
},
|
||||
.{
|
||||
.input = ".foo/bar/$VAR",
|
||||
.expect = ".foo/bar/$VAR",
|
||||
},
|
||||
.{
|
||||
.input = ".config/ghostty/config",
|
||||
.expect = ".config/ghostty/config",
|
||||
},
|
||||
.{
|
||||
.input = "loaded from .local/share/ghostty/state.db now",
|
||||
.expect = ".local/share/ghostty/state.db",
|
||||
},
|
||||
.{
|
||||
.input = "../some/where",
|
||||
.expect = "../some/where",
|
||||
},
|
||||
// comma-separated file paths
|
||||
.{
|
||||
.input = " - shared/src/foo/SomeItem.m:12, shared/src/",
|
||||
.expect = "shared/src/foo/SomeItem.m:12",
|
||||
},
|
||||
// mid-string dot should not partially match but fully
|
||||
.{
|
||||
.input = "foo.local/share",
|
||||
.expect = "foo.local/share",
|
||||
},
|
||||
// numeric directory should match fully
|
||||
.{
|
||||
.input = "2024/report.txt",
|
||||
.expect = "2024/report.txt",
|
||||
},
|
||||
// comma should stop matching in spaced path segments
|
||||
.{
|
||||
.input = "./foo bar,baz",
|
||||
.expect = "./foo bar",
|
||||
},
|
||||
.{
|
||||
.input = "/tmp/foo bar,baz",
|
||||
.expect = "/tmp/foo bar",
|
||||
},
|
||||
// trailing colon should not be part of the path
|
||||
.{
|
||||
.input = "./.config/ghostty: Needs upstream (main)",
|
||||
.expect = "./.config/ghostty",
|
||||
},
|
||||
.{
|
||||
.input = "./Downloads: Operation not permitted",
|
||||
.expect = "./Downloads",
|
||||
},
|
||||
};
|
||||
|
||||
for (cases) |case| {
|
||||
@@ -306,10 +478,23 @@ test "url regex" {
|
||||
try testing.expectEqualStrings(case.expect, match);
|
||||
}
|
||||
|
||||
// Bare relative paths without any dot should not match as file paths
|
||||
const no_match_cases = [_][]const u8{
|
||||
// bare relative paths without any dot should not match as file paths
|
||||
"input/output",
|
||||
"foo/bar",
|
||||
// $-numeric character should not match
|
||||
"$10/bar",
|
||||
"$10/$20",
|
||||
"$10/bar.txt",
|
||||
// comma should not let dot detection look past it
|
||||
"foo/bar,baz.txt",
|
||||
// $VAR should not match mid-word
|
||||
"foo$BAR/baz.txt",
|
||||
// ~ should not match mid-word
|
||||
"foo~/bar.txt",
|
||||
// double-slash comments are not paths
|
||||
"// foo bar",
|
||||
"//foo",
|
||||
};
|
||||
for (no_match_cases) |input| {
|
||||
var result = re.search(input, .{});
|
||||
|
||||
@@ -265,7 +265,7 @@ pub fn SplitTree(comptime V: type) type {
|
||||
// Get our spatial representation.
|
||||
var sp = try self.spatial(alloc);
|
||||
defer sp.deinit(alloc);
|
||||
break :spatial self.nearest(sp, from, d);
|
||||
break :spatial self.nearestWrapped(sp, from, d);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -385,14 +385,15 @@ pub fn SplitTree(comptime V: type) type {
|
||||
}
|
||||
|
||||
/// Returns the nearest leaf node (view) in the given direction.
|
||||
/// This does not handle wrapping and will return null if there
|
||||
/// is no node in that direction.
|
||||
fn nearest(
|
||||
self: *const Self,
|
||||
sp: Spatial,
|
||||
from: Node.Handle,
|
||||
direction: Spatial.Direction,
|
||||
target: Spatial.Slot,
|
||||
) ?Node.Handle {
|
||||
const target = sp.slots[from.idx()];
|
||||
|
||||
var result: ?struct {
|
||||
handle: Node.Handle,
|
||||
distance: f16,
|
||||
@@ -433,6 +434,45 @@ pub fn SplitTree(comptime V: type) type {
|
||||
return if (result) |n| n.handle else null;
|
||||
}
|
||||
|
||||
/// Same as nearest but supports wrapping.
|
||||
fn nearestWrapped(
|
||||
self: *const Self,
|
||||
sp: Spatial,
|
||||
from: Node.Handle,
|
||||
direction: Spatial.Direction,
|
||||
) ?Node.Handle {
|
||||
// If we can find a nearest value without wrapping, then
|
||||
// use that.
|
||||
var target = sp.slots[from.idx()];
|
||||
if (self.nearest(
|
||||
sp,
|
||||
from,
|
||||
direction,
|
||||
target,
|
||||
)) |v| return v;
|
||||
|
||||
// The spatial grid is normalized to 1x1, so wrapping is modeled
|
||||
// by shifting the target slot by one full grid in the opposite
|
||||
// direction and reusing the same nearest distance logic.
|
||||
// We don't actually modify the grid or spatial representation,
|
||||
// this just fakes it.
|
||||
assert(target.x >= 0 and target.y >= 0);
|
||||
assert(target.maxX() <= 1 and target.maxY() <= 1);
|
||||
switch (direction) {
|
||||
.left => target.x += 1,
|
||||
.right => target.x -= 1,
|
||||
.up => target.y += 1,
|
||||
.down => target.y -= 1,
|
||||
}
|
||||
|
||||
return self.nearest(
|
||||
sp,
|
||||
from,
|
||||
direction,
|
||||
target,
|
||||
);
|
||||
}
|
||||
|
||||
/// Resize the given node in place. The node MUST be a split (asserted).
|
||||
///
|
||||
/// In general, this is an immutable data structure so this is
|
||||
@@ -1974,6 +2014,60 @@ test "SplitTree: spatial goto" {
|
||||
try testing.expectEqualStrings("A", view.label);
|
||||
}
|
||||
|
||||
// Spatial A => left (wrapped)
|
||||
{
|
||||
const target = (try split.goto(
|
||||
alloc,
|
||||
from: {
|
||||
var it = split.iterator();
|
||||
break :from while (it.next()) |entry| {
|
||||
if (std.mem.eql(u8, entry.view.label, "A")) {
|
||||
break entry.handle;
|
||||
}
|
||||
} else return error.NotFound;
|
||||
},
|
||||
.{ .spatial = .left },
|
||||
)).?;
|
||||
const view = split.nodes[target.idx()].leaf;
|
||||
try testing.expectEqualStrings("B", view.label);
|
||||
}
|
||||
|
||||
// Spatial B => right (wrapped)
|
||||
{
|
||||
const target = (try split.goto(
|
||||
alloc,
|
||||
from: {
|
||||
var it = split.iterator();
|
||||
break :from while (it.next()) |entry| {
|
||||
if (std.mem.eql(u8, entry.view.label, "B")) {
|
||||
break entry.handle;
|
||||
}
|
||||
} else return error.NotFound;
|
||||
},
|
||||
.{ .spatial = .right },
|
||||
)).?;
|
||||
const view = split.nodes[target.idx()].leaf;
|
||||
try testing.expectEqualStrings("A", view.label);
|
||||
}
|
||||
|
||||
// Spatial C => down (wrapped)
|
||||
{
|
||||
const target = (try split.goto(
|
||||
alloc,
|
||||
from: {
|
||||
var it = split.iterator();
|
||||
break :from while (it.next()) |entry| {
|
||||
if (std.mem.eql(u8, entry.view.label, "C")) {
|
||||
break entry.handle;
|
||||
}
|
||||
} else return error.NotFound;
|
||||
},
|
||||
.{ .spatial = .down },
|
||||
)).?;
|
||||
const view = split.nodes[target.idx()].leaf;
|
||||
try testing.expectEqualStrings("A", view.label);
|
||||
}
|
||||
|
||||
// Equalize
|
||||
var equal = try split.equalize(alloc);
|
||||
defer equal.deinit();
|
||||
|
||||
@@ -40,6 +40,12 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
|
||||
try writer.writeAll(
|
||||
\\_ghostty() {
|
||||
\\
|
||||
\\ # compat: mapfile -t COMPREPLY < <( "$@" )
|
||||
\\ _compreply() {
|
||||
\\ COMPREPLY=()
|
||||
\\ while IFS='' read -r line; do COMPREPLY+=("$line"); done < <( "$@" )
|
||||
\\ }
|
||||
\\
|
||||
\\ # -o nospace requires we add back a space when a completion is finished
|
||||
\\ # and not part of a --key= completion
|
||||
\\ _add_spaces() {
|
||||
@@ -50,16 +56,18 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
|
||||
\\
|
||||
\\ _fonts() {
|
||||
\\ local IFS=$'\n'
|
||||
\\ mapfile -t COMPREPLY < <( compgen -P '"' -S '"' -W "$($ghostty +list-fonts | grep '^[A-Z]' )" -- "$cur")
|
||||
\\ COMPREPLY=()
|
||||
\\ while read -r line; do COMPREPLY+=("$line"); done < <( compgen -P '"' -S '"' -W "$($ghostty +list-fonts | grep '^[A-Z]' )" -- "$cur")
|
||||
\\ }
|
||||
\\
|
||||
\\ _themes() {
|
||||
\\ local IFS=$'\n'
|
||||
\\ mapfile -t COMPREPLY < <( compgen -P '"' -S '"' -W "$($ghostty +list-themes | sed -E 's/^(.*) \(.*$/\1/')" -- "$cur")
|
||||
\\ COMPREPLY=()
|
||||
\\ while read -r line; do COMPREPLY+=("$line"); done < <( compgen -P '"' -S '"' -W "$($ghostty +list-themes | sed -E 's/^(.*) \(.*$/\1/')" -- "$cur")
|
||||
\\ }
|
||||
\\
|
||||
\\ _files() {
|
||||
\\ mapfile -t COMPREPLY < <( compgen -o filenames -f -- "$cur" )
|
||||
\\ _compreply compgen -o filenames -f -- "$cur"
|
||||
\\ for i in "${!COMPREPLY[@]}"; do
|
||||
\\ if [[ -d "${COMPREPLY[i]}" ]]; then
|
||||
\\ COMPREPLY[i]="${COMPREPLY[i]}/";
|
||||
@@ -71,7 +79,7 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
|
||||
\\ }
|
||||
\\
|
||||
\\ _dirs() {
|
||||
\\ mapfile -t COMPREPLY < <( compgen -o dirnames -d -- "$cur" )
|
||||
\\ _compreply compgen -o dirnames -d -- "$cur"
|
||||
\\ for i in "${!COMPREPLY[@]}"; do
|
||||
\\ if [[ -d "${COMPREPLY[i]}" ]]; then
|
||||
\\ COMPREPLY[i]="${COMPREPLY[i]}/";
|
||||
@@ -115,8 +123,8 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
|
||||
else if (field.type == Config.RepeatablePath)
|
||||
try writer.writeAll("_files ;;")
|
||||
else {
|
||||
const compgenPrefix = "mapfile -t COMPREPLY < <( compgen -W \"";
|
||||
const compgenSuffix = "\" -- \"$cur\" ); _add_spaces ;;";
|
||||
const compgenPrefix = "_compreply compgen -W \"";
|
||||
const compgenSuffix = "\" -- \"$cur\"; _add_spaces ;;";
|
||||
switch (@typeInfo(field.type)) {
|
||||
.bool => try writer.writeAll("return ;;"),
|
||||
.@"enum" => |info| {
|
||||
@@ -147,7 +155,7 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
|
||||
}
|
||||
|
||||
try writer.writeAll(
|
||||
\\ *) mapfile -t COMPREPLY < <( compgen -W "$config" -- "$cur" ) ;;
|
||||
\\ *) _compreply compgen -W "$config" -- "$cur" ;;
|
||||
\\ esac
|
||||
\\
|
||||
\\ return 0
|
||||
@@ -206,8 +214,8 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
|
||||
|
||||
try writer.writeAll(pad5 ++ "--" ++ opt.name ++ ") ");
|
||||
|
||||
const compgenPrefix = "mapfile -t COMPREPLY < <( compgen -W \"";
|
||||
const compgenSuffix = "\" -- \"$cur\" ); _add_spaces ;;";
|
||||
const compgenPrefix = "_compreply compgen -W \"";
|
||||
const compgenSuffix = "\" -- \"$cur\"; _add_spaces ;;";
|
||||
switch (@typeInfo(opt.type)) {
|
||||
.bool => try writer.writeAll("return ;;"),
|
||||
.@"enum" => |info| {
|
||||
@@ -243,7 +251,7 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
|
||||
}
|
||||
try writer.writeAll("\n");
|
||||
}
|
||||
try writer.writeAll(pad5 ++ "*) mapfile -t COMPREPLY < <( compgen -W \"$" ++ bashName ++ "\" -- \"$cur\" ) ;;\n");
|
||||
try writer.writeAll(pad5 ++ "*) _compreply compgen -W \"$" ++ bashName ++ "\" -- \"$cur\" ;;\n");
|
||||
try writer.writeAll(
|
||||
\\ esac
|
||||
\\ ;;
|
||||
@@ -252,7 +260,7 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
|
||||
}
|
||||
|
||||
try writer.writeAll(
|
||||
\\ *) mapfile -t COMPREPLY < <( compgen -W "--help" -- "$cur" ) ;;
|
||||
\\ *) _compreply compgen -W "--help" -- "$cur" ;;
|
||||
\\ esac
|
||||
\\
|
||||
\\ return 0
|
||||
@@ -298,7 +306,7 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
|
||||
\\ case "${COMP_WORDS[1]}" in
|
||||
\\ -e | --help | --version) return 0 ;;
|
||||
\\ --*) _handle_config ;;
|
||||
\\ *) mapfile -t COMPREPLY < <( compgen -W "${topLevel}" -- "$cur" ); _add_spaces ;;
|
||||
\\ *) _compreply compgen -W "${topLevel}" -- "$cur"; _add_spaces ;;
|
||||
\\ esac
|
||||
\\ ;;
|
||||
\\ *)
|
||||
|
||||
@@ -39,10 +39,57 @@ pub fn encode(
|
||||
[]const u8 => Error![3][]const u8,
|
||||
else => unreachable,
|
||||
} {
|
||||
// These are the set of byte values that are always replaced by
|
||||
// a space (per xterm's behavior) for any text insertion method e.g.
|
||||
// a paste, drag and drop, etc. These are copied directly from xterm's
|
||||
// source.
|
||||
const strip: []const u8 = &.{
|
||||
0x00, // NUL
|
||||
0x08, // BS
|
||||
0x05, // ENQ
|
||||
0x04, // EOT
|
||||
0x1B, // ESC
|
||||
0x7F, // DEL
|
||||
|
||||
// These can be overridden by the running terminal program
|
||||
// via tcsetattr, so they aren't totally safe to hardcode like
|
||||
// this. In practice, I haven't seen modern programs change these
|
||||
// and its a much bigger architectural change to pass these through
|
||||
// so for now they're hardcoded.
|
||||
0x03, // VINTR (Ctrl+C)
|
||||
0x1C, // VQUIT (Ctrl+\)
|
||||
0x15, // VKILL (Ctrl+U)
|
||||
0x1A, // VSUSP (Ctrl+Z)
|
||||
0x11, // VSTART (Ctrl+Q)
|
||||
0x13, // VSTOP (Ctrl+S)
|
||||
0x17, // VWERASE (Ctrl+W)
|
||||
0x16, // VLNEXT (Ctrl+V)
|
||||
0x12, // VREPRINT (Ctrl+R)
|
||||
0x0F, // VDISCARD (Ctrl+O)
|
||||
};
|
||||
|
||||
const mutable = @TypeOf(data) == []u8;
|
||||
|
||||
var result: [3][]const u8 = .{ "", data, "" };
|
||||
|
||||
// If we have any of the strip values, then we need to replace them
|
||||
// with spaces. This is what xterm does and it does it regardless
|
||||
// of bracketed paste mode. This is a security measure to prevent pastes
|
||||
// from containing bytes that could be used to inject commands.
|
||||
if (std.mem.indexOfAny(u8, data, strip) != null) {
|
||||
if (comptime !mutable) return Error.MutableRequired;
|
||||
var offset: usize = 0;
|
||||
while (std.mem.indexOfAny(
|
||||
u8,
|
||||
data[offset..],
|
||||
strip,
|
||||
)) |idx| {
|
||||
offset += idx;
|
||||
data[offset] = ' ';
|
||||
offset += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Bracketed paste mode (mode 2004) wraps pasted data in
|
||||
// fenceposts so that the terminal can ignore things like newlines.
|
||||
if (opts.bracketed) {
|
||||
@@ -143,3 +190,39 @@ test "encode unbracketed windows-stye newline" {
|
||||
try testing.expectEqualStrings("hello\r\rworld", result[1]);
|
||||
try testing.expectEqualStrings("", result[2]);
|
||||
}
|
||||
|
||||
test "encode strip unsafe bytes const" {
|
||||
const testing = std.testing;
|
||||
try testing.expectError(Error.MutableRequired, encode(
|
||||
@as([]const u8, "hello\x00world"),
|
||||
.{ .bracketed = true },
|
||||
));
|
||||
}
|
||||
|
||||
test "encode strip unsafe bytes mutable bracketed" {
|
||||
const testing = std.testing;
|
||||
const data: []u8 = try testing.allocator.dupe(u8, "hel\x1blo\x00world");
|
||||
defer testing.allocator.free(data);
|
||||
const result = encode(data, .{ .bracketed = true });
|
||||
try testing.expectEqualStrings("\x1b[200~", result[0]);
|
||||
try testing.expectEqualStrings("hel lo world", result[1]);
|
||||
try testing.expectEqualStrings("\x1b[201~", result[2]);
|
||||
}
|
||||
|
||||
test "encode strip unsafe bytes mutable unbracketed" {
|
||||
const testing = std.testing;
|
||||
const data: []u8 = try testing.allocator.dupe(u8, "hel\x03lo");
|
||||
defer testing.allocator.free(data);
|
||||
const result = encode(data, .{ .bracketed = false });
|
||||
try testing.expectEqualStrings("", result[0]);
|
||||
try testing.expectEqualStrings("hel lo", result[1]);
|
||||
try testing.expectEqualStrings("", result[2]);
|
||||
}
|
||||
|
||||
test "encode strip multiple unsafe bytes" {
|
||||
const testing = std.testing;
|
||||
const data: []u8 = try testing.allocator.dupe(u8, "\x00\x08\x7f");
|
||||
defer testing.allocator.free(data);
|
||||
const result = encode(data, .{ .bracketed = true });
|
||||
try testing.expectEqualStrings(" ", result[1]);
|
||||
}
|
||||
|
||||
@@ -1,254 +1,26 @@
|
||||
const std = @import("std");
|
||||
const assert = @import("../quirks.zig").inlineAssert;
|
||||
const linux = std.os.linux;
|
||||
const posix = std.posix;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const log = std.log.scoped(.@"linux-cgroup");
|
||||
|
||||
/// Returns the path to the cgroup for the given pid.
|
||||
pub fn current(alloc: Allocator, pid: std.os.linux.pid_t) !?[]const u8 {
|
||||
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
pub fn current(buf: []u8, pid: u32) ?[]const u8 {
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
|
||||
// Read our cgroup by opening /proc/<pid>/cgroup and reading the first
|
||||
// line. The first line will look something like this:
|
||||
// 0::/user.slice/user-1000.slice/session-1.scope
|
||||
// The cgroup path is the third field.
|
||||
const path = try std.fmt.bufPrint(&buf, "/proc/{}/cgroup", .{pid});
|
||||
const file = try std.fs.cwd().openFile(path, .{});
|
||||
const path = std.fmt.bufPrint(&path_buf, "/proc/{}/cgroup", .{pid}) catch return null;
|
||||
const file = std.fs.openFileAbsolute(path, .{}) catch return null;
|
||||
defer file.close();
|
||||
|
||||
// Read it all into memory -- we don't expect this file to ever be that large.
|
||||
const contents = try file.readToEndAlloc(
|
||||
alloc,
|
||||
1 * 1024 * 1024, // 1MB
|
||||
);
|
||||
defer alloc.free(contents);
|
||||
var read_buf: [64]u8 = undefined;
|
||||
var file_reader = file.reader(&read_buf);
|
||||
const reader = &file_reader.interface;
|
||||
const len = reader.readSliceShort(buf) catch return null;
|
||||
const contents = buf[0..len];
|
||||
|
||||
// Find the last ':'
|
||||
const idx = std.mem.lastIndexOfScalar(u8, contents, ':') orelse return null;
|
||||
const result = std.mem.trimRight(u8, contents[idx + 1 ..], " \r\n");
|
||||
return try alloc.dupe(u8, result);
|
||||
}
|
||||
|
||||
/// Create a new cgroup. This will not move any process into it unless move is
|
||||
/// set. If move is set, the given pid will be moved into the created cgroup.
|
||||
pub fn create(
|
||||
cgroup: []const u8,
|
||||
child: []const u8,
|
||||
move: ?std.os.linux.pid_t,
|
||||
) !void {
|
||||
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const path = try std.fmt.bufPrint(&buf, "/sys/fs/cgroup{s}/{s}", .{ cgroup, child });
|
||||
try std.fs.cwd().makePath(path);
|
||||
|
||||
// If we have a PID to move into the cgroup immediately, do it.
|
||||
if (move) |pid| {
|
||||
const pid_path = try std.fmt.bufPrint(
|
||||
&buf,
|
||||
"/sys/fs/cgroup{s}/{s}/cgroup.procs",
|
||||
.{ cgroup, child },
|
||||
);
|
||||
const file = try std.fs.cwd().openFile(pid_path, .{ .mode = .write_only });
|
||||
defer file.close();
|
||||
|
||||
var file_buf: [64]u8 = undefined;
|
||||
var writer = file.writer(&file_buf);
|
||||
try writer.interface.print("{}", .{pid});
|
||||
try writer.interface.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a cgroup. This will only succeed if the cgroup is empty
|
||||
/// (has no processes). The cgroup path should be relative to the
|
||||
/// cgroup root (e.g. "/user.slice/surfaces/abc123.scope").
|
||||
pub fn remove(cgroup: []const u8) !void {
|
||||
assert(cgroup.len > 0);
|
||||
assert(cgroup[0] == '/');
|
||||
|
||||
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const path = try std.fmt.bufPrint(&buf, "/sys/fs/cgroup{s}", .{cgroup});
|
||||
std.fs.cwd().deleteDir(path) catch |err| switch (err) {
|
||||
// If it doesn't exist, that's fine - maybe it was already cleaned up
|
||||
error.FileNotFound => {},
|
||||
|
||||
// Any other error we failed to delete it so we want to notify
|
||||
// the user.
|
||||
else => return err,
|
||||
};
|
||||
}
|
||||
|
||||
/// Move the given PID into the given cgroup.
|
||||
pub fn moveInto(
|
||||
cgroup: []const u8,
|
||||
pid: std.os.linux.pid_t,
|
||||
) !void {
|
||||
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const path = try std.fmt.bufPrint(&buf, "/sys/fs/cgroup{s}/cgroup.procs", .{cgroup});
|
||||
const file = try std.fs.cwd().openFile(path, .{ .mode = .write_only });
|
||||
defer file.close();
|
||||
try file.writer().print("{}", .{pid});
|
||||
}
|
||||
|
||||
/// Use clone3 to have the kernel create a new process with the correct cgroup
|
||||
/// rather than moving the process to the correct cgroup later.
|
||||
pub fn cloneInto(cgroup: []const u8) !posix.pid_t {
|
||||
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const path = try std.fmt.bufPrintZ(&buf, "/sys/fs/cgroup{s}", .{cgroup});
|
||||
|
||||
// Get a file descriptor that refers to the cgroup directory in the cgroup
|
||||
// sysfs to pass to the kernel in clone3.
|
||||
const fd: linux.fd_t = fd: {
|
||||
const rc = linux.open(
|
||||
path,
|
||||
.{
|
||||
// Self-explanatory: we expect to open a directory, and
|
||||
// we only need the path-level permissions.
|
||||
.PATH = true,
|
||||
.DIRECTORY = true,
|
||||
|
||||
// We don't want to leak this fd to the child process
|
||||
// when we clone below since we're using this fd for
|
||||
// a cgroup clone.
|
||||
.CLOEXEC = true,
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
switch (posix.errno(rc)) {
|
||||
.SUCCESS => break :fd @as(linux.fd_t, @intCast(rc)),
|
||||
else => |errno| {
|
||||
log.err("unable to open cgroup dir {s}: {}", .{ path, errno });
|
||||
return error.CloneError;
|
||||
},
|
||||
}
|
||||
};
|
||||
assert(fd >= 0);
|
||||
defer _ = linux.close(fd);
|
||||
|
||||
const args: extern struct {
|
||||
flags: u64,
|
||||
pidfd: u64,
|
||||
child_tid: u64,
|
||||
parent_tid: u64,
|
||||
exit_signal: u64,
|
||||
stack: u64,
|
||||
stack_size: u64,
|
||||
tls: u64,
|
||||
set_tid: u64,
|
||||
set_tid_size: u64,
|
||||
cgroup: u64,
|
||||
} = .{
|
||||
.flags = linux.CLONE.INTO_CGROUP,
|
||||
.pidfd = 0,
|
||||
.child_tid = 0,
|
||||
.parent_tid = 0,
|
||||
.exit_signal = linux.SIG.CHLD,
|
||||
.stack = 0,
|
||||
.stack_size = 0,
|
||||
.tls = 0,
|
||||
.set_tid = 0,
|
||||
.set_tid_size = 0,
|
||||
.cgroup = @intCast(fd),
|
||||
};
|
||||
|
||||
const rc = linux.syscall2(linux.SYS.clone3, @intFromPtr(&args), @sizeOf(@TypeOf(args)));
|
||||
// do not use posix.errno, when linking libc it will use the libc errno which will not be set when making the syscall directly
|
||||
return switch (std.os.linux.E.init(rc)) {
|
||||
.SUCCESS => @as(posix.pid_t, @intCast(rc)),
|
||||
else => |errno| err: {
|
||||
log.err("unable to clone: {}", .{errno});
|
||||
break :err error.CloneError;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns all available cgroup controllers for the given cgroup.
|
||||
/// The cgroup should have a '/'-prefix.
|
||||
///
|
||||
/// The returned list of is the raw space-separated list of
|
||||
/// controllers from the /sys/fs directory. This avoids some extra
|
||||
/// work since creating an iterator over this is easy and much cheaper
|
||||
/// than allocating a bunch of copies for an array.
|
||||
pub fn controllers(alloc: Allocator, cgroup: []const u8) ![]const u8 {
|
||||
assert(cgroup[0] == '/');
|
||||
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
|
||||
// Read the available controllers. These will be space separated.
|
||||
const path = try std.fmt.bufPrint(
|
||||
&buf,
|
||||
"/sys/fs/cgroup{s}/cgroup.controllers",
|
||||
.{cgroup},
|
||||
);
|
||||
const file = try std.fs.cwd().openFile(path, .{});
|
||||
defer file.close();
|
||||
|
||||
// Read it all into memory -- we don't expect this file to ever
|
||||
// be that large.
|
||||
const contents = try file.readToEndAlloc(
|
||||
alloc,
|
||||
1 * 1024 * 1024, // 1MB
|
||||
);
|
||||
defer alloc.free(contents);
|
||||
|
||||
// Return our raw list of controllers
|
||||
const result = std.mem.trimRight(u8, contents, " \r\n");
|
||||
return try alloc.dupe(u8, result);
|
||||
}
|
||||
|
||||
/// Configure the set of controllers in the cgroup. The "v" should
|
||||
/// be in a valid format for "cgroup.subtree_control"
|
||||
pub fn configureControllers(
|
||||
cgroup: []const u8,
|
||||
v: []const u8,
|
||||
) !void {
|
||||
assert(cgroup[0] == '/');
|
||||
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
|
||||
// Read the available controllers. These will be space separated.
|
||||
const path = try std.fmt.bufPrint(
|
||||
&buf,
|
||||
"/sys/fs/cgroup{s}/cgroup.subtree_control",
|
||||
.{cgroup},
|
||||
);
|
||||
const file = try std.fs.cwd().openFile(path, .{ .mode = .write_only });
|
||||
defer file.close();
|
||||
|
||||
// Write
|
||||
var writer_buf: [4096]u8 = undefined;
|
||||
var writer = file.writer(&writer_buf);
|
||||
try writer.interface.writeAll(v);
|
||||
try writer.interface.flush();
|
||||
}
|
||||
|
||||
pub const Limit = union(enum) {
|
||||
memory_high: usize,
|
||||
pids_max: usize,
|
||||
};
|
||||
|
||||
/// Configure a limit for the given cgroup. Use the various
|
||||
/// fields in Limit to configure a specific type of limit.
|
||||
pub fn configureLimit(cgroup: []const u8, limit: Limit) !void {
|
||||
assert(cgroup[0] == '/');
|
||||
|
||||
const filename, const size = switch (limit) {
|
||||
.memory_high => |v| .{ "memory.high", v },
|
||||
.pids_max => |v| .{ "pids.max", v },
|
||||
};
|
||||
|
||||
// Open our file
|
||||
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const path = try std.fmt.bufPrint(
|
||||
&buf,
|
||||
"/sys/fs/cgroup{s}/{s}",
|
||||
.{ cgroup, filename },
|
||||
);
|
||||
const file = try std.fs.cwd().openFile(path, .{ .mode = .write_only });
|
||||
defer file.close();
|
||||
|
||||
// Write our limit in bytes
|
||||
var writer_buf: [4096]u8 = undefined;
|
||||
var writer = file.writer(&writer_buf);
|
||||
try writer.interface.print("{}", .{size});
|
||||
try writer.interface.flush();
|
||||
return std.mem.trimRight(u8, contents[idx + 1 ..], " \r\n");
|
||||
}
|
||||
|
||||
@@ -295,21 +295,19 @@ fn setQosClass(self: *const Thread) void {
|
||||
fn syncDrawTimer(self: *Thread) void {
|
||||
skip: {
|
||||
// If our renderer supports animations and has them, then we
|
||||
// always have a draw timer.
|
||||
// can apply draw timer based on custom shader animation configuration.
|
||||
if (@hasDecl(rendererpkg.Renderer, "hasAnimations") and
|
||||
self.renderer.hasAnimations())
|
||||
{
|
||||
break :skip;
|
||||
}
|
||||
|
||||
// If our config says to always animate, we do so.
|
||||
switch (self.config.custom_shader_animation) {
|
||||
// Always animate
|
||||
.always => break :skip,
|
||||
// Only when focused
|
||||
.true => if (self.flags.focused) break :skip,
|
||||
// Never animate
|
||||
.false => {},
|
||||
// If our config says to always animate, we do so.
|
||||
switch (self.config.custom_shader_animation) {
|
||||
// Always animate
|
||||
.always => break :skip,
|
||||
// Only when focused
|
||||
.true => if (self.flags.focused) break :skip,
|
||||
// Never animate
|
||||
.false => {},
|
||||
}
|
||||
}
|
||||
|
||||
// We're skipping the draw timer. Stop it on the next iteration.
|
||||
|
||||
@@ -2275,26 +2275,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
||||
// std.log.warn("[rebuildCells time] {}\t{}", .{start_micro, end.since(start) / std.time.ns_per_us});
|
||||
// }
|
||||
|
||||
// Determine our x/y range for preedit. We don't want to render anything
|
||||
// here because we will render the preedit separately.
|
||||
const preedit_range: ?PreeditRange = if (preedit) |preedit_v| preedit: {
|
||||
// We base the preedit on the position of the cursor in the
|
||||
// viewport. If the cursor isn't visible in the viewport we
|
||||
// don't show it.
|
||||
const cursor_vp = state.cursor.viewport orelse
|
||||
break :preedit null;
|
||||
|
||||
const range = preedit_v.range(
|
||||
cursor_vp.x,
|
||||
state.cols - 1,
|
||||
);
|
||||
break :preedit .{
|
||||
.y = @intCast(cursor_vp.y),
|
||||
.x = .{ range.start, range.end },
|
||||
.cp_offset = range.cp_offset,
|
||||
};
|
||||
} else null;
|
||||
|
||||
const grid_size_diff =
|
||||
self.cells.size.rows != state.rows or
|
||||
self.cells.size.columns != state.cols;
|
||||
@@ -2352,6 +2332,32 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
||||
state.rows,
|
||||
self.cells.size.rows,
|
||||
);
|
||||
|
||||
// Determine our x/y range for preedit. We don't want to render anything
|
||||
// here because we will render the preedit separately.
|
||||
const preedit_range: ?PreeditRange = if (preedit) |preedit_v| preedit: {
|
||||
// We base the preedit on the position of the cursor in the
|
||||
// viewport. If the cursor isn't visible in the viewport we
|
||||
// don't show it.
|
||||
const cursor_vp = state.cursor.viewport orelse
|
||||
break :preedit null;
|
||||
|
||||
// If our preedit row isn't dirty then we don't need the
|
||||
// preedit range. This also avoids an issue later where we
|
||||
// unconditionally add preedit cells when this is set.
|
||||
if (!rebuild and !row_dirty[cursor_vp.y]) break :preedit null;
|
||||
|
||||
const range = preedit_v.range(
|
||||
cursor_vp.x,
|
||||
state.cols - 1,
|
||||
);
|
||||
break :preedit .{
|
||||
.y = @intCast(cursor_vp.y),
|
||||
.x = .{ range.start, range.end },
|
||||
.cp_offset = range.cp_offset,
|
||||
};
|
||||
} else null;
|
||||
|
||||
for (
|
||||
0..,
|
||||
row_raws[0..row_len],
|
||||
@@ -2527,14 +2533,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
||||
}
|
||||
|
||||
// Setup our preedit text.
|
||||
if (preedit) |preedit_v| {
|
||||
const range = preedit_range.?;
|
||||
if (preedit) |preedit_v| preedit: {
|
||||
const range = preedit_range orelse break :preedit;
|
||||
var x = range.x[0];
|
||||
for (preedit_v.codepoints[range.cp_offset..]) |cp| {
|
||||
self.addPreeditCell(
|
||||
cp,
|
||||
.{ .x = x, .y = range.y },
|
||||
state.colors.background,
|
||||
state.colors.foreground,
|
||||
) catch |err| {
|
||||
log.warn("error building preedit cell, will be invalid x={} y={}, err={}", .{
|
||||
@@ -3264,7 +3269,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
||||
self: *Self,
|
||||
cp: renderer.State.Preedit.Codepoint,
|
||||
coord: terminal.Coordinate,
|
||||
screen_bg: terminal.color.RGB,
|
||||
screen_fg: terminal.color.RGB,
|
||||
) !void {
|
||||
// Render the glyph for our preedit text
|
||||
@@ -3283,16 +3287,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
||||
return;
|
||||
};
|
||||
|
||||
// Add our opaque background cell
|
||||
self.cells.bgCell(coord.y, coord.x).* = .{
|
||||
screen_bg.r, screen_bg.g, screen_bg.b, 255,
|
||||
};
|
||||
if (cp.wide and coord.x < self.cells.size.columns - 1) {
|
||||
self.cells.bgCell(coord.y, coord.x + 1).* = .{
|
||||
screen_bg.r, screen_bg.g, screen_bg.b, 255,
|
||||
};
|
||||
}
|
||||
|
||||
// Add our text
|
||||
try self.cells.add(self.alloc, .text, .{
|
||||
.atlas = .grayscale,
|
||||
|
||||
@@ -198,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;cl=line\a\]'$PS1'\[\e]133;B\a\]'
|
||||
PS1='\[\e]133;A;redraw=last;cl=line;aid='"$BASHPID"'\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
|
||||
@@ -213,7 +213,10 @@ function __ghostty_precmd() {
|
||||
|
||||
# Cursor
|
||||
if [[ "$GHOSTTY_SHELL_FEATURES" == *"cursor"* ]]; then
|
||||
[[ "$PS1" != *'\[\e[5 q\]'* ]] && PS1=$PS1'\[\e[5 q\]' # input
|
||||
builtin local cursor=5 # blinking bar
|
||||
[[ "$GHOSTTY_SHELL_FEATURES" == *"cursor:steady"* ]] && cursor=6 # steady bar
|
||||
|
||||
[[ "$PS1" != *"\[\e[${cursor} q\]"* ]] && PS1=$PS1"\[\e[${cursor} q\]"
|
||||
[[ "$PS0" != *'\[\e[0 q\]'* ]] && PS0=$PS0'\[\e[0 q\]' # reset
|
||||
fi
|
||||
|
||||
@@ -236,8 +239,6 @@ function __ghostty_precmd() {
|
||||
builtin printf "\e]7;kitty-shell-cwd://%s%s\a" "$HOSTNAME" "$PWD"
|
||||
fi
|
||||
|
||||
# Fresh line and start of prompt.
|
||||
builtin printf "\e]133;A;redraw=last;cl=line;aid=%s\a" "$BASHPID"
|
||||
_ghostty_executing=0
|
||||
}
|
||||
|
||||
@@ -278,7 +279,9 @@ if (( BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4) )
|
||||
__ghostty_hook() {
|
||||
builtin local ret=$?
|
||||
__ghostty_precmd "$ret"
|
||||
PS0=$__ghostty_ps0
|
||||
if [[ "$PS0" != *"$__ghostty_ps0"* ]]; then
|
||||
PS0=$PS0"${__ghostty_ps0}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Append our hook to PROMPT_COMMAND, preserving its existing type.
|
||||
|
||||
@@ -154,11 +154,16 @@
|
||||
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 cursor) {
|
||||
fn beam { printf "\e[5 q" }
|
||||
fn block { printf "\e[0 q" }
|
||||
if (str:contains $E:GHOSTTY_SHELL_FEATURES "cursor") {
|
||||
var cursor = "5" # blinking bar
|
||||
if (has-value $features cursor:steady) {
|
||||
set cursor = "6" # steady bar
|
||||
}
|
||||
|
||||
fn beam { printf "\e["$cursor" q" }
|
||||
fn reset { printf "\e[0 q" }
|
||||
set edit:before-readline = (conj $edit:before-readline $beam~)
|
||||
set edit:after-readline = (conj $edit:after-readline {|_| block })
|
||||
set edit:after-readline = (conj $edit:after-readline {|_| reset })
|
||||
}
|
||||
if (and (has-value $features path) (has-env GHOSTTY_BIN_DIR)) {
|
||||
if (not (has-value $paths $E:GHOSTTY_BIN_DIR)) {
|
||||
|
||||
@@ -72,11 +72,14 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
|
||||
set -g __ghostty_prompt_start_mark "\e]133;A;click_events=1\a"
|
||||
end
|
||||
|
||||
if contains cursor $features
|
||||
if string match -q 'cursor*' -- $features
|
||||
set -l cursor 5 # blinking bar
|
||||
contains cursor:steady $features && set cursor 6 # steady bar
|
||||
|
||||
# Change the cursor to a beam on prompt.
|
||||
function __ghostty_set_cursor_beam --on-event fish_prompt -d "Set cursor shape"
|
||||
function __ghostty_set_cursor_beam --on-event fish_prompt -V cursor -d "Set cursor shape"
|
||||
if not functions -q fish_vi_cursor_handle
|
||||
echo -en "\e[5 q"
|
||||
echo -en "\e[$cursor q"
|
||||
end
|
||||
end
|
||||
function __ghostty_reset_cursor --on-event fish_preexec -d "Reset cursor shape"
|
||||
@@ -233,7 +236,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
|
||||
set --global fish_handle_reflow 1
|
||||
|
||||
# Initial calls for first prompt
|
||||
if contains cursor $features
|
||||
if string match -q 'cursor*' -- $features
|
||||
__ghostty_set_cursor_beam
|
||||
end
|
||||
__ghostty_mark_prompt_start
|
||||
|
||||
@@ -188,7 +188,7 @@ _ghostty_deferred_init() {
|
||||
# our own prompt, user prompt, and our own prompt with user additions on
|
||||
# top. We cannot force prompt_subst on the user though, so we would
|
||||
# still need this code for the no_prompt_subst case.
|
||||
PS1=${PS1//$'%{\e]133;A\a%}'}
|
||||
PS1=${PS1//$'%{\e]133;A;cl=line\a%}'}
|
||||
PS1=${PS1//$'%{\e]133;A;k=s\a%}'}
|
||||
PS1=${PS1//$'%{\e]133;B\a%}'}
|
||||
PS2=${PS2//$'%{\e]133;A;k=s\a%}'}
|
||||
@@ -227,14 +227,14 @@ _ghostty_deferred_init() {
|
||||
# executed from zle. For example, users of fzf-based widgets may find
|
||||
# themselves with a blinking block cursor within fzf.
|
||||
_ghostty_zle_line_init _ghostty_zle_line_finish _ghostty_zle_keymap_select() {
|
||||
case ${KEYMAP-} in
|
||||
# Blinking block cursor.
|
||||
vicmd|visual) builtin print -nu "$_ghostty_fd" '\e[1 q';;
|
||||
# Blinking bar cursor.
|
||||
*) builtin print -nu "$_ghostty_fd" '\e[5 q';;
|
||||
esac
|
||||
builtin local steady=0
|
||||
[[ "$GHOSTTY_SHELL_FEATURES" == *"cursor:steady"* ]] && steady=1
|
||||
case ${KEYMAP-} in
|
||||
vicmd|visual) builtin print -nu "$_ghostty_fd" "\e[$(( 1 + steady )) q" ;; # block
|
||||
*) builtin print -nu "$_ghostty_fd" "\e[$(( 5 + steady )) q" ;; # bar
|
||||
esac
|
||||
}
|
||||
# Restore the blinking default shape before executing an external command
|
||||
# Restore the default shape before executing an external command
|
||||
functions[_ghostty_preexec]+="
|
||||
builtin print -rnu $_ghostty_fd \$'\\e[0 q'"
|
||||
fi
|
||||
|
||||
@@ -47,6 +47,115 @@ pub const default: Palette = default: {
|
||||
/// Palette is the 256 color palette.
|
||||
pub const Palette = [256]RGB;
|
||||
|
||||
/// Mask that can be used to set which palette indexes were set.
|
||||
pub const PaletteMask = std.StaticBitSet(@typeInfo(Palette).array.len);
|
||||
|
||||
/// Generate the 256-color palette from the user's base16 theme colors,
|
||||
/// terminal background, and terminal foreground.
|
||||
///
|
||||
/// Motivation: The default 256-color palette uses fixed, fully-saturated
|
||||
/// colors that clash with custom base16 themes, have poor readability in
|
||||
/// dark shades (the first non-black shade jumps to 37% intensity instead
|
||||
/// of the expected 20%), and exhibit inconsistent perceived brightness
|
||||
/// across hues of the same shade (e.g., blue appears darker than green).
|
||||
/// By generating the extended palette from the user's chosen colors,
|
||||
/// programs can use the richer 256-color range without requiring their
|
||||
/// own theme configuration, and light/dark switching works automatically.
|
||||
///
|
||||
/// The 216-color cube (indices 16–231) is built via trilinear
|
||||
/// interpolation in CIELAB space over the 8 base colors. The base16
|
||||
/// palette maps to the 8 corners of a 6×6×6 RGB cube as follows:
|
||||
///
|
||||
/// R=0 edge: bg → base[1] (red)
|
||||
/// R=5 edge: base[6] → fg
|
||||
/// G=0 edge: bg/base[6] (via R) → base[2]/base[4] (green/blue via R)
|
||||
/// G=5 edge: base[1]/fg (via R) → base[3]/base[5] (yellow/magenta via R)
|
||||
///
|
||||
/// For each R slice, four corner colors (c0–c3) are interpolated along
|
||||
/// the R axis, then for each G row two edge colors (c4–c5) are
|
||||
/// interpolated along G, and finally each B cell is interpolated along B
|
||||
/// to produce the final color. CIELAB interpolation ensures perceptually
|
||||
/// uniform brightness transitions across different hues.
|
||||
///
|
||||
/// The 24-step grayscale ramp (indices 232–255) is a simple linear
|
||||
/// interpolation in CIELAB from the background to the foreground,
|
||||
/// excluding pure black and white (available in the cube at (0,0,0)
|
||||
/// and (5,5,5)). The interpolation parameter runs from 1/25 to 24/25.
|
||||
///
|
||||
/// Fill `skip` with user-defined color indexes to avoid replacing them.
|
||||
///
|
||||
/// Reference: https://gist.github.com/jake-stewart/0a8ea46159a7da2c808e5be2177e1783
|
||||
pub fn generate256Color(
|
||||
base: Palette,
|
||||
skip: PaletteMask,
|
||||
bg: RGB,
|
||||
fg: RGB,
|
||||
) Palette {
|
||||
// Convert the background, foreground, and 8 base theme colors into
|
||||
// CIELAB space so that all interpolation is perceptually uniform.
|
||||
const bg_lab: LAB = .fromRgb(bg);
|
||||
const fg_lab: LAB = .fromRgb(fg);
|
||||
const base8_lab: [8]LAB = base8: {
|
||||
var base8: [8]LAB = undefined;
|
||||
for (0..8) |i| base8[i] = .fromRgb(base[i]);
|
||||
break :base8 base8;
|
||||
};
|
||||
|
||||
// Start from the base palette so indices 0–15 are preserved as-is.
|
||||
var result = base;
|
||||
|
||||
// Build the 216-color cube (indices 16–231) via trilinear interpolation
|
||||
// in CIELAB. The three nested loops correspond to the R, G, and B axes
|
||||
// of a 6×6×6 cube. For each R slice, four corner colors (c0–c3) are
|
||||
// interpolated along R from the 8 base colors, mapping the cube corners
|
||||
// to theme-aware anchors (see doc comment for the mapping). Then for
|
||||
// each G row, two edge colors (c4–c5) blend along G, and finally each
|
||||
// B cell interpolates along B to produce the final color.
|
||||
var idx: usize = 16;
|
||||
for (0..6) |ri| {
|
||||
// R-axis corners: blend base colors along the red dimension.
|
||||
const tr = @as(f32, @floatFromInt(ri)) / 5.0;
|
||||
const c0: LAB = .lerp(tr, bg_lab, base8_lab[1]);
|
||||
const c1: LAB = .lerp(tr, base8_lab[2], base8_lab[3]);
|
||||
const c2: LAB = .lerp(tr, base8_lab[4], base8_lab[5]);
|
||||
const c3: LAB = .lerp(tr, base8_lab[6], fg_lab);
|
||||
for (0..6) |gi| {
|
||||
// G-axis edges: blend the R-interpolated corners along green.
|
||||
const tg = @as(f32, @floatFromInt(gi)) / 5.0;
|
||||
const c4: LAB = .lerp(tg, c0, c1);
|
||||
const c5: LAB = .lerp(tg, c2, c3);
|
||||
for (0..6) |bi| {
|
||||
// B-axis: final interpolation along blue, then convert back to RGB.
|
||||
if (!skip.isSet(idx)) {
|
||||
const c6: LAB = .lerp(
|
||||
@as(f32, @floatFromInt(bi)) / 5.0,
|
||||
c4,
|
||||
c5,
|
||||
);
|
||||
result[idx] = c6.toRgb();
|
||||
}
|
||||
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the 24-step grayscale ramp (indices 232–255) by linearly
|
||||
// interpolating in CIELAB from background to foreground. The parameter
|
||||
// runs from 1/25 to 24/25, excluding the endpoints which are already
|
||||
// available in the cube at (0,0,0) and (5,5,5).
|
||||
for (0..24) |i| {
|
||||
const t = @as(f32, @floatFromInt(i + 1)) / 25.0;
|
||||
if (!skip.isSet(idx)) {
|
||||
const c: LAB = .lerp(t, bg_lab, fg_lab);
|
||||
result[idx] = c.toRgb();
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// A palette that can have its colors changed and reset. Purposely built
|
||||
/// for terminal color operations.
|
||||
pub const DynamicPalette = struct {
|
||||
@@ -58,9 +167,7 @@ pub const DynamicPalette = struct {
|
||||
|
||||
/// A bitset where each bit represents whether the corresponding
|
||||
/// palette index has been modified from its default value.
|
||||
mask: Mask,
|
||||
|
||||
const Mask = std.StaticBitSet(@typeInfo(Palette).array.len);
|
||||
mask: PaletteMask,
|
||||
|
||||
pub const default: DynamicPalette = .init(colorpkg.default);
|
||||
|
||||
@@ -519,6 +626,101 @@ pub const RGB = packed struct(u24) {
|
||||
}
|
||||
};
|
||||
|
||||
/// LAB color space
|
||||
const LAB = struct {
|
||||
l: f32,
|
||||
a: f32,
|
||||
b: f32,
|
||||
|
||||
/// RGB to LAB
|
||||
pub fn fromRgb(rgb: RGB) LAB {
|
||||
// Step 1: Normalize sRGB channels from [0, 255] to [0.0, 1.0].
|
||||
var r: f32 = @as(f32, @floatFromInt(rgb.r)) / 255.0;
|
||||
var g: f32 = @as(f32, @floatFromInt(rgb.g)) / 255.0;
|
||||
var b: f32 = @as(f32, @floatFromInt(rgb.b)) / 255.0;
|
||||
|
||||
// Step 2: Apply the inverse sRGB companding (gamma correction) to
|
||||
// convert from sRGB to linear RGB. The sRGB transfer function has
|
||||
// two segments: a linear portion for small values and a power curve
|
||||
// for the rest.
|
||||
r = if (r > 0.04045) std.math.pow(f32, (r + 0.055) / 1.055, 2.4) else r / 12.92;
|
||||
g = if (g > 0.04045) std.math.pow(f32, (g + 0.055) / 1.055, 2.4) else g / 12.92;
|
||||
b = if (b > 0.04045) std.math.pow(f32, (b + 0.055) / 1.055, 2.4) else b / 12.92;
|
||||
|
||||
// Step 3: Convert linear RGB to CIE XYZ using the sRGB to XYZ
|
||||
// transformation matrix (D65 illuminant). The X and Z values are
|
||||
// normalized by the D65 white point reference values (Xn=0.95047,
|
||||
// Zn=1.08883; Yn=1.0 is implicit).
|
||||
var x = (r * 0.4124564 + g * 0.3575761 + b * 0.1804375) / 0.95047;
|
||||
var y = r * 0.2126729 + g * 0.7151522 + b * 0.0721750;
|
||||
var z = (r * 0.0193339 + g * 0.1191920 + b * 0.9503041) / 1.08883;
|
||||
|
||||
// Step 4: Apply the CIE f(t) nonlinear transform to each XYZ
|
||||
// component. Above the threshold (epsilon ≈ 0.008856) the cube
|
||||
// root is used; below it, a linear approximation avoids numerical
|
||||
// instability near zero.
|
||||
x = if (x > 0.008856) std.math.cbrt(x) else 7.787 * x + 16.0 / 116.0;
|
||||
y = if (y > 0.008856) std.math.cbrt(y) else 7.787 * y + 16.0 / 116.0;
|
||||
z = if (z > 0.008856) std.math.cbrt(z) else 7.787 * z + 16.0 / 116.0;
|
||||
|
||||
// Step 5: Compute the final CIELAB values from the transformed XYZ.
|
||||
// L* is lightness (0–100), a* is green–red, b* is blue–yellow.
|
||||
return .{ .l = 116.0 * y - 16.0, .a = 500.0 * (x - y), .b = 200.0 * (y - z) };
|
||||
}
|
||||
|
||||
/// LAB to RGB
|
||||
pub fn toRgb(self: LAB) RGB {
|
||||
// Step 1: Recover the intermediate f(Y), f(X), f(Z) values from
|
||||
// L*a*b* by inverting the CIELAB formulas.
|
||||
const y = (self.l + 16.0) / 116.0;
|
||||
const x = self.a / 500.0 + y;
|
||||
const z = y - self.b / 200.0;
|
||||
|
||||
// Step 2: Apply the inverse CIE f(t) transform to get back to
|
||||
// XYZ. Above epsilon (≈0.008856) the cube is used; below it the
|
||||
// linear segment is inverted. Results are then scaled by the D65
|
||||
// white point reference values (Xn=0.95047, Zn=1.08883; Yn=1.0).
|
||||
const x3 = x * x * x;
|
||||
const y3 = y * y * y;
|
||||
const z3 = z * z * z;
|
||||
const xf = (if (x3 > 0.008856) x3 else (x - 16.0 / 116.0) / 7.787) * 0.95047;
|
||||
const yf = if (y3 > 0.008856) y3 else (y - 16.0 / 116.0) / 7.787;
|
||||
const zf = (if (z3 > 0.008856) z3 else (z - 16.0 / 116.0) / 7.787) * 1.08883;
|
||||
|
||||
// Step 3: Convert CIE XYZ back to linear RGB using the XYZ to sRGB
|
||||
// matrix (inverse of the sRGB to XYZ matrix, D65 illuminant).
|
||||
var r = xf * 3.2404542 - yf * 1.5371385 - zf * 0.4985314;
|
||||
var g = -xf * 0.9692660 + yf * 1.8760108 + zf * 0.0415560;
|
||||
var b = xf * 0.0556434 - yf * 0.2040259 + zf * 1.0572252;
|
||||
|
||||
// Step 4: Apply sRGB companding (gamma correction) to convert from
|
||||
// linear RGB back to sRGB. This is the forward sRGB transfer
|
||||
// function with the same two-segment split as the inverse.
|
||||
r = if (r > 0.0031308) 1.055 * std.math.pow(f32, r, 1.0 / 2.4) - 0.055 else 12.92 * r;
|
||||
g = if (g > 0.0031308) 1.055 * std.math.pow(f32, g, 1.0 / 2.4) - 0.055 else 12.92 * g;
|
||||
b = if (b > 0.0031308) 1.055 * std.math.pow(f32, b, 1.0 / 2.4) - 0.055 else 12.92 * b;
|
||||
|
||||
// Step 5: Clamp to [0.0, 1.0], scale to [0, 255], and round to
|
||||
// the nearest integer to produce the final 8-bit sRGB values.
|
||||
return .{
|
||||
.r = @intFromFloat(@min(@max(r, 0.0), 1.0) * 255.0 + 0.5),
|
||||
.g = @intFromFloat(@min(@max(g, 0.0), 1.0) * 255.0 + 0.5),
|
||||
.b = @intFromFloat(@min(@max(b, 0.0), 1.0) * 255.0 + 0.5),
|
||||
};
|
||||
}
|
||||
|
||||
/// Linearly interpolate between two LAB colors component-wise.
|
||||
/// `t` is the interpolation factor in [0, 1]: t=0 returns `a`,
|
||||
/// t=1 returns `b`, and values in between blend proportionally.
|
||||
pub fn lerp(t: f32, a: LAB, b: LAB) LAB {
|
||||
return .{
|
||||
.l = a.l + t * (b.l - a.l),
|
||||
.a = a.a + t * (b.a - a.a),
|
||||
.b = a.b + t * (b.b - a.b),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
test "palette: default" {
|
||||
const testing = std.testing;
|
||||
|
||||
@@ -683,3 +885,126 @@ test "DynamicPalette: changeDefault with multiple changes" {
|
||||
try testing.expectEqual(blue, p.current[3]);
|
||||
try testing.expectEqual(@as(usize, 3), p.mask.count());
|
||||
}
|
||||
|
||||
test "LAB.fromRgb" {
|
||||
const testing = std.testing;
|
||||
const epsilon = 0.5;
|
||||
|
||||
// White (255, 255, 255) -> L*=100, a*=0, b*=0
|
||||
const white = LAB.fromRgb(.{ .r = 255, .g = 255, .b = 255 });
|
||||
try testing.expectApproxEqAbs(@as(f32, 100.0), white.l, epsilon);
|
||||
try testing.expectApproxEqAbs(@as(f32, 0.0), white.a, epsilon);
|
||||
try testing.expectApproxEqAbs(@as(f32, 0.0), white.b, epsilon);
|
||||
|
||||
// Black (0, 0, 0) -> L*=0, a*=0, b*=0
|
||||
const black = LAB.fromRgb(.{ .r = 0, .g = 0, .b = 0 });
|
||||
try testing.expectApproxEqAbs(@as(f32, 0.0), black.l, epsilon);
|
||||
try testing.expectApproxEqAbs(@as(f32, 0.0), black.a, epsilon);
|
||||
try testing.expectApproxEqAbs(@as(f32, 0.0), black.b, epsilon);
|
||||
|
||||
// Pure red (255, 0, 0) -> L*≈53.23, a*≈80.11, b*≈67.22
|
||||
const red = LAB.fromRgb(.{ .r = 255, .g = 0, .b = 0 });
|
||||
try testing.expectApproxEqAbs(@as(f32, 53.23), red.l, epsilon);
|
||||
try testing.expectApproxEqAbs(@as(f32, 80.11), red.a, epsilon);
|
||||
try testing.expectApproxEqAbs(@as(f32, 67.22), red.b, epsilon);
|
||||
|
||||
// Pure green (0, 128, 0) -> L*≈46.23, a*≈-51.70, b*≈49.90
|
||||
const green = LAB.fromRgb(.{ .r = 0, .g = 128, .b = 0 });
|
||||
try testing.expectApproxEqAbs(@as(f32, 46.23), green.l, epsilon);
|
||||
try testing.expectApproxEqAbs(@as(f32, -51.70), green.a, epsilon);
|
||||
try testing.expectApproxEqAbs(@as(f32, 49.90), green.b, epsilon);
|
||||
|
||||
// Pure blue (0, 0, 255) -> L*≈32.30, a*≈79.20, b*≈-107.86
|
||||
const blue = LAB.fromRgb(.{ .r = 0, .g = 0, .b = 255 });
|
||||
try testing.expectApproxEqAbs(@as(f32, 32.30), blue.l, epsilon);
|
||||
try testing.expectApproxEqAbs(@as(f32, 79.20), blue.a, epsilon);
|
||||
try testing.expectApproxEqAbs(@as(f32, -107.86), blue.b, epsilon);
|
||||
}
|
||||
|
||||
test "generate256Color: base16 preserved" {
|
||||
const testing = std.testing;
|
||||
|
||||
const bg = RGB{ .r = 0, .g = 0, .b = 0 };
|
||||
const fg = RGB{ .r = 255, .g = 255, .b = 255 };
|
||||
const palette = generate256Color(default, .initEmpty(), bg, fg);
|
||||
|
||||
// The first 16 colors (base16) must remain unchanged.
|
||||
for (0..16) |i| {
|
||||
try testing.expectEqual(default[i], palette[i]);
|
||||
}
|
||||
}
|
||||
|
||||
test "generate256Color: cube corners match base colors" {
|
||||
const testing = std.testing;
|
||||
|
||||
const bg = RGB{ .r = 0, .g = 0, .b = 0 };
|
||||
const fg = RGB{ .r = 255, .g = 255, .b = 255 };
|
||||
const palette = generate256Color(default, .initEmpty(), bg, fg);
|
||||
|
||||
// Index 16 is cube (0,0,0) which should equal bg.
|
||||
try testing.expectEqual(bg, palette[16]);
|
||||
|
||||
// Index 231 is cube (5,5,5) which should equal fg.
|
||||
try testing.expectEqual(fg, palette[231]);
|
||||
}
|
||||
|
||||
test "generate256Color: grayscale ramp monotonic luminance" {
|
||||
const testing = std.testing;
|
||||
|
||||
const bg = RGB{ .r = 0, .g = 0, .b = 0 };
|
||||
const fg = RGB{ .r = 255, .g = 255, .b = 255 };
|
||||
const palette = generate256Color(default, .initEmpty(), bg, fg);
|
||||
|
||||
// The grayscale ramp (232–255) should have monotonically increasing
|
||||
// luminance from near-black to near-white.
|
||||
var prev_lum: f64 = 0.0;
|
||||
for (232..256) |i| {
|
||||
const lum = palette[i].luminance();
|
||||
try testing.expect(lum >= prev_lum);
|
||||
prev_lum = lum;
|
||||
}
|
||||
}
|
||||
|
||||
test "generate256Color: skip mask preserves original colors" {
|
||||
const testing = std.testing;
|
||||
|
||||
const bg = RGB{ .r = 0, .g = 0, .b = 0 };
|
||||
const fg = RGB{ .r = 255, .g = 255, .b = 255 };
|
||||
|
||||
// Mark a few indices as skipped; they should keep their base value.
|
||||
var skip: PaletteMask = .initEmpty();
|
||||
skip.set(20);
|
||||
skip.set(100);
|
||||
skip.set(240);
|
||||
|
||||
const palette = generate256Color(default, skip, bg, fg);
|
||||
try testing.expectEqual(default[20], palette[20]);
|
||||
try testing.expectEqual(default[100], palette[100]);
|
||||
try testing.expectEqual(default[240], palette[240]);
|
||||
|
||||
// A non-skipped index in the cube should differ from the default.
|
||||
try testing.expect(!palette[21].eql(default[21]));
|
||||
}
|
||||
|
||||
test "LAB.toRgb" {
|
||||
const testing = std.testing;
|
||||
|
||||
// Round-trip: RGB -> LAB -> RGB should recover the original values.
|
||||
const cases = [_]RGB{
|
||||
.{ .r = 255, .g = 255, .b = 255 },
|
||||
.{ .r = 0, .g = 0, .b = 0 },
|
||||
.{ .r = 255, .g = 0, .b = 0 },
|
||||
.{ .r = 0, .g = 128, .b = 0 },
|
||||
.{ .r = 0, .g = 0, .b = 255 },
|
||||
.{ .r = 128, .g = 128, .b = 128 },
|
||||
.{ .r = 64, .g = 224, .b = 208 },
|
||||
};
|
||||
|
||||
for (cases) |expected| {
|
||||
const lab = LAB.fromRgb(expected);
|
||||
const actual = lab.toRgb();
|
||||
try testing.expectEqual(expected.r, actual.r);
|
||||
try testing.expectEqual(expected.g, actual.g);
|
||||
try testing.expectEqual(expected.b, actual.b);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ const Allocator = std.mem.Allocator;
|
||||
const color = @import("color.zig");
|
||||
const size = @import("size.zig");
|
||||
const charsets = @import("charsets.zig");
|
||||
const hyperlink = @import("hyperlink.zig");
|
||||
const kitty = @import("kitty.zig");
|
||||
const modespkg = @import("modes.zig");
|
||||
const Screen = @import("Screen.zig");
|
||||
@@ -996,6 +997,10 @@ pub const PageFormatter = struct {
|
||||
// Our style for non-plain formats
|
||||
var style: Style = .{};
|
||||
|
||||
// Track hyperlink state for HTML output. We need to close </a> tags
|
||||
// when the hyperlink changes or ends.
|
||||
var current_hyperlink_id: ?hyperlink.Id = null;
|
||||
|
||||
for (start_y..end_y + 1) |y_usize| {
|
||||
const y: size.CellCountInt = @intCast(y_usize);
|
||||
const row: *Row = self.page.getRow(y);
|
||||
@@ -1232,6 +1237,63 @@ pub const PageFormatter = struct {
|
||||
}
|
||||
}
|
||||
|
||||
// Hyperlink state
|
||||
hyperlink: {
|
||||
// We currently only emit hyperlinks for HTML. In the
|
||||
// future we can support emitting OSC 8 hyperlinks for
|
||||
// VT output as well.
|
||||
if (self.opts.emit != .html) break :hyperlink;
|
||||
|
||||
// Get the hyperlink ID. This ID is our internal ID,
|
||||
// not necessarily the OSC8 ID.
|
||||
const link_id_: ?u16 = if (cell.hyperlink)
|
||||
self.page.lookupHyperlink(cell)
|
||||
else
|
||||
null;
|
||||
|
||||
// If our hyperlink IDs match (even null) then we have
|
||||
// identical hyperlink state and we do nothing.
|
||||
if (current_hyperlink_id == link_id_) break :hyperlink;
|
||||
|
||||
// If our prior hyperlink ID was non-null, we need to
|
||||
// close it because the ID has changed.
|
||||
if (current_hyperlink_id != null) {
|
||||
try self.formatHyperlinkClose(writer);
|
||||
current_hyperlink_id = null;
|
||||
}
|
||||
|
||||
// Set our current hyperlink ID
|
||||
const link_id = link_id_ orelse break :hyperlink;
|
||||
current_hyperlink_id = link_id;
|
||||
|
||||
// Emit the opening hyperlink tag
|
||||
const uri = uri: {
|
||||
const link = self.page.hyperlink_set.get(
|
||||
self.page.memory,
|
||||
link_id,
|
||||
);
|
||||
break :uri link.uri.offset.ptr(self.page.memory)[0..link.uri.len];
|
||||
};
|
||||
try self.formatHyperlinkOpen(
|
||||
writer,
|
||||
uri,
|
||||
);
|
||||
|
||||
// If we have a point map, we map the hyperlink to
|
||||
// this cell.
|
||||
if (self.point_map) |*map| {
|
||||
var discarding: std.Io.Writer.Discarding = .init(&.{});
|
||||
try self.formatHyperlinkOpen(
|
||||
&discarding.writer,
|
||||
uri,
|
||||
);
|
||||
for (0..discarding.count) |_| map.map.append(map.alloc, .{
|
||||
.x = x,
|
||||
.y = y,
|
||||
}) catch return error.WriteFailed;
|
||||
}
|
||||
}
|
||||
|
||||
switch (cell.content_tag) {
|
||||
// We combine codepoint and graphemes because both have
|
||||
// shared style handling. We use comptime to dup it.
|
||||
@@ -1266,6 +1328,9 @@ pub const PageFormatter = struct {
|
||||
// If the style is non-default, we need to close our style tag.
|
||||
if (!style.default()) try self.formatStyleClose(writer);
|
||||
|
||||
// Close any open hyperlink for HTML output
|
||||
if (current_hyperlink_id != null) try self.formatHyperlinkClose(writer);
|
||||
|
||||
// Close the monospace wrapper for HTML output
|
||||
if (self.opts.emit == .html) {
|
||||
const closing = "</div>";
|
||||
@@ -1415,6 +1480,8 @@ pub const PageFormatter = struct {
|
||||
};
|
||||
}
|
||||
|
||||
/// Write a string with HTML escaping. Used for escaping href attributes
|
||||
/// and other HTML attribute values.
|
||||
fn formatStyleOpen(
|
||||
self: PageFormatter,
|
||||
writer: *std.Io.Writer,
|
||||
@@ -1465,6 +1532,49 @@ pub const PageFormatter = struct {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn formatHyperlinkOpen(
|
||||
self: PageFormatter,
|
||||
writer: *std.Io.Writer,
|
||||
uri: []const u8,
|
||||
) std.Io.Writer.Error!void {
|
||||
switch (self.opts.emit) {
|
||||
.plain, .vt => unreachable,
|
||||
|
||||
// layout since we're primarily using it as a CSS wrapper.
|
||||
.html => {
|
||||
try writer.writeAll("<a href=\"");
|
||||
for (uri) |byte| try self.writeCodepoint(
|
||||
writer,
|
||||
byte,
|
||||
);
|
||||
try writer.writeAll("\">");
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn formatHyperlinkClose(
|
||||
self: PageFormatter,
|
||||
writer: *std.Io.Writer,
|
||||
) std.Io.Writer.Error!void {
|
||||
const str: []const u8 = switch (self.opts.emit) {
|
||||
.html => "</a>",
|
||||
.plain, .vt => return,
|
||||
};
|
||||
|
||||
try writer.writeAll(str);
|
||||
if (self.point_map) |*m| {
|
||||
assert(m.map.items.len > 0);
|
||||
m.map.ensureUnusedCapacity(
|
||||
m.alloc,
|
||||
str.len,
|
||||
) catch return error.WriteFailed;
|
||||
m.map.appendNTimesAssumeCapacity(
|
||||
m.map.items[m.map.items.len - 1],
|
||||
str.len,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
test "Page plain single line" {
|
||||
@@ -5937,3 +6047,222 @@ test "Page VT background color on trailing blank cells" {
|
||||
// This should be true but currently fails due to the bug
|
||||
try testing.expect(has_red_bg_line1);
|
||||
}
|
||||
|
||||
test "Page HTML with hyperlinks" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var builder: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer builder.deinit();
|
||||
|
||||
var t = try Terminal.init(alloc, .{
|
||||
.cols = 80,
|
||||
.rows = 24,
|
||||
});
|
||||
defer t.deinit(alloc);
|
||||
|
||||
var s = t.vtStream();
|
||||
defer s.deinit();
|
||||
|
||||
// Start a hyperlink, write some text, end it
|
||||
try s.nextSlice("\x1b]8;;https://example.com\x1b\\link text\x1b]8;;\x1b\\ normal");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
var formatter: PageFormatter = .init(page, .{ .emit = .html });
|
||||
|
||||
try formatter.format(&builder.writer);
|
||||
const output = builder.writer.buffered();
|
||||
|
||||
try testing.expectEqualStrings(
|
||||
"<div style=\"font-family: monospace; white-space: pre;\">" ++
|
||||
"<a href=\"https://example.com\">link text</a> normal" ++
|
||||
"</div>",
|
||||
output,
|
||||
);
|
||||
}
|
||||
|
||||
test "Page HTML with multiple hyperlinks" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var builder: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer builder.deinit();
|
||||
|
||||
var t = try Terminal.init(alloc, .{
|
||||
.cols = 80,
|
||||
.rows = 24,
|
||||
});
|
||||
defer t.deinit(alloc);
|
||||
|
||||
var s = t.vtStream();
|
||||
defer s.deinit();
|
||||
|
||||
// Two different hyperlinks
|
||||
try s.nextSlice("\x1b]8;;https://first.com\x1b\\first\x1b]8;;\x1b\\ ");
|
||||
try s.nextSlice("\x1b]8;;https://second.com\x1b\\second\x1b]8;;\x1b\\");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
var formatter: PageFormatter = .init(page, .{ .emit = .html });
|
||||
|
||||
try formatter.format(&builder.writer);
|
||||
const output = builder.writer.buffered();
|
||||
|
||||
try testing.expectEqualStrings(
|
||||
"<div style=\"font-family: monospace; white-space: pre;\">" ++
|
||||
"<a href=\"https://first.com\">first</a>" ++
|
||||
" " ++
|
||||
"<a href=\"https://second.com\">second</a>" ++
|
||||
"</div>",
|
||||
output,
|
||||
);
|
||||
}
|
||||
|
||||
test "Page HTML with hyperlink escaping" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var builder: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer builder.deinit();
|
||||
|
||||
var t = try Terminal.init(alloc, .{
|
||||
.cols = 80,
|
||||
.rows = 24,
|
||||
});
|
||||
defer t.deinit(alloc);
|
||||
|
||||
var s = t.vtStream();
|
||||
defer s.deinit();
|
||||
|
||||
// URL with special characters that need escaping
|
||||
try s.nextSlice("\x1b]8;;https://example.com?a=1&b=2\x1b\\link\x1b]8;;\x1b\\");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
var formatter: PageFormatter = .init(page, .{ .emit = .html });
|
||||
|
||||
try formatter.format(&builder.writer);
|
||||
const output = builder.writer.buffered();
|
||||
|
||||
try testing.expectEqualStrings(
|
||||
"<div style=\"font-family: monospace; white-space: pre;\">" ++
|
||||
"<a href=\"https://example.com?a=1&b=2\">link</a>" ++
|
||||
"</div>",
|
||||
output,
|
||||
);
|
||||
}
|
||||
|
||||
test "Page HTML with styled hyperlink" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var builder: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer builder.deinit();
|
||||
|
||||
var t = try Terminal.init(alloc, .{
|
||||
.cols = 80,
|
||||
.rows = 24,
|
||||
});
|
||||
defer t.deinit(alloc);
|
||||
|
||||
var s = t.vtStream();
|
||||
defer s.deinit();
|
||||
|
||||
// Bold hyperlink
|
||||
try s.nextSlice("\x1b]8;;https://example.com\x1b\\\x1b[1mbold link\x1b[0m\x1b]8;;\x1b\\");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
var formatter: PageFormatter = .init(page, .{ .emit = .html });
|
||||
|
||||
try formatter.format(&builder.writer);
|
||||
const output = builder.writer.buffered();
|
||||
|
||||
try testing.expectEqualStrings(
|
||||
"<div style=\"font-family: monospace; white-space: pre;\">" ++
|
||||
"<div style=\"display: inline;font-weight: bold;\">" ++
|
||||
"<a href=\"https://example.com\">bold link</div></a>" ++
|
||||
"</div>",
|
||||
output,
|
||||
);
|
||||
}
|
||||
|
||||
test "Page HTML hyperlink closes style before anchor" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var builder: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer builder.deinit();
|
||||
|
||||
var t = try Terminal.init(alloc, .{
|
||||
.cols = 80,
|
||||
.rows = 24,
|
||||
});
|
||||
defer t.deinit(alloc);
|
||||
|
||||
var s = t.vtStream();
|
||||
defer s.deinit();
|
||||
|
||||
// Styled hyperlink followed by plain text
|
||||
try s.nextSlice("\x1b]8;;https://example.com\x1b\\\x1b[1mbold\x1b[0m plain");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
var formatter: PageFormatter = .init(page, .{ .emit = .html });
|
||||
|
||||
try formatter.format(&builder.writer);
|
||||
const output = builder.writer.buffered();
|
||||
|
||||
try testing.expectEqualStrings(
|
||||
"<div style=\"font-family: monospace; white-space: pre;\">" ++
|
||||
"<div style=\"display: inline;font-weight: bold;\">" ++
|
||||
"<a href=\"https://example.com\">bold</div> plain</a>" ++
|
||||
"</div>",
|
||||
output,
|
||||
);
|
||||
}
|
||||
|
||||
test "Page HTML hyperlink point map maps closing to previous cell" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var builder: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer builder.deinit();
|
||||
|
||||
var t = try Terminal.init(alloc, .{
|
||||
.cols = 80,
|
||||
.rows = 24,
|
||||
});
|
||||
defer t.deinit(alloc);
|
||||
|
||||
var s = t.vtStream();
|
||||
defer s.deinit();
|
||||
|
||||
try s.nextSlice("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\ normal");
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const page = &pages.pages.last.?.data;
|
||||
var formatter: PageFormatter = .init(page, .{ .emit = .html });
|
||||
|
||||
var point_map: std.ArrayList(Coordinate) = .empty;
|
||||
defer point_map.deinit(alloc);
|
||||
formatter.point_map = .{ .alloc = alloc, .map = &point_map };
|
||||
|
||||
try formatter.format(&builder.writer);
|
||||
const output = builder.writer.buffered();
|
||||
|
||||
const expected_output =
|
||||
"<div style=\"font-family: monospace; white-space: pre;\">" ++
|
||||
"<a href=\"https://example.com\">link</a> normal" ++
|
||||
"</div>";
|
||||
try testing.expectEqualStrings(expected_output, output);
|
||||
try testing.expectEqual(expected_output.len, point_map.items.len);
|
||||
|
||||
// The </a> closing tag bytes should all map to the last cell of the link
|
||||
const closing_idx = comptime std.mem.indexOf(u8, expected_output, "</a>").?;
|
||||
const expected_coord = point_map.items[closing_idx - 1];
|
||||
for (closing_idx..closing_idx + "</a>".len) |i| {
|
||||
try testing.expectEqual(expected_coord, point_map.items[i]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,8 +153,12 @@ pub const Command = union(Key) {
|
||||
/// Kitty text sizing protocol (OSC 66)
|
||||
kitty_text_sizing: parsers.kitty_text_sizing.OSC,
|
||||
|
||||
kitty_clipboard_protocol: KittyClipboardProtocol,
|
||||
|
||||
pub const SemanticPrompt = parsers.semantic_prompt.Command;
|
||||
|
||||
pub const KittyClipboardProtocol = parsers.kitty_clipboard_protocol.OSC;
|
||||
|
||||
pub const Key = LibEnum(
|
||||
if (build_options.c_abi) .c else .zig,
|
||||
// NOTE: Order matters, see LibEnum documentation.
|
||||
@@ -182,6 +186,7 @@ pub const Command = union(Key) {
|
||||
"conemu_xterm_emulation",
|
||||
"conemu_comment",
|
||||
"kitty_text_sizing",
|
||||
"kitty_clipboard_protocol",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -325,6 +330,7 @@ pub const Parser = struct {
|
||||
@"21",
|
||||
@"22",
|
||||
@"52",
|
||||
@"55",
|
||||
@"66",
|
||||
@"77",
|
||||
@"104",
|
||||
@@ -339,8 +345,10 @@ pub const Parser = struct {
|
||||
@"118",
|
||||
@"119",
|
||||
@"133",
|
||||
@"552",
|
||||
@"777",
|
||||
@"1337",
|
||||
@"5522",
|
||||
};
|
||||
|
||||
pub fn init(alloc: ?Allocator) Parser {
|
||||
@@ -402,6 +410,7 @@ pub const Parser = struct {
|
||||
.semantic_prompt,
|
||||
.show_desktop_notification,
|
||||
.kitty_text_sizing,
|
||||
.kitty_clipboard_protocol,
|
||||
=> {},
|
||||
}
|
||||
|
||||
@@ -569,6 +578,7 @@ pub const Parser = struct {
|
||||
.@"5" => switch (c) {
|
||||
';' => if (self.ensureAllocator()) self.writeToFixed(),
|
||||
'2' => self.state = .@"52",
|
||||
'5' => self.state = .@"55",
|
||||
else => self.state = .invalid,
|
||||
},
|
||||
|
||||
@@ -584,6 +594,11 @@ pub const Parser = struct {
|
||||
else => self.state = .invalid,
|
||||
},
|
||||
|
||||
.@"55" => switch (c) {
|
||||
'2' => self.state = .@"552",
|
||||
else => self.state = .invalid,
|
||||
},
|
||||
|
||||
.@"7" => switch (c) {
|
||||
';' => self.writeToFixed(),
|
||||
'7' => self.state = .@"77",
|
||||
@@ -602,12 +617,23 @@ pub const Parser = struct {
|
||||
else => self.state = .invalid,
|
||||
},
|
||||
|
||||
.@"552" => switch (c) {
|
||||
'2' => self.state = .@"5522",
|
||||
else => self.state = .invalid,
|
||||
},
|
||||
|
||||
.@"1337",
|
||||
=> switch (c) {
|
||||
';' => self.writeToFixed(),
|
||||
else => self.state = .invalid,
|
||||
},
|
||||
|
||||
.@"5522",
|
||||
=> switch (c) {
|
||||
';' => self.writeToAllocating(),
|
||||
else => self.state = .invalid,
|
||||
},
|
||||
|
||||
.@"0",
|
||||
.@"22",
|
||||
.@"777",
|
||||
@@ -676,6 +702,8 @@ pub const Parser = struct {
|
||||
|
||||
.@"52" => parsers.clipboard_operation.parse(self, terminator_ch),
|
||||
|
||||
.@"55" => null,
|
||||
|
||||
.@"6" => null,
|
||||
|
||||
.@"66" => parsers.kitty_text_sizing.parse(self, terminator_ch),
|
||||
@@ -684,9 +712,13 @@ pub const Parser = struct {
|
||||
|
||||
.@"133" => parsers.semantic_prompt.parse(self, terminator_ch),
|
||||
|
||||
.@"552" => null,
|
||||
|
||||
.@"777" => parsers.rxvt_extension.parse(self, terminator_ch),
|
||||
|
||||
.@"1337" => parsers.iterm2.parse(self, terminator_ch),
|
||||
|
||||
.@"5522" => parsers.kitty_clipboard_protocol.parse(self, terminator_ch),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ pub const clipboard_operation = @import("parsers/clipboard_operation.zig");
|
||||
pub const color = @import("parsers/color.zig");
|
||||
pub const hyperlink = @import("parsers/hyperlink.zig");
|
||||
pub const iterm2 = @import("parsers/iterm2.zig");
|
||||
pub const kitty_clipboard_protocol = @import("parsers/kitty_clipboard_protocol.zig");
|
||||
pub const kitty_color = @import("parsers/kitty_color.zig");
|
||||
pub const kitty_text_sizing = @import("parsers/kitty_text_sizing.zig");
|
||||
pub const mouse_shape = @import("parsers/mouse_shape.zig");
|
||||
|
||||
702
src/terminal/osc/parsers/kitty_clipboard_protocol.zig
Normal file
702
src/terminal/osc/parsers/kitty_clipboard_protocol.zig
Normal file
@@ -0,0 +1,702 @@
|
||||
//! Kitty's clipboard protocol (OSC 5522)
|
||||
//! Specification: https://sw.kovidgoyal.net/kitty/clipboard/
|
||||
//! https://rockorager.dev/misc/bracketed-paste-mime/
|
||||
|
||||
const std = @import("std");
|
||||
const build_options = @import("terminal_options");
|
||||
|
||||
const assert = @import("../../../quirks.zig").inlineAssert;
|
||||
|
||||
const Parser = @import("../../osc.zig").Parser;
|
||||
const Command = @import("../../osc.zig").Command;
|
||||
const Terminator = @import("../../osc.zig").Terminator;
|
||||
const encoding = @import("../encoding.zig");
|
||||
|
||||
const log = std.log.scoped(.kitty_clipboard_protocol);
|
||||
|
||||
pub const OSC = struct {
|
||||
/// The raw metadata that was received. It can be parsed by using the `readOption` method.
|
||||
metadata: []const u8,
|
||||
/// The raw payload. It may be Base64 encoded, check the `e` option.
|
||||
payload: ?[]const u8,
|
||||
/// The terminator that was used in case we need to send a response.
|
||||
terminator: Terminator,
|
||||
|
||||
/// Decode an option from the metadata.
|
||||
pub fn readOption(self: OSC, comptime key: Option) ?key.Type() {
|
||||
return key.read(self.metadata);
|
||||
}
|
||||
};
|
||||
|
||||
pub const Location = enum {
|
||||
primary,
|
||||
|
||||
pub fn init(str: []const u8) ?Location {
|
||||
return std.meta.stringToEnum(Location, str);
|
||||
}
|
||||
};
|
||||
|
||||
pub const Operation = enum {
|
||||
read,
|
||||
walias,
|
||||
wdata,
|
||||
write,
|
||||
|
||||
pub fn init(str: []const u8) ?Operation {
|
||||
return std.meta.stringToEnum(Operation, str);
|
||||
}
|
||||
};
|
||||
|
||||
pub const Status = enum {
|
||||
DATA,
|
||||
DONE,
|
||||
EBUSY,
|
||||
EINVAL,
|
||||
EIO,
|
||||
ENOSYS,
|
||||
EPERM,
|
||||
OK,
|
||||
|
||||
pub fn init(str: []const u8) ?Status {
|
||||
return std.meta.stringToEnum(Status, str);
|
||||
}
|
||||
};
|
||||
|
||||
pub const Option = enum {
|
||||
id,
|
||||
loc,
|
||||
mime,
|
||||
name,
|
||||
password,
|
||||
pw,
|
||||
status,
|
||||
type,
|
||||
|
||||
pub fn Type(comptime key: Option) type {
|
||||
return switch (key) {
|
||||
.id => []const u8,
|
||||
.loc => Location,
|
||||
.mime => []const u8,
|
||||
.name => []const u8,
|
||||
.password => []const u8,
|
||||
.pw => []const u8,
|
||||
.status => Status,
|
||||
.type => Operation,
|
||||
};
|
||||
}
|
||||
|
||||
/// Read the option value from the raw metadata string.
|
||||
pub fn read(
|
||||
comptime key: Option,
|
||||
metadata: []const u8,
|
||||
) ?key.Type() {
|
||||
const value: []const u8 = value: {
|
||||
var pos: usize = 0;
|
||||
while (pos < metadata.len) {
|
||||
// skip any whitespace
|
||||
while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1;
|
||||
// bail if we are out of metadata
|
||||
if (pos >= metadata.len) return null;
|
||||
if (!std.mem.startsWith(u8, metadata[pos..], @tagName(key))) {
|
||||
// this isn't the key we are looking for, skip to the next option, or bail if
|
||||
// there is no next option
|
||||
pos = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse return null;
|
||||
pos += 1;
|
||||
continue;
|
||||
}
|
||||
// skip past the key
|
||||
pos += @tagName(key).len;
|
||||
// skip any whitespace
|
||||
while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1;
|
||||
// bail if we are out of metadata
|
||||
if (pos >= metadata.len) return null;
|
||||
// a valid option has an '='
|
||||
if (metadata[pos] != '=') return null;
|
||||
// the end of the value is bounded by a ':' or the end of the metadata
|
||||
const end = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse metadata.len;
|
||||
const start = pos + 1;
|
||||
// strip any leading or trailing whitespace
|
||||
break :value std.mem.trim(u8, metadata[start..end], &std.ascii.whitespace);
|
||||
}
|
||||
// the key was not found
|
||||
return null;
|
||||
};
|
||||
|
||||
// return the parsed value
|
||||
return switch (key) {
|
||||
.id => parseIdentifier(value),
|
||||
.loc => .init(value),
|
||||
.mime => value,
|
||||
.name => value,
|
||||
.password => value,
|
||||
.pw => value,
|
||||
.status => .init(value),
|
||||
.type => .init(value),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Characters that are valid in identifiers.
|
||||
const valid_identifier_characters: []const u8 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_+.";
|
||||
|
||||
fn isValidIdentifier(str: []const u8) bool {
|
||||
if (str.len == 0) return false;
|
||||
return std.mem.indexOfNone(u8, str, valid_identifier_characters) == null;
|
||||
}
|
||||
|
||||
fn parseIdentifier(str: []const u8) ?[]const u8 {
|
||||
if (isValidIdentifier(str)) return str;
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command {
|
||||
assert(parser.state == .@"5522");
|
||||
|
||||
const writer = parser.writer orelse {
|
||||
parser.state = .invalid;
|
||||
return null;
|
||||
};
|
||||
|
||||
const data = writer.buffered();
|
||||
|
||||
const metadata: []const u8, const payload: ?[]const u8 = result: {
|
||||
const start = std.mem.indexOfScalar(u8, data, ';') orelse break :result .{ data, null };
|
||||
break :result .{ data[0..start], data[start + 1 .. data.len] };
|
||||
};
|
||||
|
||||
parser.command = .{
|
||||
.kitty_clipboard_protocol = .{
|
||||
.metadata = metadata,
|
||||
.payload = payload,
|
||||
.terminator = .init(terminator_ch),
|
||||
},
|
||||
};
|
||||
|
||||
return &parser.command;
|
||||
}
|
||||
|
||||
test "OSC: 5522: empty metadata and missing payload" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "5522;";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_clipboard_protocol);
|
||||
try testing.expectEqualStrings("", cmd.kitty_clipboard_protocol.metadata);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.payload == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.mime) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.status) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.type) == null);
|
||||
}
|
||||
|
||||
test "OSC: 5522: empty metadata and empty payload" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "5522;;";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_clipboard_protocol);
|
||||
try testing.expectEqualStrings("", cmd.kitty_clipboard_protocol.metadata);
|
||||
try testing.expectEqualStrings("", cmd.kitty_clipboard_protocol.payload.?);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.mime) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.status) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.type) == null);
|
||||
}
|
||||
|
||||
test "OSC: 5522: non-empty metadata and payload" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "5522;type=read;dGV4dC9wbGFpbg==";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_clipboard_protocol);
|
||||
try testing.expectEqualStrings("type=read", cmd.kitty_clipboard_protocol.metadata);
|
||||
try testing.expectEqualStrings("dGV4dC9wbGFpbg==", cmd.kitty_clipboard_protocol.payload.?);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.mime) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.status) == null);
|
||||
try testing.expectEqual(.read, cmd.kitty_clipboard_protocol.readOption(.type));
|
||||
}
|
||||
|
||||
test "OSC: 5522: empty id" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "5522;id=";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_clipboard_protocol);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
|
||||
}
|
||||
|
||||
test "OSC: 5522: valid id" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "5522;id=5c076ad9-d36f-4705-847b-d4dbf356cc0d";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_clipboard_protocol);
|
||||
try testing.expectEqualStrings("5c076ad9-d36f-4705-847b-d4dbf356cc0d", cmd.kitty_clipboard_protocol.readOption(.id).?);
|
||||
}
|
||||
|
||||
test "OSC: 5522: invalid id" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "5522;id=*42*";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_clipboard_protocol);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
|
||||
}
|
||||
|
||||
test "OSC: 5522: invalid status" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "5522;status=BOBR";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_clipboard_protocol);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.status) == null);
|
||||
}
|
||||
|
||||
test "OSC: 5522: valid status" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "5522;status=DONE";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_clipboard_protocol);
|
||||
try testing.expectEqual(.DONE, cmd.kitty_clipboard_protocol.readOption(.status).?);
|
||||
}
|
||||
|
||||
test "OSC: 5522: invalid location" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "5522;loc=bobr";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_clipboard_protocol);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
|
||||
}
|
||||
|
||||
test "OSC: 5522: valid location" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "5522;loc=primary";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_clipboard_protocol);
|
||||
try testing.expectEqual(.primary, cmd.kitty_clipboard_protocol.readOption(.loc).?);
|
||||
}
|
||||
|
||||
test "OSC: 5522: password 1" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "5522;pw=R2hvc3R0eQ==:name=Qk9CUiBLVVJXQQ==";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_clipboard_protocol);
|
||||
try testing.expectEqualStrings("R2hvc3R0eQ==", cmd.kitty_clipboard_protocol.readOption(.pw).?);
|
||||
try testing.expectEqualStrings("Qk9CUiBLVVJXQQ==", cmd.kitty_clipboard_protocol.readOption(.name).?);
|
||||
}
|
||||
|
||||
test "OSC: 5522: password 2" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "5522;password=R2hvc3R0eQ==";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_clipboard_protocol);
|
||||
try testing.expectEqualStrings("R2hvc3R0eQ==", cmd.kitty_clipboard_protocol.readOption(.password).?);
|
||||
}
|
||||
|
||||
test "OSC: 5522: example 1" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "5522;type=read:status=OK";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_clipboard_protocol);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.payload == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.mime) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
|
||||
try testing.expectEqual(.OK, cmd.kitty_clipboard_protocol.readOption(.status).?);
|
||||
try testing.expectEqual(.read, cmd.kitty_clipboard_protocol.readOption(.type).?);
|
||||
}
|
||||
|
||||
test "OSC: 5522: example 2" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "5522;type=read:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_clipboard_protocol);
|
||||
try testing.expectEqualStrings("R2hvc3R0eQ==", cmd.kitty_clipboard_protocol.payload.?);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
|
||||
try testing.expectEqualStrings("dGV4dC9wbGFpbg==", cmd.kitty_clipboard_protocol.readOption(.mime).?);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.status) == null);
|
||||
try testing.expectEqual(.read, cmd.kitty_clipboard_protocol.readOption(.type).?);
|
||||
}
|
||||
|
||||
test "OSC: 5522: example 3" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "5522;type=read:status=OK";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_clipboard_protocol);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.payload == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.mime) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
|
||||
try testing.expectEqual(.OK, cmd.kitty_clipboard_protocol.readOption(.status).?);
|
||||
try testing.expectEqual(.read, cmd.kitty_clipboard_protocol.readOption(.type).?);
|
||||
}
|
||||
|
||||
test "OSC: 5522: example 4" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "5522;type=write";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_clipboard_protocol);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.payload == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.mime) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.status) == null);
|
||||
try testing.expectEqual(.write, cmd.kitty_clipboard_protocol.readOption(.type).?);
|
||||
}
|
||||
|
||||
test "OSC: 5522: example 5" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_clipboard_protocol);
|
||||
try testing.expectEqualStrings("R2hvc3R0eQ==", cmd.kitty_clipboard_protocol.payload.?);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
|
||||
try testing.expectEqualStrings("dGV4dC9wbGFpbg==", cmd.kitty_clipboard_protocol.readOption(.mime).?);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.status) == null);
|
||||
try testing.expectEqual(.wdata, cmd.kitty_clipboard_protocol.readOption(.type).?);
|
||||
}
|
||||
|
||||
test "OSC: 5522: example 6" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "5522;type=wdata";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_clipboard_protocol);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.payload == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.mime) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.status) == null);
|
||||
try testing.expectEqual(.wdata, cmd.kitty_clipboard_protocol.readOption(.type).?);
|
||||
}
|
||||
|
||||
test "OSC: 5522: example 7" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "5522;type=write:status=DONE";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_clipboard_protocol);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.payload == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.mime) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
|
||||
try testing.expectEqual(.DONE, cmd.kitty_clipboard_protocol.readOption(.status).?);
|
||||
try testing.expectEqual(.write, cmd.kitty_clipboard_protocol.readOption(.type).?);
|
||||
}
|
||||
|
||||
test "OSC: 5522: example 8" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "5522;type=write:status=EPERM";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_clipboard_protocol);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.payload == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.mime) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
|
||||
try testing.expectEqual(.EPERM, cmd.kitty_clipboard_protocol.readOption(.status).?);
|
||||
try testing.expectEqual(.write, cmd.kitty_clipboard_protocol.readOption(.type).?);
|
||||
}
|
||||
|
||||
test "OSC: 5522: example 9" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "5522;type=walias:mime=dGV4dC9wbGFpbg==;dGV4dC9odG1sIGFwcGxpY2F0aW9uL2pzb24=";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_clipboard_protocol);
|
||||
try testing.expectEqualStrings("dGV4dC9odG1sIGFwcGxpY2F0aW9uL2pzb24=", cmd.kitty_clipboard_protocol.payload.?);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
|
||||
try testing.expectEqualStrings("dGV4dC9wbGFpbg==", cmd.kitty_clipboard_protocol.readOption(.mime).?);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.status) == null);
|
||||
try testing.expectEqual(.walias, cmd.kitty_clipboard_protocol.readOption(.type).?);
|
||||
}
|
||||
|
||||
test "OSC: 5522: example 10" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "5522;type=read:status=OK:password=Qk9CUiBLVVJXQQ==";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_clipboard_protocol);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.payload == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.mime) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
|
||||
try testing.expectEqualStrings("Qk9CUiBLVVJXQQ==", cmd.kitty_clipboard_protocol.readOption(.password).?);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
|
||||
try testing.expectEqual(.OK, cmd.kitty_clipboard_protocol.readOption(.status).?);
|
||||
try testing.expectEqual(.read, cmd.kitty_clipboard_protocol.readOption(.type).?);
|
||||
}
|
||||
|
||||
test "OSC: 5522: example 11" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_clipboard_protocol);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.payload == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
|
||||
try testing.expectEqualStrings("dGV4dC9wbGFpbg==", cmd.kitty_clipboard_protocol.readOption(.mime).?);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
|
||||
try testing.expectEqual(.DATA, cmd.kitty_clipboard_protocol.readOption(.status).?);
|
||||
try testing.expectEqual(.read, cmd.kitty_clipboard_protocol.readOption(.type).?);
|
||||
}
|
||||
|
||||
test "OSC: 5522: example 12" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "5522;type=read:mime=dGV4dC9wbGFpbg==:password=Qk9CUiBLVVJXQQ==";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_clipboard_protocol);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.payload == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
|
||||
try testing.expectEqualStrings("dGV4dC9wbGFpbg==", cmd.kitty_clipboard_protocol.readOption(.mime).?);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
|
||||
try testing.expectEqualStrings("Qk9CUiBLVVJXQQ==", cmd.kitty_clipboard_protocol.readOption(.password).?);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.status) == null);
|
||||
try testing.expectEqual(.read, cmd.kitty_clipboard_protocol.readOption(.type).?);
|
||||
}
|
||||
|
||||
test "OSC: 5522: example 13" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "5522;type=read:status=OK";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_clipboard_protocol);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.payload == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.mime) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
|
||||
try testing.expectEqual(.OK, cmd.kitty_clipboard_protocol.readOption(.status).?);
|
||||
try testing.expectEqual(.read, cmd.kitty_clipboard_protocol.readOption(.type).?);
|
||||
}
|
||||
|
||||
test "OSC: 5522: example 14" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==;Qk9CUiBLVVJXQQ==";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_clipboard_protocol);
|
||||
try testing.expectEqualStrings("Qk9CUiBLVVJXQQ==", cmd.kitty_clipboard_protocol.payload.?);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
|
||||
try testing.expectEqualStrings("dGV4dC9wbGFpbg==", cmd.kitty_clipboard_protocol.readOption(.mime).?);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
|
||||
try testing.expectEqual(.DATA, cmd.kitty_clipboard_protocol.readOption(.status).?);
|
||||
try testing.expectEqual(.read, cmd.kitty_clipboard_protocol.readOption(.type).?);
|
||||
}
|
||||
|
||||
test "OSC: 5522: example 15" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .init(testing.allocator);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "5522;type=read:status=OK";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?.*;
|
||||
try testing.expect(cmd == .kitty_clipboard_protocol);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.payload == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.mime) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
|
||||
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
|
||||
try testing.expectEqual(.OK, cmd.kitty_clipboard_protocol.readOption(.status).?);
|
||||
try testing.expectEqual(.read, cmd.kitty_clipboard_protocol.readOption(.type).?);
|
||||
}
|
||||
@@ -2047,6 +2047,7 @@ pub fn Stream(comptime Handler: type) type {
|
||||
.conemu_output_environment_variable,
|
||||
.conemu_run_process,
|
||||
.kitty_text_sizing,
|
||||
.kitty_clipboard_protocol,
|
||||
=> {
|
||||
log.debug("unimplemented OSC callback: {}", .{cmd});
|
||||
},
|
||||
|
||||
@@ -562,10 +562,13 @@ pub const Config = struct {
|
||||
env_override: configpkg.RepeatableStringMap = .{},
|
||||
shell_integration: configpkg.Config.ShellIntegration = .detect,
|
||||
shell_integration_features: configpkg.Config.ShellIntegrationFeatures = .{},
|
||||
cursor_blink: ?bool = null,
|
||||
working_directory: ?[]const u8 = null,
|
||||
resources_dir: ?[]const u8,
|
||||
term: []const u8,
|
||||
linux_cgroup: Command.LinuxCgroup = Command.linux_cgroup_default,
|
||||
|
||||
rt_pre_exec_info: Command.RtPreExecInfo,
|
||||
rt_post_fork_info: Command.RtPostForkInfo,
|
||||
};
|
||||
|
||||
const Subprocess = struct {
|
||||
@@ -583,7 +586,9 @@ const Subprocess = struct {
|
||||
screen_size: renderer.ScreenSize,
|
||||
pty: ?Pty = null,
|
||||
process: ?Process = null,
|
||||
linux_cgroup: Command.LinuxCgroup = Command.linux_cgroup_default,
|
||||
|
||||
rt_pre_exec_info: Command.RtPreExecInfo,
|
||||
rt_post_fork_info: Command.RtPostForkInfo,
|
||||
|
||||
/// Union that represents the running process type.
|
||||
const Process = union(enum) {
|
||||
@@ -755,6 +760,7 @@ const Subprocess = struct {
|
||||
try shell_integration.setupFeatures(
|
||||
&env,
|
||||
cfg.shell_integration_features,
|
||||
cfg.cursor_blink orelse true,
|
||||
);
|
||||
|
||||
const force: ?shell_integration.Shell = switch (cfg.shell_integration) {
|
||||
@@ -849,21 +855,14 @@ const Subprocess = struct {
|
||||
// https://github.com/ghostty-org/ghostty/discussions/7769
|
||||
if (cwd) |pwd| try env.put("PWD", pwd);
|
||||
|
||||
// If we have a cgroup, then we copy that into our arena so the
|
||||
// memory remains valid when we start.
|
||||
const linux_cgroup: Command.LinuxCgroup = cgroup: {
|
||||
const default = Command.linux_cgroup_default;
|
||||
if (comptime builtin.os.tag != .linux) break :cgroup default;
|
||||
const path = cfg.linux_cgroup orelse break :cgroup default;
|
||||
break :cgroup try alloc.dupe(u8, path);
|
||||
};
|
||||
|
||||
return .{
|
||||
.arena = arena,
|
||||
.env = env,
|
||||
.cwd = cwd,
|
||||
.args = args,
|
||||
.linux_cgroup = linux_cgroup,
|
||||
|
||||
.rt_pre_exec_info = cfg.rt_pre_exec_info,
|
||||
.rt_post_fork_info = cfg.rt_post_fork_info,
|
||||
|
||||
// Should be initialized with initTerminal call.
|
||||
.grid_size = .{},
|
||||
@@ -1012,17 +1011,27 @@ const Subprocess = struct {
|
||||
.stdout = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave },
|
||||
.stderr = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave },
|
||||
.pseudo_console = if (builtin.os.tag == .windows) pty.pseudo_console else {},
|
||||
.pre_exec = if (builtin.os.tag == .windows) null else (struct {
|
||||
fn callback(cmd: *Command) void {
|
||||
const sp = cmd.getData(Subprocess) orelse unreachable;
|
||||
sp.childPreExec() catch |err| log.err(
|
||||
"error initializing child: {}",
|
||||
.{err},
|
||||
);
|
||||
}
|
||||
}).callback,
|
||||
.os_pre_exec = switch (comptime builtin.os.tag) {
|
||||
.windows => null,
|
||||
else => f: {
|
||||
const f = struct {
|
||||
fn callback(cmd: *Command) ?u8 {
|
||||
const sp = cmd.getData(Subprocess) orelse unreachable;
|
||||
sp.childPreExec() catch |err| log.err(
|
||||
"error initializing child: {}",
|
||||
.{err},
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
break :f f.callback;
|
||||
},
|
||||
},
|
||||
.rt_pre_exec = if (comptime @hasDecl(apprt.runtime, "pre_exec")) apprt.runtime.pre_exec.preExec else null,
|
||||
.rt_pre_exec_info = self.rt_pre_exec_info,
|
||||
.rt_post_fork = if (comptime @hasDecl(apprt.runtime, "post_fork")) apprt.runtime.post_fork.postFork else null,
|
||||
.rt_post_fork_info = self.rt_post_fork_info,
|
||||
.data = self,
|
||||
.linux_cgroup = self.linux_cgroup,
|
||||
};
|
||||
|
||||
cmd.start(alloc) catch |err| {
|
||||
@@ -1044,9 +1053,6 @@ const Subprocess = struct {
|
||||
log.warn("error killing command during cleanup err={}", .{err});
|
||||
};
|
||||
log.info("started subcommand path={s} pid={?}", .{ self.args[0], cmd.pid });
|
||||
if (comptime builtin.os.tag == .linux) {
|
||||
log.info("subcommand cgroup={s}", .{self.linux_cgroup orelse "-"});
|
||||
}
|
||||
|
||||
self.process = .{ .fork_exec = cmd };
|
||||
return switch (builtin.os.tag) {
|
||||
|
||||
@@ -175,8 +175,28 @@ pub const DerivedConfig = struct {
|
||||
errdefer arena.deinit();
|
||||
const alloc = arena.allocator();
|
||||
|
||||
const palette: terminalpkg.color.Palette = palette: {
|
||||
if (config.@"palette-generate") generate: {
|
||||
if (config.palette.mask.findFirstSet() == null) {
|
||||
// If the user didn't set any values manually, then
|
||||
// we're using the default palette and we don't need
|
||||
// to apply the generation code to it.
|
||||
break :generate;
|
||||
}
|
||||
|
||||
break :palette terminalpkg.color.generate256Color(
|
||||
config.palette.value,
|
||||
config.palette.mask,
|
||||
config.background.toTerminalRGB(),
|
||||
config.foreground.toTerminalRGB(),
|
||||
);
|
||||
}
|
||||
|
||||
break :palette config.palette.value;
|
||||
};
|
||||
|
||||
return .{
|
||||
.palette = config.palette.value,
|
||||
.palette = palette,
|
||||
.image_storage_limit = config.@"image-storage-limit",
|
||||
.cursor_style = config.@"cursor-style",
|
||||
.cursor_blink = config.@"cursor-style-blink",
|
||||
|
||||
@@ -188,11 +188,13 @@ test detectShell {
|
||||
pub fn setupFeatures(
|
||||
env: *EnvMap,
|
||||
features: config.ShellIntegrationFeatures,
|
||||
cursor_blink: bool,
|
||||
) !void {
|
||||
const fields = @typeInfo(@TypeOf(features)).@"struct".fields;
|
||||
const capacity: usize = capacity: {
|
||||
comptime var n: usize = fields.len - 1; // commas
|
||||
inline for (fields) |field| n += field.name.len;
|
||||
n += ":steady".len; // cursor value
|
||||
break :capacity n;
|
||||
};
|
||||
|
||||
@@ -221,6 +223,10 @@ pub fn setupFeatures(
|
||||
if (@field(features, name)) {
|
||||
if (writer.end > 0) try writer.writeByte(',');
|
||||
try writer.writeAll(name);
|
||||
|
||||
if (std.mem.eql(u8, name, "cursor")) {
|
||||
try writer.writeAll(if (cursor_blink) ":blink" else ":steady");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,8 +247,8 @@ test "setup features" {
|
||||
var env = EnvMap.init(alloc);
|
||||
defer env.deinit();
|
||||
|
||||
try setupFeatures(&env, .{ .cursor = true, .sudo = true, .title = true, .@"ssh-env" = true, .@"ssh-terminfo" = true, .path = true });
|
||||
try testing.expectEqualStrings("cursor,path,ssh-env,ssh-terminfo,sudo,title", env.get("GHOSTTY_SHELL_FEATURES").?);
|
||||
try setupFeatures(&env, .{ .cursor = true, .sudo = true, .title = true, .@"ssh-env" = true, .@"ssh-terminfo" = true, .path = true }, true);
|
||||
try testing.expectEqualStrings("cursor:blink,path,ssh-env,ssh-terminfo,sudo,title", env.get("GHOSTTY_SHELL_FEATURES").?);
|
||||
}
|
||||
|
||||
// Test: all features disabled
|
||||
@@ -250,7 +256,7 @@ test "setup features" {
|
||||
var env = EnvMap.init(alloc);
|
||||
defer env.deinit();
|
||||
|
||||
try setupFeatures(&env, std.mem.zeroes(config.ShellIntegrationFeatures));
|
||||
try setupFeatures(&env, std.mem.zeroes(config.ShellIntegrationFeatures), true);
|
||||
try testing.expect(env.get("GHOSTTY_SHELL_FEATURES") == null);
|
||||
}
|
||||
|
||||
@@ -259,9 +265,25 @@ test "setup features" {
|
||||
var env = EnvMap.init(alloc);
|
||||
defer env.deinit();
|
||||
|
||||
try setupFeatures(&env, .{ .cursor = false, .sudo = true, .title = false, .@"ssh-env" = true, .@"ssh-terminfo" = false, .path = false });
|
||||
try setupFeatures(&env, .{ .cursor = false, .sudo = true, .title = false, .@"ssh-env" = true, .@"ssh-terminfo" = false, .path = false }, true);
|
||||
try testing.expectEqualStrings("ssh-env,sudo", env.get("GHOSTTY_SHELL_FEATURES").?);
|
||||
}
|
||||
|
||||
// Test: blinking cursor
|
||||
{
|
||||
var env = EnvMap.init(alloc);
|
||||
defer env.deinit();
|
||||
try setupFeatures(&env, .{ .cursor = true, .sudo = false, .title = false, .@"ssh-env" = false, .@"ssh-terminfo" = false, .path = false }, true);
|
||||
try testing.expectEqualStrings("cursor:blink", env.get("GHOSTTY_SHELL_FEATURES").?);
|
||||
}
|
||||
|
||||
// Test: steady cursor
|
||||
{
|
||||
var env = EnvMap.init(alloc);
|
||||
defer env.deinit();
|
||||
try setupFeatures(&env, .{ .cursor = true, .sudo = false, .title = false, .@"ssh-env" = false, .@"ssh-terminfo" = false, .path = false }, false);
|
||||
try testing.expectEqualStrings("cursor:steady", env.get("GHOSTTY_SHELL_FEATURES").?);
|
||||
}
|
||||
}
|
||||
|
||||
/// Setup the bash automatic shell integration. This works by
|
||||
|
||||
Reference in New Issue
Block a user