diff --git a/include/ghostty.h b/include/ghostty.h index 48915b179..d6e6fba70 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -454,6 +454,12 @@ typedef struct { size_t len; } ghostty_config_color_list_s; +// config.RepeatableCommand +typedef struct { + const ghostty_command_s* commands; + size_t len; +} ghostty_config_command_list_s; + // config.Palette typedef struct { ghostty_config_color_s colors[256]; diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 1a810e621..91c2300cc 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -137,7 +137,6 @@ Features/Update/UpdateSimulator.swift, Features/Update/UpdateViewModel.swift, "Ghostty/FullscreenMode+Extension.swift", - Ghostty/Ghostty.Command.swift, Ghostty/Ghostty.Error.swift, Ghostty/Ghostty.Event.swift, Ghostty/Ghostty.Input.swift, diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 902186ad3..6efb588cd 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -64,7 +64,7 @@ struct TerminalCommandPaletteView: View { // Sort the rest. We replace ":" with a character that sorts before space // so that "Foo:" sorts before "Foo Bar:". Use sortKey as a tie-breaker // for stable ordering when titles are equal. - options.append(contentsOf: (jumpOptions + terminalOptions).sorted { a, b in + options.append(contentsOf: (jumpOptions + terminalOptions + customEntries).sorted { a, b in let aNormalized = a.title.replacingOccurrences(of: ":", with: "\t") let bNormalized = b.title.replacingOccurrences(of: ":", with: "\t") let comparison = aNormalized.localizedCaseInsensitiveCompare(bNormalized) @@ -135,6 +135,19 @@ struct TerminalCommandPaletteView: View { } } + /// Custom commands from the command-palette-entry configuration. + private var customEntries: [CommandOption] { + guard let appDelegate = NSApp.delegate as? AppDelegate else { return [] } + return appDelegate.ghostty.config.commandPaletteEntries.map { c in + CommandOption( + title: c.title, + description: c.description + ) { + onAction(c.action) + } + } + } + /// Commands for jumping to other terminal surfaces. private var jumpOptions: [CommandOption] { TerminalController.all.flatMap { controller -> [CommandOption] in diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 7ea545f7a..5aa79a149 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -622,6 +622,16 @@ extension Ghostty { let str = String(cString: ptr) return Scrollbar(rawValue: str) ?? defaultValue } + + var commandPaletteEntries: [Ghostty.Command] { + guard let config = self.config else { return [] } + var v: ghostty_config_command_list_s = .init() + let key = "command-palette-entry" + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return [] } + guard v.len > 0 else { return [] } + let buffer = UnsafeBufferPointer(start: v.commands, count: v.len) + return buffer.map { Ghostty.Command(cValue: $0) } + } } } diff --git a/src/config/Config.zig b/src/config/Config.zig index 0df5c91b0..92caa5744 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -8041,15 +8041,37 @@ pub const SplitPreserveZoom = packed struct { }; pub const RepeatableCommand = struct { - value: std.ArrayListUnmanaged(inputpkg.Command) = .empty, + const Self = @This(); - pub fn init(self: *RepeatableCommand, alloc: Allocator) !void { + value: std.ArrayListUnmanaged(inputpkg.Command) = .empty, + value_c: std.ArrayListUnmanaged(inputpkg.Command.C) = .empty, + + /// ghostty_config_command_list_s + pub const C = extern struct { + commands: [*]inputpkg.Command.C, + len: usize, + }; + + pub fn cval(self: *const Self) C { + return .{ + .commands = self.value_c.items.ptr, + .len = self.value_c.items.len, + }; + } + + pub fn init(self: *Self, alloc: Allocator) !void { self.value = .empty; + self.value_c = .empty; + errdefer { + self.value.deinit(alloc); + self.value_c.deinit(alloc); + } try self.value.appendSlice(alloc, inputpkg.command.defaults); + try self.value_c.appendSlice(alloc, inputpkg.command.defaultsC); } pub fn parseCLI( - self: *RepeatableCommand, + self: *Self, alloc: Allocator, input_: ?[]const u8, ) !void { @@ -8057,26 +8079,36 @@ pub const RepeatableCommand = struct { const input = input_ orelse ""; if (input.len == 0) { self.value.clearRetainingCapacity(); + self.value_c.clearRetainingCapacity(); return; } + // Reserve space in our lists + try self.value.ensureUnusedCapacity(alloc, 1); + try self.value_c.ensureUnusedCapacity(alloc, 1); + const cmd = try cli.args.parseAutoStruct( inputpkg.Command, alloc, input, null, ); - try self.value.append(alloc, cmd); + const cmd_c = try cmd.cval(alloc); + self.value.appendAssumeCapacity(cmd); + self.value_c.appendAssumeCapacity(cmd_c); } /// Deep copy of the struct. Required by Config. - pub fn clone(self: *const RepeatableCommand, alloc: Allocator) Allocator.Error!RepeatableCommand { + pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self { const value = try self.value.clone(alloc); for (value.items) |*item| { item.* = try item.clone(alloc); } - return .{ .value = value }; + return .{ + .value = value, + .value_c = try self.value_c.clone(alloc), + }; } /// Compare if two of our value are equal. Required by Config. @@ -8232,6 +8264,50 @@ pub const RepeatableCommand = struct { try testing.expectEqualStrings("kurwa", item.action.text); } } + + test "RepeatableCommand cval" { + const testing = std.testing; + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: RepeatableCommand = .{}; + try list.parseCLI(alloc, "title:Foo,action:ignore"); + try list.parseCLI(alloc, "title:Bar,description:bobr,action:text:ale bydle"); + + try testing.expectEqual(@as(usize, 2), list.value.items.len); + try testing.expectEqual(@as(usize, 2), list.value_c.items.len); + + const cv = list.cval(); + try testing.expectEqual(@as(usize, 2), cv.len); + + // First entry + try testing.expectEqualStrings("Foo", std.mem.sliceTo(cv.commands[0].title, 0)); + try testing.expectEqualStrings("ignore", std.mem.sliceTo(cv.commands[0].action_key, 0)); + try testing.expectEqualStrings("ignore", std.mem.sliceTo(cv.commands[0].action, 0)); + + // Second entry + try testing.expectEqualStrings("Bar", std.mem.sliceTo(cv.commands[1].title, 0)); + try testing.expectEqualStrings("bobr", std.mem.sliceTo(cv.commands[1].description, 0)); + try testing.expectEqualStrings("text", std.mem.sliceTo(cv.commands[1].action_key, 0)); + try testing.expectEqualStrings("text:ale bydle", std.mem.sliceTo(cv.commands[1].action, 0)); + } + + test "RepeatableCommand cval cleared" { + const testing = std.testing; + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: RepeatableCommand = .{}; + try list.parseCLI(alloc, "title:Foo,action:ignore"); + try testing.expectEqual(@as(usize, 1), list.cval().len); + + try list.parseCLI(alloc, ""); + try testing.expectEqual(@as(usize, 0), list.cval().len); + } }; /// OSC 4, 10, 11, and 12 default color reporting format. diff --git a/src/input/command.zig b/src/input/command.zig index 67086f7ec..936f2211c 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -43,7 +43,7 @@ pub const Command = struct { return true; } - /// Convert this command to a C struct. + /// Convert this command to a C struct at comptime. pub fn comptimeCval(self: Command) C { assert(@inComptime()); @@ -55,6 +55,27 @@ pub const Command = struct { }; } + /// Convert this command to a C struct at runtime. + /// + /// This shares memory with the original command. + /// + /// The action string is allocated using the provided allocator. You can + /// free the slice directly if you need to but we recommend an arena + /// for this. + pub fn cval(self: Command, alloc: Allocator) Allocator.Error!C { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + self.action.format(&buf.writer) catch return error.OutOfMemory; + const action = try buf.toOwnedSliceSentinel(0); + + return .{ + .action_key = @tagName(self.action), + .action = action.ptr, + .title = self.title, + .description = self.description, + }; + } + /// Implements a comparison function for std.mem.sortUnstable /// and similar functions. The sorting is defined by Ghostty /// to be what we prefer. If a caller wants some other sorting,