diff --git a/build.zig b/build.zig index 093afe481..f1ff1b1c1 100644 --- a/build.zig +++ b/build.zig @@ -12,6 +12,7 @@ const terminfo = @import("src/terminfo/main.zig"); const config_vim = @import("src/config/vim.zig"); const config_sublime_syntax = @import("src/config/sublime_syntax.zig"); const fish_completions = @import("src/build/fish_completions.zig"); +const zsh_completions = @import("src/build/zsh_completions.zig"); const build_config = @import("src/build_config.zig"); const BuildConfig = build_config.BuildConfig; const WasmTarget = @import("src/os/wasm/target.zig").Target; @@ -504,6 +505,18 @@ pub fn build(b: *std.Build) !void { }); } + // zsh shell completions + { + const wf = b.addWriteFiles(); + _ = wf.add("_ghostty", zsh_completions.zsh_completions); + + b.installDirectory(.{ + .source_dir = wf.getDirectory(), + .install_dir = .prefix, + .install_subdir = "share/zsh/site-functions", + }); + } + // Vim plugin { const wf = b.addWriteFiles(); diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index 56912a28a..a16f329f8 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -167,6 +167,9 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { self.savedState = savedState // We hide the dock if the window is on a screen with the dock. + // We must hide the dock FIRST then hide the menu: + // If you specify autoHideMenuBar, it must be accompanied by either hideDock or autoHideDock. + // https://developer.apple.com/documentation/appkit/nsapplication/presentationoptions-swift.struct if (savedState.dock) { hideDock() } @@ -176,18 +179,6 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { hideMenu() } - // When this window becomes or resigns main we need to run some logic. - NotificationCenter.default.addObserver( - self, - selector: #selector(windowDidBecomeMain), - name: NSWindow.didBecomeMainNotification, - object: window) - NotificationCenter.default.addObserver( - self, - selector: #selector(windowDidResignMain), - name: NSWindow.didResignMainNotification, - object: window) - // When we change screens we need to redo everything. NotificationCenter.default.addObserver( self, @@ -222,8 +213,6 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { // Remove all our notifications. We remove them one by one because // we don't want to remove the observers that our superclass sets. let center = NotificationCenter.default - center.removeObserver(self, name: NSWindow.didBecomeMainNotification, object: window) - center.removeObserver(self, name: NSWindow.didResignMainNotification, object: window) center.removeObserver(self, name: NSWindow.didChangeScreenNotification, object: window) // Unhide our elements @@ -315,42 +304,6 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { exit() } - @objc func windowDidBecomeMain(_ notification: Notification) { - guard let savedState else { return } - - // This should always be true due to how we register but just be sure - guard let object = notification.object as? NSWindow, - object == window else { return } - - // This is crazy but at least on macOS 15.0, you must hide the dock - // FIRST then hide the menu. If you do the opposite, it does not - // work. - - if savedState.dock { - hideDock() - } - - if (properties.hideMenu) { - hideMenu() - } - } - - @objc func windowDidResignMain(_ notification: Notification) { - guard let savedState else { return } - - // This should always be true due to how we register but just be sure - guard let object = notification.object as? NSWindow, - object == window else { return } - - if (properties.hideMenu) { - unhideMenu() - } - - if savedState.dock { - unhideDock() - } - } - // MARK: Dock private func hideDock() { diff --git a/src/Command.zig b/src/Command.zig index daca54f94..2b600979f 100644 --- a/src/Command.zig +++ b/src/Command.zig @@ -440,9 +440,9 @@ fn isExecutable(mode: std.fs.File.Mode) bool { return mode & 0o0111 != 0; } -// `hostname` is present on both *nix and windows +// `uname -n` is the *nix equivalent of `hostname.exe` on Windows test "expandPath: hostname" { - const executable = if (builtin.os.tag == .windows) "hostname.exe" else "hostname"; + const executable = if (builtin.os.tag == .windows) "hostname.exe" else "uname"; const path = (try expandPath(testing.allocator, executable)).?; defer testing.allocator.free(path); try testing.expect(path.len > executable.len); diff --git a/src/Surface.zig b/src/Surface.zig index 3e7300d08..9fc5b1d90 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -245,7 +245,7 @@ const DerivedConfig = struct { mouse_scroll_multiplier: f64, mouse_shift_capture: configpkg.MouseShiftCapture, macos_non_native_fullscreen: configpkg.NonNativeFullscreen, - macos_option_as_alt: configpkg.OptionAsAlt, + macos_option_as_alt: ?configpkg.OptionAsAlt, vt_kam_allowed: bool, window_padding_top: u32, window_padding_bottom: u32, @@ -1990,12 +1990,26 @@ fn encodeKey( // inputs there are many keybindings that result in no encoding // whatsoever. const enc: input.KeyEncoder = enc: { + const option_as_alt: configpkg.OptionAsAlt = self.config.macos_option_as_alt orelse detect: { + // Non-macOS doesn't use this value so ignore. + if (comptime builtin.os.tag != .macos) break :detect .false; + + // If we don't have alt pressed, it doesn't matter what this + // config is so we can just say "false" and break out and avoid + // more expensive checks below. + if (!event.mods.alt) break :detect .false; + + // Alt is pressed, we're on macOS. We break some encapsulation + // here and assume libghostty for ease... + break :detect self.rt_app.keyboardLayout().detectOptionAsAlt(); + }; + self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); const t = &self.io.terminal; break :enc .{ .event = event, - .macos_option_as_alt = self.config.macos_option_as_alt, + .macos_option_as_alt = option_as_alt, .alt_esc_prefix = t.modes.get(.alt_esc_prefix), .cursor_key_application = t.modes.get(.cursor_keys), .keypad_key_application = t.modes.get(.keypad_keys), diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 6a4411a85..451605af7 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -105,11 +105,14 @@ pub const App = struct { var config_clone = try config.clone(alloc); errdefer config_clone.deinit(); + var keymap = try input.Keymap.init(); + errdefer keymap.deinit(); + return .{ .core_app = core_app, .config = config_clone, .opts = opts, - .keymap = try input.Keymap.init(), + .keymap = keymap, .keymap_state = .{}, }; } @@ -161,8 +164,15 @@ pub const App = struct { // then we strip the alt modifier from the mods for translation. const translate_mods = translate_mods: { var translate_mods = mods; - if (comptime builtin.target.isDarwin()) { - const strip = switch (self.config.@"macos-option-as-alt") { + if ((comptime builtin.target.isDarwin()) and translate_mods.alt) { + // Note: the keyboardLayout() function is not super cheap + // so we only want to run it if alt is already pressed hence + // the above condition. + const option_as_alt: configpkg.OptionAsAlt = + self.config.@"macos-option-as-alt" orelse + self.keyboardLayout().detectOptionAsAlt(); + + const strip = switch (option_as_alt) { .false => false, .true => mods.alt, .left => mods.sides.alt == .left, @@ -382,6 +392,25 @@ pub const App = struct { } } + /// Loads the keyboard layout. + /// + /// Kind of expensive so this should be avoided if possible. When I say + /// "kind of expensive" I mean that its not something you probably want + /// to run on every keypress. + pub fn keyboardLayout(self: *const App) input.KeyboardLayout { + // We only support keyboard layout detection on macOS. + if (comptime builtin.os.tag != .macos) return .unknown; + + // Any layout larger than this is not something we can handle. + var buf: [256]u8 = undefined; + const id = self.keymap.sourceId(&buf) catch |err| { + comptime assert(@TypeOf(err) == error{OutOfMemory}); + return .unknown; + }; + + return input.KeyboardLayout.mapAppleId(id) orelse .unknown; + } + pub fn wakeup(self: *const App) void { self.opts.wakeup(self.opts.userdata); } @@ -1551,7 +1580,8 @@ pub const CAPI = struct { @truncate(@as(c_uint, @bitCast(mods_raw))), )); const result = mods.translation( - surface.core_surface.config.macos_option_as_alt, + surface.core_surface.config.macos_option_as_alt orelse + surface.app.keyboardLayout().detectOptionAsAlt(), ); return @intCast(@as(input.Mods.Backing, @bitCast(result))); } diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index bf4c44ad0..64b0cbe81 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -409,6 +409,13 @@ pub const App = struct { } } + pub fn keyboardLayout(self: *const App) input.KeyboardLayout { + _ = self; + + // Not supported by glfw + return .unknown; + } + /// Mac-specific settings. This is only enabled when the target is /// Mac and the artifact is a standalone exe. We don't target libs because /// the embedded API doesn't do windowing. diff --git a/src/apprt/gtk/ClipboardConfirmationWindow.zig b/src/apprt/gtk/ClipboardConfirmationWindow.zig index bcefb9d8a..30b38f1d4 100644 --- a/src/apprt/gtk/ClipboardConfirmationWindow.zig +++ b/src/apprt/gtk/ClipboardConfirmationWindow.zig @@ -17,13 +17,13 @@ window: *c.GtkWindow, view: PrimaryView, data: [:0]u8, -core_surface: CoreSurface, +core_surface: *CoreSurface, pending_req: apprt.ClipboardRequest, pub fn create( app: *App, data: []const u8, - core_surface: CoreSurface, + core_surface: *CoreSurface, request: apprt.ClipboardRequest, ) !void { if (app.clipboard_confirmation_window != null) return error.WindowAlreadyExists; @@ -54,7 +54,7 @@ fn init( self: *ClipboardConfirmation, app: *App, data: []const u8, - core_surface: CoreSurface, + core_surface: *CoreSurface, request: apprt.ClipboardRequest, ) !void { // Create the window diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 9a361c228..3ad695909 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -1051,7 +1051,7 @@ pub fn clipboardRequest( } pub fn setClipboardString( - self: *const Surface, + self: *Surface, val: [:0]const u8, clipboard_type: apprt.Clipboard, confirm: bool, @@ -1065,7 +1065,7 @@ pub fn setClipboardString( ClipboardConfirmationWindow.create( self.app, val, - self.core_surface, + &self.core_surface, .{ .osc_52_write = clipboard_type }, ) catch |window_err| { log.err("failed to create clipboard confirmation window err={}", .{window_err}); @@ -1113,7 +1113,7 @@ fn gtkClipboardRead( ClipboardConfirmationWindow.create( self.app, str, - self.core_surface, + &self.core_surface, req.state, ) catch |window_err| { log.err("failed to create clipboard confirmation window err={}", .{window_err}); diff --git a/src/build/fish_completions.zig b/src/build/fish_completions.zig index 2ac67bdad..5212dab61 100644 --- a/src/build/fish_completions.zig +++ b/src/build/fish_completions.zig @@ -2,9 +2,6 @@ const std = @import("std"); const Config = @import("../config/Config.zig"); const Action = @import("../cli/action.zig").Action; -const ListFontsConfig = @import("../cli/list_fonts.zig").Config; -const ShowConfigOptions = @import("../cli/show_config.zig").Options; -const ListKeybindsOptions = @import("../cli/list_keybinds.zig").Options; /// A fish completions configuration that contains all the available commands /// and options. @@ -100,66 +97,35 @@ fn writeFishCompletions(writer: anytype) !void { try writer.writeAll("\"\n"); } - for (@typeInfo(ListFontsConfig).Struct.fields) |field| { - if (field.name[0] == '_') continue; - try writer.writeAll("complete -c ghostty -n \"__fish_seen_subcommand_from +list-fonts\" -l "); - try writer.writeAll(field.name); - try writer.writeAll(if (field.type != bool) " -r" else " "); - try writer.writeAll(" -f"); - switch (@typeInfo(field.type)) { - .Bool => try writer.writeAll(" -a \"true false\""), - .Enum => |info| { - try writer.writeAll(" -a \""); - for (info.fields, 0..) |f, i| { - if (i > 0) try writer.writeAll(" "); - try writer.writeAll(f.name); - } - try writer.writeAll("\""); - }, - else => {}, - } - try writer.writeAll("\n"); - } + for (@typeInfo(Action).Enum.fields) |field| { + if (std.mem.eql(u8, "help", field.name)) continue; + if (std.mem.eql(u8, "version", field.name)) continue; - for (@typeInfo(ShowConfigOptions).Struct.fields) |field| { - if (field.name[0] == '_') continue; - try writer.writeAll("complete -c ghostty -n \"__fish_seen_subcommand_from +show-config\" -l "); - try writer.writeAll(field.name); - try writer.writeAll(if (field.type != bool) " -r" else " "); - try writer.writeAll(" -f"); - switch (@typeInfo(field.type)) { - .Bool => try writer.writeAll(" -a \"true false\""), - .Enum => |info| { - try writer.writeAll(" -a \""); - for (info.fields, 0..) |f, i| { - if (i > 0) try writer.writeAll(" "); - try writer.writeAll(f.name); - } - try writer.writeAll("\""); - }, - else => {}, - } - try writer.writeAll("\n"); - } + const options = @field(Action, field.name).options(); + for (@typeInfo(options).Struct.fields) |opt| { + if (opt.name[0] == '_') continue; + try writer.writeAll("complete -c ghostty -n \"__fish_seen_subcommand_from +" ++ field.name ++ "\" -l "); + try writer.writeAll(opt.name); + try writer.writeAll(if (opt.type != bool) " -r" else ""); - for (@typeInfo(ListKeybindsOptions).Struct.fields) |field| { - if (field.name[0] == '_') continue; - try writer.writeAll("complete -c ghostty -n \"__fish_seen_subcommand_from +list-keybinds\" -l "); - try writer.writeAll(field.name); - try writer.writeAll(if (field.type != bool) " -r" else " "); - try writer.writeAll(" -f"); - switch (@typeInfo(field.type)) { - .Bool => try writer.writeAll(" -a \"true false\""), - .Enum => |info| { - try writer.writeAll(" -a \""); - for (info.fields, 0..) |f, i| { - if (i > 0) try writer.writeAll(" "); - try writer.writeAll(f.name); - } - try writer.writeAll("\""); - }, - else => {}, + // special case +validate_config --config-file + if (std.mem.eql(u8, "config-file", opt.name)) { + try writer.writeAll(" -F"); + } else try writer.writeAll(" -f"); + + switch (@typeInfo(opt.type)) { + .Bool => try writer.writeAll(" -a \"true false\""), + .Enum => |info| { + try writer.writeAll(" -a \""); + for (info.opts, 0..) |f, i| { + if (i > 0) try writer.writeAll(" "); + try writer.writeAll(f.name); + } + try writer.writeAll("\""); + }, + else => {}, + } + try writer.writeAll("\n"); } - try writer.writeAll("\n"); } } diff --git a/src/build/mdgen/mdgen.zig b/src/build/mdgen/mdgen.zig index 7e05596d7..c7e8c8638 100644 --- a/src/build/mdgen/mdgen.zig +++ b/src/build/mdgen/mdgen.zig @@ -30,10 +30,10 @@ pub fn genConfig(writer: anytype, cli: bool) !void { inline for (@typeInfo(Config).Struct.fields) |field| { if (field.name[0] == '_') continue; - try writer.writeAll("`"); + try writer.writeAll("**`"); if (cli) try writer.writeAll("--"); try writer.writeAll(field.name); - try writer.writeAll("`\n\n"); + try writer.writeAll("`**\n\n"); if (@hasDecl(help_strings.Config, field.name)) { var iter = std.mem.splitScalar(u8, @field(help_strings.Config, field.name), '\n'); var first = true; @@ -60,12 +60,12 @@ pub fn genActions(writer: anytype) !void { const action = std.meta.stringToEnum(Action, field.name).?; switch (action) { - .help => try writer.writeAll("`--help`\n\n"), - .version => try writer.writeAll("`--version`\n\n"), + .help => try writer.writeAll("**`--help`**\n\n"), + .version => try writer.writeAll("**`--version`**\n\n"), else => { - try writer.writeAll("`+"); + try writer.writeAll("**`+"); try writer.writeAll(field.name); - try writer.writeAll("`\n\n"); + try writer.writeAll("`**\n\n"); }, } @@ -97,9 +97,9 @@ pub fn genKeybindActions(writer: anytype) !void { inline for (info.Union.fields) |field| { if (field.name[0] == '_') continue; - try writer.writeAll("`"); + try writer.writeAll("**`"); try writer.writeAll(field.name); - try writer.writeAll("`\n\n"); + try writer.writeAll("`**\n\n"); if (@hasDecl(help_strings.KeybindAction, field.name)) { var iter = std.mem.splitScalar(u8, @field(help_strings.KeybindAction, field.name), '\n'); diff --git a/src/build/zsh_completions.zig b/src/build/zsh_completions.zig new file mode 100644 index 000000000..78d256ee2 --- /dev/null +++ b/src/build/zsh_completions.zig @@ -0,0 +1,201 @@ +const std = @import("std"); + +const Config = @import("../config/Config.zig"); +const Action = @import("../cli/action.zig").Action; + +/// A zsh completions configuration that contains all the available commands +/// and options. +pub const zsh_completions = comptimeGenerateZshCompletions(); + +fn comptimeGenerateZshCompletions() []const u8 { + comptime { + @setEvalBranchQuota(19000); + var counter = std.io.countingWriter(std.io.null_writer); + try writeZshCompletions(&counter.writer()); + + var buf: [counter.bytes_written]u8 = undefined; + var stream = std.io.fixedBufferStream(&buf); + try writeZshCompletions(stream.writer()); + const final = buf; + return final[0..stream.getWritten().len]; + } +} + +fn writeZshCompletions(writer: anytype) !void { + try writer.writeAll( + \\#compdef ghostty + \\ + \\_fonts () { + \\ local font_list=$(ghostty +list-fonts | grep -Z '^[A-Z]') + \\ local fonts=(${(f)font_list}) + \\ _describe -t fonts 'fonts' fonts + \\} + \\ + \\_themes() { + \\ local theme_list=$(ghostty +list-themes | sed -E 's/^(.*) \(.*\$/\0/') + \\ local themes=(${(f)theme_list}) + \\ _describe -t themes 'themes' themes + \\} + \\ + ); + + try writer.writeAll("_config() {\n"); + try writer.writeAll(" _arguments \\\n"); + try writer.writeAll(" \"--help\" \\\n"); + try writer.writeAll(" \"--version\" \\\n"); + for (@typeInfo(Config).Struct.fields) |field| { + if (field.name[0] == '_') continue; + try writer.writeAll(" \"--"); + try writer.writeAll(field.name); + try writer.writeAll("=-:::"); + + if (std.mem.startsWith(u8, field.name, "font-family")) + try writer.writeAll("_fonts") + else if (std.mem.eql(u8, "theme", field.name)) + try writer.writeAll("_themes") + else if (std.mem.eql(u8, "working-directory", field.name)) + try writer.writeAll("{_files -/}") + else if (field.type == Config.RepeatablePath) + try writer.writeAll("_files") // todo check if this is needed + else { + try writer.writeAll("("); + switch (@typeInfo(field.type)) { + .Bool => try writer.writeAll("true false"), + .Enum => |info| { + for (info.fields, 0..) |f, i| { + if (i > 0) try writer.writeAll(" "); + try writer.writeAll(f.name); + } + }, + .Struct => |info| { + if (!@hasDecl(field.type, "parseCLI") and info.layout == .@"packed") { + for (info.fields, 0..) |f, i| { + if (i > 0) try writer.writeAll(" "); + try writer.writeAll(f.name); + try writer.writeAll(" no-"); + try writer.writeAll(f.name); + } + } else { + //resize-overlay-duration + //keybind + //window-padding-x ...-y + //link + //palette + //background + //foreground + //font-variation* + //font-feature + try writer.writeAll(" "); + } + }, + else => try writer.writeAll(" "), + } + try writer.writeAll(")"); + } + + try writer.writeAll("\" \\\n"); + } + try writer.writeAll("\n}\n\n"); + + try writer.writeAll( + \\_ghostty() { + \\ typeset -A opt_args + \\ local context state line + \\ local opt=('--help' '--version') + \\ + \\ _arguments -C \ + \\ '1:actions:->actions' \ + \\ '*:: :->rest' \ + \\ + \\ if [[ "$line[1]" == "--help" || "$line[1]" == "--version" ]]; then + \\ return + \\ fi + \\ + \\ if [[ "$line[1]" == -* ]]; then + \\ _config + \\ return + \\ fi + \\ + \\ case "$state" in + \\ (actions) + \\ local actions; actions=( + \\ + ); + + { + // how to get 'commands' + var count: usize = 0; + const padding = " "; + for (@typeInfo(Action).Enum.fields) |field| { + try writer.writeAll(padding ++ "'+"); + try writer.writeAll(field.name); + try writer.writeAll("'\n"); + count += 1; + } + } + + try writer.writeAll( + \\ ) + \\ _describe '' opt + \\ _describe -t action 'action' actions + \\ ;; + \\ (rest) + \\ if [[ "$line[2]" == "--help" ]]; then + \\ return + \\ fi + \\ + \\ local help=('--help') + \\ _describe '' help + \\ + \\ case $line[1] in + \\ + ); + { + const padding = " "; + for (@typeInfo(Action).Enum.fields) |field| { + if (std.mem.eql(u8, "help", field.name)) continue; + if (std.mem.eql(u8, "version", field.name)) continue; + + const options = @field(Action, field.name).options(); + // assumes options will never be created with only <_name> members + if (@typeInfo(options).Struct.fields.len == 0) continue; + + try writer.writeAll(padding ++ "(+" ++ field.name ++ ")\n"); + try writer.writeAll(padding ++ " _arguments \\\n"); + for (@typeInfo(options).Struct.fields) |opt| { + if (opt.name[0] == '_') continue; + + try writer.writeAll(padding ++ " '--"); + try writer.writeAll(opt.name); + try writer.writeAll("=-:::"); + switch (@typeInfo(opt.type)) { + .Bool => try writer.writeAll("(true false)"), + .Enum => |info| { + try writer.writeAll("("); + for (info.opts, 0..) |f, i| { + if (i > 0) try writer.writeAll(" "); + try writer.writeAll(f.name); + } + try writer.writeAll(")"); + }, + else => { + if (std.mem.eql(u8, "config-file", opt.name)) { + try writer.writeAll("_files"); + } else try writer.writeAll("( )"); + }, + } + try writer.writeAll("' \\\n"); + } + try writer.writeAll(padding ++ ";;\n"); + } + } + try writer.writeAll( + \\ esac + \\ ;; + \\ esac + \\} + \\ + \\_ghostty "$@" + \\ + ); +} diff --git a/src/cli/action.zig b/src/cli/action.zig index 1da0c0609..2f4b63638 100644 --- a/src/cli/action.zig +++ b/src/cli/action.zig @@ -163,6 +163,26 @@ pub const Action = enum { return "cli/" ++ filename ++ ".zig"; } } + + /// Returns the options of action. Supports generating shell completions + /// without duplicating the mapping from Action to relevant Option + /// @import(..) declaration. + pub fn options(comptime self: Action) type { + comptime { + return switch (self) { + .version => version.Options, + .help => help.Options, + .@"list-fonts" => list_fonts.Options, + .@"list-keybinds" => list_keybinds.Options, + .@"list-themes" => list_themes.Options, + .@"list-colors" => list_colors.Options, + .@"list-actions" => list_actions.Options, + .@"show-config" => show_config.Options, + .@"validate-config" => validate_config.Options, + .@"crash-report" => crash_report.Options, + }; + } + } }; test "parse action none" { diff --git a/src/cli/list_fonts.zig b/src/cli/list_fonts.zig index aba596b64..9d1f34cd1 100644 --- a/src/cli/list_fonts.zig +++ b/src/cli/list_fonts.zig @@ -7,7 +7,7 @@ const font = @import("../font/main.zig"); const log = std.log.scoped(.list_fonts); -pub const Config = struct { +pub const Options = struct { /// This is set by the CLI parser for deinit. _arena: ?ArenaAllocator = null, @@ -23,13 +23,13 @@ pub const Config = struct { bold: bool = false, italic: bool = false, - pub fn deinit(self: *Config) void { + pub fn deinit(self: *Options) void { if (self._arena) |arena| arena.deinit(); self.* = undefined; } /// Enables "-h" and "--help" to work. - pub fn help(self: Config) !void { + pub fn help(self: Options) !void { _ = self; return Action.help_error; } @@ -59,9 +59,9 @@ pub fn run(alloc: Allocator) !u8 { } fn runArgs(alloc_gpa: Allocator, argsIter: anytype) !u8 { - var config: Config = .{}; + var config: Options = .{}; defer config.deinit(); - try args.parse(Config, alloc_gpa, &config, argsIter); + try args.parse(Options, alloc_gpa, &config, argsIter); // Use an arena for all our memory allocs var arena = ArenaAllocator.init(alloc_gpa); diff --git a/src/cli/version.zig b/src/cli/version.zig index 26d5dcc74..259cb7453 100644 --- a/src/cli/version.zig +++ b/src/cli/version.zig @@ -7,6 +7,8 @@ const xev = @import("xev"); const renderer = @import("../renderer.zig"); const gtk = if (build_config.app_runtime == .gtk) @import("../apprt/gtk/c.zig").c else void; +pub const Options = struct {}; + /// The `version` command is used to display information about Ghostty. pub fn run(alloc: Allocator) !u8 { _ = alloc; diff --git a/src/config/Config.zig b/src/config/Config.zig index fa531dc7e..86d045c6a 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1574,20 +1574,41 @@ keybind: Keybinds = .{}, /// editor, etc. @"macos-titlebar-proxy-icon": MacTitlebarProxyIcon = .visible, -/// If `true`, the *Option* key will be treated as *Alt*. This makes terminal -/// sequences expecting *Alt* to work properly, but will break Unicode input -/// sequences on macOS if you use them via the *Alt* key. You may set this to -/// `false` to restore the macOS *Alt* key unicode sequences but this will break -/// terminal sequences expecting *Alt* to work. +/// macOS doesn't have a distinct "alt" key and instead has the "option" +/// key which behaves slightly differently. On macOS by default, the +/// option key plus a character will sometimes produces a Unicode character. +/// For example, on US standard layouts option-b produces "∫". This may be +/// undesirable if you want to use "option" as an "alt" key for keybindings +/// in terminal programs or shells. /// -/// The values `left` or `right` enable this for the left or right *Option* -/// key, respectively. +/// This configuration lets you change the behavior so that option is treated +/// as alt. +/// +/// The default behavior (unset) will depend on your active keyboard +/// layout. If your keyboard layout is one of the keyboard layouts listed +/// below, then the default value is "true". Otherwise, the default +/// value is "false". Keyboard layouts with a default value of "true" are: +/// +/// - U.S. Standard +/// - U.S. International /// /// Note that if an *Option*-sequence doesn't produce a printable character, it /// will be treated as *Alt* regardless of this setting. (i.e. `alt+ctrl+a`). /// +/// Explicit values that can be set: +/// +/// If `true`, the *Option* key will be treated as *Alt*. This makes terminal +/// sequences expecting *Alt* to work properly, but will break Unicode input +/// sequences on macOS if you use them via the *Alt* key. +/// +/// You may set this to `false` to restore the macOS *Alt* key unicode +/// sequences but this will break terminal sequences expecting *Alt* to work. +/// +/// The values `left` or `right` enable this for the left or right *Option* +/// key, respectively. +/// /// This does not work with GLFW builds. -@"macos-option-as-alt": OptionAsAlt = .false, +@"macos-option-as-alt": ?OptionAsAlt = null, /// Whether to enable the macOS window shadow. The default value is true. /// With some window managers and window transparency settings, you may @@ -4213,14 +4234,9 @@ pub const Keybinds = struct { } } - try formatter.formatEntry( - []const u8, - std.fmt.bufPrint( - &buf, - "{}{}", - .{ k, v }, - ) catch return error.OutOfMemory, - ); + var buffer_stream = std.io.fixedBufferStream(&buf); + std.fmt.format(buffer_stream.writer(), "{}", .{k}) catch return error.OutOfMemory; + try v.formatEntries(&buffer_stream, formatter); } } @@ -4254,6 +4270,56 @@ pub const Keybinds = struct { try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); try std.testing.expectEqualSlices(u8, "a = shift+a=csi:hello\n", buf.items); } + + // Regression test for https://github.com/ghostty-org/ghostty/issues/2734 + test "formatConfig multiple items" { + const testing = std.testing; + var buf = std.ArrayList(u8).init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Keybinds = .{}; + try list.parseCLI(alloc, "ctrl+z>1=goto_tab:1"); + try list.parseCLI(alloc, "ctrl+z>2=goto_tab:2"); + try list.formatEntry(formatterpkg.entryFormatter("keybind", buf.writer())); + + const want = + \\keybind = ctrl+z>1=goto_tab:1 + \\keybind = ctrl+z>2=goto_tab:2 + \\ + ; + try std.testing.expectEqualStrings(want, buf.items); + } + + test "formatConfig multiple items nested" { + const testing = std.testing; + var buf = std.ArrayList(u8).init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Keybinds = .{}; + try list.parseCLI(alloc, "ctrl+a>ctrl+b>n=new_window"); + try list.parseCLI(alloc, "ctrl+a>ctrl+b>w=close_window"); + try list.parseCLI(alloc, "ctrl+a>ctrl+c>t=new_tab"); + try list.parseCLI(alloc, "ctrl+b>ctrl+d>a=previous_tab"); + try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); + + // NB: This does not currently retain the order of the keybinds. + const want = + \\a = ctrl+a>ctrl+b>w=close_window + \\a = ctrl+a>ctrl+b>n=new_window + \\a = ctrl+a>ctrl+c>t=new_tab + \\a = ctrl+b>ctrl+d>a=previous_tab + \\ + ; + try std.testing.expectEqualStrings(want, buf.items); + } }; /// See "font-codepoint-map" for documentation. diff --git a/src/input.zig b/src/input.zig index 9e3997d97..83be38d3d 100644 --- a/src/input.zig +++ b/src/input.zig @@ -3,6 +3,7 @@ const builtin = @import("builtin"); const mouse = @import("input/mouse.zig"); const key = @import("input/key.zig"); +const keyboard = @import("input/keyboard.zig"); pub const function_keys = @import("input/function_keys.zig"); pub const keycodes = @import("input/keycodes.zig"); @@ -13,6 +14,7 @@ pub const Action = key.Action; pub const Binding = @import("input/Binding.zig"); pub const Link = @import("input/Link.zig"); pub const Key = key.Key; +pub const KeyboardLayout = keyboard.Layout; pub const KeyEncoder = @import("input/KeyEncoder.zig"); pub const KeyEvent = key.KeyEvent; pub const InspectorMode = Binding.Action.InspectorMode; diff --git a/src/input/Binding.zig b/src/input/Binding.zig index a467bfc2b..b451b5ec9 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1149,6 +1149,41 @@ pub const Set = struct { }, } } + + /// Writes the configuration entries for the binding + /// that this value is part of. + /// + /// The value may be part of multiple configuration entries + /// if they're all part of the same prefix sequence (e.g. 'a>b', 'a>c'). + /// These will result in multiple separate entries in the configuration. + /// + /// `buffer_stream` is a FixedBufferStream used for temporary storage + /// that is shared between calls to nested levels of the set. + /// For example, 'a>b>c=x' and 'a>b>d=y' will re-use the 'a>b' written + /// to the buffer before flushing it to the formatter with 'c=x' and 'd=y'. + pub fn formatEntries(self: Value, buffer_stream: anytype, formatter: anytype) !void { + switch (self) { + .leader => |set| { + // We'll rewind to this position after each sub-entry, + // sharing the prefix between siblings. + const pos = try buffer_stream.getPos(); + + var iter = set.bindings.iterator(); + while (iter.next()) |binding| { + buffer_stream.seekTo(pos) catch unreachable; // can't fail + std.fmt.format(buffer_stream.writer(), ">{s}", .{binding.key_ptr.*}) catch return error.OutOfMemory; + try binding.value_ptr.*.formatEntries(buffer_stream, formatter); + } + }, + + .leaf => |leaf| { + // When we get to the leaf, the buffer_stream contains + // the full sequence of keys needed to reach this action. + std.fmt.format(buffer_stream.writer(), "={s}", .{leaf.action}) catch return error.OutOfMemory; + try formatter.formatEntry([]const u8, buffer_stream.getWritten()); + }, + } + } }; /// Leaf node of a set is an action to trigger. This is a "leaf" compared diff --git a/src/input/KeyEncoder.zig b/src/input/KeyEncoder.zig index 25d85e78d..4bac7ee6b 100644 --- a/src/input/KeyEncoder.zig +++ b/src/input/KeyEncoder.zig @@ -208,7 +208,7 @@ fn kitty( // Determine if the Alt modifier should be treated as an actual // modifier (in which case it prevents associated text) or as // the macOS Option key, which does not prevent associated text. - const alt_prevents_text = if (comptime builtin.target.isDarwin()) + const alt_prevents_text = if (comptime builtin.os.tag == .macos) switch (self.macos_option_as_alt) { .left => all_mods.sides.alt == .left, .right => all_mods.sides.alt == .right, @@ -422,7 +422,7 @@ fn legacyAltPrefix( // On macOS, we only handle option like alt in certain // circumstances. Otherwise, macOS does a unicode translation // and we allow that to happen. - if (comptime builtin.target.isDarwin()) { + if (comptime builtin.os.tag == .macos) { switch (self.macos_option_as_alt) { .false => return null, .left => if (mods.sides.alt == .right) return null, diff --git a/src/input/KeymapDarwin.zig b/src/input/KeymapDarwin.zig index 5ba7c6440..3d81b0f4b 100644 --- a/src/input/KeymapDarwin.zig +++ b/src/input/KeymapDarwin.zig @@ -14,6 +14,7 @@ const Keymap = @This(); const std = @import("std"); const builtin = @import("builtin"); +const Allocator = std.mem.Allocator; const macos = @import("macos"); const codes = @import("keycodes.zig").entries; const Key = @import("key.zig").Key; @@ -72,6 +73,24 @@ pub fn reload(self: *Keymap) !void { try self.reinit(); } +/// Get the input source ID for the current keyboard layout. The input +/// source ID is a unique identifier for the keyboard layout which is uniquely +/// defined by Apple. +/// +/// This is macOS-only. Other platforms don't have an equivalent of this +/// so this isn't expected to be generally implemented. +pub fn sourceId(self: *const Keymap, buf: []u8) Allocator.Error![]const u8 { + // Get the raw CFStringRef + const id_raw = TISGetInputSourceProperty( + self.source, + kTISPropertyInputSourceID, + ) orelse return error.OutOfMemory; + + // Convert the CFStringRef to a C string into our buffer. + const id: *CFString = @ptrCast(id_raw); + return id.cstring(buf, .utf8) orelse error.OutOfMemory; +} + /// Reinit reinitializes the keymap. It assumes that all the memory associated /// with the keymap is already freed. fn reinit(self: *Keymap) !void { @@ -89,6 +108,12 @@ fn reinit(self: *Keymap) !void { // The CFDataRef contains a UCKeyboardLayout pointer break :layout @ptrCast(data.getPointer()); }; + + if (comptime builtin.mode == .Debug) id: { + var buf: [256]u8 = undefined; + const id = self.sourceId(&buf) catch break :id; + std.log.debug("keyboard layout={s}", .{id}); + } } /// Translate a single key input into a utf8 sequence. @@ -200,6 +225,7 @@ extern "c" fn LMGetKbdType() u8; extern "c" fn UCKeyTranslate(*const UCKeyboardLayout, u16, u16, u32, u32, u32, *u32, c_ulong, *c_ulong, [*]u16) i32; extern const kTISPropertyLocalizedName: *CFString; extern const kTISPropertyUnicodeKeyLayoutData: *CFString; +extern const kTISPropertyInputSourceID: *CFString; const TISInputSource = opaque {}; const UCKeyboardLayout = opaque {}; const kUCKeyActionDown: u16 = 0; diff --git a/src/input/keyboard.zig b/src/input/keyboard.zig new file mode 100644 index 000000000..73674df2c --- /dev/null +++ b/src/input/keyboard.zig @@ -0,0 +1,58 @@ +const std = @import("std"); +const OptionAsAlt = @import("../config.zig").OptionAsAlt; + +/// Keyboard layouts. +/// +/// These aren't heavily used in Ghostty and having a fully comprehensive +/// list is not important. We only need to distinguish between a few +/// different layouts for some nice-to-have features, such as setting a default +/// value for "macos-option-as-alt". +pub const Layout = enum { + // Unknown, unmapped layout. Ghostty should not make any assumptions + // about the layout of the keyboard. + unknown, + + // The remaining should be fairly self-explanatory: + us_standard, + us_international, + + /// Map an Apple keyboard layout ID to a value in this enum. The layout + /// ID can be retrieved using Carbon's TIKeyboardLayoutGetInputSourceProperty + /// function. + /// + /// Even though our layout supports "unknown", we return null if we don't + /// recognize the layout ID so callers can detect this scenario. + pub fn mapAppleId(id: []const u8) ?Layout { + if (std.mem.eql(u8, id, "com.apple.keylayout.US")) { + return .us_standard; + } else if (std.mem.eql(u8, id, "com.apple.keylayout.USInternational")) { + return .us_international; + } + + return null; + } + + /// Returns the default macos-option-as-alt value for this layout. + /// + /// We apply some heuristics to change the default based on the keyboard + /// layout if "macos-option-as-alt" is unset. We do this because on some + /// keyboard layouts such as US standard layouts, users generally expect + /// an input such as option-b to map to alt-b but macOS by default will + /// convert it to the codepoint "∫". + /// + /// This behavior however is desired on international layout where the + /// option key is used for important, regularly used inputs. + pub fn detectOptionAsAlt(self: Layout) OptionAsAlt { + return switch (self) { + // On US standard, the option key is typically used as alt + // and not as a modifier for other codepoints. For example, + // option-B = ∫ but usually the user wants alt-B. + .us_standard, + .us_international, + => .true, + + .unknown, + => .false, + }; + } +};