From 8bec01f2370591b5d10213512ceb96af148e022f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 10 Sep 2023 16:02:09 -0700 Subject: [PATCH 1/4] macos: disable hit testing for unfocused split rectangle --- macos/Sources/Ghostty/SurfaceView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index b042cea9f..015600392 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -129,6 +129,7 @@ extension Ghostty { if (isSplit && !surfaceFocus) { Rectangle() .fill(.white) + .allowsHitTesting(false) .opacity(0.15) } } From b14ba8c0227f48a63b4c8eef60d52264d3f26418 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 10 Sep 2023 16:17:19 -0700 Subject: [PATCH 2/4] config: extract into dedicated dir, split into files --- src/config.zig | 1637 +---------------------------------------- src/config/CAPI.zig | 98 +++ src/config/Config.zig | 1462 ++++++++++++++++++++++++++++++++++++ src/config/Wasm.zig | 54 ++ src/config/key.zig | 32 + 5 files changed, 1659 insertions(+), 1624 deletions(-) create mode 100644 src/config/CAPI.zig create mode 100644 src/config/Config.zig create mode 100644 src/config/Wasm.zig create mode 100644 src/config/key.zig diff --git a/src/config.zig b/src/config.zig index cc90a3c28..8d0dd2e44 100644 --- a/src/config.zig +++ b/src/config.zig @@ -1,1627 +1,16 @@ -const config = @This(); -const std = @import("std"); -const builtin = @import("builtin"); -const Allocator = std.mem.Allocator; -const ArenaAllocator = std.heap.ArenaAllocator; -const fontpkg = @import("font/main.zig"); -const inputpkg = @import("input.zig"); -const terminal = @import("terminal/main.zig"); -const internal_os = @import("os/main.zig"); -const cli_args = @import("cli_args.zig"); - -const log = std.log.scoped(.config); - -/// Used on Unixes for some defaults. -const c = @cImport({ - @cInclude("unistd.h"); -}); - -/// Config is the main config struct. These fields map directly to the -/// CLI flag names hence we use a lot of `@""` syntax to support hyphens. -pub const Config = struct { - /// The font families to use. - @"font-family": ?[:0]const u8 = null, - @"font-family-bold": ?[:0]const u8 = null, - @"font-family-italic": ?[:0]const u8 = null, - @"font-family-bold-italic": ?[:0]const u8 = null, - - /// Apply a font feature. This can be repeated multiple times to enable - /// multiple font features. You can NOT set multiple font features with - /// a single value (yet). - /// - /// The font feature will apply to all fonts rendered by Ghostty. A - /// future enhancement will allow targeting specific faces. - /// - /// A valid value is the name of a feature. Prefix the feature with a - /// "-" to explicitly disable it. Example: "ss20" or "-ss20". - @"font-feature": RepeatableString = .{}, - - /// Font size in points - @"font-size": u8 = switch (builtin.os.tag) { - // On Mac we default a little bigger since this tends to look better. - // This is purely subjective but this is easy to modify. - .macos => 13, - else => 12, - }, - - /// A repeatable configuration to set one or more font variations values - /// for a variable font. A variable font is a single font, usually - /// with a filename ending in "-VF.ttf" or "-VF.otf" that contains - /// one or more configurable axes for things such as weight, slant, - /// etc. Not all fonts support variations; only fonts that explicitly - /// state they are variable fonts will work. - /// - /// The format of this is "id=value" where "id" is the axis identifier. - /// An axis identifier is always a 4 character string, such as "wght". - /// To get the list of supported axes, look at your font documentation - /// or use a font inspection tool. - /// - /// Invalid ids and values are usually ignored. For example, if a font - /// only supports weights from 100 to 700, setting "wght=800" will - /// do nothing (it will not be clamped to 700). You must consult your - /// font's documentation to see what values are supported. - /// - /// Common axes are: "wght" (weight), "slnt" (slant), "ital" (italic), - /// "opsz" (optical size), "wdth" (width), "GRAD" (gradient), etc. - @"font-variation": RepeatableFontVariation = .{}, - @"font-variation-bold": RepeatableFontVariation = .{}, - @"font-variation-italic": RepeatableFontVariation = .{}, - @"font-variation-bold-italic": RepeatableFontVariation = .{}, - - /// Draw fonts with a thicker stroke, if supported. This is only supported - /// currently on macOS. - @"font-thicken": bool = false, - - /// Background color for the window. - background: Color = .{ .r = 0x28, .g = 0x2C, .b = 0x34 }, - - /// Foreground color for the window. - foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, - - /// The foreground and background color for selection. If this is not - /// set, then the selection color is just the inverted window background - /// and foreground (note: not to be confused with the cell bg/fg). - @"selection-foreground": ?Color = null, - @"selection-background": ?Color = null, - - /// Color palette for the 256 color form that many terminal applications - /// use. The syntax of this configuration is "N=HEXCODE" where "n" - /// is 0 to 255 (for the 256 colors) and HEXCODE is a typical RGB - /// color code such as "#AABBCC". The 0 to 255 correspond to the - /// terminal color table. - /// - /// For definitions on all the codes: - /// https://www.ditig.com/256-colors-cheat-sheet - palette: Palette = .{}, - - /// The color of the cursor. If this is not set, a default will be chosen. - @"cursor-color": ?Color = null, - - /// The style of the cursor. This sets the default style. A running - /// programn can still request an explicit cursor style using escape - /// sequences (such as CSI q). Shell configurations will often request - /// specific cursor styles. - /// - /// Caveat: Shell integration currently defaults to always be a bar - /// In order to fix it, we probably would want to add something similar to Kitty's - /// shell integration options (no-cursor). For more information see: - /// https://sw.kovidgoyal.net/kitty/conf/#opt-kitty.shell_integration - @"cursor-style": terminal.Cursor.Style = .bar, - - /// Whether the cursor shall blink - @"cursor-style-blink": bool = true, - - /// The color of the text under the cursor. If this is not set, a default - /// will be chosen. - @"cursor-text": ?Color = null, - - /// The opacity level (opposite of transparency) of the background. - /// A value of 1 is fully opaque and a value of 0 is fully transparent. - /// A value less than 0 or greater than 1 will be clamped to the nearest - /// valid value. - /// - /// Changing this value at runtime (and reloading config) will only - /// affect new windows, tabs, and splits. - @"background-opacity": f64 = 1.0, - - /// A positive value enables blurring of the background when - /// background-opacity is less than 1. The value is the blur radius to - /// apply. A value of 20 is reasonable for a good looking blur. - /// Higher values will cause strange rendering issues as well as - /// performance issues. - /// - /// This is only supported on macOS. - @"background-blur-radius": u8 = 0, - - /// The command to run, usually a shell. If this is not an absolute path, - /// it'll be looked up in the PATH. If this is not set, a default will - /// be looked up from your system. The rules for the default lookup are: - /// - /// - SHELL environment variable - /// - passwd entry (user information) - /// - command: ?[]const u8 = null, - - /// The directory to change to after starting the command. - /// - /// The default is "inherit" except in special scenarios listed next. - /// If ghostty can detect it is launched on macOS from launchd - /// (double-clicked), then it defaults to "home". - /// - /// The value of this must be an absolute value or one of the special - /// values below: - /// - /// - "home" - The home directory of the executing user. - /// - "inherit" - The working directory of the launching process. - /// - @"working-directory": ?[]const u8 = null, - - /// Key bindings. The format is "trigger=action". Duplicate triggers - /// will overwrite previously set values. - /// - /// Trigger: "+"-separated list of keys and modifiers. Example: - /// "ctrl+a", "ctrl+shift+b", "up". Some notes: - /// - /// - modifiers cannot repeat, "ctrl+ctrl+a" is invalid. - /// - modifiers and key scan be in any order, "shift+a+ctrl" is weird, - /// but valid. - /// - only a single key input is allowed, "ctrl+a+b" is invalid. - /// - /// Action is the action to take when the trigger is satisfied. It takes - /// the format "action" or "action:param". The latter form is only valid - /// if the action requires a parameter. - /// - /// - "ignore" - Do nothing, ignore the key input. This can be used to - /// black hole certain inputs to have no effect. - /// - "unbind" - Remove the binding. This makes it so the previous action - /// is removed, and the key will be sent through to the child command - /// if it is printable. - /// - "csi:text" - Send a CSI sequence. i.e. "csi:A" sends "cursor up". - /// - /// Some notes for the action: - /// - /// - The parameter is taken as-is after the ":". Double quotes or - /// other mechanisms are included and NOT parsed. If you want to - /// send a string value that includes spaces, wrap the entire - /// trigger/action in double quotes. Example: --keybind="up=csi:A B" - /// - keybind: Keybinds = .{}, - - /// Window padding. This applies padding between the terminal cells and - /// the window border. The "x" option applies to the left and right - /// padding and the "y" option is top and bottom. The value is in points, - /// meaning that it will be scaled appropriately for screen DPI. - /// - /// If this value is set too large, the screen will render nothing, because - /// the grid will be completely squished by the padding. It is up to you - /// as the user to pick a reasonable value. If you pick an unreasonable - /// value, a warning will appear in the logs. - @"window-padding-x": u32 = 2, - @"window-padding-y": u32 = 2, - - /// The viewport dimensions are usually not perfectly divisible by - /// the cell size. In this case, some extra padding on the end of a - /// column and the bottom of the final row may exist. If this is true, - /// then this extra padding is automatically balanced between all four - /// edges to minimize imbalance on one side. If this is false, the top - /// left grid cell will always hug the edge with zero padding other than - /// what may be specified with the other "window-padding" options. - /// - /// If other "window-padding" fields are set and this is true, this will - /// still apply. The other padding is applied first and may affect how - /// many grid cells actually exist, and this is applied last in order - /// to balance the padding given a certain viewport size and grid cell size. - @"window-padding-balance": bool = false, - - /// If true, new windows and tabs will inherit the font size of the previously - /// focused window. If no window was previously focused, the default - /// font size will be used. If this is false, the default font size - /// specified in the configuration "font-size" will be used. - @"window-inherit-font-size": bool = true, - - /// If false, windows won't have native decorations, i.e. titlebar and - /// borders. - /// Currently only supported with GTK. - @"window-decoration": bool = true, - - /// Whether to allow programs running in the terminal to read/write to - /// the system clipboard (OSC 52, for googling). The default is to - /// disallow clipboard reading but allow writing. - @"clipboard-read": bool = false, - @"clipboard-write": bool = true, - - /// Trims trailing whitespace on data that is copied to the clipboard. - /// This does not affect data sent to the clipboard via "clipboard-write". - @"clipboard-trim-trailing-spaces": bool = true, - - /// The total amount of bytes that can be used for image data (i.e. - /// the Kitty image protocol) per terminal scren. The maximum value - /// is 4,294,967,295 (4GB). The default is 320MB. If this is set to zero, - /// then all image protocols will be disabled. - /// - /// This value is separate for primary and alternate screens so the - /// effective limit per surface is double. - @"image-storage-limit": u32 = 320 * 1000 * 1000, - - /// Whether to automatically copy selected text to the clipboard. "true" - /// will only copy on systems that support a selection clipboard. - /// - /// The value "clipboard" will copy to the system clipboard, making this - /// work on macOS. Note that middle-click will also paste from the system - /// clipboard in this case. - /// - /// Note that if this is disabled, middle-click paste will also be - /// disabled. - @"copy-on-select": CopyOnSelect = .true, - - /// The time in milliseconds between clicks to consider a click a repeat - /// (double, triple, etc.) or an entirely new single click. A value of - /// zero will use a platform-specific default. The default on macOS - /// is determined by the OS settings. On every other platform it is 500ms. - @"click-repeat-interval": u32 = 0, - - /// Additional configuration files to read. - @"config-file": RepeatableString = .{}, - - /// Confirms that a surface should be closed before closing it. This defaults - /// to true. If set to false, surfaces will close without any confirmation. - @"confirm-close-surface": bool = true, - - /// Whether to enable shell integration auto-injection or not. Shell - /// integration greatly enhances the terminal experience by enabling - /// a number of features: - /// - /// * Working directory reporting so new tabs, splits inherit the - /// previous terminal's working directory. - /// * Prompt marking that enables the "scroll_to_prompt" keybinding. - /// * If you're sitting at a prompt, closing a terminal will not ask - /// for confirmation. - /// * Resizing the window with a complex prompt usually paints much - /// better. - /// - /// Allowable values are: - /// - /// * "none" - Do not do any automatic injection. You can still manually - /// configure your shell to enable the integration. - /// * "detect" - Detect the shell based on the filename. - /// * "fish", "zsh" - Use this specific shell injection scheme. - /// - /// The default value is "detect". - @"shell-integration": ShellIntegration = .detect, - - /// If anything other than false, fullscreen mode on macOS will not use the - /// native fullscreen, but make the window fullscreen without animations and - /// using a new space. It's faster than the native fullscreen mode since it - /// doesn't use animations. - /// - /// Allowable values are: - /// - /// * "visible-menu" - Use non-native macOS fullscreen, keep the menu bar visible - /// * "true" - Use non-native macOS fullscreen, hide the menu bar - /// * "false" - Use native macOS fullscreeen - @"macos-non-native-fullscreen": NonNativeFullscreen = .false, - - /// 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. - /// - /// 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). - /// - /// This does not work with GLFW builds. - @"macos-option-as-alt": OptionAsAlt = .false, - - /// If true (default), then the Ghostty GTK application will run in - /// single-instance mode: each new `ghostty` process launched will result - /// in a new window, if there is already a running process. - /// - /// If false, each new ghostty process will launch a separate application. - /// - /// Debug builds of Ghostty have a separate single-instance ID. - @"gtk-single-instance": bool = true, - - /// This is set by the CLI parser for deinit. - _arena: ?ArenaAllocator = null, - - /// Key is an enum of all the available configuration keys. This is used - /// when paired with diff to determine what fields have changed in a config, - /// amongst other things. - pub const Key = key: { - const field_infos = std.meta.fields(Config); - var enumFields: [field_infos.len]std.builtin.Type.EnumField = undefined; - var i: usize = 0; - inline for (field_infos) |field| { - // Ignore fields starting with "_" since they're internal and - // not copied ever. - if (field.name[0] == '_') continue; - - enumFields[i] = .{ - .name = field.name, - .value = i, - }; - i += 1; - } - - var decls = [_]std.builtin.Type.Declaration{}; - break :key @Type(.{ - .Enum = .{ - .tag_type = std.math.IntFittingRange(0, field_infos.len - 1), - .fields = enumFields[0..i], - .decls = &decls, - .is_exhaustive = true, - }, - }); - }; - - pub fn deinit(self: *Config) void { - if (self._arena) |arena| arena.deinit(); - self.* = undefined; - } - - /// Load the configuration according to the default rules: - /// - /// 1. Defaults - /// 2. XDG Config File - /// 3. CLI flags - /// 4. Recursively defined configuration files - /// - pub fn load(alloc_gpa: Allocator) !Config { - var result = try default(alloc_gpa); - errdefer result.deinit(); - - // If we have a configuration file in our home directory, parse that first. - try result.loadDefaultFiles(alloc_gpa); - - // Parse the config from the CLI args - try result.loadCliArgs(alloc_gpa); - - // Parse the config files that were added from our file and CLI args. - try result.loadRecursiveFiles(alloc_gpa); - try result.finalize(); - - return result; - } - - pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { - // Build up our basic config - var result: Config = .{ - ._arena = ArenaAllocator.init(alloc_gpa), - }; - errdefer result.deinit(); - const alloc = result._arena.?.allocator(); - - // Add our default keybindings - try result.keybind.set.put( - alloc, - .{ .key = .space, .mods = .{ .super = true, .alt = true, .ctrl = true } }, - .{ .reload_config = {} }, - ); - - { - // On macOS we default to super but Linux ctrl+shift since - // ctrl+c is to kill the process. - const mods: inputpkg.Mods = if (builtin.target.isDarwin()) - .{ .super = true } - else - .{ .ctrl = true, .shift = true }; - - try result.keybind.set.put( - alloc, - .{ .key = .c, .mods = mods }, - .{ .copy_to_clipboard = {} }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .v, .mods = mods }, - .{ .paste_from_clipboard = {} }, - ); - } - - // Fonts - try result.keybind.set.put( - alloc, - .{ .key = .equal, .mods = ctrlOrSuper(.{}) }, - .{ .increase_font_size = 1 }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .minus, .mods = ctrlOrSuper(.{}) }, - .{ .decrease_font_size = 1 }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .zero, .mods = ctrlOrSuper(.{}) }, - .{ .reset_font_size = {} }, - ); - - try result.keybind.set.put( - alloc, - .{ .key = .j, .mods = ctrlOrSuper(.{ .shift = true }) }, - .{ .write_scrollback_file = {} }, - ); - - // Windowing - if (comptime !builtin.target.isDarwin()) { - try result.keybind.set.put( - alloc, - .{ .key = .n, .mods = .{ .ctrl = true, .shift = true } }, - .{ .new_window = {} }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .w, .mods = .{ .ctrl = true, .shift = true } }, - .{ .close_surface = {} }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .q, .mods = .{ .ctrl = true, .shift = true } }, - .{ .quit = {} }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .f4, .mods = .{ .alt = true } }, - .{ .close_window = {} }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .t, .mods = .{ .ctrl = true, .shift = true } }, - .{ .new_tab = {} }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .left, .mods = .{ .ctrl = true, .shift = true } }, - .{ .previous_tab = {} }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .right, .mods = .{ .ctrl = true, .shift = true } }, - .{ .next_tab = {} }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .o, .mods = .{ .ctrl = true, .shift = true } }, - .{ .new_split = .right }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .e, .mods = .{ .ctrl = true, .shift = true } }, - .{ .new_split = .down }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .left_bracket, .mods = .{ .ctrl = true, .super = true } }, - .{ .goto_split = .previous }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .right_bracket, .mods = .{ .ctrl = true, .super = true } }, - .{ .goto_split = .next }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .up, .mods = .{ .ctrl = true, .alt = true } }, - .{ .goto_split = .top }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .down, .mods = .{ .ctrl = true, .alt = true } }, - .{ .goto_split = .bottom }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .left, .mods = .{ .ctrl = true, .alt = true } }, - .{ .goto_split = .left }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .right, .mods = .{ .ctrl = true, .alt = true } }, - .{ .goto_split = .right }, - ); - - // Viewport scrolling - try result.keybind.set.put( - alloc, - .{ .key = .home, .mods = .{ .shift = true } }, - .{ .scroll_to_top = {} }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .end, .mods = .{ .shift = true } }, - .{ .scroll_to_bottom = {} }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .page_up, .mods = .{ .shift = true } }, - .{ .scroll_page_up = {} }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .page_down, .mods = .{ .shift = true } }, - .{ .scroll_page_down = {} }, - ); - - // Semantic prompts - try result.keybind.set.put( - alloc, - .{ .key = .page_up, .mods = .{ .shift = true, .ctrl = true } }, - .{ .jump_to_prompt = -1 }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .page_down, .mods = .{ .shift = true, .ctrl = true } }, - .{ .jump_to_prompt = 1 }, - ); - } - { - // Cmd+N for goto tab N - const start = @intFromEnum(inputpkg.Key.one); - const end = @intFromEnum(inputpkg.Key.nine); - var i: usize = start; - while (i <= end) : (i += 1) { - // On macOS we default to super but everywhere else - // is alt. - const mods: inputpkg.Mods = if (builtin.target.isDarwin()) - .{ .super = true } - else - .{ .alt = true }; - - try result.keybind.set.put( - alloc, - .{ .key = @enumFromInt(i), .mods = mods }, - .{ .goto_tab = (i - start) + 1 }, - ); - } - } - - // Toggle fullscreen - try result.keybind.set.put( - alloc, - .{ .key = .enter, .mods = ctrlOrSuper(.{}) }, - .{ .toggle_fullscreen = {} }, - ); - - // Toggle zoom a split - try result.keybind.set.put( - alloc, - .{ .key = .enter, .mods = ctrlOrSuper(.{ .shift = true }) }, - .{ .toggle_split_zoom = {} }, - ); - - // Mac-specific keyboard bindings. - if (comptime builtin.target.isDarwin()) { - try result.keybind.set.put( - alloc, - .{ .key = .q, .mods = .{ .super = true } }, - .{ .quit = {} }, - ); - - try result.keybind.set.put( - alloc, - .{ .key = .k, .mods = .{ .super = true } }, - .{ .clear_screen = {} }, - ); - - // Viewport scrolling - try result.keybind.set.put( - alloc, - .{ .key = .home, .mods = .{ .super = true } }, - .{ .scroll_to_top = {} }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .end, .mods = .{ .super = true } }, - .{ .scroll_to_bottom = {} }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .page_up, .mods = .{ .super = true } }, - .{ .scroll_page_up = {} }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .page_down, .mods = .{ .super = true } }, - .{ .scroll_page_down = {} }, - ); - - // Semantic prompts - try result.keybind.set.put( - alloc, - .{ .key = .up, .mods = .{ .super = true, .shift = true } }, - .{ .jump_to_prompt = -1 }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .down, .mods = .{ .super = true, .shift = true } }, - .{ .jump_to_prompt = 1 }, - ); - - // Mac windowing - try result.keybind.set.put( - alloc, - .{ .key = .n, .mods = .{ .super = true } }, - .{ .new_window = {} }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .w, .mods = .{ .super = true } }, - .{ .close_surface = {} }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .w, .mods = .{ .super = true, .shift = true } }, - .{ .close_window = {} }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .t, .mods = .{ .super = true } }, - .{ .new_tab = {} }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .left_bracket, .mods = .{ .super = true, .shift = true } }, - .{ .previous_tab = {} }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .right_bracket, .mods = .{ .super = true, .shift = true } }, - .{ .next_tab = {} }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .d, .mods = .{ .super = true } }, - .{ .new_split = .right }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .d, .mods = .{ .super = true, .shift = true } }, - .{ .new_split = .down }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .left_bracket, .mods = .{ .super = true } }, - .{ .goto_split = .previous }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .right_bracket, .mods = .{ .super = true } }, - .{ .goto_split = .next }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .up, .mods = .{ .super = true, .alt = true } }, - .{ .goto_split = .top }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .down, .mods = .{ .super = true, .alt = true } }, - .{ .goto_split = .bottom }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .left, .mods = .{ .super = true, .alt = true } }, - .{ .goto_split = .left }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .right, .mods = .{ .super = true, .alt = true } }, - .{ .goto_split = .right }, - ); - } - - return result; - } - - /// This sets either "ctrl" or "super" to true (but not both) - /// on mods depending on if the build target is Mac or not. On - /// Mac, we default to super (i.e. super+c for copy) and on - /// non-Mac we default to ctrl (i.e. ctrl+c for copy). - fn ctrlOrSuper(mods: inputpkg.Mods) inputpkg.Mods { - var copy = mods; - if (comptime builtin.target.isDarwin()) { - copy.super = true; - } else { - copy.ctrl = true; - } - - return copy; - } - - /// Load the configuration from the default file locations. Currently, - /// this loads from $XDG_CONFIG_HOME/ghostty/config. - pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void { - const home_config_path = try internal_os.xdg.config(alloc, .{ .subdir = "ghostty/config" }); - defer alloc.free(home_config_path); - - const cwd = std.fs.cwd(); - if (cwd.openFile(home_config_path, .{})) |file| { - defer file.close(); - std.log.info("reading configuration file path={s}", .{home_config_path}); - - var buf_reader = std.io.bufferedReader(file.reader()); - var iter = cli_args.lineIterator(buf_reader.reader()); - try cli_args.parse(Config, alloc, self, &iter); - } else |err| switch (err) { - error.FileNotFound => std.log.info( - "homedir config not found, not loading path={s}", - .{home_config_path}, - ), - - else => std.log.warn( - "error reading homedir config file, not loading err={} path={s}", - .{ err, home_config_path }, - ), - } - } - - /// Load and parse the CLI args. - pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { - switch (builtin.os.tag) { - .windows => {}, - - // Fast-path if we are non-Windows and no args, do nothing. - else => if (std.os.argv.len <= 1) return, - } - - // Parse the config from the CLI args - var iter = try std.process.argsWithAllocator(alloc_gpa); - defer iter.deinit(); - try cli_args.parse(Config, alloc_gpa, self, &iter); - } - - /// Load and parse the config files that were added in the "config-file" key. - pub fn loadRecursiveFiles(self: *Config, alloc: Allocator) !void { - // TODO(mitchellh): we should parse the files form the homedir first - // TODO(mitchellh): support nesting (config-file in a config file) - // TODO(mitchellh): detect cycles when nesting - - if (self.@"config-file".list.items.len == 0) return; - - const cwd = std.fs.cwd(); - const len = self.@"config-file".list.items.len; - for (self.@"config-file".list.items) |path| { - var file = try cwd.openFile(path, .{}); - defer file.close(); - - var buf_reader = std.io.bufferedReader(file.reader()); - var iter = cli_args.lineIterator(buf_reader.reader()); - try cli_args.parse(Config, alloc, self, &iter); - - // We don't currently support adding more config files to load - // from within a loaded config file. This can be supported - // later. - if (self.@"config-file".list.items.len > len) - return error.ConfigFileInConfigFile; - } - } - - pub fn finalize(self: *Config) !void { - // If we have a font-family set and don't set the others, default - // the others to the font family. This way, if someone does - // --font-family=foo, then we try to get the stylized versions of - // "foo" as well. - if (self.@"font-family") |family| { - const fields = &[_][]const u8{ - "font-family-bold", - "font-family-italic", - "font-family-bold-italic", - }; - inline for (fields) |field| { - if (@field(self, field) == null) { - @field(self, field) = family; - } - } - } - - // The default for the working directory depends on the system. - const wd = self.@"working-directory" orelse switch (builtin.os.tag) { - .macos => if (c.getppid() == 1) "home" else "inherit", - else => "inherit", - }; - - // If we are missing either a command or home directory, we need - // to look up defaults which is kind of expensive. We only do this - // on desktop. - const wd_home = std.mem.eql(u8, "home", wd); - if (comptime !builtin.target.isWasm()) { - if (self.command == null or wd_home) command: { - const alloc = self._arena.?.allocator(); - - // First look up the command using the SHELL env var if needed. - // We don't do this in flatpak because SHELL in Flatpak is always - // set to /bin/sh. - if (self.command) |cmd| - log.info("shell src=config value={s}", .{cmd}) - else { - if (!internal_os.isFlatpak()) { - if (std.process.getEnvVarOwned(alloc, "SHELL")) |value| { - log.info("default shell source=env value={s}", .{value}); - self.command = value; - - // If we don't need the working directory, then we can exit now. - if (!wd_home) break :command; - } else |_| {} - } - } - - // We need the passwd entry for the remainder - const pw = try internal_os.passwd.get(alloc); - if (self.command == null) { - if (pw.shell) |sh| { - log.info("default shell src=passwd value={s}", .{sh}); - self.command = sh; - } - } - - if (wd_home) { - if (pw.home) |home| { - log.info("default working directory src=passwd value={s}", .{home}); - self.@"working-directory" = home; - } - } - - if (self.command == null) { - log.warn("no default shell found, will default to using sh", .{}); - } - } - } - - // If we have the special value "inherit" then set it to null which - // does the same. In the future we should change to a tagged union. - if (std.mem.eql(u8, wd, "inherit")) self.@"working-directory" = null; - - // Default our click interval - if (self.@"click-repeat-interval" == 0) { - self.@"click-repeat-interval" = internal_os.clickInterval() orelse 500; - } - } - - /// Create a shallow copy of this config. This will share all the memory - /// allocated with the previous config but will have a new arena for - /// any changes or new allocations. The config should have `deinit` - /// called when it is complete. - /// - /// Beware: these shallow clones are not meant for a long lifetime, - /// they are just meant to exist temporarily for the duration of some - /// modifications. It is very important that the original config not - /// be deallocated while shallow clones exist. - pub fn shallowClone(self: *const Config, alloc_gpa: Allocator) Config { - var result = self.*; - result._arena = ArenaAllocator.init(alloc_gpa); - return result; - } - - /// Create a copy of this configuration. This is useful as a starting - /// point for modifying a configuration since a config can NOT be - /// modified once it is in use by an app or surface. - pub fn clone(self: *const Config, alloc_gpa: Allocator) !Config { - // Start with an empty config with a new arena we're going - // to use for all our copies. - var result: Config = .{ - ._arena = ArenaAllocator.init(alloc_gpa), - }; - errdefer result.deinit(); - const alloc = result._arena.?.allocator(); - - inline for (@typeInfo(Config).Struct.fields) |field| { - if (!@hasField(Key, field.name)) continue; - @field(result, field.name) = try cloneValue( - alloc, - field.type, - @field(self, field.name), - ); - } - - return result; - } - - fn cloneValue(alloc: Allocator, comptime T: type, src: T) !T { - // Do known named types first - switch (T) { - []const u8 => return try alloc.dupe(u8, src), - [:0]const u8 => return try alloc.dupeZ(u8, src), - - else => {}, - } - - // Back into types of types - switch (@typeInfo(T)) { - inline .Bool, - .Int, - .Float, - .Enum, - => return src, - - .Optional => |info| return try cloneValue( - alloc, - info.child, - src orelse return null, - ), - - .Struct => return try src.clone(alloc), - - else => { - @compileLog(T); - @compileError("unsupported field type"); - }, - } - } - - /// Returns an iterator that goes through each changed field from - /// old to new. The order of old or new do not matter. - pub fn changeIterator(old: *const Config, new: *const Config) ChangeIterator { - return .{ - .old = old, - .new = new, - }; - } - - /// Returns true if the given key has changed from old to new. This - /// requires the key to be comptime known to make this more efficient. - pub fn changed(self: *const Config, new: *const Config, comptime key: Key) bool { - // Get the field at comptime - const field = comptime field: { - const fields = std.meta.fields(Config); - for (fields) |field| { - if (@field(Key, field.name) == key) { - break :field field; - } - } - - unreachable; - }; - - const old_value = @field(self, field.name); - const new_value = @field(new, field.name); - return !equal(field.type, old_value, new_value); - } - - /// This yields a key for every changed field between old and new. - pub const ChangeIterator = struct { - old: *const Config, - new: *const Config, - i: usize = 0, - - pub fn next(self: *ChangeIterator) ?Key { - const fields = comptime std.meta.fields(Key); - while (self.i < fields.len) { - switch (self.i) { - inline 0...(fields.len - 1) => |i| { - const field = fields[i]; - const key = @field(Key, field.name); - self.i += 1; - if (self.old.changed(self.new, key)) return key; - }, - - else => unreachable, - } - } - - return null; - } - }; - - test "clone default" { - const testing = std.testing; - const alloc = testing.allocator; - - var source = try Config.default(alloc); - defer source.deinit(); - var dest = try source.clone(alloc); - defer dest.deinit(); - - // Should have no changes - var it = source.changeIterator(&dest); - try testing.expectEqual(@as(?Key, null), it.next()); - - // I want to do this but this doesn't work (the API doesn't work) - // try testing.expectEqualDeep(dest, source); - } - - test "changed" { - const testing = std.testing; - const alloc = testing.allocator; - - var source = try Config.default(alloc); - defer source.deinit(); - var dest = try source.clone(alloc); - defer dest.deinit(); - dest.@"font-family" = "something else"; - - try testing.expect(source.changed(&dest, .@"font-family")); - try testing.expect(!source.changed(&dest, .@"font-size")); - } -}; - -/// A config-specific helper to determine if two values of the same -/// type are equal. This isn't the same as std.mem.eql or std.testing.equals -/// because we expect structs to implement their own equality. -/// -/// This also doesn't support ALL Zig types, because we only add to it -/// as we need types for the config. -fn equal(comptime T: type, old: T, new: T) bool { - // Do known named types first - switch (T) { - inline []const u8, - [:0]const u8, - => return std.mem.eql(u8, old, new), - - else => {}, - } - - // Back into types of types - switch (@typeInfo(T)) { - .Void => return true, - - inline .Bool, - .Int, - .Float, - .Enum, - => return old == new, - - .Optional => |info| { - if (old == null and new == null) return true; - if (old == null or new == null) return false; - return equal(info.child, old.?, new.?); - }, - - .Struct => |info| { - if (@hasDecl(T, "equal")) return old.equal(new); - - // If a struct doesn't declare an "equal" function, we fall back - // to a recursive field-by-field compare. - inline for (info.fields) |field_info| { - if (!equal( - field_info.type, - @field(old, field_info.name), - @field(new, field_info.name), - )) return false; - } - return true; - }, - - .Union => |info| { - const tag_type = info.tag_type.?; - const old_tag = std.meta.activeTag(old); - const new_tag = std.meta.activeTag(new); - if (old_tag != new_tag) return false; - - inline for (info.fields) |field_info| { - if (@field(tag_type, field_info.name) == old_tag) { - return equal( - field_info.type, - @field(old, field_info.name), - @field(new, field_info.name), - ); - } - } - - unreachable; - }, - - else => { - @compileLog(T); - @compileError("unsupported field type"); - }, - } -} - -/// Valid values for macos-non-native-fullscreen -/// c_int because it needs to be extern compatible -/// If this is changed, you must also update ghostty.h -pub const NonNativeFullscreen = enum(c_int) { - false, - true, - @"visible-menu", -}; - -/// Valid values for macos-option-as-alt. -pub const OptionAsAlt = enum { - false, - true, - left, - right, -}; - -/// Color represents a color using RGB. -pub const Color = struct { - r: u8, - g: u8, - b: u8, - - pub const Error = error{ - InvalidFormat, - }; - - /// Convert this to the terminal RGB struct - pub fn toTerminalRGB(self: Color) terminal.color.RGB { - return .{ .r = self.r, .g = self.g, .b = self.b }; - } - - pub fn parseCLI(input: ?[]const u8) !Color { - return fromHex(input orelse return error.ValueRequired); - } - - /// Deep copy of the struct. Required by Config. - pub fn clone(self: Color, _: Allocator) !Color { - return self; - } - - /// Compare if two of our value are requal. Required by Config. - pub fn equal(self: Color, other: Color) bool { - return std.meta.eql(self, other); - } - - /// fromHex parses a color from a hex value such as #RRGGBB. The "#" - /// is optional. - pub fn fromHex(input: []const u8) !Color { - // Trim the beginning '#' if it exists - const trimmed = if (input.len != 0 and input[0] == '#') input[1..] else input; - - // We expect exactly 6 for RRGGBB - if (trimmed.len != 6) return Error.InvalidFormat; - - // Parse the colors two at a time. - var result: Color = undefined; - comptime var i: usize = 0; - inline while (i < 6) : (i += 2) { - const v: u8 = - ((try std.fmt.charToDigit(trimmed[i], 16)) * 16) + - try std.fmt.charToDigit(trimmed[i + 1], 16); - - @field(result, switch (i) { - 0 => "r", - 2 => "g", - 4 => "b", - else => unreachable, - }) = v; - } - - return result; - } - - test "fromHex" { - const testing = std.testing; - - try testing.expectEqual(Color{ .r = 0, .g = 0, .b = 0 }, try Color.fromHex("#000000")); - try testing.expectEqual(Color{ .r = 10, .g = 11, .b = 12 }, try Color.fromHex("#0A0B0C")); - try testing.expectEqual(Color{ .r = 10, .g = 11, .b = 12 }, try Color.fromHex("0A0B0C")); - try testing.expectEqual(Color{ .r = 255, .g = 255, .b = 255 }, try Color.fromHex("FFFFFF")); - } -}; - -/// Palette is the 256 color palette for 256-color mode. This is still -/// used by many terminal applications. -pub const Palette = struct { - const Self = @This(); - - /// The actual value that is updated as we parse. - value: terminal.color.Palette = terminal.color.default, - - pub const Error = error{ - InvalidFormat, - }; - - pub fn parseCLI( - self: *Self, - input: ?[]const u8, - ) !void { - const value = input orelse return error.ValueRequired; - const eqlIdx = std.mem.indexOf(u8, value, "=") orelse - return Error.InvalidFormat; - - const key = try std.fmt.parseInt(u8, value[0..eqlIdx], 10); - const rgb = try Color.parseCLI(value[eqlIdx + 1 ..]); - self.value[key] = .{ .r = rgb.r, .g = rgb.g, .b = rgb.b }; - } - - /// Deep copy of the struct. Required by Config. - pub fn clone(self: Self, _: Allocator) !Self { - return self; - } - - /// Compare if two of our value are requal. Required by Config. - pub fn equal(self: Self, other: Self) bool { - return std.meta.eql(self, other); - } - - test "parseCLI" { - const testing = std.testing; - - var p: Self = .{}; - try p.parseCLI("0=#AABBCC"); - try testing.expect(p.value[0].r == 0xAA); - try testing.expect(p.value[0].g == 0xBB); - try testing.expect(p.value[0].b == 0xCC); - } - - test "parseCLI overflow" { - const testing = std.testing; - - var p: Self = .{}; - try testing.expectError(error.Overflow, p.parseCLI("256=#AABBCC")); - } -}; - -/// RepeatableString is a string value that can be repeated to accumulate -/// a list of strings. This isn't called "StringList" because I find that -/// sometimes leads to confusion that it _accepts_ a list such as -/// comma-separated values. -pub const RepeatableString = struct { - const Self = @This(); - - // Allocator for the list is the arena for the parent config. - list: std.ArrayListUnmanaged([]const u8) = .{}, - - pub fn parseCLI(self: *Self, alloc: Allocator, input: ?[]const u8) !void { - const value = input orelse return error.ValueRequired; - const copy = try alloc.dupe(u8, value); - try self.list.append(alloc, copy); - } - - /// Deep copy of the struct. Required by Config. - pub fn clone(self: *const Self, alloc: Allocator) !Self { - return .{ - .list = try self.list.clone(alloc), - }; - } - - /// Compare if two of our value are requal. Required by Config. - pub fn equal(self: Self, other: Self) bool { - const itemsA = self.list.items; - const itemsB = other.list.items; - if (itemsA.len != itemsB.len) return false; - for (itemsA, itemsB) |a, b| { - if (!std.mem.eql(u8, a, b)) return false; - } else return true; - } - - test "parseCLI" { - const testing = std.testing; - var arena = ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const alloc = arena.allocator(); - - var list: Self = .{}; - try list.parseCLI(alloc, "A"); - try list.parseCLI(alloc, "B"); - - try testing.expectEqual(@as(usize, 2), list.list.items.len); - } -}; - -/// FontVariation is a repeatable configuration value that sets a single -/// font variation value. Font variations are configurations for what -/// are often called "variable fonts." The font files usually end in -/// "-VF.ttf." -/// -/// The value for this is in the format of `id=value` where `id` is the -/// 4-character font variation axis identifier and `value` is the -/// floating point value for that axis. For more details on font variations -/// see the MDN font-variation-settings documentation since this copies that -/// behavior almost exactly: -/// -/// https://developer.mozilla.org/en-US/docs/Web/CSS/font-variation-settings -pub const RepeatableFontVariation = struct { - const Self = @This(); - - // Allocator for the list is the arena for the parent config. - list: std.ArrayListUnmanaged(fontpkg.face.Variation) = .{}, - - pub fn parseCLI(self: *Self, alloc: Allocator, input_: ?[]const u8) !void { - const input = input_ orelse return error.ValueRequired; - const eql_idx = std.mem.indexOf(u8, input, "=") orelse return error.InvalidFormat; - const whitespace = " \t"; - const key = std.mem.trim(u8, input[0..eql_idx], whitespace); - const value = std.mem.trim(u8, input[eql_idx + 1 ..], whitespace); - if (key.len != 4) return error.InvalidFormat; - try self.list.append(alloc, .{ - .id = fontpkg.face.Variation.Id.init(@ptrCast(key.ptr)), - .value = std.fmt.parseFloat(f64, value) catch return error.InvalidFormat, - }); - } - - /// Deep copy of the struct. Required by Config. - pub fn clone(self: *const Self, alloc: Allocator) !Self { - return .{ - .list = try self.list.clone(alloc), - }; - } - - /// Compare if two of our value are requal. Required by Config. - pub fn equal(self: Self, other: Self) bool { - const itemsA = self.list.items; - const itemsB = other.list.items; - if (itemsA.len != itemsB.len) return false; - for (itemsA, itemsB) |a, b| { - if (!std.meta.eql(a, b)) return false; - } else return true; - } - - test "parseCLI" { - const testing = std.testing; - var arena = ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const alloc = arena.allocator(); - - var list: Self = .{}; - try list.parseCLI(alloc, "wght=200"); - try list.parseCLI(alloc, "slnt=-15"); - - try testing.expectEqual(@as(usize, 2), list.list.items.len); - try testing.expectEqual(fontpkg.face.Variation{ - .id = fontpkg.face.Variation.Id.init("wght"), - .value = 200, - }, list.list.items[0]); - try testing.expectEqual(fontpkg.face.Variation{ - .id = fontpkg.face.Variation.Id.init("slnt"), - .value = -15, - }, list.list.items[1]); - } - - test "parseCLI with whitespace" { - const testing = std.testing; - var arena = ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const alloc = arena.allocator(); - - var list: Self = .{}; - try list.parseCLI(alloc, "wght =200"); - try list.parseCLI(alloc, "slnt= -15"); - - try testing.expectEqual(@as(usize, 2), list.list.items.len); - try testing.expectEqual(fontpkg.face.Variation{ - .id = fontpkg.face.Variation.Id.init("wght"), - .value = 200, - }, list.list.items[0]); - try testing.expectEqual(fontpkg.face.Variation{ - .id = fontpkg.face.Variation.Id.init("slnt"), - .value = -15, - }, list.list.items[1]); - } -}; - -/// Stores a set of keybinds. -pub const Keybinds = struct { - set: inputpkg.Binding.Set = .{}, - - pub fn parseCLI(self: *Keybinds, alloc: Allocator, input: ?[]const u8) !void { - var copy: ?[]u8 = null; - var value = value: { - const value = input orelse return error.ValueRequired; - - // If we don't have a colon, use the value as-is, no copy - if (std.mem.indexOf(u8, value, ":") == null) - break :value value; - - // If we have a colon, we copy the whole value for now. We could - // do this more efficiently later if we wanted to. - const buf = try alloc.alloc(u8, value.len); - copy = buf; - - std.mem.copy(u8, buf, value); - break :value buf; - }; - errdefer if (copy) |v| alloc.free(v); - - const binding = try inputpkg.Binding.parse(value); - switch (binding.action) { - .unbind => self.set.remove(binding.trigger), - else => try self.set.put(alloc, binding.trigger, binding.action), - } - } - - /// Deep copy of the struct. Required by Config. - pub fn clone(self: *const Keybinds, alloc: Allocator) !Keybinds { - return .{ - .set = .{ - .bindings = try self.set.bindings.clone(alloc), - }, - }; - } - - /// Compare if two of our value are requal. Required by Config. - pub fn equal(self: Keybinds, other: Keybinds) bool { - const self_map = self.set.bindings; - const other_map = other.set.bindings; - if (self_map.count() != other_map.count()) return false; - - var it = self_map.iterator(); - while (it.next()) |self_entry| { - const other_entry = other_map.getEntry(self_entry.key_ptr.*) orelse - return false; - if (!config.equal( - inputpkg.Binding.Action, - self_entry.value_ptr.*, - other_entry.value_ptr.*, - )) return false; - } - - return true; - } - - test "parseCLI" { - const testing = std.testing; - var arena = ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const alloc = arena.allocator(); - - var set: Keybinds = .{}; - try set.parseCLI(alloc, "shift+a=copy_to_clipboard"); - try set.parseCLI(alloc, "shift+a=csi:hello"); - } -}; - -/// Options for copy on select behavior. -pub const CopyOnSelect = enum { - /// Disables copy on select entirely. - false, - - /// Copy on select is enabled, but goes to the selection clipboard. - /// This is not supported on platforms such as macOS. This is the default. - true, - - /// Copy on select is enabled and goes to the system clipboard. - clipboard, -}; - -/// Shell integration values -pub const ShellIntegration = enum { - none, - detect, - fish, - zsh, -}; - -// Wasm API. -pub const Wasm = if (!builtin.target.isWasm()) struct {} else struct { - const wasm = @import("os/wasm.zig"); - const alloc = wasm.alloc; - - /// Create a new configuration filled with the initial default values. - export fn config_new() ?*Config { - const result = alloc.create(Config) catch |err| { - log.err("error allocating config err={}", .{err}); - return null; - }; - - result.* = Config.default(alloc) catch |err| { - log.err("error creating config err={}", .{err}); - return null; - }; - - return result; - } - - export fn config_free(ptr: ?*Config) void { - if (ptr) |v| { - v.deinit(); - alloc.destroy(v); - } - } - - /// Load the configuration from a string in the same format as - /// the file-based syntax for the desktop version of the terminal. - export fn config_load_string( - self: *Config, - str: [*]const u8, - len: usize, - ) void { - config_load_string_(self, str[0..len]) catch |err| { - log.err("error loading config err={}", .{err}); - }; - } - - fn config_load_string_(self: *Config, str: []const u8) !void { - var fbs = std.io.fixedBufferStream(str); - var iter = cli_args.lineIterator(fbs.reader()); - try cli_args.parse(Config, alloc, self, &iter); - } - - export fn config_finalize(self: *Config) void { - self.finalize() catch |err| { - log.err("error finalizing config err={}", .{err}); - }; - } -}; - -// C API. -pub const CAPI = struct { - const global = &@import("main.zig").state; - - /// Create a new configuration filled with the initial default values. - export fn ghostty_config_new() ?*Config { - const result = global.alloc.create(Config) catch |err| { - log.err("error allocating config err={}", .{err}); - return null; - }; - - result.* = Config.default(global.alloc) catch |err| { - log.err("error creating config err={}", .{err}); - return null; - }; - - return result; - } - - export fn ghostty_config_free(ptr: ?*Config) void { - if (ptr) |v| { - v.deinit(); - global.alloc.destroy(v); - } - } - - /// Load the configuration from the CLI args. - export fn ghostty_config_load_cli_args(self: *Config) void { - self.loadCliArgs(global.alloc) catch |err| { - log.err("error loading config err={}", .{err}); - }; - } - - /// Load the configuration from a string in the same format as - /// the file-based syntax for the desktop version of the terminal. - export fn ghostty_config_load_string( - self: *Config, - str: [*]const u8, - len: usize, - ) void { - config_load_string_(self, str[0..len]) catch |err| { - log.err("error loading config err={}", .{err}); - }; - } - - fn config_load_string_(self: *Config, str: []const u8) !void { - var fbs = std.io.fixedBufferStream(str); - var iter = cli_args.lineIterator(fbs.reader()); - try cli_args.parse(Config, global.alloc, self, &iter); - } - - /// Load the configuration from the default file locations. This - /// is usually done first. The default file locations are locations - /// such as the home directory. - export fn ghostty_config_load_default_files(self: *Config) void { - self.loadDefaultFiles(global.alloc) catch |err| { - log.err("error loading config err={}", .{err}); - }; - } - - /// Load the configuration from the user-specified configuration - /// file locations in the previously loaded configuration. This will - /// recursively continue to load up to a built-in limit. - export fn ghostty_config_load_recursive_files(self: *Config) void { - self.loadRecursiveFiles(global.alloc) catch |err| { - log.err("error loading config err={}", .{err}); - }; - } - - export fn ghostty_config_finalize(self: *Config) void { - self.finalize() catch |err| { - log.err("error finalizing config err={}", .{err}); - }; - } - - export fn ghostty_config_trigger( - self: *Config, - str: [*]const u8, - len: usize, - ) inputpkg.Binding.Trigger { - return config_trigger_(self, str[0..len]) catch |err| err: { - log.err("error finding trigger err={}", .{err}); - break :err .{}; - }; - } - - fn config_trigger_( - self: *Config, - str: []const u8, - ) !inputpkg.Binding.Trigger { - const action = try inputpkg.Binding.Action.parse(str); - return self.keybind.set.getTrigger(action) orelse .{}; - } -}; +pub const Config = @import("config/Config.zig"); +pub const Key = @import("config/key.zig").Key; + +// Field types +pub const CopyOnSelect = Config.CopyOnSelect; +pub const Keybinds = Config.Keybinds; +pub const NonNativeFullscreen = Config.NonNativeFullscreen; +pub const OptionAsAlt = Config.OptionAsAlt; + +// Alternate APIs +pub const CAPI = @import("config/CAPI.zig"); +pub const Wasm = @import("config/Wasm.zig"); test { - std.testing.refAllDecls(@This()); + @import("std").testing.refAllDecls(@This()); } diff --git a/src/config/CAPI.zig b/src/config/CAPI.zig new file mode 100644 index 000000000..1d559a51e --- /dev/null +++ b/src/config/CAPI.zig @@ -0,0 +1,98 @@ +const std = @import("std"); +const cli_args = @import("../cli_args.zig"); +const inputpkg = @import("../input.zig"); +const global = &@import("../main.zig").state; + +const Config = @import("Config.zig"); + +const log = std.log.scoped(.config); + +/// Create a new configuration filled with the initial default values. +export fn ghostty_config_new() ?*Config { + const result = global.alloc.create(Config) catch |err| { + log.err("error allocating config err={}", .{err}); + return null; + }; + + result.* = Config.default(global.alloc) catch |err| { + log.err("error creating config err={}", .{err}); + return null; + }; + + return result; +} + +export fn ghostty_config_free(ptr: ?*Config) void { + if (ptr) |v| { + v.deinit(); + global.alloc.destroy(v); + } +} + +/// Load the configuration from the CLI args. +export fn ghostty_config_load_cli_args(self: *Config) void { + self.loadCliArgs(global.alloc) catch |err| { + log.err("error loading config err={}", .{err}); + }; +} + +/// Load the configuration from a string in the same format as +/// the file-based syntax for the desktop version of the terminal. +export fn ghostty_config_load_string( + self: *Config, + str: [*]const u8, + len: usize, +) void { + config_load_string_(self, str[0..len]) catch |err| { + log.err("error loading config err={}", .{err}); + }; +} + +fn config_load_string_(self: *Config, str: []const u8) !void { + var fbs = std.io.fixedBufferStream(str); + var iter = cli_args.lineIterator(fbs.reader()); + try cli_args.parse(Config, global.alloc, self, &iter); +} + +/// Load the configuration from the default file locations. This +/// is usually done first. The default file locations are locations +/// such as the home directory. +export fn ghostty_config_load_default_files(self: *Config) void { + self.loadDefaultFiles(global.alloc) catch |err| { + log.err("error loading config err={}", .{err}); + }; +} + +/// Load the configuration from the user-specified configuration +/// file locations in the previously loaded configuration. This will +/// recursively continue to load up to a built-in limit. +export fn ghostty_config_load_recursive_files(self: *Config) void { + self.loadRecursiveFiles(global.alloc) catch |err| { + log.err("error loading config err={}", .{err}); + }; +} + +export fn ghostty_config_finalize(self: *Config) void { + self.finalize() catch |err| { + log.err("error finalizing config err={}", .{err}); + }; +} + +export fn ghostty_config_trigger( + self: *Config, + str: [*]const u8, + len: usize, +) inputpkg.Binding.Trigger { + return config_trigger_(self, str[0..len]) catch |err| err: { + log.err("error finding trigger err={}", .{err}); + break :err .{}; + }; +} + +fn config_trigger_( + self: *Config, + str: []const u8, +) !inputpkg.Binding.Trigger { + const action = try inputpkg.Binding.Action.parse(str); + return self.keybind.set.getTrigger(action) orelse .{}; +} diff --git a/src/config/Config.zig b/src/config/Config.zig new file mode 100644 index 000000000..9868860a5 --- /dev/null +++ b/src/config/Config.zig @@ -0,0 +1,1462 @@ +/// Config is the main config struct. These fields map directly to the +/// CLI flag names hence we use a lot of `@""` syntax to support hyphens. +const Config = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const fontpkg = @import("../font/main.zig"); +const inputpkg = @import("../input.zig"); +const terminal = @import("../terminal/main.zig"); +const internal_os = @import("../os/main.zig"); +const cli_args = @import("../cli_args.zig"); + +const Key = @import("key.zig").Key; + +const log = std.log.scoped(.config); + +/// Used on Unixes for some defaults. +const c = @cImport({ + @cInclude("unistd.h"); +}); + +/// The font families to use. +@"font-family": ?[:0]const u8 = null, +@"font-family-bold": ?[:0]const u8 = null, +@"font-family-italic": ?[:0]const u8 = null, +@"font-family-bold-italic": ?[:0]const u8 = null, + +/// Apply a font feature. This can be repeated multiple times to enable +/// multiple font features. You can NOT set multiple font features with +/// a single value (yet). +/// +/// The font feature will apply to all fonts rendered by Ghostty. A +/// future enhancement will allow targeting specific faces. +/// +/// A valid value is the name of a feature. Prefix the feature with a +/// "-" to explicitly disable it. Example: "ss20" or "-ss20". +@"font-feature": RepeatableString = .{}, + +/// Font size in points +@"font-size": u8 = switch (builtin.os.tag) { + // On Mac we default a little bigger since this tends to look better. + // This is purely subjective but this is easy to modify. + .macos => 13, + else => 12, +}, + +/// A repeatable configuration to set one or more font variations values +/// for a variable font. A variable font is a single font, usually +/// with a filename ending in "-VF.ttf" or "-VF.otf" that contains +/// one or more configurable axes for things such as weight, slant, +/// etc. Not all fonts support variations; only fonts that explicitly +/// state they are variable fonts will work. +/// +/// The format of this is "id=value" where "id" is the axis identifier. +/// An axis identifier is always a 4 character string, such as "wght". +/// To get the list of supported axes, look at your font documentation +/// or use a font inspection tool. +/// +/// Invalid ids and values are usually ignored. For example, if a font +/// only supports weights from 100 to 700, setting "wght=800" will +/// do nothing (it will not be clamped to 700). You must consult your +/// font's documentation to see what values are supported. +/// +/// Common axes are: "wght" (weight), "slnt" (slant), "ital" (italic), +/// "opsz" (optical size), "wdth" (width), "GRAD" (gradient), etc. +@"font-variation": RepeatableFontVariation = .{}, +@"font-variation-bold": RepeatableFontVariation = .{}, +@"font-variation-italic": RepeatableFontVariation = .{}, +@"font-variation-bold-italic": RepeatableFontVariation = .{}, + +/// Draw fonts with a thicker stroke, if supported. This is only supported +/// currently on macOS. +@"font-thicken": bool = false, + +/// Background color for the window. +background: Color = .{ .r = 0x28, .g = 0x2C, .b = 0x34 }, + +/// Foreground color for the window. +foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, + +/// The foreground and background color for selection. If this is not +/// set, then the selection color is just the inverted window background +/// and foreground (note: not to be confused with the cell bg/fg). +@"selection-foreground": ?Color = null, +@"selection-background": ?Color = null, + +/// Color palette for the 256 color form that many terminal applications +/// use. The syntax of this configuration is "N=HEXCODE" where "n" +/// is 0 to 255 (for the 256 colors) and HEXCODE is a typical RGB +/// color code such as "#AABBCC". The 0 to 255 correspond to the +/// terminal color table. +/// +/// For definitions on all the codes: +/// https://www.ditig.com/256-colors-cheat-sheet +palette: Palette = .{}, + +/// The color of the cursor. If this is not set, a default will be chosen. +@"cursor-color": ?Color = null, + +/// The style of the cursor. This sets the default style. A running +/// programn can still request an explicit cursor style using escape +/// sequences (such as CSI q). Shell configurations will often request +/// specific cursor styles. +/// +/// Caveat: Shell integration currently defaults to always be a bar +/// In order to fix it, we probably would want to add something similar to Kitty's +/// shell integration options (no-cursor). For more information see: +/// https://sw.kovidgoyal.net/kitty/conf/#opt-kitty.shell_integration +@"cursor-style": terminal.Cursor.Style = .bar, + +/// Whether the cursor shall blink +@"cursor-style-blink": bool = true, + +/// The color of the text under the cursor. If this is not set, a default +/// will be chosen. +@"cursor-text": ?Color = null, + +/// The opacity level (opposite of transparency) of the background. +/// A value of 1 is fully opaque and a value of 0 is fully transparent. +/// A value less than 0 or greater than 1 will be clamped to the nearest +/// valid value. +/// +/// Changing this value at runtime (and reloading config) will only +/// affect new windows, tabs, and splits. +@"background-opacity": f64 = 1.0, + +/// A positive value enables blurring of the background when +/// background-opacity is less than 1. The value is the blur radius to +/// apply. A value of 20 is reasonable for a good looking blur. +/// Higher values will cause strange rendering issues as well as +/// performance issues. +/// +/// This is only supported on macOS. +@"background-blur-radius": u8 = 0, + +/// The opacity level (opposite of transparency) of an unfocused split. +/// Unfocused splits by default are slightly faded out to make it easier +/// to see which split is focused. To disable this feature, set this +/// value to 1. +/// +/// A value of 1 is fully opaque and a value of 0 is fully transparent. +/// Because "0" is not useful (it makes the window look very weird), the +/// minimum value is 0.15. This value still looks weird but you can at least +/// see what's going on. A value outside of the range 0.15 to 1 will be +/// clamped to the nearest valid value. +@"unfocused-split-opacity": f64 = 0.85, + +/// The command to run, usually a shell. If this is not an absolute path, +/// it'll be looked up in the PATH. If this is not set, a default will +/// be looked up from your system. The rules for the default lookup are: +/// +/// - SHELL environment variable +/// - passwd entry (user information) +/// +command: ?[]const u8 = null, + +/// The directory to change to after starting the command. +/// +/// The default is "inherit" except in special scenarios listed next. +/// If ghostty can detect it is launched on macOS from launchd +/// (double-clicked), then it defaults to "home". +/// +/// The value of this must be an absolute value or one of the special +/// values below: +/// +/// - "home" - The home directory of the executing user. +/// - "inherit" - The working directory of the launching process. +/// +@"working-directory": ?[]const u8 = null, + +/// Key bindings. The format is "trigger=action". Duplicate triggers +/// will overwrite previously set values. +/// +/// Trigger: "+"-separated list of keys and modifiers. Example: +/// "ctrl+a", "ctrl+shift+b", "up". Some notes: +/// +/// - modifiers cannot repeat, "ctrl+ctrl+a" is invalid. +/// - modifiers and key scan be in any order, "shift+a+ctrl" is weird, +/// but valid. +/// - only a single key input is allowed, "ctrl+a+b" is invalid. +/// +/// Action is the action to take when the trigger is satisfied. It takes +/// the format "action" or "action:param". The latter form is only valid +/// if the action requires a parameter. +/// +/// - "ignore" - Do nothing, ignore the key input. This can be used to +/// black hole certain inputs to have no effect. +/// - "unbind" - Remove the binding. This makes it so the previous action +/// is removed, and the key will be sent through to the child command +/// if it is printable. +/// - "csi:text" - Send a CSI sequence. i.e. "csi:A" sends "cursor up". +/// +/// Some notes for the action: +/// +/// - The parameter is taken as-is after the ":". Double quotes or +/// other mechanisms are included and NOT parsed. If you want to +/// send a string value that includes spaces, wrap the entire +/// trigger/action in double quotes. Example: --keybind="up=csi:A B" +/// +keybind: Keybinds = .{}, + +/// Window padding. This applies padding between the terminal cells and +/// the window border. The "x" option applies to the left and right +/// padding and the "y" option is top and bottom. The value is in points, +/// meaning that it will be scaled appropriately for screen DPI. +/// +/// If this value is set too large, the screen will render nothing, because +/// the grid will be completely squished by the padding. It is up to you +/// as the user to pick a reasonable value. If you pick an unreasonable +/// value, a warning will appear in the logs. +@"window-padding-x": u32 = 2, +@"window-padding-y": u32 = 2, + +/// The viewport dimensions are usually not perfectly divisible by +/// the cell size. In this case, some extra padding on the end of a +/// column and the bottom of the final row may exist. If this is true, +/// then this extra padding is automatically balanced between all four +/// edges to minimize imbalance on one side. If this is false, the top +/// left grid cell will always hug the edge with zero padding other than +/// what may be specified with the other "window-padding" options. +/// +/// If other "window-padding" fields are set and this is true, this will +/// still apply. The other padding is applied first and may affect how +/// many grid cells actually exist, and this is applied last in order +/// to balance the padding given a certain viewport size and grid cell size. +@"window-padding-balance": bool = false, + +/// If true, new windows and tabs will inherit the font size of the previously +/// focused window. If no window was previously focused, the default +/// font size will be used. If this is false, the default font size +/// specified in the configuration "font-size" will be used. +@"window-inherit-font-size": bool = true, + +/// If false, windows won't have native decorations, i.e. titlebar and +/// borders. +/// Currently only supported with GTK. +@"window-decoration": bool = true, + +/// Whether to allow programs running in the terminal to read/write to +/// the system clipboard (OSC 52, for googling). The default is to +/// disallow clipboard reading but allow writing. +@"clipboard-read": bool = false, +@"clipboard-write": bool = true, + +/// Trims trailing whitespace on data that is copied to the clipboard. +/// This does not affect data sent to the clipboard via "clipboard-write". +@"clipboard-trim-trailing-spaces": bool = true, + +/// The total amount of bytes that can be used for image data (i.e. +/// the Kitty image protocol) per terminal scren. The maximum value +/// is 4,294,967,295 (4GB). The default is 320MB. If this is set to zero, +/// then all image protocols will be disabled. +/// +/// This value is separate for primary and alternate screens so the +/// effective limit per surface is double. +@"image-storage-limit": u32 = 320 * 1000 * 1000, + +/// Whether to automatically copy selected text to the clipboard. "true" +/// will only copy on systems that support a selection clipboard. +/// +/// The value "clipboard" will copy to the system clipboard, making this +/// work on macOS. Note that middle-click will also paste from the system +/// clipboard in this case. +/// +/// Note that if this is disabled, middle-click paste will also be +/// disabled. +@"copy-on-select": CopyOnSelect = .true, + +/// The time in milliseconds between clicks to consider a click a repeat +/// (double, triple, etc.) or an entirely new single click. A value of +/// zero will use a platform-specific default. The default on macOS +/// is determined by the OS settings. On every other platform it is 500ms. +@"click-repeat-interval": u32 = 0, + +/// Additional configuration files to read. +@"config-file": RepeatableString = .{}, + +/// Confirms that a surface should be closed before closing it. This defaults +/// to true. If set to false, surfaces will close without any confirmation. +@"confirm-close-surface": bool = true, + +/// Whether to enable shell integration auto-injection or not. Shell +/// integration greatly enhances the terminal experience by enabling +/// a number of features: +/// +/// * Working directory reporting so new tabs, splits inherit the +/// previous terminal's working directory. +/// * Prompt marking that enables the "scroll_to_prompt" keybinding. +/// * If you're sitting at a prompt, closing a terminal will not ask +/// for confirmation. +/// * Resizing the window with a complex prompt usually paints much +/// better. +/// +/// Allowable values are: +/// +/// * "none" - Do not do any automatic injection. You can still manually +/// configure your shell to enable the integration. +/// * "detect" - Detect the shell based on the filename. +/// * "fish", "zsh" - Use this specific shell injection scheme. +/// +/// The default value is "detect". +@"shell-integration": ShellIntegration = .detect, + +/// If anything other than false, fullscreen mode on macOS will not use the +/// native fullscreen, but make the window fullscreen without animations and +/// using a new space. It's faster than the native fullscreen mode since it +/// doesn't use animations. +/// +/// Allowable values are: +/// +/// * "visible-menu" - Use non-native macOS fullscreen, keep the menu bar visible +/// * "true" - Use non-native macOS fullscreen, hide the menu bar +/// * "false" - Use native macOS fullscreeen +@"macos-non-native-fullscreen": NonNativeFullscreen = .false, + +/// 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. +/// +/// 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). +/// +/// This does not work with GLFW builds. +@"macos-option-as-alt": OptionAsAlt = .false, + +/// If true (default), then the Ghostty GTK application will run in +/// single-instance mode: each new `ghostty` process launched will result +/// in a new window, if there is already a running process. +/// +/// If false, each new ghostty process will launch a separate application. +/// +/// Debug builds of Ghostty have a separate single-instance ID. +@"gtk-single-instance": bool = true, + +/// This is set by the CLI parser for deinit. +_arena: ?ArenaAllocator = null, + +pub fn deinit(self: *Config) void { + if (self._arena) |arena| arena.deinit(); + self.* = undefined; +} + +/// Load the configuration according to the default rules: +/// +/// 1. Defaults +/// 2. XDG Config File +/// 3. CLI flags +/// 4. Recursively defined configuration files +/// +pub fn load(alloc_gpa: Allocator) !Config { + var result = try default(alloc_gpa); + errdefer result.deinit(); + + // If we have a configuration file in our home directory, parse that first. + try result.loadDefaultFiles(alloc_gpa); + + // Parse the config from the CLI args + try result.loadCliArgs(alloc_gpa); + + // Parse the config files that were added from our file and CLI args. + try result.loadRecursiveFiles(alloc_gpa); + try result.finalize(); + + return result; +} + +pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { + // Build up our basic config + var result: Config = .{ + ._arena = ArenaAllocator.init(alloc_gpa), + }; + errdefer result.deinit(); + const alloc = result._arena.?.allocator(); + + // Add our default keybindings + try result.keybind.set.put( + alloc, + .{ .key = .space, .mods = .{ .super = true, .alt = true, .ctrl = true } }, + .{ .reload_config = {} }, + ); + + { + // On macOS we default to super but Linux ctrl+shift since + // ctrl+c is to kill the process. + const mods: inputpkg.Mods = if (builtin.target.isDarwin()) + .{ .super = true } + else + .{ .ctrl = true, .shift = true }; + + try result.keybind.set.put( + alloc, + .{ .key = .c, .mods = mods }, + .{ .copy_to_clipboard = {} }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .v, .mods = mods }, + .{ .paste_from_clipboard = {} }, + ); + } + + // Fonts + try result.keybind.set.put( + alloc, + .{ .key = .equal, .mods = ctrlOrSuper(.{}) }, + .{ .increase_font_size = 1 }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .minus, .mods = ctrlOrSuper(.{}) }, + .{ .decrease_font_size = 1 }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .zero, .mods = ctrlOrSuper(.{}) }, + .{ .reset_font_size = {} }, + ); + + try result.keybind.set.put( + alloc, + .{ .key = .j, .mods = ctrlOrSuper(.{ .shift = true }) }, + .{ .write_scrollback_file = {} }, + ); + + // Windowing + if (comptime !builtin.target.isDarwin()) { + try result.keybind.set.put( + alloc, + .{ .key = .n, .mods = .{ .ctrl = true, .shift = true } }, + .{ .new_window = {} }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .w, .mods = .{ .ctrl = true, .shift = true } }, + .{ .close_surface = {} }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .q, .mods = .{ .ctrl = true, .shift = true } }, + .{ .quit = {} }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .f4, .mods = .{ .alt = true } }, + .{ .close_window = {} }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .t, .mods = .{ .ctrl = true, .shift = true } }, + .{ .new_tab = {} }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .left, .mods = .{ .ctrl = true, .shift = true } }, + .{ .previous_tab = {} }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .right, .mods = .{ .ctrl = true, .shift = true } }, + .{ .next_tab = {} }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .o, .mods = .{ .ctrl = true, .shift = true } }, + .{ .new_split = .right }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .e, .mods = .{ .ctrl = true, .shift = true } }, + .{ .new_split = .down }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .left_bracket, .mods = .{ .ctrl = true, .super = true } }, + .{ .goto_split = .previous }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .right_bracket, .mods = .{ .ctrl = true, .super = true } }, + .{ .goto_split = .next }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .up, .mods = .{ .ctrl = true, .alt = true } }, + .{ .goto_split = .top }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .down, .mods = .{ .ctrl = true, .alt = true } }, + .{ .goto_split = .bottom }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .left, .mods = .{ .ctrl = true, .alt = true } }, + .{ .goto_split = .left }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .right, .mods = .{ .ctrl = true, .alt = true } }, + .{ .goto_split = .right }, + ); + + // Viewport scrolling + try result.keybind.set.put( + alloc, + .{ .key = .home, .mods = .{ .shift = true } }, + .{ .scroll_to_top = {} }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .end, .mods = .{ .shift = true } }, + .{ .scroll_to_bottom = {} }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .page_up, .mods = .{ .shift = true } }, + .{ .scroll_page_up = {} }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .page_down, .mods = .{ .shift = true } }, + .{ .scroll_page_down = {} }, + ); + + // Semantic prompts + try result.keybind.set.put( + alloc, + .{ .key = .page_up, .mods = .{ .shift = true, .ctrl = true } }, + .{ .jump_to_prompt = -1 }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .page_down, .mods = .{ .shift = true, .ctrl = true } }, + .{ .jump_to_prompt = 1 }, + ); + } + { + // Cmd+N for goto tab N + const start = @intFromEnum(inputpkg.Key.one); + const end = @intFromEnum(inputpkg.Key.nine); + var i: usize = start; + while (i <= end) : (i += 1) { + // On macOS we default to super but everywhere else + // is alt. + const mods: inputpkg.Mods = if (builtin.target.isDarwin()) + .{ .super = true } + else + .{ .alt = true }; + + try result.keybind.set.put( + alloc, + .{ .key = @enumFromInt(i), .mods = mods }, + .{ .goto_tab = (i - start) + 1 }, + ); + } + } + + // Toggle fullscreen + try result.keybind.set.put( + alloc, + .{ .key = .enter, .mods = ctrlOrSuper(.{}) }, + .{ .toggle_fullscreen = {} }, + ); + + // Toggle zoom a split + try result.keybind.set.put( + alloc, + .{ .key = .enter, .mods = ctrlOrSuper(.{ .shift = true }) }, + .{ .toggle_split_zoom = {} }, + ); + + // Mac-specific keyboard bindings. + if (comptime builtin.target.isDarwin()) { + try result.keybind.set.put( + alloc, + .{ .key = .q, .mods = .{ .super = true } }, + .{ .quit = {} }, + ); + + try result.keybind.set.put( + alloc, + .{ .key = .k, .mods = .{ .super = true } }, + .{ .clear_screen = {} }, + ); + + // Viewport scrolling + try result.keybind.set.put( + alloc, + .{ .key = .home, .mods = .{ .super = true } }, + .{ .scroll_to_top = {} }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .end, .mods = .{ .super = true } }, + .{ .scroll_to_bottom = {} }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .page_up, .mods = .{ .super = true } }, + .{ .scroll_page_up = {} }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .page_down, .mods = .{ .super = true } }, + .{ .scroll_page_down = {} }, + ); + + // Semantic prompts + try result.keybind.set.put( + alloc, + .{ .key = .up, .mods = .{ .super = true, .shift = true } }, + .{ .jump_to_prompt = -1 }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .down, .mods = .{ .super = true, .shift = true } }, + .{ .jump_to_prompt = 1 }, + ); + + // Mac windowing + try result.keybind.set.put( + alloc, + .{ .key = .n, .mods = .{ .super = true } }, + .{ .new_window = {} }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .w, .mods = .{ .super = true } }, + .{ .close_surface = {} }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .w, .mods = .{ .super = true, .shift = true } }, + .{ .close_window = {} }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .t, .mods = .{ .super = true } }, + .{ .new_tab = {} }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .left_bracket, .mods = .{ .super = true, .shift = true } }, + .{ .previous_tab = {} }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .right_bracket, .mods = .{ .super = true, .shift = true } }, + .{ .next_tab = {} }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .d, .mods = .{ .super = true } }, + .{ .new_split = .right }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .d, .mods = .{ .super = true, .shift = true } }, + .{ .new_split = .down }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .left_bracket, .mods = .{ .super = true } }, + .{ .goto_split = .previous }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .right_bracket, .mods = .{ .super = true } }, + .{ .goto_split = .next }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .up, .mods = .{ .super = true, .alt = true } }, + .{ .goto_split = .top }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .down, .mods = .{ .super = true, .alt = true } }, + .{ .goto_split = .bottom }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .left, .mods = .{ .super = true, .alt = true } }, + .{ .goto_split = .left }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .right, .mods = .{ .super = true, .alt = true } }, + .{ .goto_split = .right }, + ); + } + + return result; +} + +/// This sets either "ctrl" or "super" to true (but not both) +/// on mods depending on if the build target is Mac or not. On +/// Mac, we default to super (i.e. super+c for copy) and on +/// non-Mac we default to ctrl (i.e. ctrl+c for copy). +fn ctrlOrSuper(mods: inputpkg.Mods) inputpkg.Mods { + var copy = mods; + if (comptime builtin.target.isDarwin()) { + copy.super = true; + } else { + copy.ctrl = true; + } + + return copy; +} + +/// Load the configuration from the default file locations. Currently, +/// this loads from $XDG_CONFIG_HOME/ghostty/config. +pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void { + const home_config_path = try internal_os.xdg.config(alloc, .{ .subdir = "ghostty/config" }); + defer alloc.free(home_config_path); + + const cwd = std.fs.cwd(); + if (cwd.openFile(home_config_path, .{})) |file| { + defer file.close(); + std.log.info("reading configuration file path={s}", .{home_config_path}); + + var buf_reader = std.io.bufferedReader(file.reader()); + var iter = cli_args.lineIterator(buf_reader.reader()); + try cli_args.parse(Config, alloc, self, &iter); + } else |err| switch (err) { + error.FileNotFound => std.log.info( + "homedir config not found, not loading path={s}", + .{home_config_path}, + ), + + else => std.log.warn( + "error reading homedir config file, not loading err={} path={s}", + .{ err, home_config_path }, + ), + } +} + +/// Load and parse the CLI args. +pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { + switch (builtin.os.tag) { + .windows => {}, + + // Fast-path if we are non-Windows and no args, do nothing. + else => if (std.os.argv.len <= 1) return, + } + + // Parse the config from the CLI args + var iter = try std.process.argsWithAllocator(alloc_gpa); + defer iter.deinit(); + try cli_args.parse(Config, alloc_gpa, self, &iter); +} + +/// Load and parse the config files that were added in the "config-file" key. +pub fn loadRecursiveFiles(self: *Config, alloc: Allocator) !void { + // TODO(mitchellh): we should parse the files form the homedir first + // TODO(mitchellh): support nesting (config-file in a config file) + // TODO(mitchellh): detect cycles when nesting + + if (self.@"config-file".list.items.len == 0) return; + + const cwd = std.fs.cwd(); + const len = self.@"config-file".list.items.len; + for (self.@"config-file".list.items) |path| { + var file = try cwd.openFile(path, .{}); + defer file.close(); + + var buf_reader = std.io.bufferedReader(file.reader()); + var iter = cli_args.lineIterator(buf_reader.reader()); + try cli_args.parse(Config, alloc, self, &iter); + + // We don't currently support adding more config files to load + // from within a loaded config file. This can be supported + // later. + if (self.@"config-file".list.items.len > len) + return error.ConfigFileInConfigFile; + } +} + +pub fn finalize(self: *Config) !void { + // If we have a font-family set and don't set the others, default + // the others to the font family. This way, if someone does + // --font-family=foo, then we try to get the stylized versions of + // "foo" as well. + if (self.@"font-family") |family| { + const fields = &[_][]const u8{ + "font-family-bold", + "font-family-italic", + "font-family-bold-italic", + }; + inline for (fields) |field| { + if (@field(self, field) == null) { + @field(self, field) = family; + } + } + } + + // The default for the working directory depends on the system. + const wd = self.@"working-directory" orelse switch (builtin.os.tag) { + .macos => if (c.getppid() == 1) "home" else "inherit", + else => "inherit", + }; + + // If we are missing either a command or home directory, we need + // to look up defaults which is kind of expensive. We only do this + // on desktop. + const wd_home = std.mem.eql(u8, "home", wd); + if (comptime !builtin.target.isWasm()) { + if (self.command == null or wd_home) command: { + const alloc = self._arena.?.allocator(); + + // First look up the command using the SHELL env var if needed. + // We don't do this in flatpak because SHELL in Flatpak is always + // set to /bin/sh. + if (self.command) |cmd| + log.info("shell src=config value={s}", .{cmd}) + else { + if (!internal_os.isFlatpak()) { + if (std.process.getEnvVarOwned(alloc, "SHELL")) |value| { + log.info("default shell source=env value={s}", .{value}); + self.command = value; + + // If we don't need the working directory, then we can exit now. + if (!wd_home) break :command; + } else |_| {} + } + } + + // We need the passwd entry for the remainder + const pw = try internal_os.passwd.get(alloc); + if (self.command == null) { + if (pw.shell) |sh| { + log.info("default shell src=passwd value={s}", .{sh}); + self.command = sh; + } + } + + if (wd_home) { + if (pw.home) |home| { + log.info("default working directory src=passwd value={s}", .{home}); + self.@"working-directory" = home; + } + } + + if (self.command == null) { + log.warn("no default shell found, will default to using sh", .{}); + } + } + } + + // If we have the special value "inherit" then set it to null which + // does the same. In the future we should change to a tagged union. + if (std.mem.eql(u8, wd, "inherit")) self.@"working-directory" = null; + + // Default our click interval + if (self.@"click-repeat-interval" == 0) { + self.@"click-repeat-interval" = internal_os.clickInterval() orelse 500; + } + + // Clamp our split opacity + self.@"unfocused-split-opacity" = @min(1.0, @max(0.15, self.@"unfocused-split-opacity")); +} + +/// Create a shallow copy of this config. This will share all the memory +/// allocated with the previous config but will have a new arena for +/// any changes or new allocations. The config should have `deinit` +/// called when it is complete. +/// +/// Beware: these shallow clones are not meant for a long lifetime, +/// they are just meant to exist temporarily for the duration of some +/// modifications. It is very important that the original config not +/// be deallocated while shallow clones exist. +pub fn shallowClone(self: *const Config, alloc_gpa: Allocator) Config { + var result = self.*; + result._arena = ArenaAllocator.init(alloc_gpa); + return result; +} + +/// Create a copy of this configuration. This is useful as a starting +/// point for modifying a configuration since a config can NOT be +/// modified once it is in use by an app or surface. +pub fn clone(self: *const Config, alloc_gpa: Allocator) !Config { + // Start with an empty config with a new arena we're going + // to use for all our copies. + var result: Config = .{ + ._arena = ArenaAllocator.init(alloc_gpa), + }; + errdefer result.deinit(); + const alloc = result._arena.?.allocator(); + + inline for (@typeInfo(Config).Struct.fields) |field| { + if (!@hasField(Key, field.name)) continue; + @field(result, field.name) = try cloneValue( + alloc, + field.type, + @field(self, field.name), + ); + } + + return result; +} + +fn cloneValue(alloc: Allocator, comptime T: type, src: T) !T { + // Do known named types first + switch (T) { + []const u8 => return try alloc.dupe(u8, src), + [:0]const u8 => return try alloc.dupeZ(u8, src), + + else => {}, + } + + // Back into types of types + switch (@typeInfo(T)) { + inline .Bool, + .Int, + .Float, + .Enum, + => return src, + + .Optional => |info| return try cloneValue( + alloc, + info.child, + src orelse return null, + ), + + .Struct => return try src.clone(alloc), + + else => { + @compileLog(T); + @compileError("unsupported field type"); + }, + } +} + +/// Returns an iterator that goes through each changed field from +/// old to new. The order of old or new do not matter. +pub fn changeIterator(old: *const Config, new: *const Config) ChangeIterator { + return .{ + .old = old, + .new = new, + }; +} + +/// Returns true if the given key has changed from old to new. This +/// requires the key to be comptime known to make this more efficient. +pub fn changed(self: *const Config, new: *const Config, comptime key: Key) bool { + // Get the field at comptime + const field = comptime field: { + const fields = std.meta.fields(Config); + for (fields) |field| { + if (@field(Key, field.name) == key) { + break :field field; + } + } + + unreachable; + }; + + const old_value = @field(self, field.name); + const new_value = @field(new, field.name); + return !equalField(field.type, old_value, new_value); +} + +/// This yields a key for every changed field between old and new. +pub const ChangeIterator = struct { + old: *const Config, + new: *const Config, + i: usize = 0, + + pub fn next(self: *ChangeIterator) ?Key { + const fields = comptime std.meta.fields(Key); + while (self.i < fields.len) { + switch (self.i) { + inline 0...(fields.len - 1) => |i| { + const field = fields[i]; + const key = @field(Key, field.name); + self.i += 1; + if (self.old.changed(self.new, key)) return key; + }, + + else => unreachable, + } + } + + return null; + } +}; + +test "clone default" { + const testing = std.testing; + const alloc = testing.allocator; + + var source = try Config.default(alloc); + defer source.deinit(); + var dest = try source.clone(alloc); + defer dest.deinit(); + + // Should have no changes + var it = source.changeIterator(&dest); + try testing.expectEqual(@as(?Key, null), it.next()); + + // I want to do this but this doesn't work (the API doesn't work) + // try testing.expectEqualDeep(dest, source); +} + +test "changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var source = try Config.default(alloc); + defer source.deinit(); + var dest = try source.clone(alloc); + defer dest.deinit(); + dest.@"font-family" = "something else"; + + try testing.expect(source.changed(&dest, .@"font-family")); + try testing.expect(!source.changed(&dest, .@"font-size")); +} + +/// A config-specific helper to determine if two values of the same +/// type are equal. This isn't the same as std.mem.eql or std.testing.equals +/// because we expect structs to implement their own equality. +/// +/// This also doesn't support ALL Zig types, because we only add to it +/// as we need types for the config. +fn equalField(comptime T: type, old: T, new: T) bool { + // Do known named types first + switch (T) { + inline []const u8, + [:0]const u8, + => return std.mem.eql(u8, old, new), + + else => {}, + } + + // Back into types of types + switch (@typeInfo(T)) { + .Void => return true, + + inline .Bool, + .Int, + .Float, + .Enum, + => return old == new, + + .Optional => |info| { + if (old == null and new == null) return true; + if (old == null or new == null) return false; + return equalField(info.child, old.?, new.?); + }, + + .Struct => |info| { + if (@hasDecl(T, "equal")) return old.equal(new); + + // If a struct doesn't declare an "equal" function, we fall back + // to a recursive field-by-field compare. + inline for (info.fields) |field_info| { + if (!equalField( + field_info.type, + @field(old, field_info.name), + @field(new, field_info.name), + )) return false; + } + return true; + }, + + .Union => |info| { + const tag_type = info.tag_type.?; + const old_tag = std.meta.activeTag(old); + const new_tag = std.meta.activeTag(new); + if (old_tag != new_tag) return false; + + inline for (info.fields) |field_info| { + if (@field(tag_type, field_info.name) == old_tag) { + return equalField( + field_info.type, + @field(old, field_info.name), + @field(new, field_info.name), + ); + } + } + + unreachable; + }, + + else => { + @compileLog(T); + @compileError("unsupported field type"); + }, + } +} + +/// Valid values for macos-non-native-fullscreen +/// c_int because it needs to be extern compatible +/// If this is changed, you must also update ghostty.h +pub const NonNativeFullscreen = enum(c_int) { + false, + true, + @"visible-menu", +}; + +/// Valid values for macos-option-as-alt. +pub const OptionAsAlt = enum { + false, + true, + left, + right, +}; + +/// Color represents a color using RGB. +pub const Color = struct { + r: u8, + g: u8, + b: u8, + + pub const Error = error{ + InvalidFormat, + }; + + /// Convert this to the terminal RGB struct + pub fn toTerminalRGB(self: Color) terminal.color.RGB { + return .{ .r = self.r, .g = self.g, .b = self.b }; + } + + pub fn parseCLI(input: ?[]const u8) !Color { + return fromHex(input orelse return error.ValueRequired); + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: Color, _: Allocator) !Color { + return self; + } + + /// Compare if two of our value are requal. Required by Config. + pub fn equal(self: Color, other: Color) bool { + return std.meta.eql(self, other); + } + + /// fromHex parses a color from a hex value such as #RRGGBB. The "#" + /// is optional. + pub fn fromHex(input: []const u8) !Color { + // Trim the beginning '#' if it exists + const trimmed = if (input.len != 0 and input[0] == '#') input[1..] else input; + + // We expect exactly 6 for RRGGBB + if (trimmed.len != 6) return Error.InvalidFormat; + + // Parse the colors two at a time. + var result: Color = undefined; + comptime var i: usize = 0; + inline while (i < 6) : (i += 2) { + const v: u8 = + ((try std.fmt.charToDigit(trimmed[i], 16)) * 16) + + try std.fmt.charToDigit(trimmed[i + 1], 16); + + @field(result, switch (i) { + 0 => "r", + 2 => "g", + 4 => "b", + else => unreachable, + }) = v; + } + + return result; + } + + test "fromHex" { + const testing = std.testing; + + try testing.expectEqual(Color{ .r = 0, .g = 0, .b = 0 }, try Color.fromHex("#000000")); + try testing.expectEqual(Color{ .r = 10, .g = 11, .b = 12 }, try Color.fromHex("#0A0B0C")); + try testing.expectEqual(Color{ .r = 10, .g = 11, .b = 12 }, try Color.fromHex("0A0B0C")); + try testing.expectEqual(Color{ .r = 255, .g = 255, .b = 255 }, try Color.fromHex("FFFFFF")); + } +}; + +/// Palette is the 256 color palette for 256-color mode. This is still +/// used by many terminal applications. +pub const Palette = struct { + const Self = @This(); + + /// The actual value that is updated as we parse. + value: terminal.color.Palette = terminal.color.default, + + pub const Error = error{ + InvalidFormat, + }; + + pub fn parseCLI( + self: *Self, + input: ?[]const u8, + ) !void { + const value = input orelse return error.ValueRequired; + const eqlIdx = std.mem.indexOf(u8, value, "=") orelse + return Error.InvalidFormat; + + const key = try std.fmt.parseInt(u8, value[0..eqlIdx], 10); + const rgb = try Color.parseCLI(value[eqlIdx + 1 ..]); + self.value[key] = .{ .r = rgb.r, .g = rgb.g, .b = rgb.b }; + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: Self, _: Allocator) !Self { + return self; + } + + /// Compare if two of our value are requal. Required by Config. + pub fn equal(self: Self, other: Self) bool { + return std.meta.eql(self, other); + } + + test "parseCLI" { + const testing = std.testing; + + var p: Self = .{}; + try p.parseCLI("0=#AABBCC"); + try testing.expect(p.value[0].r == 0xAA); + try testing.expect(p.value[0].g == 0xBB); + try testing.expect(p.value[0].b == 0xCC); + } + + test "parseCLI overflow" { + const testing = std.testing; + + var p: Self = .{}; + try testing.expectError(error.Overflow, p.parseCLI("256=#AABBCC")); + } +}; + +/// RepeatableString is a string value that can be repeated to accumulate +/// a list of strings. This isn't called "StringList" because I find that +/// sometimes leads to confusion that it _accepts_ a list such as +/// comma-separated values. +pub const RepeatableString = struct { + const Self = @This(); + + // Allocator for the list is the arena for the parent config. + list: std.ArrayListUnmanaged([]const u8) = .{}, + + pub fn parseCLI(self: *Self, alloc: Allocator, input: ?[]const u8) !void { + const value = input orelse return error.ValueRequired; + const copy = try alloc.dupe(u8, value); + try self.list.append(alloc, copy); + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const Self, alloc: Allocator) !Self { + return .{ + .list = try self.list.clone(alloc), + }; + } + + /// Compare if two of our value are requal. Required by Config. + pub fn equal(self: Self, other: Self) bool { + const itemsA = self.list.items; + const itemsB = other.list.items; + if (itemsA.len != itemsB.len) return false; + for (itemsA, itemsB) |a, b| { + if (!std.mem.eql(u8, a, b)) return false; + } else return true; + } + + test "parseCLI" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "A"); + try list.parseCLI(alloc, "B"); + + try testing.expectEqual(@as(usize, 2), list.list.items.len); + } +}; + +/// FontVariation is a repeatable configuration value that sets a single +/// font variation value. Font variations are configurations for what +/// are often called "variable fonts." The font files usually end in +/// "-VF.ttf." +/// +/// The value for this is in the format of `id=value` where `id` is the +/// 4-character font variation axis identifier and `value` is the +/// floating point value for that axis. For more details on font variations +/// see the MDN font-variation-settings documentation since this copies that +/// behavior almost exactly: +/// +/// https://developer.mozilla.org/en-US/docs/Web/CSS/font-variation-settings +pub const RepeatableFontVariation = struct { + const Self = @This(); + + // Allocator for the list is the arena for the parent config. + list: std.ArrayListUnmanaged(fontpkg.face.Variation) = .{}, + + pub fn parseCLI(self: *Self, alloc: Allocator, input_: ?[]const u8) !void { + const input = input_ orelse return error.ValueRequired; + const eql_idx = std.mem.indexOf(u8, input, "=") orelse return error.InvalidFormat; + const whitespace = " \t"; + const key = std.mem.trim(u8, input[0..eql_idx], whitespace); + const value = std.mem.trim(u8, input[eql_idx + 1 ..], whitespace); + if (key.len != 4) return error.InvalidFormat; + try self.list.append(alloc, .{ + .id = fontpkg.face.Variation.Id.init(@ptrCast(key.ptr)), + .value = std.fmt.parseFloat(f64, value) catch return error.InvalidFormat, + }); + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const Self, alloc: Allocator) !Self { + return .{ + .list = try self.list.clone(alloc), + }; + } + + /// Compare if two of our value are requal. Required by Config. + pub fn equal(self: Self, other: Self) bool { + const itemsA = self.list.items; + const itemsB = other.list.items; + if (itemsA.len != itemsB.len) return false; + for (itemsA, itemsB) |a, b| { + if (!std.meta.eql(a, b)) return false; + } else return true; + } + + test "parseCLI" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "wght=200"); + try list.parseCLI(alloc, "slnt=-15"); + + try testing.expectEqual(@as(usize, 2), list.list.items.len); + try testing.expectEqual(fontpkg.face.Variation{ + .id = fontpkg.face.Variation.Id.init("wght"), + .value = 200, + }, list.list.items[0]); + try testing.expectEqual(fontpkg.face.Variation{ + .id = fontpkg.face.Variation.Id.init("slnt"), + .value = -15, + }, list.list.items[1]); + } + + test "parseCLI with whitespace" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "wght =200"); + try list.parseCLI(alloc, "slnt= -15"); + + try testing.expectEqual(@as(usize, 2), list.list.items.len); + try testing.expectEqual(fontpkg.face.Variation{ + .id = fontpkg.face.Variation.Id.init("wght"), + .value = 200, + }, list.list.items[0]); + try testing.expectEqual(fontpkg.face.Variation{ + .id = fontpkg.face.Variation.Id.init("slnt"), + .value = -15, + }, list.list.items[1]); + } +}; + +/// Stores a set of keybinds. +pub const Keybinds = struct { + set: inputpkg.Binding.Set = .{}, + + pub fn parseCLI(self: *Keybinds, alloc: Allocator, input: ?[]const u8) !void { + var copy: ?[]u8 = null; + var value = value: { + const value = input orelse return error.ValueRequired; + + // If we don't have a colon, use the value as-is, no copy + if (std.mem.indexOf(u8, value, ":") == null) + break :value value; + + // If we have a colon, we copy the whole value for now. We could + // do this more efficiently later if we wanted to. + const buf = try alloc.alloc(u8, value.len); + copy = buf; + + std.mem.copy(u8, buf, value); + break :value buf; + }; + errdefer if (copy) |v| alloc.free(v); + + const binding = try inputpkg.Binding.parse(value); + switch (binding.action) { + .unbind => self.set.remove(binding.trigger), + else => try self.set.put(alloc, binding.trigger, binding.action), + } + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const Keybinds, alloc: Allocator) !Keybinds { + return .{ + .set = .{ + .bindings = try self.set.bindings.clone(alloc), + }, + }; + } + + /// Compare if two of our value are requal. Required by Config. + pub fn equal(self: Keybinds, other: Keybinds) bool { + const self_map = self.set.bindings; + const other_map = other.set.bindings; + if (self_map.count() != other_map.count()) return false; + + var it = self_map.iterator(); + while (it.next()) |self_entry| { + const other_entry = other_map.getEntry(self_entry.key_ptr.*) orelse + return false; + if (!equalField( + inputpkg.Binding.Action, + self_entry.value_ptr.*, + other_entry.value_ptr.*, + )) return false; + } + + return true; + } + + test "parseCLI" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var set: Keybinds = .{}; + try set.parseCLI(alloc, "shift+a=copy_to_clipboard"); + try set.parseCLI(alloc, "shift+a=csi:hello"); + } +}; + +/// Options for copy on select behavior. +pub const CopyOnSelect = enum { + /// Disables copy on select entirely. + false, + + /// Copy on select is enabled, but goes to the selection clipboard. + /// This is not supported on platforms such as macOS. This is the default. + true, + + /// Copy on select is enabled and goes to the system clipboard. + clipboard, +}; + +/// Shell integration values +pub const ShellIntegration = enum { + none, + detect, + fish, + zsh, +}; diff --git a/src/config/Wasm.zig b/src/config/Wasm.zig new file mode 100644 index 000000000..e50b4853b --- /dev/null +++ b/src/config/Wasm.zig @@ -0,0 +1,54 @@ +const std = @import("std"); +const wasm = @import("../os/wasm.zig"); +const cli_args = @import("../cli_args.zig"); +const alloc = wasm.alloc; + +const Config = @import("Config.zig"); + +const log = std.log.scoped(.config); + +/// Create a new configuration filled with the initial default values. +export fn config_new() ?*Config { + const result = alloc.create(Config) catch |err| { + log.err("error allocating config err={}", .{err}); + return null; + }; + + result.* = Config.default(alloc) catch |err| { + log.err("error creating config err={}", .{err}); + return null; + }; + + return result; +} + +export fn config_free(ptr: ?*Config) void { + if (ptr) |v| { + v.deinit(); + alloc.destroy(v); + } +} + +/// Load the configuration from a string in the same format as +/// the file-based syntax for the desktop version of the terminal. +export fn config_load_string( + self: *Config, + str: [*]const u8, + len: usize, +) void { + config_load_string_(self, str[0..len]) catch |err| { + log.err("error loading config err={}", .{err}); + }; +} + +fn config_load_string_(self: *Config, str: []const u8) !void { + var fbs = std.io.fixedBufferStream(str); + var iter = cli_args.lineIterator(fbs.reader()); + try cli_args.parse(Config, alloc, self, &iter); +} + +export fn config_finalize(self: *Config) void { + self.finalize() catch |err| { + log.err("error finalizing config err={}", .{err}); + }; +} diff --git a/src/config/key.zig b/src/config/key.zig new file mode 100644 index 000000000..bd50a684d --- /dev/null +++ b/src/config/key.zig @@ -0,0 +1,32 @@ +const std = @import("std"); +const Config = @import("Config.zig"); + +/// Key is an enum of all the available configuration keys. This is used +/// when paired with diff to determine what fields have changed in a config, +/// amongst other things. +pub const Key = key: { + const field_infos = std.meta.fields(Config); + var enumFields: [field_infos.len]std.builtin.Type.EnumField = undefined; + var i: usize = 0; + inline for (field_infos) |field| { + // Ignore fields starting with "_" since they're internal and + // not copied ever. + if (field.name[0] == '_') continue; + + enumFields[i] = .{ + .name = field.name, + .value = i, + }; + i += 1; + } + + var decls = [_]std.builtin.Type.Declaration{}; + break :key @Type(.{ + .Enum = .{ + .tag_type = std.math.IntFittingRange(0, field_infos.len - 1), + .fields = enumFields[0..i], + .decls = &decls, + .is_exhaustive = true, + }, + }); +}; From 2820db55bee278306f44158415c35896de2a8298 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 10 Sep 2023 18:45:02 -0700 Subject: [PATCH 3/4] config: add C API ghostty_config_get to read configuration values --- include/ghostty.h | 1 + src/config.zig | 8 +++-- src/config/CAPI.zig | 12 +++++++ src/config/Config.zig | 1 + src/config/c_get.zig | 76 +++++++++++++++++++++++++++++++++++++++++++ src/config/key.zig | 23 +++++++++++++ 6 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 src/config/c_get.zig diff --git a/include/ghostty.h b/include/ghostty.h index d11b49fb1..94b69b829 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -301,6 +301,7 @@ void ghostty_config_load_string(ghostty_config_t, const char *, uintptr_t); void ghostty_config_load_default_files(ghostty_config_t); void ghostty_config_load_recursive_files(ghostty_config_t); void ghostty_config_finalize(ghostty_config_t); +bool ghostty_config_get(ghostty_config_t, void *, const char *, uintptr_t); ghostty_input_trigger_s ghostty_config_trigger(ghostty_config_t, const char *, uintptr_t); ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s *, ghostty_config_t); diff --git a/src/config.zig b/src/config.zig index 8d0dd2e44..23687d14b 100644 --- a/src/config.zig +++ b/src/config.zig @@ -1,5 +1,7 @@ +const builtin = @import("builtin"); + +pub usingnamespace @import("config/key.zig"); pub const Config = @import("config/Config.zig"); -pub const Key = @import("config/key.zig").Key; // Field types pub const CopyOnSelect = Config.CopyOnSelect; @@ -9,8 +11,10 @@ pub const OptionAsAlt = Config.OptionAsAlt; // Alternate APIs pub const CAPI = @import("config/CAPI.zig"); -pub const Wasm = @import("config/Wasm.zig"); +pub const Wasm = if (!builtin.target.isWasm()) struct {} else @import("config/Wasm.zig"); test { @import("std").testing.refAllDecls(@This()); + + _ = @import("config/c_get.zig"); } diff --git a/src/config/CAPI.zig b/src/config/CAPI.zig index 1d559a51e..cc031ad7a 100644 --- a/src/config/CAPI.zig +++ b/src/config/CAPI.zig @@ -4,6 +4,8 @@ const inputpkg = @import("../input.zig"); const global = &@import("../main.zig").state; const Config = @import("Config.zig"); +const c_get = @import("c_get.zig"); +const Key = @import("key.zig").Key; const log = std.log.scoped(.config); @@ -78,6 +80,16 @@ export fn ghostty_config_finalize(self: *Config) void { }; } +export fn ghostty_config_get( + self: *Config, + ptr: *anyopaque, + key_str: [*]const u8, + len: usize, +) bool { + const key = std.meta.stringToEnum(Key, key_str[0..len]) orelse return false; + return c_get.get(self, key, ptr); +} + export fn ghostty_config_trigger( self: *Config, str: [*]const u8, diff --git a/src/config/Config.zig b/src/config/Config.zig index 9868860a5..c37939d22 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -13,6 +13,7 @@ const internal_os = @import("../os/main.zig"); const cli_args = @import("../cli_args.zig"); const Key = @import("key.zig").Key; +const KeyValue = @import("key.zig").Value; const log = std.log.scoped(.config); diff --git a/src/config/c_get.zig b/src/config/c_get.zig new file mode 100644 index 000000000..05fa5fe7d --- /dev/null +++ b/src/config/c_get.zig @@ -0,0 +1,76 @@ +const std = @import("std"); + +const key = @import("key.zig"); +const Config = @import("Config.zig"); +const Key = key.Key; +const Value = key.Value; + +/// Get a value from the config by key into the given pointer. This is +/// specifically for C-compatible APIs. If you're using Zig, just access +/// the configuration directly. +/// +/// The return value is false if the given key is not supported by the +/// C API yet. This is a fixable problem so if it is important to support +/// some key, please open an issue. +pub fn get(config: *const Config, k: Key, ptr_raw: *anyopaque) bool { + @setEvalBranchQuota(10_000); + switch (k) { + inline else => |tag| { + const value = fieldByKey(config, tag); + switch (@TypeOf(value)) { + ?[:0]const u8 => { + const ptr: *[*c]const u8 = @ptrCast(@alignCast(ptr_raw)); + ptr.* = if (value) |slice| @ptrCast(slice.ptr) else null; + }, + + bool => { + const ptr: *bool = @ptrCast(@alignCast(ptr_raw)); + ptr.* = value; + }, + + u8, u32 => { + const ptr: *c_uint = @ptrCast(@alignCast(ptr_raw)); + ptr.* = @intCast(value); + }, + + f32, f64 => { + const ptr: *f64 = @ptrCast(@alignCast(ptr_raw)); + ptr.* = @floatCast(value); + }, + + else => return false, + } + + return true; + }, + } +} + +/// Get a value from the config by key. +fn fieldByKey(self: *const Config, comptime k: Key) Value(k) { + const field = comptime field: { + const fields = std.meta.fields(Config); + for (fields) |field| { + if (@field(Key, field.name) == k) { + break :field field; + } + } + + unreachable; + }; + + return @field(self, field.name); +} + +test "u8" { + const testing = std.testing; + const alloc = testing.allocator; + + var c = try Config.default(alloc); + defer c.deinit(); + c.@"font-size" = 24; + + var cval: c_uint = undefined; + try testing.expect(get(&c, .@"font-size", &cval)); + try testing.expectEqual(@as(c_uint, 24), cval); +} diff --git a/src/config/key.zig b/src/config/key.zig index bd50a684d..a6ad127c7 100644 --- a/src/config/key.zig +++ b/src/config/key.zig @@ -30,3 +30,26 @@ pub const Key = key: { }, }); }; + +/// Returns the value type for a key +pub fn Value(comptime key: Key) type { + const field = comptime field: { + const fields = std.meta.fields(Config); + for (fields) |field| { + if (@field(Key, field.name) == key) { + break :field field; + } + } + + unreachable; + }; + + return field.type; +} + +test "Value" { + const testing = std.testing; + + try testing.expectEqual(?[:0]const u8, Value(.@"font-family")); + try testing.expectEqual(bool, Value(.@"cursor-style-blink")); +} From 2b04a7114ba0e2581f8ad487f918943cc9fda504 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 10 Sep 2023 18:52:40 -0700 Subject: [PATCH 4/4] macos: use the configured unfocused split opacity --- .../Features/Primary Window/PrimaryView.swift | 1 + macos/Sources/Ghostty/AppState.swift | 13 +++++++++++++ macos/Sources/Ghostty/SurfaceView.swift | 12 +++++++++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Primary Window/PrimaryView.swift b/macos/Sources/Features/Primary Window/PrimaryView.swift index cd9f8e920..c3020ba3e 100644 --- a/macos/Sources/Features/Primary Window/PrimaryView.swift +++ b/macos/Sources/Features/Primary Window/PrimaryView.swift @@ -96,6 +96,7 @@ struct PrimaryView: View { Ghostty.TerminalSplit(onClose: Self.closeWindow, baseConfig: self.baseConfig) .ghosttyApp(ghostty.app!) + .ghosttyConfig(ghostty.config!) .background(WindowAccessor(window: $window)) .onReceive(gotoTab) { onGotoTab(notification: $0) } .onReceive(toggleFullscreen) { onToggleFullscreen(notification: $0) } diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index 4b7b85d82..12ece198b 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -350,15 +350,28 @@ private struct GhosttyAppKey: EnvironmentKey { static let defaultValue: ghostty_app_t? = nil } +private struct GhosttyConfigKey: EnvironmentKey { + static let defaultValue: ghostty_config_t? = nil +} + extension EnvironmentValues { var ghosttyApp: ghostty_app_t? { get { self[GhosttyAppKey.self] } set { self[GhosttyAppKey.self] = newValue } } + + var ghosttyConfig: ghostty_config_t? { + get { self[GhosttyConfigKey.self] } + set { self[GhosttyConfigKey.self] = newValue } + } } extension View { func ghosttyApp(_ app: ghostty_app_t?) -> some View { environment(\.ghosttyApp, app) } + + func ghosttyConfig(_ config: ghostty_config_t?) -> some View { + environment(\.ghosttyConfig, config) + } } diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 015600392..ae7f73652 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -48,10 +48,20 @@ extension Ghostty { // Maintain whether our window has focus (is key) or not @State private var windowFocus: Bool = true + @Environment(\.ghosttyConfig) private var ghostty_config + // This is true if the terminal is considered "focused". The terminal is focused if // it is both individually focused and the containing window is key. private var hasFocus: Bool { surfaceFocus && windowFocus } + // The opacity of the rectangle when unfocused. + private var unfocusedOpacity: Double { + var opacity: Double = 0.85 + let key = "unfocused-split-opacity" + _ = ghostty_config_get(ghostty_config, &opacity, key, UInt(key.count)) + return 1 - opacity + } + var body: some View { ZStack { // We use a GeometryReader to get the frame bounds so that our metal surface @@ -130,7 +140,7 @@ extension Ghostty { Rectangle() .fill(.white) .allowsHitTesting(false) - .opacity(0.15) + .opacity(unfocusedOpacity) } } }