diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-04 19:22:04 +0300 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-04-05 01:23:06 +0300 |
| commit | 41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch) | |
| tree | 3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/config/Config.zig | |
| parent | 55c138263e190a1e79d4b997bfb9397306c0ec1f (diff) | |
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/config/Config.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/config/Config.zig | 10887 |
1 files changed, 10887 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/config/Config.zig b/vendor/libghostty-vt/src/config/Config.zig new file mode 100644 index 00000000..13f78eea --- /dev/null +++ b/vendor/libghostty-vt/src/config/Config.zig @@ -0,0 +1,10887 @@ +/// 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. + +// Pandoc is used to automatically generate manual pages and other forms of +// documentation, so documentation comments on fields in the Config struct +// should use Pandoc's flavor of Markdown. +// +// For a reference to Pandoc's Markdown see their [online +// manual.](https://pandoc.org/MANUAL.html#pandocs-markdown) + +const Config = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const build_config = @import("../build_config.zig"); +const assert = @import("../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const global_state = &@import("../global.zig").state; +const deepEqual = @import("../datastruct/comparison.zig").deepEqual; +const fontpkg = @import("../font/main.zig"); +const inputpkg = @import("../input.zig"); +const internal_os = @import("../os/main.zig"); +const cli = @import("../cli.zig"); + +const conditional = @import("conditional.zig"); +const Conditional = conditional.Conditional; +const file_load = @import("file_load.zig"); +const formatterpkg = @import("formatter.zig"); +const themepkg = @import("theme.zig"); +const url = @import("url.zig"); +pub const Key = @import("key.zig").Key; +const MetricModifier = fontpkg.Metrics.Modifier; +const help_strings = @import("help_strings"); +pub const Command = @import("command.zig").Command; +const RepeatableReadableIO = @import("io.zig").RepeatableReadableIO; +const RepeatableStringMap = @import("RepeatableStringMap.zig"); +pub const Path = @import("path.zig").Path; +pub const RepeatablePath = @import("path.zig").RepeatablePath; +const ClipboardCodepointMap = @import("ClipboardCodepointMap.zig"); +const KeyRemapSet = @import("../input/key_mods.zig").RemapSet; +pub const WindowPaddingBalance = @import("../renderer/size.zig").PaddingBalance; +const string = @import("string.zig"); + +// We do this instead of importing all of terminal/main.zig to +// limit the dependency graph. This is important because some things +// like the `ghostty-build-data` binary depend on the Config but don't +// want to include all the other stuff. +const terminal = struct { + const CursorStyle = @import("../terminal/cursor.zig").Style; + const color = @import("../terminal/color.zig"); + const style = @import("../terminal/style.zig"); + const x11_color = @import("../terminal/x11_color.zig"); +}; + +const log = std.log.scoped(.config); + +/// Used on Unixes for some defaults. +const c = @cImport({ + @cInclude("unistd.h"); +}); + +pub const compatibility = std.StaticStringMap( + cli.CompatibilityHandler(Config), +).initComptime(&.{ + // Ghostty 1.1 introduced background-blur support for Linux which + // doesn't support a specific radius value. The renaming is to let + // one field be used for both platforms (macOS retained the ability + // to set a radius). + .{ "background-blur-radius", cli.compatibilityRenamed(Config, "background-blur") }, + + // Ghostty 1.2 renamed all our adw options to gtk because we now have + // a hard dependency on libadwaita. + .{ "adw-toolbar-style", cli.compatibilityRenamed(Config, "gtk-toolbar-style") }, + + // Ghostty 1.2 removed the `hidden` value from `gtk-tabs-location` and + // moved it to `window-show-tab-bar`. + .{ "gtk-tabs-location", compatGtkTabsLocation }, + + // Ghostty 1.2 lets you set `cell-foreground` and `cell-background` + // to match the cell foreground and background colors, respectively. + // This can be used with `cursor-color` and `cursor-text` to recreate + // this behavior. This applies to selection too. + .{ "cursor-invert-fg-bg", compatCursorInvertFgBg }, + .{ "selection-invert-fg-bg", compatSelectionInvertFgBg }, + + // Ghostty 1.2 merged `bold-is-bright` into the new `bold-color` + // by setting the value to "bright". + .{ "bold-is-bright", compatBoldIsBright }, + + // Ghostty 1.2 removed the "desktop" option and renamed it to "detect". + // The semantics also changed slightly but this is the correct mapping. + .{ "gtk-single-instance", compatGtkSingleInstance }, + + // Ghostty 1.3 rename the "window" option to "new-window". + // See: https://github.com/ghostty-org/ghostty/pull/9764 + .{ "macos-dock-drop-behavior", compatMacOSDockDropBehavior }, +}); + +/// Set Ghostty's graphical user interface language to a language other than the +/// system default language. For example: +/// +/// language = de +/// +/// will force the strings in Ghostty's graphical user interface to be in German +/// rather than the system default. +/// +/// This will not affect the language used by programs run _within_ Ghostty. +/// Those will continue to use the default system language. There are also many +/// non-GUI elements in Ghostty that are not translated - this setting will have +/// no effect on those. +/// +/// Warning: This setting cannot be reloaded at runtime. To change the language +/// you must fully restart Ghostty. +/// +/// GTK only. +/// Available since 1.3.0. +language: ?[:0]const u8 = null, + +/// The font families to use. +/// +/// You can generate the list of valid values using the CLI: +/// +/// ghostty +list-fonts +/// +/// This configuration can be repeated multiple times to specify preferred +/// fallback fonts when the requested codepoint is not available in the primary +/// font. This is particularly useful for multiple languages, symbolic fonts, +/// etc. +/// +/// Notes on emoji specifically: On macOS, Ghostty by default will always use +/// Apple Color Emoji and on Linux will always use Noto Emoji. You can +/// override this behavior by specifying a font family here that contains +/// emoji glyphs. +/// +/// The specific styles (bold, italic, bold italic) do not need to be +/// explicitly set. If a style is not set, then the regular style (font-family) +/// will be searched for stylistic variants. If a stylistic variant is not +/// found, Ghostty will use the regular style. This prevents falling back to a +/// different font family just to get a style such as bold. This also applies +/// if you explicitly specify a font family for a style. For example, if you +/// set `font-family-bold = FooBar` and "FooBar" cannot be found, Ghostty will +/// use whatever font is set for `font-family` for the bold style. +/// +/// Finally, some styles may be synthesized if they are not supported. +/// For example, if a font does not have an italic style and no alternative +/// italic font is specified, Ghostty will synthesize an italic style by +/// applying a slant to the regular style. If you want to disable these +/// synthesized styles then you can use the `font-style` configurations +/// as documented below. +/// +/// You can disable styles completely by using the `font-style` set of +/// configurations. See the documentation for `font-style` for more information. +/// +/// If you want to overwrite a previous set value rather than append a fallback, +/// specify the value as `""` (empty string) to reset the list and then set the +/// new values. For example: +/// +/// font-family = "" +/// font-family = "My Favorite Font" +/// +/// Setting any of these as CLI arguments will automatically clear the +/// values set in configuration files so you don't need to specify +/// `--font-family=""` before setting a new value. You only need to specify +/// this within config files if you want to clear previously set values in +/// configuration files or on the CLI if you want to clear values set on the +/// CLI. +@"font-family": RepeatableString = .{}, +@"font-family-bold": RepeatableString = .{}, +@"font-family-italic": RepeatableString = .{}, +@"font-family-bold-italic": RepeatableString = .{}, + +/// The named font style to use for each of the requested terminal font styles. +/// This looks up the style based on the font style string advertised by the +/// font itself. For example, "Iosevka Heavy" has a style of "Heavy". +/// +/// You can also use these fields to completely disable a font style. If you set +/// the value of the configuration below to literal `false` then that font style +/// will be disabled. If the running program in the terminal requests a disabled +/// font style, the regular font style will be used instead. +/// +/// These are only valid if its corresponding font-family is also specified. If +/// no font-family is specified, then the font-style is ignored unless you're +/// disabling the font style. +@"font-style": FontStyle = .{ .default = {} }, +@"font-style-bold": FontStyle = .{ .default = {} }, +@"font-style-italic": FontStyle = .{ .default = {} }, +@"font-style-bold-italic": FontStyle = .{ .default = {} }, + +/// Control whether Ghostty should synthesize a style if the requested style is +/// not available in the specified font-family. +/// +/// Ghostty can synthesize bold, italic, and bold italic styles if the font +/// does not have a specific style. For bold, this is done by drawing an +/// outline around the glyph of varying thickness. For italic, this is done by +/// applying a slant to the glyph. For bold italic, both of these are applied. +/// +/// Synthetic styles are not perfect and will generally not look as good +/// as a font that has the style natively. However, they are useful to +/// provide styled text when the font does not have the style. +/// +/// Set this to "false" or "true" to disable or enable synthetic styles +/// completely. You can disable specific styles using "no-bold", "no-italic", +/// and "no-bold-italic". You can disable multiple styles by separating them +/// with a comma. For example, "no-bold,no-italic". +/// +/// Available style keys are: `bold`, `italic`, `bold-italic`. +/// +/// If synthetic styles are disabled, then the regular style will be used +/// instead if the requested style is not available. If the font has the +/// requested style, then the font will be used as-is since the style is +/// not synthetic. +/// +/// Warning: An easy mistake is to disable `bold` or `italic` but not +/// `bold-italic`. Disabling only `bold` or `italic` will NOT disable either +/// in the `bold-italic` style. If you want to disable `bold-italic`, you must +/// explicitly disable it. You cannot partially disable `bold-italic`. +/// +/// By default, synthetic styles are enabled. +@"font-synthetic-style": FontSyntheticStyle = .{}, + +/// Apply a font feature. To enable multiple font features you can repeat +/// this multiple times or use a comma-separated list of feature settings. +/// +/// The syntax for feature settings is as follows, where `feat` is a feature: +/// +/// * Enable features with e.g. `feat`, `+feat`, `feat on`, `feat=1`. +/// * Disabled features with e.g. `-feat`, `feat off`, `feat=0`. +/// * Set a feature value with e.g. `feat=2`, `feat = 3`, `feat 4`. +/// * Feature names may be wrapped in quotes, meaning this config should be +/// syntactically compatible with the `font-feature-settings` CSS property. +/// +/// The syntax is fairly loose, but invalid settings will be silently ignored. +/// +/// The font feature will apply to all fonts rendered by Ghostty. A future +/// enhancement will allow targeting specific faces. +/// +/// To disable programming ligatures, use `-calt` since this is the typical +/// feature name for programming ligatures. To look into what font features +/// your font has and what they do, use a font inspection tool such as +/// [fontdrop.info](https://fontdrop.info). +/// +/// To generally disable most ligatures, use `-calt, -liga, -dlig`. +@"font-feature": RepeatableString = .{}, + +/// Font size in points. This value can be a non-integer and the nearest integer +/// pixel size will be selected. If you have a high dpi display where 1pt = 2px +/// then you can get an odd numbered pixel size by specifying a half point. +/// +/// For example, 13.5pt @ 2px/pt = 27px +/// +/// Changing this configuration at runtime will only affect existing +/// terminals that have NOT manually adjusted their font size in some way +/// (e.g. increased or decreased the font size). Terminals that have manually +/// adjusted their font size will retain their manually adjusted size. +/// Otherwise, the font size of existing terminals will be updated on +/// reload. +/// +/// On Linux with GTK, font size is scaled according to both display-wide and +/// text-specific scaling factors, which are often managed by your desktop +/// environment (e.g. the GNOME display scale and large text settings). +@"font-size": f32 = switch (builtin.os.tag) { + // On macOS 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 = .{}, + +/// Force one or a range of Unicode codepoints to map to a specific named font. +/// This is useful if you want to support special symbols or if you want to use +/// specific glyphs that render better for your specific font. +/// +/// The syntax is `codepoint=fontname` where `codepoint` is either a single +/// codepoint or a range. Codepoints must be specified as full Unicode +/// hex values, such as `U+ABCD`. Codepoints ranges are specified as +/// `U+ABCD-U+DEFG`. You can specify multiple ranges for the same font separated +/// by commas, such as `U+ABCD-U+DEFG,U+1234-U+5678=fontname`. The font name is +/// the same value as you would use for `font-family`. +/// +/// This configuration can be repeated multiple times to specify multiple +/// codepoint mappings. +/// +/// Changing this configuration at runtime will only affect new terminals, +/// i.e. new windows, tabs, etc. +@"font-codepoint-map": RepeatableCodepointMap = .{}, + +/// Map specific Unicode codepoints to replacement values when copying text +/// to clipboard. +/// +/// This configuration allows you to replace specific Unicode characters with +/// other characters or strings when copying terminal content to the clipboard. +/// This is useful for converting special terminal symbols to more compatible +/// characters for pasting into other applications. +/// +/// The syntax is similar to `font-codepoint-map`: +/// - Single codepoint: `U+1234=U+ABCD` or `U+1234=replacement_text` +/// - Codepoint range: `U+1234-U+5678=U+ABCD` +/// +/// Examples: +/// - `clipboard-codepoint-map = U+2500=U+002D` (box drawing horizontal → hyphen) +/// - `clipboard-codepoint-map = U+2502=U+007C` (box drawing vertical → pipe) +/// - `clipboard-codepoint-map = U+03A3=SUM` (Greek sigma → "SUM") +/// +/// This configuration can be repeated multiple times to specify multiple +/// mappings. Later entries take priority over earlier ones for overlapping +/// ranges. +/// +/// Note: This only applies to text copying operations, not URL copying. +@"clipboard-codepoint-map": RepeatableClipboardCodepointMap = .{}, + +/// Draw fonts with a thicker stroke, if supported. +/// This is currently only supported on macOS. +@"font-thicken": bool = false, + +/// Strength of thickening when `font-thicken` is enabled. +/// +/// Valid values are integers between `0` and `255`. `0` does not correspond to +/// *no* thickening, rather it corresponds to the lightest available thickening. +/// +/// Has no effect when `font-thicken` is set to `false`. +/// +/// This is currently only supported on macOS. +@"font-thicken-strength": u8 = 255, + +/// Locations to break font shaping into multiple runs. +/// +/// A "run" is a contiguous segment of text that is shaped together. "Shaping" +/// is the process of converting text (codepoints) into glyphs (renderable +/// characters). This is how ligatures are formed, among other things. +/// For example, if a coding font turns "!=" into a single glyph, then it +/// must see "!" and "=" next to each other in a single run. When a run +/// is broken, the text is shaped separately. To continue our example, if +/// "!" is at the end of one run and "=" is at the start of the next run, +/// then the ligature will not be formed. +/// +/// Ghostty breaks runs at certain points to improve readability or usability. +/// For example, Ghostty by default will break runs under the cursor so that +/// text editing can see the individual characters rather than a ligature. +/// This configuration lets you configure this behavior. +/// +/// Combine values with a comma to set multiple options. Prefix an +/// option with "no-" to disable it. Enabling and disabling options +/// can be done at the same time. +/// +/// Available options: +/// +/// * `cursor` - Break runs under the cursor. +/// +/// Available since: 1.2.0 +@"font-shaping-break": FontShapingBreak = .{}, + +/// What color space to use when performing alpha blending. +/// +/// This affects the appearance of text and of any images with transparency. +/// Additionally, custom shaders will receive colors in the configured space. +/// +/// On macOS the default is `native`, on all other platforms the default is +/// `linear-corrected`. +/// +/// Valid values: +/// +/// * `native` - Perform alpha blending in the native color space for the OS. +/// On macOS this corresponds to Display P3, and on Linux it's sRGB. +/// +/// * `linear` - Perform alpha blending in linear space. This will eliminate +/// the darkening artifacts around the edges of text that are very visible +/// when certain color combinations are used (e.g. red / green), but makes +/// dark text look much thinner than normal and light text much thicker. +/// This is also sometimes known as "gamma correction". +/// +/// * `linear-corrected` - Same as `linear`, but with a correction step applied +/// for text that makes it look nearly or completely identical to `native`, +/// but without any of the darkening artifacts. +/// +/// Available since: 1.1.0 +@"alpha-blending": AlphaBlending = + if (builtin.os.tag == .macos) + .native + else + .@"linear-corrected", + +/// All of the configurations behavior adjust various metrics determined by the +/// font. The values can be integers (1, -1, etc.) or a percentage (20%, -15%, +/// etc.). In each case, the values represent the amount to change the original +/// value. +/// +/// For example, a value of `1` increases the value by 1; it does not set it to +/// literally 1. A value of `20%` increases the value by 20%. And so on. +/// +/// There is little to no validation on these values so the wrong values (e.g. +/// `-100%`) can cause the terminal to be unusable. Use with caution and reason. +/// +/// Some values are clamped to minimum or maximum values. This can make it +/// appear that certain values are ignored. For example, many `*-thickness` +/// adjustments cannot go below 1px. +/// +/// `adjust-cell-height` has some additional behaviors to describe: +/// +/// * The font will be centered vertically in the cell. +/// +/// * The cursor will remain the same size as the font, but may be +/// adjusted separately with `adjust-cursor-height`. +/// +/// * Powerline glyphs will be adjusted along with the cell height so +/// that things like status lines continue to look aligned. +@"adjust-cell-width": ?MetricModifier = null, +@"adjust-cell-height": ?MetricModifier = null, +/// Distance in pixels or percentage adjustment from the bottom of the cell to the text baseline. +/// Increase to move baseline UP, decrease to move baseline DOWN. +/// See the notes about adjustments in `adjust-cell-width`. +@"adjust-font-baseline": ?MetricModifier = null, +/// Distance in pixels or percentage adjustment from the top of the cell to the top of the underline. +/// Increase to move underline DOWN, decrease to move underline UP. +/// See the notes about adjustments in `adjust-cell-width`. +@"adjust-underline-position": ?MetricModifier = null, +/// Thickness in pixels of the underline. +/// See the notes about adjustments in `adjust-cell-width`. +@"adjust-underline-thickness": ?MetricModifier = null, +/// Distance in pixels or percentage adjustment from the top of the cell to the top of the strikethrough. +/// Increase to move strikethrough DOWN, decrease to move strikethrough UP. +/// See the notes about adjustments in `adjust-cell-width`. +@"adjust-strikethrough-position": ?MetricModifier = null, +/// Thickness in pixels or percentage adjustment of the strikethrough. +/// See the notes about adjustments in `adjust-cell-width`. +@"adjust-strikethrough-thickness": ?MetricModifier = null, +/// Distance in pixels or percentage adjustment from the top of the cell to the top of the overline. +/// Increase to move overline DOWN, decrease to move overline UP. +/// See the notes about adjustments in `adjust-cell-width`. +@"adjust-overline-position": ?MetricModifier = null, +/// Thickness in pixels or percentage adjustment of the overline. +/// See the notes about adjustments in `adjust-cell-width`. +@"adjust-overline-thickness": ?MetricModifier = null, +/// Thickness in pixels or percentage adjustment of the bar cursor and outlined rect cursor. +/// See the notes about adjustments in `adjust-cell-width`. +@"adjust-cursor-thickness": ?MetricModifier = null, +/// Height in pixels or percentage adjustment of the cursor. Currently applies to all cursor types: +/// bar, rect, and outlined rect. +/// See the notes about adjustments in `adjust-cell-width`. +@"adjust-cursor-height": ?MetricModifier = null, +/// Thickness in pixels or percentage adjustment of box drawing characters. +/// See the notes about adjustments in `adjust-cell-width`. +@"adjust-box-thickness": ?MetricModifier = null, +/// Height in pixels or percentage adjustment of maximum height for nerd font icons. +/// +/// A positive (negative) value will increase (decrease) the maximum icon +/// height. This may not affect all icons equally: the effect depends on whether +/// the default size of the icon is height-constrained, which in turn depends on +/// the aspect ratio of both the icon and your primary font. +/// +/// Certain icons designed for box drawing and terminal graphics, such as +/// Powerline symbols, are not affected by this option. +/// +/// See the notes about adjustments in `adjust-cell-width`. +/// +/// Available in: 1.2.0 +@"adjust-icon-height": ?MetricModifier = null, + +/// The method to use for calculating the cell width of a grapheme cluster. +/// The default value is `unicode` which uses the Unicode standard to determine +/// grapheme width. This results in correct grapheme width but may result in +/// cursor-desync issues with some programs (such as shells) that may use a +/// legacy method such as `wcswidth`. +/// +/// Valid values are: +/// +/// * `legacy` - Use a legacy method to determine grapheme width, such as +/// wcswidth This maximizes compatibility with legacy programs but may result +/// in incorrect grapheme width for certain graphemes such as skin-tone +/// emoji, non-English characters, etc. +/// +/// This is called "legacy" and not something more specific because the +/// behavior is undefined and we want to retain the ability to modify it. +/// For example, we may or may not use libc `wcswidth` now or in the future. +/// +/// * `unicode` - Use the Unicode standard to determine grapheme width. +/// +/// If a running program explicitly enables terminal mode 2027, then `unicode` +/// width will be forced regardless of this configuration. When mode 2027 is +/// reset, this configuration will be used again. +/// +/// This configuration can be changed at runtime but will not affect existing +/// terminals. Only new terminals will use the new configuration. +@"grapheme-width-method": GraphemeWidthMethod = .unicode, + +/// FreeType load flags to enable. The format of this is a list of flags to +/// enable separated by commas. If you prefix a flag with `no-` then it is +/// disabled. If you omit a flag, its default value is used, so you must +/// explicitly disable flags you don't want. You can also use `true` or `false` +/// to turn all flags on or off. +/// +/// This configuration only applies to Ghostty builds that use FreeType. +/// This is usually the case only for Linux builds. macOS uses CoreText +/// and does not have an equivalent configuration. +/// +/// Available flags: +/// +/// * `hinting` - Enable or disable hinting. Enabled by default. +/// +/// * `force-autohint` - Always use the freetype auto-hinter instead of +/// the font's native hinter. Disabled by default. +/// +/// * `monochrome` - Instructs renderer to use 1-bit monochrome rendering. +/// This will disable anti-aliasing, and probably not look very good unless +/// you're using a pixel font. Disabled by default. +/// +/// * `autohint` - Enable the freetype auto-hinter. Enabled by default. +/// +/// * `light` - Use a light hinting style, better preserving glyph shapes. +/// This is the most common setting in GTK apps and therefore also Ghostty's +/// default. This has no effect if `monochrome` is enabled. Enabled by +/// default. +/// +/// Example: `hinting`, `no-hinting`, `force-autohint`, `no-force-autohint` +@"freetype-load-flags": FreetypeLoadFlags = .{}, + +/// A theme to use. This can be a built-in theme name, a custom theme +/// name, or an absolute path to a custom theme file. Ghostty also supports +/// specifying a different theme to use for light and dark mode. Each +/// option is documented below. +/// +/// If the theme is an absolute pathname, Ghostty will attempt to load that +/// file as a theme. If that file does not exist or is inaccessible, an error +/// will be logged and no other directories will be searched. +/// +/// If the theme is not an absolute pathname, two different directories will be +/// searched for a file name that matches the theme. This is case sensitive on +/// systems with case-sensitive filesystems. It is an error for a theme name to +/// include path separators unless it is an absolute pathname. +/// +/// The first directory is the `themes` subdirectory of your Ghostty +/// configuration directory. This is `$XDG_CONFIG_HOME/ghostty/themes` or +/// `~/.config/ghostty/themes`. +/// +/// The second directory is the `themes` subdirectory of the Ghostty resources +/// directory. Ghostty ships with a multitude of themes that will be installed +/// into this directory. On macOS, this list is in the +/// `Ghostty.app/Contents/Resources/ghostty/themes` directory. On Linux, this +/// list is in the `share/ghostty/themes` directory (wherever you installed the +/// Ghostty "share" directory. +/// +/// To see a list of available themes, run `ghostty +list-themes`. +/// +/// A theme file is simply another Ghostty configuration file. They share +/// the same syntax and same configuration options. A theme can set any valid +/// configuration option so please do not use a theme file from an untrusted +/// source. The built-in themes are audited to only set safe configuration +/// options. +/// +/// Some options cannot be set within theme files. The reason these are not +/// supported should be self-evident. A theme file cannot set `theme` or +/// `config-file`. At the time of writing this, Ghostty will not show any +/// warnings or errors if you set these options in a theme file but they will +/// be silently ignored. +/// +/// Any additional colors specified via background, foreground, palette, etc. +/// will override the colors specified in the theme. +/// +/// To specify a different theme for light and dark mode, use the following +/// syntax: `light:theme-name,dark:theme-name`. For example: +/// `light:Rose Pine Dawn,dark:Rose Pine`. Whitespace around all values are +/// trimmed and order of light and dark does not matter. Both light and dark +/// must be specified in this form. In this form, the theme used will be +/// based on the current desktop environment theme. +/// +/// There are some known bugs with light/dark mode theming. These will +/// be fixed in a future update: +/// +/// - macOS: titlebar tabs style is not updated when switching themes. +theme: ?Theme = null, + +/// Background color for the window. +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. +background: Color = .{ .r = 0x28, .g = 0x2C, .b = 0x34 }, + +/// Foreground color for the window. +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. +foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, + +/// Background image for the terminal. +/// +/// This should be a path to a PNG or JPEG file, other image formats are +/// not yet supported. +/// +/// The background image is currently per-terminal, not per-window. If +/// you are a heavy split user, the background image will be repeated across +/// splits. A future improvement to Ghostty will address this. +/// +/// WARNING: Background images are currently duplicated in VRAM per-terminal. +/// For sufficiently large images, this could lead to a large increase in +/// memory usage (specifically VRAM usage). A future Ghostty improvement +/// will resolve this by sharing image textures across terminals. +/// +/// Available since: 1.2.0 +@"background-image": ?Path = null, + +/// Background image opacity. +/// +/// This is relative to the value of `background-opacity`. +/// +/// A value of `1.0` (the default) will result in the background image being +/// placed on top of the general background color, and then the combined result +/// will be adjusted to the opacity specified by `background-opacity`. +/// +/// A value less than `1.0` will result in the background image being mixed +/// with the general background color before the combined result is adjusted +/// to the configured `background-opacity`. +/// +/// A value greater than `1.0` will result in the background image having a +/// higher opacity than the general background color. For instance, if the +/// configured `background-opacity` is `0.5` and `background-image-opacity` +/// is set to `1.5`, then the final opacity of the background image will be +/// `0.5 * 1.5 = 0.75`. +/// +/// Available since: 1.2.0 +@"background-image-opacity": f32 = 1.0, + +/// Background image position. +/// +/// Valid values are: +/// * `top-left` +/// * `top-center` +/// * `top-right` +/// * `center-left` +/// * `center` +/// * `center-right` +/// * `bottom-left` +/// * `bottom-center` +/// * `bottom-right` +/// +/// The default value is `center`. +/// +/// Available since: 1.2.0 +@"background-image-position": BackgroundImagePosition = .center, + +/// Background image fit. +/// +/// Valid values are: +/// +/// * `contain` +/// +/// Preserving the aspect ratio, scale the background image to the largest +/// size that can still be contained within the terminal, so that the whole +/// image is visible. +/// +/// * `cover` +/// +/// Preserving the aspect ratio, scale the background image to the smallest +/// size that can completely cover the terminal. This may result in one or +/// more edges of the image being clipped by the edge of the terminal. +/// +/// * `stretch` +/// +/// Stretch the background image to the full size of the terminal, without +/// preserving the aspect ratio. +/// +/// * `none` +/// +/// Don't scale the background image. +/// +/// The default value is `contain`. +/// +/// Available since: 1.2.0 +@"background-image-fit": BackgroundImageFit = .contain, + +/// Whether to repeat the background image or not. +/// +/// If this is set to true, the background image will be repeated if there +/// would otherwise be blank space around it because it doesn't completely +/// fill the terminal area. +/// +/// The default value is `false`. +/// +/// Available since: 1.2.0 +@"background-image-repeat": bool = false, + +/// 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). +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. +/// Since version 1.2.0, this can also be set to `cell-foreground` to match +/// the cell foreground color, or `cell-background` to match the cell +/// background color. +@"selection-foreground": ?TerminalColor = null, +@"selection-background": ?TerminalColor = null, + +/// Whether to clear selected text when typing. This defaults to `true`. +/// This is typical behavior for most terminal emulators as well as +/// text input fields. If you set this to `false`, then the selected text +/// will not be cleared when typing. +/// +/// "Typing" is specifically defined as any non-modifier (shift, control, +/// alt, etc.) keypress that produces data to be sent to the application +/// running within the terminal (e.g. the shell). Additionally, selection +/// is cleared when any preedit or composition state is started (e.g. +/// when typing languages such as Japanese). +/// +/// If this is `false`, then the selection can still be manually +/// cleared by clicking once or by pressing `escape`. +/// +/// Available since: 1.2.0 +@"selection-clear-on-typing": bool = true, + +/// Whether to clear selected text after copying. This defaults to `false`. +/// +/// When set to `true`, the selection will be automatically cleared after +/// any copy operation that invokes the `copy_to_clipboard` keyboard binding. +/// Importantly, this will not clear the selection if the copy operation +/// was invoked via `copy-on-select`. +/// +/// When set to `false`, the selection remains visible after copying, allowing +/// to see what was copied and potentially perform additional operations +/// on the same selection. +@"selection-clear-on-copy": bool = false, + +/// Characters that mark word boundaries during text selection operations such +/// as double-clicking. When selecting a word, the selection will stop at any +/// of these characters. +/// +/// This is similar to the `WORDCHARS` environment variable in zsh, except this +/// specifies the boundary characters rather than the word characters. The +/// default includes common delimiters and punctuation that typically separate +/// words in code and prose. +/// +/// Each character in this string becomes a word boundary. Multi-byte UTF-8 +/// characters are supported, but only single codepoints can be specified. +/// Multi-codepoint sequences (e.g. emoji) are not supported. +/// +/// The null character (U+0000) is always treated as a boundary and does not +/// need to be included in this configuration. +/// +/// Default: `` \t'"│`|:;,()[]{}<>$ `` +/// +/// To add or remove specific characters, you can set this to a custom value. +/// For example, to treat semicolons as part of words: +/// +/// selection-word-chars = " \t'\"│`|:,()[]{}<>$" +/// +/// Available since: 1.3.0 +@"selection-word-chars": SelectionWordChars = .{}, + +/// The minimum contrast ratio between the foreground and background colors. +/// The contrast ratio is a value between 1 and 21. A value of 1 allows for no +/// contrast (e.g. black on black). This value is the contrast ratio as defined +/// by the [WCAG 2.0 specification](https://www.w3.org/TR/WCAG20/). +/// +/// If you want to avoid invisible text (same color as background), a value of +/// 1.1 is a good value. If you want to avoid text that is difficult to read, a +/// value of 3 or higher is a good value. The higher the value, the more likely +/// that text will become black or white. +/// +/// This value does not apply to Emoji or images. +@"minimum-contrast": f64 = 1, + +/// Color palette for the 256 color form that many terminal applications use. +/// The syntax of this configuration is `N=COLOR` where `N` is 0 to 255 (for +/// the 256 colors in the terminal color table) and `COLOR` is a typical RGB +/// color code such as `#AABBCC` or `AABBCC`, or a named X11 color. For example, +/// `palette = 5=#BB78D9` will set the 'purple' color. +/// +/// The palette index can be in decimal, binary, octal, or hexadecimal. +/// Decimal is assumed unless a prefix is used: `0b` for binary, `0o` for octal, +/// and `0x` for hexadecimal. +/// +/// For definitions on the color indices and what they canonically map to, +/// [see this cheat sheet](https://www.ditig.com/256-colors-cheat-sheet). +/// +/// For most themes, you only need to set the first 16 colors (0–15) since the +/// rest of the palette (16–255) will be automatically generated by +/// default (see `palette-generate` for more details). +palette: Palette = .{}, + +/// Whether to automatically generate the extended 256 color palette +/// (indices 16–255) from the base 16 ANSI colors. +/// +/// This lets theme authors specify only the base 16 colors and have the +/// rest of the palette be automatically generated in a consistent and +/// aesthetic way. +/// +/// When enabled, the 6×6×6 color cube and 24-step grayscale ramp are +/// derived from interpolations of the base palette, giving a more cohesive +/// look. Colors that have been explicitly set via `palette` are never +/// overwritten. +/// +/// The default value is false (disabled), because many legacy programs +/// using the 256-color palette hardcode assumptions about what these +/// colors are (mostly assuming the xterm 256 color palette). However, this +/// is still a very useful tool for theme authors and users who want +/// to customize their palette without having to specify all 256 colors. +/// +/// For more information on how the generation works, see here: +/// https://gist.github.com/jake-stewart/0a8ea46159a7da2c808e5be2177e1783 +/// +/// Available since: 1.3.0 +@"palette-generate": bool = false, + +/// Invert the palette colors generated when `palette-generate` is enabled, +/// so that the colors go in reverse order. This allows palette-based +/// applications to work well in both light and dark mode since the +/// palettes are always relatively good colors. +/// +/// This defaults to off because some legacy terminal applications +/// hardcode the assumption that palette indices 16–231 are ordered from +/// darkest to lightest, so enabling this would make them unreadable. +/// This is not a generally good assumption and we encourage modern +/// terminal applications to use the indices in a more semantic way. +/// +/// This has no effect if `palette-generate` is disabled. +/// +/// For more information see `palette-generate`. +/// +/// Available since: 1.3.0 +@"palette-harmonious": bool = false, + +/// The color of the cursor. If this is not set, a default will be chosen. +/// +/// Direct colors can be specified as either hex (`#RRGGBB` or `RRGGBB`) +/// or a named X11 color. +/// +/// Additionally, special values can be used to set the color to match +/// other colors at runtime: +/// +/// * `cell-foreground` - Match the cell foreground color. +/// (Available since: 1.2.0) +/// +/// * `cell-background` - Match the cell background color. +/// (Available since: 1.2.0) +@"cursor-color": ?TerminalColor = null, + +/// The opacity level (opposite of transparency) of the cursor. 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. Note that a +/// sufficiently small value such as 0.3 may be effectively invisible and may +/// make it difficult to find the cursor. +@"cursor-opacity": f64 = 1.0, + +/// The style of the cursor. This sets the default style. A running program can +/// still request an explicit cursor style using escape sequences (such as `CSI +/// q`). Shell configurations will often request specific cursor styles. +/// +/// Note that shell integration will automatically set the cursor to a bar at +/// a prompt, regardless of this configuration. You can disable that behavior +/// by specifying `shell-integration-features = no-cursor` or disabling shell +/// integration entirely. +/// +/// Valid values are: +/// +/// * `block` +/// * `bar` +/// * `underline` +/// * `block_hollow` +@"cursor-style": terminal.CursorStyle = .block, + +/// Sets the default blinking state of the cursor. This is just the default +/// state; running programs may override the cursor style using `DECSCUSR` (`CSI +/// q`). +/// +/// If this is not set, the cursor blinks by default. Note that this is not the +/// same as a "true" value, as noted below. +/// +/// If this is not set at all (`null`), then Ghostty will respect DEC Mode 12 +/// (AT&T cursor blink) as an alternate approach to turning blinking on/off. If +/// this is set to any value other than null, DEC mode 12 will be ignored but +/// `DECSCUSR` will still be respected. +/// +/// Valid values are: +/// +/// * ` ` (blank) +/// * `true` +/// * `false` +@"cursor-style-blink": ?bool = null, + +/// The color of the text under the cursor. If this is not set, a default will +/// be chosen. +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. +/// Since version 1.2.0, this can also be set to `cell-foreground` to match +/// the cell foreground color, or `cell-background` to match the cell +/// background color. +@"cursor-text": ?TerminalColor = null, + +/// Enables the ability to move the cursor at prompts by clicking on a +/// location in the prompt text. +/// +/// This feature requires shell integration, specifically prompt marking +/// via `OSC 133`. Some shells like Fish (v4) and Nu (0.111+) natively +/// support this while others may require additional configuration or +/// Ghostty's shell integration features to be enabled. +/// +/// Depending on the shell, this works either by translating your click +/// position into a series of synthetic arrow key movements or by sending +/// a click event directly to the shell. In either case, some unexpected +/// behavior around edge cases is possible. +@"cursor-click-to-move": bool = true, + +/// Hide the mouse immediately when typing. The mouse becomes visible again +/// when the mouse is used (button, movement, etc.). Platform-specific behavior +/// may dictate other scenarios where the mouse is shown. For example on macOS, +/// the mouse is shown again when a new window, tab, or split is created. +@"mouse-hide-while-typing": bool = false, + +/// When to scroll the surface to the bottom. The format of this is a list of +/// options to enable separated by commas. If you prefix an option with `no-` +/// then it is disabled. If you omit an option, its default value is used. +/// +/// Available options: +/// +/// - `keystroke` If set, scroll the surface to the bottom when the user +/// presses a key that results in data being sent to the PTY (basically +/// anything but modifiers or keybinds that are processed by Ghostty). +/// +/// - `output` If set, scroll the surface to the bottom if there is new data +/// to display (e.g., when new lines are printed to the terminal). +/// +/// The default is `keystroke, no-output`. +@"scroll-to-bottom": ScrollToBottom = .default, + +/// Determines whether running programs can detect the shift key pressed with a +/// mouse click. Typically, the shift key is used to extend mouse selection. +/// +/// The default value of `false` means that the shift key is not sent with +/// the mouse protocol and will extend the selection. This value can be +/// conditionally overridden by the running program with the `XTSHIFTESCAPE` +/// sequence. +/// +/// The value `true` means that the shift key is sent with the mouse protocol +/// but the running program can override this behavior with `XTSHIFTESCAPE`. +/// +/// The value `never` is the same as `false` but the running program cannot +/// override this behavior with `XTSHIFTESCAPE`. The value `always` is the +/// same as `true` but the running program cannot override this behavior with +/// `XTSHIFTESCAPE`. +/// +/// If you always want shift to extend mouse selection even if the program +/// requests otherwise, set this to `never`. +/// +/// Valid values are: +/// +/// * `true` +/// * `false` +/// * `always` +/// * `never` +@"mouse-shift-capture": MouseShiftCapture = .false, + +/// Enable or disable mouse reporting. When set to `false`, mouse events will +/// not be reported to terminal applications even if they request it. This +/// allows you to always use the mouse for selection and other terminal UI +/// interactions without applications capturing mouse input. +/// +/// When set to `true` (the default), terminal applications can request mouse +/// reporting and will receive mouse events according to their requested mode. +/// +/// This can be toggled at runtime using the `toggle_mouse_reporting` keybind +/// action. +@"mouse-reporting": bool = true, + +/// Multiplier for scrolling distance with the mouse wheel. +/// +/// A prefix of `precision:` or `discrete:` can be used to set the multiplier +/// only for scrolling with the specific type of devices. These can be +/// comma-separated to set both types of multipliers at the same time, e.g. +/// `precision:0.1,discrete:3`. If no prefix is used, the multiplier applies +/// to all scrolling devices. Specifying a prefix was introduced in Ghostty +/// 1.2.1. +/// +/// The value will be clamped to [0.01, 10,000]. Both of these are extreme +/// and you're likely to have a bad experience if you set either extreme. +/// +/// The default value is "3" for discrete devices and "1" for precision devices. +@"mouse-scroll-multiplier": MouseScrollMultiplier = .default, + +/// 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. +/// +/// On macOS, background opacity is disabled when the terminal enters native +/// fullscreen. This is because the background becomes gray and it can cause +/// widgets to show through which isn't generally desirable. +/// +/// On macOS, changing this configuration requires restarting Ghostty completely. +@"background-opacity": f64 = 1.0, + +/// Applies background opacity to cells with an explicit background color +/// set. +/// +/// Normally, `background-opacity` is only applied to the window background. +/// If a cell has an explicit background color set, such as red, then that +/// background color will be fully opaque. An effect of this is that some +/// terminal applications that repaint the background color of the terminal +/// such as a Neovim and Tmux may not respect the `background-opacity` +/// (by design). +/// +/// Setting this to `true` will apply the `background-opacity` to all cells +/// regardless of whether they have an explicit background color set or not. +/// +/// Available since: 1.2.0 +@"background-opacity-cells": bool = false, + +/// Whether to blur the background when `background-opacity` is less than 1. +/// +/// Valid values are: +/// +/// * a nonnegative integer specifying the *blur intensity* +/// * `false`, equivalent to a blur intensity of 0 +/// * `true`, equivalent to the default blur intensity of 20, which is +/// reasonable for a good looking blur. Higher blur intensities may +/// cause strange rendering and performance issues. +/// +/// On macOS 26.0 and later, there are additional special values that +/// can be set to use the native macOS glass effects: +/// +/// * `macos-glass-regular` - Standard glass effect with some opacity +/// * `macos-glass-clear` - Highly transparent glass effect +/// +/// If the macOS values are set, then this implies `background-blur = true` +/// on non-macOS platforms. +/// +/// Supported on macOS and on some Linux desktop environments, including: +/// +/// * KDE Plasma (Wayland and X11) +/// +/// Warning: the exact blur intensity is _ignored_ under KDE Plasma, and setting +/// this setting to either `true` or any positive blur intensity value would +/// achieve the same effect. The reason is that KWin, the window compositor +/// powering Plasma, only has one global blur setting and does not allow +/// applications to specify individual blur settings. +/// +/// To configure KWin's global blur setting, open System Settings and go to +/// "Apps & Windows" > "Window Management" > "Desktop Effects" and select the +/// "Blur" plugin. If disabled, enable it by ticking the checkbox to the left. +/// Then click on the "Configure" button and there will be two sliders that +/// allow you to set background blur and noise intensities for all apps, +/// including Ghostty. +/// +/// All other Linux desktop environments are as of now unsupported. Users may +/// need to set environment-specific settings and/or install third-party plugins +/// in order to support background blur, as there isn't a unified interface for +/// doing so. +@"background-blur": BackgroundBlur = .false, + +/// 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.7, + +/// The color to dim the unfocused split. Unfocused splits are dimmed by +/// rendering a semi-transparent rectangle over the split. This sets the color of +/// that rectangle and can be used to carefully control the dimming effect. +/// +/// This will default to the background color. +/// +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. +@"unfocused-split-fill": ?Color = null, + +/// The color of the split divider. If this is not set, a default will be chosen. +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. +/// +/// Available since: 1.1.0 +@"split-divider-color": ?Color = null, + +/// Control when Ghostty preserves a zoomed split. Under normal circumstances, +/// any operation that changes focus or layout of the split tree in a window +/// will unzoom any zoomed split. This configuration allows you to control +/// this behavior. +/// +/// This can be set to `navigation` to preserve the zoomed split state +/// when navigating to another split (e.g. via `goto_split`). This will +/// change the zoomed split to the newly focused split instead of unzooming. +/// +/// Any options can also be prefixed with `no-` to disable that option. +/// +/// Example: `split-preserve-zoom = navigation` +/// +/// Available since: 1.3.0 +@"split-preserve-zoom": SplitPreserveZoom = .{}, + +/// The foreground and background color for search matches. This only applies +/// to non-focused search matches, also known as candidate matches. +/// +/// Valid values: +/// +/// - Hex (`#RRGGBB` or `RRGGBB`) +/// - Named X11 color +/// - "cell-foreground" to match the cell foreground color +/// - "cell-background" to match the cell background color +/// +/// The default value is black text on a golden yellow background. +@"search-foreground": TerminalColor = .{ .color = .{ .r = 0, .g = 0, .b = 0 } }, +@"search-background": TerminalColor = .{ .color = .{ .r = 0xFF, .g = 0xE0, .b = 0x82 } }, + +/// The foreground and background color for the currently selected search match. +/// This is the focused match that will be jumped to when using next/previous +/// search navigation. +/// +/// Valid values: +/// +/// - Hex (`#RRGGBB` or `RRGGBB`) +/// - Named X11 color +/// - "cell-foreground" to match the cell foreground color +/// - "cell-background" to match the cell background color +/// +/// The default value is black text on a soft peach background. +@"search-selected-foreground": TerminalColor = .{ .color = .{ .r = 0, .g = 0, .b = 0 } }, +@"search-selected-background": TerminalColor = .{ .color = .{ .r = 0xF2, .g = 0xA5, .b = 0x7E } }, + +/// 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) +/// +/// This can contain additional arguments to run the command with. If additional +/// arguments are provided, the command will be executed using `/bin/sh -c` +/// to offload shell argument expansion. +/// +/// To avoid shell expansion altogether, prefix the command with `direct:`, e.g. +/// `direct:nvim foo`. This will avoid the roundtrip to `/bin/sh` but will also +/// not support any shell parsing such as arguments with spaces, filepaths with +/// `~`, globs, etc. (Available since: 1.2.0) +/// +/// You can also explicitly prefix the command with `shell:` to always wrap the +/// command in a shell. This can be used to ensure our heuristics to choose the +/// right mode are not used in case they are wrong. (Available since: 1.2.0) +/// +/// This command will be used for all new terminal surfaces, i.e. new windows, +/// tabs, etc. If you want to run a command only for the first terminal surface +/// created when Ghostty starts, use the `initial-command` configuration. +/// +/// Ghostty supports the common `-e` flag for executing a command with +/// arguments. For example, `ghostty -e fish --with --custom --args`. +/// This flag sets the `initial-command` configuration, see that for more +/// information. +command: ?Command = null, + +/// This is the same as "command", but only applies to the first terminal +/// surface created when Ghostty starts. Subsequent terminal surfaces will use +/// the `command` configuration. +/// +/// After the first terminal surface is created (or closed), there is no +/// way to run this initial command again automatically. As such, setting +/// this at runtime works but will only affect the next terminal surface +/// if it is the first one ever created. +/// +/// If you're using the `ghostty` CLI there is also a shortcut to set this +/// with arguments directly: you can use the `-e` flag. For example: `ghostty -e +/// fish --with --custom --args`. The `-e` flag automatically forces some +/// other behaviors as well: +/// +/// * Disables shell expansion since the input is expected to already +/// be shell-expanded by the upstream (e.g. the shell used to type in +/// the `ghostty -e` command). +/// +/// * `gtk-single-instance=false` - This ensures that a new instance is +/// launched and the CLI args are respected. +/// +/// * `quit-after-last-window-closed=true` - This ensures that the Ghostty +/// process will exit when the command exits. Additionally, the +/// `quit-after-last-window-closed-delay` is unset. +/// +/// * `shell-integration=detect` (if not `none`) - This prevents forcibly +/// injecting any configured shell integration into the command's +/// environment. With `-e` its highly unlikely that you're executing a +/// shell and forced shell integration is likely to cause problems +/// (e.g. by wrapping your command in a shell, setting env vars, etc.). +/// This is a safety measure to prevent unexpected behavior. If you want +/// shell integration with a `-e`-executed command, you must either +/// name your binary appropriately or source the shell integration script +/// manually. +@"initial-command": ?Command = null, + +/// Controls when command finished notifications are sent. There are +/// three options: +/// +/// * `never` - Never send notifications (the default). +/// * `unfocused` - Only send notifications if the surface that the command is +/// running in is not focused. +/// * `always` - Always send notifications. +/// +/// Command finished notifications requires that either shell integration is +/// enabled, or that your shell sends OSC 133 escape sequences to mark the start +/// and end of commands. +/// +/// On GTK, there is a context menu item that will enable command finished +/// notifications for a single command, overriding the `never` and `unfocused` +/// options. +/// +/// Available since 1.3.0. +@"notify-on-command-finish": NotifyOnCommandFinish = .never, + +/// If command finished notifications are enabled, this controls how the user is +/// notified. +/// +/// Available options: +/// +/// * `bell` - enabled by default +/// * `notify` - disabled by default +/// +/// Options can be combined by listing them as a comma separated list. Options +/// can be negated by prefixing them with `no-`. For example `no-bell,notify`. +/// +/// Available since 1.3.0. +@"notify-on-command-finish-action": NotifyOnCommandFinishAction = .{ + .bell = true, + .notify = false, +}, + +/// If command finished notifications are enabled, this controls how long a +/// command must have been running before a notification will be sent. The +/// default is five seconds. +/// +/// The duration is specified as a series of numbers followed by time units. +/// Whitespace is allowed between numbers and units. Each number and unit will +/// be added together to form the total duration. +/// +/// The allowed time units are as follows: +/// +/// * `y` - 365 SI days, or 8760 hours, or 31536000 seconds. No adjustments +/// are made for leap years or leap seconds. +/// * `d` - one SI day, or 86400 seconds. +/// * `h` - one hour, or 3600 seconds. +/// * `m` - one minute, or 60 seconds. +/// * `s` - one second. +/// * `ms` - one millisecond, or 0.001 second. +/// * `us` or `µs` - one microsecond, or 0.000001 second. +/// * `ns` - one nanosecond, or 0.000000001 second. +/// +/// Examples: +/// * `1h30m` +/// * `45s` +/// +/// Units can be repeated and will be added together. This means that +/// `1h1h` is equivalent to `2h`. This is confusing and should be avoided. +/// A future update may disallow this. +/// +/// The maximum value is `584y 49w 23h 34m 33s 709ms 551µs 615ns`. Any +/// value larger than this will be clamped to the maximum value. +/// +/// Available since 1.3.0 +@"notify-on-command-finish-after": Duration = .{ .duration = 5 * std.time.ns_per_s }, + +/// Extra environment variables to pass to commands launched in a terminal +/// surface. The format is `env=KEY=VALUE`. +/// +/// `env = foo=bar` +/// `env = bar=baz` +/// +/// Setting `env` to an empty string will reset the entire map to default +/// (empty). +/// +/// `env =` +/// +/// Setting a key to an empty string will remove that particular key and +/// corresponding value from the map. +/// +/// `env = foo=bar` +/// `env = foo=` +/// +/// will result in `foo` not being passed to the launched commands. +/// +/// Setting a key multiple times will overwrite previous entries. +/// +/// `env = foo=bar` +/// `env = foo=baz` +/// +/// will result in `foo=baz` being passed to the launched commands. +/// +/// These environment variables will override any existing environment +/// variables set by Ghostty. For example, if you set `GHOSTTY_RESOURCES_DIR` +/// then the value you set here will override the value Ghostty typically +/// automatically injects. +/// +/// These environment variables _will not_ be passed to commands run by Ghostty +/// for other purposes, like `open` or `xdg-open` used to open URLs in your +/// browser. +/// +/// Available since: 1.2.0 +env: RepeatableStringMap = .{}, + +/// Data to send as input to the command on startup. +/// +/// The configured `command` will be launched using the typical rules, +/// then the data specified as this input will be written to the pty +/// before any other input can be provided. +/// +/// The bytes are sent as-is with no additional encoding. Therefore, be +/// cautious about input that can contain control characters, because this +/// can be used to execute programs in a shell. +/// +/// The format of this value is: +/// +/// * `raw:<string>` - Send raw text as-is. This uses Zig string literal +/// syntax so you can specify control characters and other standard +/// escapes. +/// +/// * `path:<path>` - Read a filepath and send the contents. The path +/// must be to a file with finite length. e.g. don't use a device +/// such as `/dev/stdin` or `/dev/urandom` as these will block +/// terminal startup indefinitely. Files are limited to 10MB +/// in size to prevent excessive memory usage. If you have files +/// larger than this you should write a script to read the file +/// and send it to the terminal. +/// +/// If no valid prefix is found, it is assumed to be a `raw:` input. +/// This is an ergonomic choice to allow you to simply write +/// `input = "Hello, world!"` (a common case) without needing to prefix +/// every value with `raw:`. +/// +/// This can be repeated multiple times to send more data. The data +/// is concatenated directly with no separator characters in between +/// (e.g. no newline). +/// +/// If any of the input sources do not exist, then none of the input +/// will be sent. Input sources are not verified until the terminal +/// is starting, so missing paths will not show up in config validation. +/// +/// Changing this configuration at runtime will only affect new +/// terminals. +/// +/// Available since: 1.2.0 +input: RepeatableReadableIO = .{}, + +/// If true, keep the terminal open after the command exits. Normally, the +/// terminal window closes when the running command (such as a shell) exits. +/// With this true, the terminal window will stay open until any keypress is +/// received. +/// +/// This is primarily useful for scripts or debugging. +@"wait-after-command": bool = false, + +/// The number of milliseconds of runtime below which we consider a process exit +/// to be abnormal. This is used to show an error message when the process exits +/// too quickly. +/// +/// On Linux, this must be paired with a non-zero exit code. On macOS, we allow +/// any exit code because of the way shell processes are launched via the login +/// command. +@"abnormal-command-exit-runtime": u32 = 250, + +/// The size of the scrollback buffer in bytes. This also includes the active +/// screen. No matter what this is set to, enough memory will always be +/// allocated for the visible screen and anything leftover is the limit for +/// the scrollback. +/// +/// When this limit is reached, the oldest lines are removed from the +/// scrollback. +/// +/// Scrollback currently exists completely in memory. This means that the +/// larger this value, the larger potential memory usage. Scrollback is +/// allocated lazily up to this limit, so if you set this to a very large +/// value, it will not immediately consume a lot of memory. +/// +/// This size is per terminal surface, not for the entire application. +/// +/// It is not currently possible to set an unlimited scrollback buffer. +/// This is a future planned feature. +/// +/// This can be changed at runtime but will only affect new terminal surfaces. +@"scrollback-limit": usize = 10_000_000, // 10MB + +/// Control when the scrollbar is shown to scroll the scrollback buffer. +/// +/// The default value is `system`. +/// +/// Valid values: +/// +/// * `system` - Respect the system settings for when to show scrollbars. +/// For example, on macOS, this will respect the "Scrollbar behavior" +/// system setting which by default usually only shows scrollbars while +/// actively scrolling or hovering the gutter. +/// +/// * `never` - Never show a scrollbar. You can still scroll using the mouse, +/// keybind actions, etc. but you will not have a visual UI widget showing +/// a scrollbar. +scrollbar: Scrollbar = .system, + +/// Match a regular expression against the terminal text and associate clicking +/// it with an action. This can be used to match URLs, file paths, etc. Actions +/// can be opening using the system opener (e.g. `open` or `xdg-open`) or +/// executing any arbitrary binding action. +/// +/// Links that are configured earlier take precedence over links that are +/// configured later. +/// +/// A default link that matches a URL and opens it in the system opener always +/// exists. This can be disabled using `link-url`. +/// +/// TODO: This can't currently be set! +link: RepeatableLink = .{}, + +/// Enable URL matching. URLs are matched on hover with control (Linux) or +/// command (macOS) pressed and open using the default system application for +/// the linked URL. +/// +/// The URL matcher is always lowest priority of any configured links (see +/// `link`). If you want to customize URL matching, use `link` and disable this. +@"link-url": bool = true, + +/// Show link previews for a matched URL. +/// +/// When true, link previews are shown for all matched URLs. When false, link +/// previews are never shown. When set to "osc8", link previews are only shown +/// for hyperlinks created with the OSC 8 sequence (in this case, the link text +/// can differ from the link destination). +/// +/// Available since: 1.2.0 +@"link-previews": LinkPreviews = .true, + +/// Whether to start the window in a maximized state. This setting applies +/// to new windows and does not apply to tabs, splits, etc. However, this setting +/// will apply to all new windows, not just the first one. +/// +/// Available since: 1.1.0 +maximize: bool = false, + +/// Start new windows in fullscreen. This setting applies to new windows and +/// does not apply to tabs, splits, etc. However, this setting will apply to all +/// new windows, not just the first one. +/// +/// Allowable values are: +/// +/// * `false` - Don't start in fullscreen (default) +/// * `true` - Start in native fullscreen +/// * `non-native` - (macOS only) Start in non-native fullscreen, hiding the +/// menu bar. This is faster than native fullscreen since it doesn't use +/// animations. On non-macOS platforms, this behaves the same as `true`. +/// * `non-native-visible-menu` - (macOS only) Start in non-native fullscreen, +/// keeping the menu bar visible. On non-macOS platforms, behaves like `true`. +/// * `non-native-padded-notch` - (macOS only) Start in non-native fullscreen, +/// hiding the menu bar but padding for the notch on applicable devices. +/// On non-macOS platforms, behaves like `true`. +/// +/// Important: tabs DO NOT WORK with non-native fullscreen modes. Non-native +/// fullscreen removes the titlebar and macOS native tabs require the titlebar. +/// If you use tabs, use `true` (native) instead. +/// +/// On macOS, `true` (native fullscreen) does not work if `window-decoration` +/// is set to `false`, because native fullscreen on macOS requires window +/// decorations. +fullscreen: Fullscreen = .false, + +/// The title Ghostty will use for the window. This will force the title of the +/// window to be this title at all times and Ghostty will ignore any set title +/// escape sequences programs (such as Neovim) may send. +/// +/// If you want a blank title, set this to one or more spaces by quoting +/// the value. For example, `title = " "`. This effectively hides the title. +/// This is necessary because setting a blank value resets the title to the +/// default value of the running program. +/// +/// This configuration can be reloaded at runtime. If it is set, the title +/// will update for all windows. If it is unset, the next title change escape +/// sequence will be honored but previous changes will not retroactively +/// be set. This latter case may require you to restart programs such as Neovim +/// to get the new title. +title: ?[:0]const u8 = null, + +/// The setting that will change the application class value. +/// +/// This controls the class field of the `WM_CLASS` X11 property (when running +/// under X11), the Wayland application ID (when running under Wayland), and the +/// bus name that Ghostty uses to connect to DBus. +/// +/// Note that changing this value between invocations will create new, separate +/// instances, of Ghostty when running with `gtk-single-instance=true`. See that +/// option for more details. +/// +/// Changing this value may break launching Ghostty from `.desktop` files, via +/// DBus activation, or systemd user services as the system is expecting Ghostty +/// to connect to DBus using the default `class` when it is launched. +/// +/// The class name must follow the requirements defined [in the GTK +/// documentation](https://docs.gtk.org/gio/type_func.Application.id_is_valid.html). +/// +/// The default is `com.mitchellh.ghostty`. +/// +/// This only affects GTK builds. +class: ?[:0]const u8 = null, + +/// This controls the instance name field of the `WM_CLASS` X11 property when +/// running under X11. It has no effect otherwise. +/// +/// The default is `ghostty`. +/// +/// This only affects GTK builds. +@"x11-instance-name": ?[:0]const u8 = null, + +/// The directory to change to after starting the command. +/// +/// This setting is secondary to the `window-inherit-working-directory` +/// setting. If a previous Ghostty terminal exists in the same process, +/// `window-inherit-working-directory` will take precedence. Otherwise, this +/// setting will be used. Typically, this setting is used only for the first +/// window. +/// +/// The default is `inherit` except in special scenarios listed next. On macOS, +/// if Ghostty can detect it is launched from launchd (double-clicked) or +/// `open`, then it defaults to `home`. On Linux with GTK, if Ghostty can detect +/// it was launched from a desktop launcher, then it defaults to `home`. +/// +/// The value of this must be an absolute path, a path prefixed with `~/` +/// (the tilde will be expanded to the user's home directory), 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": ?WorkingDirectory = null, + +/// Key bindings. The format is `trigger=action`. Duplicate triggers will +/// overwrite previously set values. The list of actions is available in +/// the documentation or using the `ghostty +list-actions` command. +/// +/// Trigger: `+`-separated list of keys and modifiers. Example: `ctrl+a`, +/// `ctrl+shift+b`, `up`. +/// +/// If the key is a single Unicode codepoint, the trigger will match +/// any presses that produce that codepoint. These are impacted by +/// keyboard layouts. For example, `a` will match the `a` key on a +/// QWERTY keyboard, but will match the `q` key on a AZERTY keyboard +/// (assuming US physical layout). +/// +/// For Unicode codepoints, matching is done by comparing the set of +/// modifiers with the unmodified codepoint. The unmodified codepoint is +/// sometimes called an "unshifted character" in other software, but all +/// modifiers are considered, not only shift. For example, `ctrl+a` will match +/// `a` but not `ctrl+shift+a` (which is `A` on a US keyboard). +/// +/// Further, codepoint matching is case-insensitive and the unmodified +/// codepoint is always case folded for comparison. As a result, +/// `ctrl+A` configured will match when `ctrl+a` is pressed. Note that +/// this means some key combinations are impossible depending on keyboard +/// layout. For example, `ctrl+_` is impossible on a US keyboard because +/// `_` is `shift+-` and `ctrl+shift+-` is not equal to `ctrl+_` (because +/// the modifiers don't match!). More details on impossible key combinations +/// can be found at this excellent source written by Qt developers: +/// https://doc.qt.io/qt-6/qkeysequence.html#keyboard-layout-issues +/// +/// Physical key codes can be specified by using any of the key codes +/// as specified by the [W3C specification](https://www.w3.org/TR/uievents-code/). +/// For example, `KeyA` will match the physical `a` key on a US standard +/// keyboard regardless of the keyboard layout. These are case-sensitive. +/// +/// For aesthetic reasons, the w3c codes also support snake case. For +/// example, `key_a` is equivalent to `KeyA`. The only exceptions are +/// function keys, e.g. `F1` is `f1` (no underscore). This is a consequence +/// of our internal code using snake case but is purposely supported +/// and tested so it is safe to use. It allows an all-lowercase binding +/// which I find more aesthetically pleasing. +/// +/// Function keys such as `insert`, `up`, `f5`, etc. are also specified +/// using the keys as specified by the previously linked W3C specification. +/// +/// Physical keys always match with a higher priority than Unicode codepoints, +/// so if you specify both `a` and `KeyA`, the physical key will always be used +/// regardless of what order they are configured. +/// +/// The special key `catch_all` can be used to match any key that is not +/// otherwise bound. This can be combined with modifiers, for example +/// `ctrl+catch_all` will match any key pressed with `ctrl` that is not +/// otherwise bound. When looking up a binding, Ghostty first tries to match +/// `catch_all` with modifiers. If no match is found and the event has +/// modifiers, it falls back to `catch_all` without modifiers. +/// +/// Valid modifiers are `shift`, `ctrl` (alias: `control`), `alt` (alias: `opt`, +/// `option`), and `super` (alias: `cmd`, `command`). You may use the modifier +/// or the alias. When debugging keybinds, the non-aliased modifier will always +/// be used in output. +/// +/// Note: The fn or "globe" key on keyboards are not supported as a +/// modifier. This is a limitation of the operating systems and GUI toolkits +/// that Ghostty uses. +/// +/// Some additional notes for triggers: +/// +/// * modifiers cannot repeat, `ctrl+ctrl+a` is invalid. +/// +/// * modifiers and keys can be in any order, `shift+a+ctrl` is *weird*, +/// but valid. +/// +/// * only a single key input is allowed, `ctrl+a+b` is invalid. +/// +/// You may also specify multiple triggers separated by `>` to require a +/// sequence of triggers to activate the action. For example, +/// `ctrl+a>n=new_window` will only trigger the `new_window` action if the +/// user presses `ctrl+a` followed separately by `n`. In other software, this +/// is sometimes called a leader key, a key chord, a key table, etc. There +/// is no hardcoded limit on the number of parts in a sequence. +/// +/// Warning: If you define a sequence as a CLI argument to `ghostty`, +/// you probably have to quote the keybind since `>` is a special character +/// in most shells. Example: ghostty --keybind='ctrl+a>n=new_window' +/// +/// A trigger sequence has some special handling: +/// +/// * Ghostty will wait an indefinite amount of time for the next key in +/// the sequence. There is no way to specify a timeout. The only way to +/// force the output of a prefix key is to assign another keybind to +/// specifically output that key (e.g. `ctrl+a>ctrl+a=text:foo`) or +/// press an unbound key which will send both keys to the program. +/// +/// * If an unbound key is pressed during a sequence and a `catch_all` +/// binding exists that would `ignore` the input, the entire sequence +/// is dropped and nothing happens. Otherwise, the entire sequence is +/// encoded and sent to the running program as if no keybind existed. +/// +/// * If a prefix in a sequence is previously bound, the sequence will +/// override the previous binding. For example, if `ctrl+a` is bound to +/// `new_window` and `ctrl+a>n` is bound to `new_tab`, pressing `ctrl+a` +/// will do nothing. +/// +/// * Adding to the above, if a previously bound sequence prefix is +/// used in a new, non-sequence binding, the entire previously bound +/// sequence will be unbound. For example, if you bind `ctrl+a>n` and +/// `ctrl+a>t`, and then bind `ctrl+a` directly, both `ctrl+a>n` and +/// `ctrl+a>t` will become unbound. +/// +/// * Trigger sequences are not allowed for `global:` or `all:`-prefixed +/// triggers. This is a limitation we could remove in the future. +/// +/// 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. Unbind will remove any matching trigger, +/// including `physical:`-prefixed triggers without specifying the +/// prefix. +/// +/// * `csi:text` - Send a CSI sequence. e.g. `csi:A` sends "cursor up". +/// +/// * `esc:text` - Send an escape sequence. e.g. `esc:d` deletes to the +/// end of the word to the right. +/// +/// * `text:text` - Send a string. Uses Zig string literal syntax. +/// e.g. `text:\x15` sends Ctrl-U. +/// +/// * All other actions can be found in the documentation or by using the +/// `ghostty +list-actions` command. +/// +/// 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"` +/// +/// There are some additional special values that can be specified for +/// keybind: +/// +/// * `keybind=clear` will clear all set keybindings. Warning: this +/// removes ALL keybindings up to this point, including the default +/// keybindings. +/// +/// The keybind trigger can be prefixed with some special values to change +/// the behavior of the keybind. These are: +/// +/// * `all:` +/// +/// Make the keybind apply to all terminal surfaces. By default, +/// keybinds only apply to the focused terminal surface. If this is true, +/// then the keybind will be sent to all terminal surfaces. This only +/// applies to actions that are surface-specific. For actions that +/// are already global (e.g. `quit`), this prefix has no effect. +/// +/// Available since: 1.0.0 +/// +/// * `global:` +/// +/// Make the keybind global. By default, keybinds only work within Ghostty +/// and under the right conditions (application focused, sometimes terminal +/// focused, etc.). If you want a keybind to work globally across your system +/// (e.g. even when Ghostty is not focused), specify this prefix. +/// This prefix implies `all:`. +/// +/// Note: this does not work in all environments; see the additional notes +/// below for more information. +/// +/// Available since: 1.0.0 on macOS, 1.2.0 on GTK +/// +/// * `unconsumed:` +/// +/// Do not consume the input. By default, a keybind will consume the input, +/// meaning that the associated encoding (if any) will not be sent to the +/// running program in the terminal. If you wish to send the encoded value +/// to the program, specify the `unconsumed:` prefix before the entire +/// keybind. For example: `unconsumed:ctrl+a=reload_config`. `global:` and +/// `all:`-prefixed keybinds will always consume the input regardless of +/// this setting. Since they are not associated with a specific terminal +/// surface, they're never encoded. +/// +/// Available since: 1.0.0 +/// +/// * `performable:` +/// +/// Only consume the input if the action is able to be performed. +/// For example, the `copy_to_clipboard` action will only consume the input +/// if there is a selection to copy. If there is no selection, Ghostty +/// behaves as if the keybind was not set. This has no effect with `global:` +/// or `all:`-prefixed keybinds. For key sequences, this will reset the +/// sequence if the action is not performable (acting identically to not +/// having a keybind set at all). +/// +/// Performable keybinds will not appear as menu shortcuts in the +/// application menu. This is because the menu shortcuts force the +/// action to be performed regardless of the state of the terminal. +/// Performable keybinds will still work, they just won't appear as +/// a shortcut label in the menu. +/// +/// Available since: 1.1.0 +/// +/// Keybind triggers are not unique per prefix combination. For example, +/// `ctrl+a` and `global:ctrl+a` are not two separate keybinds. The keybind +/// set later will overwrite the keybind set earlier. In this case, the +/// `global:` keybind will be used. +/// +/// Multiple prefixes can be specified. For example, +/// `global:unconsumed:ctrl+a=reload_config` will make the keybind global +/// and not consume the input to reload the config. +/// +/// Note: `global:` is only supported on macOS and certain Linux platforms. +/// +/// On macOS, this feature requires accessibility permissions to be granted +/// to Ghostty. When a `global:` keybind is specified and Ghostty is launched +/// or reloaded, Ghostty will attempt to request these permissions. +/// If the permissions are not granted, the keybind will not work. On macOS, +/// you can find these permissions in System Preferences -> Privacy & Security +/// -> Accessibility. +/// +/// On Linux, you need a desktop environment that implements the +/// [Global Shortcuts](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html) +/// protocol as a part of its XDG desktop protocol implementation. +/// Desktop environments that are known to support (or not support) +/// global shortcuts include: +/// +/// - Users using KDE Plasma (since [5.27](https://kde.org/announcements/plasma/5/5.27.0/#wayland)) +/// and GNOME (since [48](https://release.gnome.org/48/#and-thats-not-all)) should be able +/// to use global shortcuts with little to no configuration. +/// +/// - Some manual configuration is required on Hyprland. Consult the steps +/// outlined on the [Hyprland Wiki](https://wiki.hyprland.org/Configuring/Binds/#dbus-global-shortcuts) +/// to set up global shortcuts correctly. +/// (Important: [`xdg-desktop-portal-hyprland`](https://wiki.hyprland.org/Hypr-Ecosystem/xdg-desktop-portal-hyprland/) +/// must also be installed!) +/// +/// - Notably, global shortcuts have not been implemented on wlroots-based +/// compositors like Sway (see [upstream issue](https://github.com/emersion/xdg-desktop-portal-wlr/issues/240)). +/// +/// ## Chained Actions +/// +/// A keybind can have multiple actions by using the `chain` keyword for +/// subsequent actions. When a keybind is activated, all chained actions are +/// executed in order. The syntax is: +/// +/// ```ini +/// keybind = ctrl+a=new_window +/// keybind = chain=goto_split:left +/// ``` +/// +/// This binds `ctrl+a` to first open a new window, then move focus to the +/// left split. Each `chain` entry appends an action to the most recently +/// defined keybind. You can chain as many actions as you want: +/// +/// ```ini +/// keybind = ctrl+a=new_window +/// keybind = chain=goto_split:left +/// keybind = chain=toggle_fullscreen +/// ``` +/// +/// Chained actions cannot have prefixes like `global:` or `unconsumed:`. +/// The flags from the original keybind apply to the entire chain. +/// +/// Chained actions work with key sequences as well. For example: +/// +/// ```ini +/// keybind = ctrl+a>n=new_window +/// keybind = chain=goto_split:left +/// ```` +/// +/// Chains with key sequences apply to the most recent binding in the +/// sequence. +/// +/// Chained keybinds are available since Ghostty 1.3.0. +/// +/// ## Key Tables +/// +/// You may also create a named set of keybindings known as a "key table." +/// A key table must be explicitly activated for the bindings to become +/// available. This can be used to implement features such as a +/// "copy mode", "vim mode", etc. Generically, this can implement modal +/// keyboard input. +/// +/// Key tables are defined using the syntax `<table>/<binding>`. The +/// `<binding>` value is everything documented above for keybinds. The +/// `<table>` value is the name of the key table. Table names can contain +/// anything except `/`, `=`, `+`, and `>`. The characters `+` and `>` are +/// reserved for keybind syntax (modifier combinations and key sequences). +/// For example `foo/ctrl+a=new_window` defines a binding within a table +/// named `foo`. +/// +/// Tables are activated and deactivated using the binding actions +/// `activate_key_table:<name>` and `deactivate_key_table`. Other table +/// related binding actions also exist; see the documentation for a full list. +/// These are the primary way to interact with key tables. +/// +/// Binding lookup proceeds from the innermost table outward, so keybinds in +/// the default table remain available unless explicitly unbound in an inner +/// table. +/// +/// A key table has some special syntax and handling: +/// +/// * `<name>/` (with no binding) defines and clears a table, resetting all +/// of its keybinds and settings. +/// +/// * You cannot activate a table that is already the innermost table; such +/// attempts are ignored. However, the same table can appear multiple times +/// in the stack as long as it is not innermost (e.g., `A -> B -> A -> B` +/// is valid, but `A -> B -> B` is not). +/// +/// * A table can be activated in one-shot mode using +/// `activate_key_table_once:<name>`. A one-shot table is automatically +/// deactivated when any non-catch-all binding is invoked. +/// +/// * Key sequences work within tables: `foo/ctrl+a>ctrl+b=new_window`. +/// If an invalid key is pressed, the sequence ends but the table remains +/// active. +/// +/// * Chain actions work within tables, the `chain` keyword applies to +/// the most recently defined binding in the table. e.g. if you set +/// `table/ctrl+a=new_window` you can chain by using `chain=text:hello`. +/// Important: chain itself doesn't get prefixed with the table name, +/// since it applies to the most recent binding in any table. +/// +/// * Prefixes like `global:` work within tables: +/// `foo/global:ctrl+a=new_window`. +/// +/// Key tables are available since Ghostty 1.3.0. +keybind: Keybinds = .{}, + +/// Remap modifier keys within Ghostty. This allows you to swap or reassign +/// modifier keys at the application level without affecting system-wide +/// settings. +/// +/// The format is `from=to` where both `from` and `to` are modifier key names. +/// You can use generic names like `ctrl`, `alt`, `shift`, `super` (macOS: +/// `cmd`/`command`) or sided names like `left_ctrl`, `right_alt`, etc. +/// +/// This will NOT change keyboard layout or key encodings outside of Ghostty. +/// For example, on macOS, `option+a` may still produce `å` even if `option` is +/// remapped to `ctrl`. Desktop environments usually handle key layout long +/// before Ghostty receives the key events. +/// +/// Example: +/// +/// key-remap = ctrl=super +/// key-remap = left_control=right_alt +/// +/// Important notes: +/// +/// * This is a one-way remap. If you remap `ctrl=super`, then the physical +/// Ctrl key acts as Super, but the Super key remains Super. +/// +/// * Remaps are not transitive. If you remap `ctrl=super` and `alt=ctrl`, +/// pressing Alt will produce Ctrl, NOT Super. +/// +/// * This affects both keybind matching and terminal input encoding. +/// This does NOT impact keyboard layout or how keys are interpreted +/// prior to Ghostty receiving them. For example, `option+a` on macOS +/// may still produce `å` even if `option` is remapped to `ctrl`. +/// +/// * Generic modifiers (e.g. `ctrl`) match both left and right physical keys. +/// Use sided names (e.g. `left_ctrl`) to remap only one side. +/// +/// There are other edge case scenarios that may not behave as expected +/// but are working as intended the way this feature is designed: +/// +/// * On macOS, bindings in the main menu will trigger before any remapping +/// is done. This is because macOS itself handles menu activation and +/// this happens before Ghostty receives the key event. To workaround +/// this, you should unbind the menu items and rebind them using your +/// desired modifier. +/// +/// This configuration can be repeated to specify multiple remaps. +@"key-remap": KeyRemapSet = .empty, + +/// Horizontal window padding. This applies padding between the terminal cells +/// and the left and right window borders. 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. +/// +/// Changing this configuration at runtime will only affect new terminals, i.e. +/// new windows, tabs, etc. +/// +/// To set a different left and right padding, specify two numerical values +/// separated by a comma. For example, `window-padding-x = 2,4` will set the +/// left padding to 2 and the right padding to 4. If you want to set both +/// paddings to the same value, you can use a single value. For example, +/// `window-padding-x = 2` will set both paddings to 2. +@"window-padding-x": WindowPadding = .{ .top_left = 2, .bottom_right = 2 }, + +/// Vertical window padding. This applies padding between the terminal cells and +/// the top and bottom window borders. 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. +/// +/// Changing this configuration at runtime will only affect new terminals, +/// i.e. new windows, tabs, etc. +/// +/// To set a different top and bottom padding, specify two numerical values +/// separated by a comma. For example, `window-padding-y = 2,4` will set the +/// top padding to 2 and the bottom padding to 4. If you want to set both +/// paddings to the same value, you can use a single value. For example, +/// `window-padding-y = 2` will set both paddings to 2. +@"window-padding-y": WindowPadding = .{ .top_left = 2, .bottom_right = 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. +/// +/// Valid values are: +/// +/// * `false` - No balancing is applied. +/// * `true` - Balance the padding, but cap the top padding to avoid +/// excessive space above the first row. Any excess is shifted to the +/// bottom. +/// * `equal` - Balance the padding equally on all sides without any +/// top-padding cap. (Available since: 1.4.0) +@"window-padding-balance": WindowPaddingBalance = .false, + +/// The color of the padding area of the window. Valid values are: +/// +/// * `background` - The background color specified in `background`. +/// * `extend` - Extend the background color of the nearest grid cell. +/// * `extend-always` - Same as "extend" but always extends without applying +/// any of the heuristics that disable extending noted below. +/// +/// The "extend" value will be disabled in certain scenarios. On primary +/// screen applications (e.g. not something like Neovim), the color will not +/// be extended vertically if any of the following are true: +/// +/// * The nearest row has any cells that have the default background color. +/// The thinking is that in this case, the default background color looks +/// fine as a padding color. +/// * The nearest row is a prompt row (requires shell integration). The +/// thinking here is that prompts often contain powerline glyphs that +/// do not look good extended. +/// * The nearest row contains a perfect fit powerline character. These +/// don't look good extended. +@"window-padding-color": WindowPaddingColor = .background, + +/// Synchronize rendering with the screen refresh rate. If true, this will +/// minimize tearing and align redraws with the screen but may cause input +/// latency. If false, this will maximize redraw frequency but may cause tearing, +/// and under heavy load may use more CPU and power. +/// +/// This defaults to true because out-of-sync rendering on macOS can +/// cause kernel panics (macOS 14.4+) and performance issues for external +/// displays over some hardware such as DisplayLink. If you want to minimize +/// input latency, set this to false with the known aforementioned risks. +/// +/// Changing this value at runtime will only affect new terminals. +/// +/// This setting is only supported currently on macOS. +@"window-vsync": bool = true, + +/// If true, new windows will inherit the working directory of the +/// previously focused window. If no window was previously focused, the default +/// working directory will be used (the `working-directory` option). +@"window-inherit-working-directory": bool = true, + +/// If true, new tabs will inherit the working directory of the +/// previously focused tab. If no tab was previously focused, the default +/// working directory will be used (the `working-directory` option). +@"tab-inherit-working-directory": bool = true, + +/// If true, new split panes will inherit the working directory of the +/// previously focused split. If no split was previously focused, the default +/// working directory will be used (the `working-directory` option). +@"split-inherit-working-directory": bool = true, + +/// 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, + +/// Configure a preference for window decorations. This setting specifies +/// a _preference_; the actual OS, desktop environment, window manager, etc. +/// may override this preference. Ghostty will do its best to respect this +/// preference but it may not always be possible. +/// +/// Valid values: +/// +/// * `none` +/// +/// All window decorations will be disabled. Titlebar, borders, etc. will +/// not be shown. On macOS, this will also disable tabs (enforced by the +/// system). +/// +/// * `auto` +/// +/// Automatically decide to use either client-side or server-side +/// decorations based on the detected preferences of the current OS and +/// desktop environment. This option usually makes Ghostty look the most +/// "native" for your desktop. +/// +/// * `client` +/// +/// Prefer client-side decorations. +/// +/// Available since: 1.1.0 +/// +/// * `server` +/// +/// Prefer server-side decorations. This is only relevant on Linux with GTK, +/// either on X11, or Wayland on a compositor that supports the +/// `org_kde_kwin_server_decoration` protocol (e.g. KDE Plasma, but almost +/// any non-GNOME desktop supports this protocol). +/// +/// If `server` is set but the environment doesn't support server-side +/// decorations, client-side decorations will be used instead. +/// +/// Available since: 1.1.0 +/// +/// The default value is `auto`. +/// +/// For the sake of backwards compatibility and convenience, this setting also +/// accepts boolean true and false values. If set to `true`, this is equivalent +/// to `auto`. If set to `false`, this is equivalent to `none`. +/// This is convenient for users who live primarily on systems that don't +/// differentiate between client and server-side decorations (e.g. macOS and +/// Windows). +/// +/// The "toggle_window_decorations" keybind action can be used to create +/// a keybinding to toggle this setting at runtime. +/// +/// macOS: To hide the titlebar without removing the native window borders +/// or rounded corners, use `macos-titlebar-style = hidden` instead. +@"window-decoration": WindowDecoration = .auto, + +/// The font that will be used for the application's window and tab titles. +/// +/// If this setting is left unset, the system default font will be used. +/// +/// Note: any font available on the system may be used, this font is not +/// required to be a fixed-width font. +/// +/// Available since: 1.0.0 on macOS, 1.1.0 on GTK +@"window-title-font-family": ?[:0]const u8 = null, + +/// The text that will be displayed in the subtitle of the window. Valid values: +/// +/// * `false` - Disable the subtitle. +/// * `working-directory` - Set the subtitle to the working directory of the +/// surface. +/// +/// This feature is only supported on GTK. +/// +/// Available since: 1.1.0 +@"window-subtitle": WindowSubtitle = .false, + +/// The theme to use for the windows. Valid values: +/// +/// * `auto` - Determine the theme based on the configured terminal +/// background color. This has no effect if the "theme" configuration +/// has separate light and dark themes. In that case, the behavior +/// of "auto" is equivalent to "system". +/// * `system` - Use the system theme. +/// * `light` - Use the light theme regardless of system theme. +/// * `dark` - Use the dark theme regardless of system theme. +/// * `ghostty` - Use the background and foreground colors specified in the +/// Ghostty configuration. This is only supported on Linux builds. +/// +/// On macOS, if `macos-titlebar-style` is `tabs` or `transparent`, the window theme will be +/// automatically set based on the luminosity of the terminal background color. +/// This only applies to terminal windows. This setting will still apply to +/// non-terminal windows within Ghostty. +/// +/// This is currently only supported on macOS and Linux. +@"window-theme": WindowTheme = .auto, + +/// The color space to use when interpreting terminal colors. "Terminal colors" +/// refers to colors specified in your configuration and colors produced by +/// direct-color SGR sequences. +/// +/// Valid values: +/// +/// * `srgb` - Interpret colors in the sRGB color space. This is the default. +/// * `display-p3` - Interpret colors in the Display P3 color space. +/// +/// This setting is currently only supported on macOS. +@"window-colorspace": WindowColorspace = .srgb, + +/// The initial window size. This size is in terminal grid cells by default. +/// Both values must be set to take effect. If only one value is set, it is +/// ignored. +/// +/// We don't currently support specifying a size in pixels but a future change +/// can enable that. If this isn't specified, the app runtime will determine +/// some default size. +/// +/// Note that the window manager may put limits on the size or override the +/// size. For example, a tiling window manager may force the window to be a +/// certain size to fit within the grid. There is nothing Ghostty will do about +/// this, but it will make an effort. +/// +/// Sizes larger than the screen size will be clamped to the screen size. +/// This can be used to create a maximized-by-default window size. +/// +/// This will not affect new tabs, splits, or other nested terminal elements. +/// This only affects the initial window size of any new window. Changing this +/// value will not affect the size of the window after it has been created. This +/// is only used for the initial size. +/// +/// BUG: On Linux with GTK, the calculated window size will not properly take +/// into account window decorations. As a result, the grid dimensions will not +/// exactly match this configuration. If window decorations are disabled (see +/// `window-decoration`), then this will work as expected. +/// +/// Windows smaller than 10 wide by 4 high are not allowed. +@"window-height": u32 = 0, +@"window-width": u32 = 0, + +/// The starting window position. This position is in pixels and is relative +/// to the top-left corner of the primary monitor. Both values must be set to take +/// effect. If only one value is set, it is ignored. +/// +/// Note that the window manager may put limits on the position or override +/// the position. For example, a tiling window manager may force the window +/// to be a certain position to fit within the grid. There is nothing Ghostty +/// will do about this, but it will make an effort. +/// +/// Also note that negative values are also up to the operating system and +/// window manager. Some window managers may not allow windows to be placed +/// off-screen. +/// +/// Invalid positions are runtime-specific, but generally the positions are +/// clamped to the nearest valid position. +/// +/// On macOS, the window position is relative to the top-left corner of +/// the visible screen area. This means that if the menu bar is visible, the +/// window will be placed below the menu bar. +/// +/// Note: this is only supported on macOS. The GTK runtime does not support +/// setting the window position, as windows are only allowed position +/// themselves in X11 and not Wayland. +@"window-position-x": ?i16 = null, +@"window-position-y": ?i16 = null, + +/// Whether to enable saving and restoring window state. Window state includes +/// their position, size, tabs, splits, etc. Some window state requires shell +/// integration, such as preserving working directories. See `shell-integration` +/// for more information. +/// +/// There are three valid values for this configuration: +/// +/// * `default` will use the default system behavior. On macOS, this +/// will only save state if the application is forcibly terminated +/// or if it is configured systemwide via Settings.app. +/// +/// * `never` will never save window state. +/// +/// * `always` will always save window state whenever Ghostty is exited. +/// +/// If you change this value to `never` while Ghostty is not running, the next +/// Ghostty launch will NOT restore the window state. +/// +/// If you change this value to `default` while Ghostty is not running and the +/// previous exit saved state, the next Ghostty launch will still restore the +/// window state. This is because Ghostty cannot know if the previous exit was +/// due to a forced save or not (macOS doesn't provide this information). +/// +/// If you change this value so that window state is saved while Ghostty is not +/// running, the previous window state will not be restored because Ghostty only +/// saves state on exit if this is enabled. +/// +/// The default value is `default`. +/// +/// This is currently only supported on macOS. This has no effect on Linux. +@"window-save-state": WindowSaveState = .default, + +/// Resize the window in discrete increments of the focused surface's cell size. +/// If this is disabled, surfaces are resized in pixel increments. Currently +/// only supported on macOS. +@"window-step-resize": bool = false, + +/// The position where new tabs are created. Valid values: +/// +/// * `current` - Insert the new tab after the currently focused tab, +/// or at the end if there are no focused tabs. +/// +/// * `end` - Insert the new tab at the end of the tab list. +@"window-new-tab-position": WindowNewTabPosition = .current, + +/// Whether to show the tab bar. +/// +/// Valid values: +/// +/// - `always` +/// +/// Always display the tab bar, even when there's only one tab. +/// +/// Available since: 1.2.0 +/// +/// - `auto` *(default)* +/// +/// Automatically show and hide the tab bar. The tab bar is only +/// shown when there are two or more tabs present. +/// +/// - `never` +/// +/// Never show the tab bar. Tabs are only accessible via the tab +/// overview or by keybind actions. +/// +/// Currently only supported on Linux (GTK). +@"window-show-tab-bar": WindowShowTabBar = .auto, + +/// Background color for the window titlebar. This only takes effect if +/// window-theme is set to ghostty. Currently only supported in the GTK app +/// runtime. +/// +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. +@"window-titlebar-background": ?Color = null, + +/// Foreground color for the window titlebar. This only takes effect if +/// window-theme is set to ghostty. Currently only supported in the GTK app +/// runtime. +/// +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. +@"window-titlebar-foreground": ?Color = null, + +/// This controls when resize overlays are shown. Resize overlays are a +/// transient popup that shows the size of the terminal while the surfaces are +/// being resized. The possible options are: +/// +/// * `always` - Always show resize overlays. +/// * `never` - Never show resize overlays. +/// * `after-first` - The resize overlay will not appear when the surface +/// is first created, but will show up if the surface is +/// subsequently resized. +/// +/// The default is `after-first`. +@"resize-overlay": ResizeOverlay = .@"after-first", + +/// If resize overlays are enabled, this controls the position of the overlay. +/// The possible options are: +/// +/// * `center` +/// * `top-left` +/// * `top-center` +/// * `top-right` +/// * `bottom-left` +/// * `bottom-center` +/// * `bottom-right` +/// +/// The default is `center`. +@"resize-overlay-position": ResizeOverlayPosition = .center, + +/// If resize overlays are enabled, this controls how long the overlay is +/// visible on the screen before it is hidden. The default is ¾ of a second or +/// 750 ms. +/// +/// The duration is specified as a series of numbers followed by time units. +/// Whitespace is allowed between numbers and units. Each number and unit will +/// be added together to form the total duration. +/// +/// The allowed time units are as follows: +/// +/// * `y` - 365 SI days, or 8760 hours, or 31536000 seconds. No adjustments +/// are made for leap years or leap seconds. +/// * `d` - one SI day, or 86400 seconds. +/// * `h` - one hour, or 3600 seconds. +/// * `m` - one minute, or 60 seconds. +/// * `s` - one second. +/// * `ms` - one millisecond, or 0.001 second. +/// * `us` or `µs` - one microsecond, or 0.000001 second. +/// * `ns` - one nanosecond, or 0.000000001 second. +/// +/// Examples: +/// * `1h30m` +/// * `45s` +/// +/// Units can be repeated and will be added together. This means that +/// `1h1h` is equivalent to `2h`. This is confusing and should be avoided. +/// A future update may disallow this. +/// +/// The maximum value is `584y 49w 23h 34m 33s 709ms 551µs 615ns`. Any +/// value larger than this will be clamped to the maximum value. +/// +/// Available since 1.0.0 +@"resize-overlay-duration": Duration = .{ .duration = 750 * std.time.ns_per_ms }, + +/// If true, when there are multiple split panes, the mouse selects the pane +/// that is focused. This only applies to the currently focused window; e.g. +/// mousing over a split in an unfocused window will not focus that split +/// and bring the window to front. +/// +/// Default is false. +@"focus-follows-mouse": bool = false, + +/// Whether to allow programs running in the terminal to read/write to the +/// system clipboard (OSC 52, for googling). The default is to allow clipboard +/// reading after prompting the user and allow writing unconditionally. +/// +/// Valid values are: +/// +/// * `ask` +/// * `allow` +/// * `deny` +/// +@"clipboard-read": ClipboardAccess = .ask, +@"clipboard-write": ClipboardAccess = .allow, + +/// Trims trailing whitespace on data that is copied to the clipboard. This does +/// not affect data sent to the clipboard via `clipboard-write`. This only +/// applies to trailing whitespace on lines that have other characters. +/// Completely blank lines always have their whitespace trimmed. +@"clipboard-trim-trailing-spaces": bool = true, + +/// Require confirmation before pasting text that appears unsafe. This helps +/// prevent a "copy/paste attack" where a user may accidentally execute unsafe +/// commands by pasting text with newlines. +@"clipboard-paste-protection": bool = true, + +/// If true, bracketed pastes will be considered safe. By default, bracketed +/// pastes are considered safe. "Bracketed" pastes are pastes while the running +/// program has bracketed paste mode enabled (a setting set by the running +/// program, not the terminal emulator). +@"clipboard-paste-bracketed-safe": bool = true, + +/// Enables or disabled title reporting (CSI 21 t). This escape sequence +/// allows the running program to query the terminal title. This is a common +/// security issue and is disabled by default. +/// +/// Warning: This can expose sensitive information at best and enable +/// arbitrary code execution at worst (with a maliciously crafted title +/// and a minor amount of user interaction). +/// +/// Available since: 1.0.1 +@"title-report": bool = false, + +/// The total amount of bytes that can be used for image data (e.g. the Kitty +/// image protocol) per terminal screen. The maximum value is 4,294,967,295 +/// (4GiB). 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 prefer to copy to the selection clipboard, otherwise it will copy to +/// the system clipboard. +/// +/// The value `clipboard` will always copy text to the selection clipboard +/// as well as the system clipboard. +/// +/// Middle-click paste will always use the selection clipboard. Middle-click +/// paste is always enabled even if this is `false`. +/// +/// The default value is true on Linux and macOS. +@"copy-on-select": CopyOnSelect = switch (builtin.os.tag) { + .linux => .true, + .macos => .true, + else => .false, +}, + +/// The action to take when the user right-clicks on the terminal surface. +/// +/// Valid values: +/// * `context-menu` - Show the context menu. +/// * `paste` - Paste the contents of the clipboard. +/// * `copy` - Copy the selected text to the clipboard. +/// * `copy-or-paste` - If there is a selection, copy the selected text to +/// the clipboard; otherwise, paste the contents of the clipboard. +/// * `ignore` - Do nothing, ignore the right-click. +/// +/// The default value is `context-menu`. +@"right-click-action": RightClickAction = .@"context-menu", + +/// 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. This configuration can be repeated +/// to read multiple configuration files. Configuration files themselves can +/// load more configuration files. Paths are relative to the file containing the +/// `config-file` directive. For command-line arguments, paths are relative to +/// the current working directory. +/// +/// Prepend a ? character to the file path to suppress errors if the file does +/// not exist. If you want to include a file that begins with a literal ? +/// character, surround the file path in double quotes ("). +/// +/// Cycles are not allowed. If a cycle is detected, an error will be logged and +/// the configuration file will be ignored. +/// +/// Configuration files are loaded after the configuration they're defined +/// within in the order they're defined. **THIS IS A VERY SUBTLE BUT IMPORTANT +/// POINT.** To put it another way: configuration files do not take effect +/// until after the entire configuration is loaded. For example, in the +/// configuration below: +/// +/// ``` +/// config-file = "foo" +/// a = 1 +/// ``` +/// +/// If "foo" contains `a = 2`, the final value of `a` will be 2, because +/// `foo` is loaded after the configuration file that configures the +/// nested `config-file` value. +@"config-file": RepeatablePath = .{}, + +/// When this is true, the default configuration file paths will be loaded. +/// The default configuration file paths are currently only the XDG +/// config path ($XDG_CONFIG_HOME/ghostty/config.ghostty). +/// +/// If this is false, the default configuration paths will not be loaded. +/// This is targeted directly at using Ghostty from the CLI in a way +/// that minimizes external effects. +/// +/// This is a CLI-only configuration. Setting this in a configuration file +/// will have no effect. It is not an error, but it will not do anything. +/// This configuration can only be set via CLI arguments. +@"config-default-files": bool = true, + +/// Confirms that a surface should be closed before closing it. +/// +/// This defaults to `true`. If set to `false`, surfaces will close without +/// any confirmation. This can also be set to `always`, which will always +/// confirm closing a surface, even if shell integration says a process isn't +/// running. +@"confirm-close-surface": ConfirmCloseSurface = .true, + +/// Whether or not to quit after the last surface is closed. +/// +/// This defaults to `false` on macOS since that is standard behavior for +/// a macOS application. On Linux, this defaults to `true` since that is +/// generally expected behavior. +/// +/// On Linux, if this is `true`, Ghostty can delay quitting fully until a +/// configurable amount of time has passed after the last window is closed. +/// See the documentation of `quit-after-last-window-closed-delay`. +@"quit-after-last-window-closed": bool = builtin.os.tag == .linux, + +/// Controls how long Ghostty will stay running after the last open surface has +/// been closed. This only has an effect if `quit-after-last-window-closed` is +/// also set to `true`. +/// +/// The minimum value for this configuration is `1s`. Any values lower than +/// this will be clamped to `1s`. +/// +/// The duration is specified as a series of numbers followed by time units. +/// Whitespace is allowed between numbers and units. Each number and unit will +/// be added together to form the total duration. +/// +/// The allowed time units are as follows: +/// +/// * `y` - 365 SI days, or 8760 hours, or 31536000 seconds. No adjustments +/// are made for leap years or leap seconds. +/// * `d` - one SI day, or 86400 seconds. +/// * `h` - one hour, or 3600 seconds. +/// * `m` - one minute, or 60 seconds. +/// * `s` - one second. +/// * `ms` - one millisecond, or 0.001 second. +/// * `us` or `µs` - one microsecond, or 0.000001 second. +/// * `ns` - one nanosecond, or 0.000000001 second. +/// +/// Examples: +/// * `1h30m` +/// * `45s` +/// +/// Units can be repeated and will be added together. This means that +/// `1h1h` is equivalent to `2h`. This is confusing and should be avoided. +/// A future update may disallow this. +/// +/// The maximum value is `584y 49w 23h 34m 33s 709ms 551µs 615ns`. Any +/// value larger than this will be clamped to the maximum value. +/// +/// By default `quit-after-last-window-closed-delay` is unset and +/// Ghostty will quit immediately after the last window is closed if +/// `quit-after-last-window-closed` is `true`. +/// +/// Only implemented on Linux. +@"quit-after-last-window-closed-delay": ?Duration = null, + +/// This controls whether an initial window is created when Ghostty +/// is run. Note that if `quit-after-last-window-closed` is `true` and +/// `quit-after-last-window-closed-delay` is set, setting `initial-window` to +/// `false` will mean that Ghostty will quit after the configured delay if no +/// window is ever created. Only implemented on Linux and macOS. +@"initial-window": bool = true, + +/// The duration that undo operations remain available. After this +/// time, the operation will be removed from the undo stack and +/// cannot be undone. +/// +/// The default value is 5 seconds. +/// +/// This timeout applies per operation, meaning that if you perform +/// multiple operations, each operation will have its own timeout. +/// New operations do not reset the timeout of previous operations. +/// +/// A timeout of zero will effectively disable undo operations. It is +/// not possible to set an infinite timeout, but you can set a very +/// large timeout to effectively disable the timeout (on the order of years). +/// This is highly discouraged, as it will cause the undo stack to grow +/// indefinitely, memory usage to grow unbounded, and terminal sessions +/// to never actually quit. +/// +/// The duration is specified as a series of numbers followed by time units. +/// Whitespace is allowed between numbers and units. Each number and unit will +/// be added together to form the total duration. +/// +/// The allowed time units are as follows: +/// +/// * `y` - 365 SI days, or 8760 hours, or 31536000 seconds. No adjustments +/// are made for leap years or leap seconds. +/// * `d` - one SI day, or 86400 seconds. +/// * `h` - one hour, or 3600 seconds. +/// * `m` - one minute, or 60 seconds. +/// * `s` - one second. +/// * `ms` - one millisecond, or 0.001 second. +/// * `us` or `µs` - one microsecond, or 0.000001 second. +/// * `ns` - one nanosecond, or 0.000000001 second. +/// +/// Examples: +/// * `1h30m` +/// * `45s` +/// +/// Units can be repeated and will be added together. This means that +/// `1h1h` is equivalent to `2h`. This is confusing and should be avoided. +/// A future update may disallow this. +/// +/// This configuration is only supported on macOS. Linux doesn't +/// support undo operations at all so this configuration has no +/// effect. +/// +/// Available since: 1.2.0 +@"undo-timeout": Duration = .{ .duration = 5 * std.time.ns_per_s }, + +/// The position of the "quick" terminal window. To learn more about the +/// quick terminal, see the documentation for the `toggle_quick_terminal` +/// binding action. +/// +/// Valid values are: +/// +/// * `top` - Terminal appears at the top of the screen. +/// * `bottom` - Terminal appears at the bottom of the screen. +/// * `left` - Terminal appears at the left of the screen. +/// * `right` - Terminal appears at the right of the screen. +/// * `center` - Terminal appears at the center of the screen. +/// +/// On macOS, changing this configuration requires restarting Ghostty +/// completely. +/// +/// Note: There is no default keybind for toggling the quick terminal. +/// To enable this feature, bind the `toggle_quick_terminal` action to a key. +@"quick-terminal-position": QuickTerminalPosition = .top, + +/// The size of the quick terminal. +/// +/// The size can be specified either as a percentage of the screen dimensions +/// (height/width), or as an absolute size in pixels. Percentage values are +/// suffixed with `%` (e.g. `20%`) while pixel values are suffixed with `px` +/// (e.g. `300px`). A bare value without a suffix is a config error. +/// +/// When only one size is specified, the size parameter affects the size of +/// the quick terminal on its *primary axis*, which depends on its position: +/// height for quick terminals placed on the top or bottom, and width for left +/// or right. The primary axis of a centered quick terminal depends on the +/// monitor's orientation: height when on a landscape monitor, and width when +/// on a portrait monitor. +/// +/// The *secondary axis* would be maximized for non-center positioned +/// quick terminals unless another size parameter is specified, separated +/// from the first by a comma (`,`). Percentage and pixel sizes can be mixed +/// together: for instance, a size of `50%,500px` for a top-positioned quick +/// terminal would be half a screen tall, and 500 pixels wide. +/// +/// Available since: 1.2.0 +@"quick-terminal-size": QuickTerminalSize = .{}, + +/// The layer of the quick terminal window. The higher the layer, +/// the more windows the quick terminal may conceal. +/// +/// Valid values are: +/// +/// * `overlay` +/// +/// The quick terminal appears in front of all windows. +/// +/// * `top` (default) +/// +/// The quick terminal appears in front of normal windows but behind +/// fullscreen overlays like lock screens. +/// +/// * `bottom` +/// +/// The quick terminal appears behind normal windows but in front of +/// wallpapers and other windows in the background layer. +/// +/// * `background` +/// +/// The quick terminal appears behind all windows. +/// +/// GTK Wayland only. +/// +/// Available since: 1.2.0 +@"gtk-quick-terminal-layer": QuickTerminalLayer = .top, +/// The namespace for the quick terminal window. +/// +/// This is an identifier that is used by the Wayland compositor and/or +/// scripts to determine the type of layer surfaces and to possibly apply +/// unique effects. +/// +/// GTK Wayland only. +/// +/// Available since: 1.2.0 +@"gtk-quick-terminal-namespace": [:0]const u8 = "ghostty-quick-terminal", + +/// The screen where the quick terminal should show up. +/// +/// Valid values are: +/// +/// * `main` - The screen that the operating system recommends as the main +/// screen. On macOS, this is the screen that is currently receiving +/// keyboard input. This screen is defined by the operating system and +/// not chosen by Ghostty. +/// +/// * `mouse` - The screen that the mouse is currently hovered over. +/// +/// * `macos-menu-bar` - The screen that contains the macOS menu bar as +/// set in the display settings on macOS. This is a bit confusing because +/// every screen on macOS has a menu bar, but this is the screen that +/// contains the primary menu bar. +/// +/// The default value is `main` because this is the recommended screen +/// by the operating system. +/// +/// On macOS, `macos-menu-bar` uses the screen containing the menu bar. +/// On Linux/Wayland, `macos-menu-bar` is treated as equivalent to `main`. +/// +/// Note: On Linux, there is no universal concept of a "primary" monitor. +/// Ghostty uses the compositor-reported primary output when available and +/// falls back to the first monitor reported by GDK if no primary output can +/// be resolved. +@"quick-terminal-screen": QuickTerminalScreen = .main, + +/// Duration (in seconds) of the quick terminal enter and exit animation. +/// Set it to 0 to disable animation completely. This can be changed at +/// runtime. +/// +/// Only implemented on macOS. +@"quick-terminal-animation-duration": f64 = 0.2, + +/// Automatically hide the quick terminal when focus shifts to another window. +/// Set it to false for the quick terminal to remain open even when it loses focus. +/// +/// Defaults to true on macOS and on false on Linux/BSD. This is because global +/// shortcuts on Linux require system configuration and are considerably less +/// accessible than on macOS, meaning that it is more preferable to keep the +/// quick terminal open until the user has completed their task. +/// This default may change in the future. +@"quick-terminal-autohide": bool = switch (builtin.os.tag) { + .linux => false, + .macos => true, + else => false, +}, + +/// This configuration option determines the behavior of the quick terminal +/// when switching between macOS spaces. macOS spaces are virtual desktops +/// that can be manually created or are automatically created when an +/// application is in full-screen mode. +/// +/// Valid values are: +/// +/// * `move` - When switching to another space, the quick terminal will +/// also moved to the current space. +/// +/// * `remain` - The quick terminal will stay only in the space where it +/// was originally opened and will not follow when switching to another +/// space. +/// +/// The default value is `move`. +/// +/// Only implemented on macOS. +/// On Linux the behavior is always equivalent to `move`. +/// +/// Available since: 1.1.0 +@"quick-terminal-space-behavior": QuickTerminalSpaceBehavior = .move, + +/// Determines under which circumstances that the quick terminal should receive +/// keyboard input. See the corresponding [Wayland documentation](https://wayland.app/protocols/wlr-layer-shell-unstable-v1#zwlr_layer_surface_v1:enum:keyboard_interactivity) +/// for a more detailed explanation of the behavior of each option. +/// +/// > [!NOTE] +/// > The exact behavior of each option may differ significantly across +/// > compositors -- experiment with them on your system to find one that +/// > suits your liking! +/// +/// Valid values are: +/// +/// * `none` +/// +/// The quick terminal will not receive any keyboard input. +/// +/// * `on-demand` (default) +/// +/// The quick terminal would only receive keyboard input when it is focused. +/// +/// * `exclusive` +/// +/// The quick terminal will always receive keyboard input, even when another +/// window is currently focused. +/// +/// Only has an effect on Linux Wayland. +/// On macOS the behavior is always equivalent to `on-demand`. +/// +/// Available since: 1.2.0 +@"quick-terminal-keyboard-interactivity": QuickTerminalKeyboardInteractivity = .@"on-demand", + +/// 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 "jump_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. +/// +/// * `bash`, `elvish`, `fish`, `nushell`, `zsh` - Use this specific shell injection scheme. +/// +/// The default value is `detect`. +@"shell-integration": ShellIntegration = .detect, + +/// Shell integration features to enable. These require our shell integration +/// to be loaded, either automatically via shell-integration or manually. +/// +/// The format of this is a list of features to enable separated by commas. If +/// you prefix a feature with `no-` then it is disabled. If you omit a feature, +/// its default value is used, so you must explicitly disable features you don't +/// want. You can also use `true` or `false` to turn all features on or off. +/// +/// Example: `cursor`, `no-cursor`, `sudo`, `no-sudo`, `title`, `no-title` +/// +/// Available features: +/// +/// * `cursor` - Set the cursor to a bar at the prompt. +/// +/// * `sudo` - Set sudo wrapper to preserve terminfo. +/// +/// * `title` - Set the window title via shell integration. +/// +/// * `ssh-env` - Enable SSH environment variable compatibility. Automatically +/// converts TERM from `xterm-ghostty` to `xterm-256color` when connecting to +/// remote hosts and propagates COLORTERM, TERM_PROGRAM, and TERM_PROGRAM_VERSION. +/// Whether or not these variables will be accepted by the remote host(s) will +/// depend on whether or not the variables are allowed in their sshd_config. +/// (Available since: 1.2.0) +/// +/// * `ssh-terminfo` - Enable automatic terminfo installation on remote hosts. +/// Attempts to install Ghostty's terminfo entry using `infocmp` and `tic` when +/// connecting to hosts that lack it. Requires `infocmp` to be available locally +/// and `tic` to be available on remote hosts. Once terminfo is installed on a +/// remote host, it will be automatically "cached" to avoid repeat installations. +/// If desired, the `+ssh-cache` CLI action can be used to manage the installation +/// cache manually using various arguments. +/// (Available since: 1.2.0) +/// +/// * `path` - Add Ghostty's binary directory to PATH. This ensures the `ghostty` +/// command is available in the shell even if shell init scripts reset PATH. +/// This is particularly useful on macOS where PATH is often overridden by +/// system scripts. The directory is only added if not already present. +/// +/// SSH features work independently and can be combined for optimal experience: +/// when both `ssh-env` and `ssh-terminfo` are enabled, Ghostty will install its +/// terminfo on remote hosts and use `xterm-ghostty` as TERM, falling back to +/// `xterm-256color` with environment variables if terminfo installation fails. +@"shell-integration-features": ShellIntegrationFeatures = .{}, + +/// Custom entries into the command palette. +/// +/// Each entry requires the title, the corresponding action, and an optional +/// description. Each field should be prefixed with the field name, a colon +/// (`:`), and then the specified value. The syntax for actions is identical +/// to the one for keybind actions. Whitespace in between fields is ignored. +/// +/// If you need to embed commas or any other special characters in the values, +/// enclose the value in double quotes and it will be interpreted as a Zig +/// string literal. This is also useful for including whitespace at the +/// beginning or the end of a value. See the +/// [Zig documentation](https://ziglang.org/documentation/master/#Escape-Sequences) +/// for more information on string literals. Note that multiline string literals +/// are not supported. +/// +/// Double quotes can not be used around the field names. +/// +/// ```ini +/// command-palette-entry = title:Reset Font Style, action:csi:0m +/// command-palette-entry = title:Crash on Main Thread,description:Causes a crash on the main (UI) thread.,action:crash:main +/// command-palette-entry = title:Focus Split: Right,description:"Focus the split to the right, if it exists.",action:goto_split:right +/// command-palette-entry = title:"Ghostty",description:"Add a little Ghostty to your terminal.",action:"text:\xf0\x9f\x91\xbb" +/// ``` +/// +/// By default, the command palette is preloaded with most actions that might +/// be useful in an interactive setting yet do not have easily accessible or +/// memorizable shortcuts. The default entries can be cleared by setting this +/// setting to an empty value: +/// +/// ```ini +/// command-palette-entry = +/// ``` +/// +/// Available since: 1.2.0 +@"command-palette-entry": RepeatableCommand = .{}, + +/// Sets the reporting format for OSC sequences that request color information. +/// Ghostty currently supports OSC 10 (foreground), OSC 11 (background), and +/// OSC 4 (256 color palette) queries, and by default the reported values +/// are scaled-up RGB values, where each component are 16 bits. This is how +/// most terminals report these values. However, some legacy applications may +/// require 8-bit, unscaled, components. We also support turning off reporting +/// altogether. The components are lowercase hex values. +/// +/// Allowable values are: +/// +/// * `none` - OSC 4/10/11 queries receive no reply +/// +/// * `8-bit` - Color components are return unscaled, e.g. `rr/gg/bb` +/// +/// * `16-bit` - Color components are returned scaled, e.g. `rrrr/gggg/bbbb` +/// +/// The default value is `16-bit`. +@"osc-color-report-format": OSCColorReportFormat = .@"16-bit", + +/// If true, allows the "KAM" mode (ANSI mode 2) to be used within +/// the terminal. KAM disables keyboard input at the request of the +/// application. This is not a common feature and is not recommended +/// to be enabled. This will not be documented further because +/// if you know you need KAM, you know. If you don't know if you +/// need KAM, you don't need it. +@"vt-kam-allowed": bool = false, + +/// Custom shaders to run after the default shaders. This is a file path +/// to a GLSL-syntax shader for all platforms. +/// +/// Warning: Invalid shaders can cause Ghostty to become unusable such as by +/// causing the window to be completely black. If this happens, you can +/// unset this configuration to disable the shader. +/// +/// Custom shader support is based on and compatible with the Shadertoy shaders. +/// Shaders should specify a `mainImage` function and the available uniforms +/// largely match Shadertoy, with some caveats and Ghostty-specific extensions. +/// +/// The uniform values available to shaders are as follows: +/// +/// * `sampler2D iChannel0` - Input texture. +/// +/// A texture containing the current terminal screen. If multiple custom +/// shaders are specified, the output of previous shaders is written to +/// this texture, to allow combining multiple effects. +/// +/// * `vec3 iResolution` - Output texture size, `[width, height, 1]` (in px). +/// +/// * `float iTime` - Time in seconds since first frame was rendered. +/// +/// * `float iTimeDelta` - Time in seconds since previous frame was rendered. +/// +/// * `float iFrameRate` - Average framerate. (NOT CURRENTLY SUPPORTED) +/// +/// * `int iFrame` - Number of frames that have been rendered so far. +/// +/// * `float iChannelTime[4]` - Current time for video or sound input. (N/A) +/// +/// * `vec3 iChannelResolution[4]` - Resolutions of the 4 input samplers. +/// +/// Currently only `iChannel0` exists, and `iChannelResolution[0]` is +/// identical to `iResolution`. +/// +/// * `vec4 iMouse` - Mouse input info. (NOT CURRENTLY SUPPORTED) +/// +/// * `vec4 iDate` - Date/time info. (NOT CURRENTLY SUPPORTED) +/// +/// * `float iSampleRate` - Sample rate for audio. (N/A) +/// +/// Ghostty-specific extensions: +/// +/// * `vec4 iCurrentCursor` - Info about the terminal cursor. +/// +/// - `iCurrentCursor.xy` is the -X, +Y corner of the current cursor. +/// - `iCurrentCursor.zw` is the width and height of the current cursor. +/// +/// * `vec4 iPreviousCursor` - Info about the previous terminal cursor. +/// +/// * `vec4 iCurrentCursorColor` - Color of the terminal cursor. +/// +/// * `vec4 iPreviousCursorColor` - Color of the previous terminal cursor. +/// +/// * `vec4 iCurrentCursorStyle` - Style of the terminal cursor +/// +/// Macros simplified use are defined for the various cursor styles: +/// +/// - `CURSORSTYLE_BLOCK` or `0` +/// - `CURSORSTYLE_BLOCK_HOLLOW` or `1` +/// - `CURSORSTYLE_BAR` or `2` +/// - `CURSORSTYLE_UNDERLINE` or `3` +/// - `CURSORSTYLE_LOCK` or `4` +/// +/// * `vec4 iPreviousCursorStyle` - Style of the previous terminal cursor +/// +/// * `vec4 iCursorVisible` - Visibility of the terminal cursor. +/// +/// * `float iTimeCursorChange` - Timestamp of terminal cursor change. +/// +/// When the terminal cursor changes position or color, this is set to +/// the same time as the `iTime` uniform, allowing you to compute the +/// time since the change by subtracting this from `iTime`. +/// +/// * `float iTimeFocus` - Timestamp when the surface last gained iFocus. +/// +/// When the surface gains focus, this is set to the current value of +/// `iTime`, similar to how `iTimeCursorChange` works. This allows you +/// to compute the time since focus was gained or lost by calculating +/// `iTime - iTimeFocus`. Use this to create animations that restart +/// when the terminal regains focus. +/// +/// * `int iFocus` - Current focus state of the surface. +/// +/// Set to 1.0 when the surface is focused, 0.0 when unfocused. This +/// allows shaders to detect unfocused state and avoid animation artifacts +/// from large time deltas caused by infrequent "deceptive frames" +/// (e.g., modifier key presses, link hover events in unfocused split panes). +/// Check `iFocus > 0` to determine if the surface is currently focused. +/// +/// * `vec3 iPalette[256]` - The 256-color terminal palette. +/// +/// RGB values for all 256 colors in the terminal palette, normalized +/// to [0.0, 1.0]. Index 0-15 are the ANSI colors, 16-231 are the 6x6x6 +/// color cube, and 232-255 are the grayscale colors. +/// +/// * `vec3 iBackgroundColor` - Terminal background color (RGB). +/// +/// * `vec3 iForegroundColor` - Terminal foreground color (RGB). +/// +/// * `vec3 iCursorColor` - Terminal cursor color (RGB). +/// +/// * `vec3 iCursorText` - Terminal cursor text color (RGB). +/// +/// * `vec3 iSelectionBackgroundColor` - Selection background color (RGB). +/// +/// * `vec3 iSelectionForegroundColor` - Selection foreground color (RGB). +/// +/// If the shader fails to compile, the shader will be ignored. Any errors +/// related to shader compilation will not show up as configuration errors +/// and only show up in the log, since shader compilation happens after +/// configuration loading on the dedicated render thread. For interactive +/// development, use [shadertoy.com](https://shadertoy.com). +/// +/// This can be repeated multiple times to load multiple shaders. The shaders +/// will be run in the order they are specified. +/// +/// This can be changed at runtime and will affect all open terminals. +@"custom-shader": RepeatablePath = .{}, + +/// If `true` (default), the focused terminal surface will run an animation +/// loop when custom shaders are used. This uses slightly more CPU (generally +/// less than 10%) but allows the shader to animate. This only runs if there +/// are custom shaders and the terminal is focused. +/// +/// If this is set to `false`, the terminal and custom shader will only render +/// when the terminal is updated. This is more efficient but the shader will +/// not animate. +/// +/// This can also be set to `always`, which will always run the animation +/// loop regardless of whether the terminal is focused or not. The animation +/// loop will still only run when custom shaders are used. Note that this +/// will use more CPU per terminal surface and can become quite expensive +/// depending on the shader and your terminal usage. +/// +/// This can be changed at runtime and will affect all open terminals. +@"custom-shader-animation": CustomShaderAnimation = .true, + +/// Bell features to enable if bell support is available in your runtime. Not +/// all features are available on all runtimes. The format of this is a list of +/// features to enable separated by commas. If you prefix a feature with `no-` +/// then it is disabled. If you omit a feature, its default value is used. +/// +/// Valid values are: +/// +/// * `system` +/// +/// Instruct the system to notify the user using built-in system functions. +/// This could result in an audiovisual effect, a notification, or something +/// else entirely. Changing these effects require altering system settings: +/// for instance under the "Sound > Alert Sound" setting in GNOME, +/// or the "Accessibility > System Bell" settings in KDE Plasma. +/// +/// On macOS, this plays the system alert sound. +/// +/// * `audio` +/// +/// Play a custom sound. (Available since 1.3.0 on macOS) +/// +/// * `attention` *(enabled by default)* +/// +/// Request the user's attention when Ghostty is unfocused, until it has +/// received focus again. On macOS, this will bounce the app icon in the +/// dock once. On Linux, the behavior depends on the desktop environment +/// and/or the window manager/compositor: +/// +/// - On KDE, the background of the desktop icon in the task bar would be +/// highlighted; +/// +/// - On GNOME, you may receive a notification that, when clicked, would +/// bring the Ghostty window into focus; +/// +/// - On Sway, the window may be decorated with a distinctly colored border; +/// +/// - On other systems this may have no effect at all. +/// +/// * `title` *(enabled by default)* +/// +/// Prepend a bell emoji (🔔) to the title of the alerted surface until the +/// terminal is re-focused or interacted with (such as on keyboard input). +/// +/// * `border` +/// +/// Display a border around the alerted surface until the terminal is +/// re-focused or interacted with (such as on keyboard input). +/// +/// Available since: 1.2.0 on GTK, 1.2.1 on macOS +/// +/// Example: `audio`, `no-audio`, `system`, `no-system` +/// +/// Available since: 1.2.0 +@"bell-features": BellFeatures = .{}, + +/// If `audio` is an enabled bell feature, this is a path to an audio file. If +/// the path is not absolute, it is considered relative to the directory of the +/// configuration file that it is referenced from, or from the current working +/// directory if this is used as a CLI flag. The path may be prefixed with `~/` +/// to reference the user's home directory. +/// +/// Available since: 1.2.0 on GTK, 1.3.0 on macOS. +@"bell-audio-path": ?Path = null, + +/// If `audio` is an enabled bell feature, this is the volume to play the audio +/// file at (relative to the system volume). This is a floating point number +/// ranging from 0.0 (silence) to 1.0 (as loud as possible). The default is 0.5. +/// +/// Available since: 1.2.0 on GTK, 1.3.0 on macOS. +@"bell-audio-volume": f64 = 0.5, + +/// Control the in-app notifications that Ghostty shows. +/// +/// On Linux (GTK), in-app notifications show up as toasts. Toasts appear +/// overlaid on top of the terminal window. They are used to show information +/// that is not critical but may be important. +/// +/// Possible notifications are: +/// +/// - `clipboard-copy` (default: true) - Show a notification when text is copied +/// to the clipboard. +/// - `config-reload` (default: true) - Show a notification when +/// the configuration is reloaded. +/// +/// To specify a notification to enable, specify the name of the notification. +/// To specify a notification to disable, prefix the name with `no-`. For +/// example, to disable `clipboard-copy`, set this configuration to +/// `no-clipboard-copy`. To enable it, set this configuration to `clipboard-copy`. +/// +/// Multiple notifications can be enabled or disabled by separating them +/// with a comma. +/// +/// A value of "false" will disable all notifications. A value of "true" will +/// enable all notifications. +/// +/// This configuration only applies to GTK. +/// +/// Available since: 1.1.0 +@"app-notifications": AppNotifications = .{}, + +/// 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. +/// +/// Important: tabs DO NOT WORK in this mode. Non-native fullscreen removes +/// the titlebar and macOS native tabs require the titlebar. If you use tabs, +/// you should not use this mode. +/// +/// If you fullscreen a window with tabs, the currently focused tab will +/// become fullscreen while the others will remain in a separate window in +/// the background. You can switch to that window using normal window-switching +/// keybindings such as command+tilde. When you exit fullscreen, the window +/// will return to the tabbed state it was in before. +/// +/// Allowable values are: +/// +/// * `true` - Use non-native macOS fullscreen, hide the menu bar +/// * `false` - Use native macOS fullscreen +/// * `visible-menu` - Use non-native macOS fullscreen, keep the menu bar +/// visible +/// * `padded-notch` - Use non-native macOS fullscreen, hide the menu bar, +/// but ensure the window is not obscured by the notch on applicable +/// devices. The area around the notch will remain transparent currently, +/// but in the future we may fill it with the window background color. +/// +/// Changing this option at runtime works, but will only apply to the next +/// time the window is made fullscreen. If a window is already fullscreen, +/// it will retain the previous setting until fullscreen is exited. +@"macos-non-native-fullscreen": NonNativeFullscreen = .false, + +/// Whether the window buttons in the macOS titlebar are visible. The window +/// buttons are the colored buttons in the upper left corner of most macOS apps, +/// also known as the traffic lights, that allow you to close, miniaturize, and +/// zoom the window. +/// +/// This setting has no effect when `window-decoration = none` or +/// `macos-titlebar-style = hidden`, as the window buttons are always hidden in +/// these modes. +/// +/// Valid values are: +/// +/// * `visible` - Show the window buttons. +/// * `hidden` - Hide the window buttons. +/// +/// The default value is `visible`. +/// +/// Changing this option at runtime only applies to new windows. +/// +/// Available since: 1.2.0 +@"macos-window-buttons": MacWindowButtons = .visible, + +/// The style of the macOS titlebar. Available values are: "native", +/// "transparent", "tabs", and "hidden". +/// +/// The "native" style uses the native macOS titlebar with zero customization. +/// The titlebar will match your window theme (see `window-theme`). +/// +/// The "transparent" style is the same as "native" but the titlebar will +/// be transparent and allow your window background color to come through. +/// This makes a more seamless window appearance but looks a little less +/// typical for a macOS application and may not work well with all themes. +/// +/// The "transparent" style will also update in real-time to dynamic +/// changes to the window background color, e.g. via OSC 11. To make this +/// more aesthetically pleasing, this only happens if the terminal is +/// a window, tab, or split that borders the top of the window. This +/// avoids a disjointed appearance where the titlebar color changes +/// but all the topmost terminals don't match. +/// +/// The "tabs" style is a completely custom titlebar that integrates the +/// tab bar into the titlebar. This titlebar always matches the background +/// color of the terminal. There are some limitations to this style: +/// On macOS 13 and below, saved window state will not restore tabs correctly. +/// macOS 14 does not have this issue and any other macOS version has not +/// been tested. +/// +/// The "hidden" style hides the titlebar. Unlike `window-decoration = none`, +/// however, it does not remove the frame from the window or cause it to have +/// squared corners. Changing to or from this option at run-time may affect +/// existing windows in buggy ways. +/// +/// When "hidden", the top titlebar area can no longer be used for dragging +/// the window. To drag the window, you can use option+click on the resizable +/// areas of the frame to drag the window. This is a standard macOS behavior +/// and not something Ghostty enables. +/// +/// The default value is "transparent". This is an opinionated choice +/// but its one I think is the most aesthetically pleasing and works in +/// most cases. +/// +/// Changing this option at runtime only applies to new windows. +@"macos-titlebar-style": MacTitlebarStyle = .transparent, + +/// Whether the proxy icon in the macOS titlebar is visible. The proxy icon +/// is the icon that represents the folder of the current working directory. +/// You can see this very clearly in the macOS built-in Terminal.app +/// titlebar. +/// +/// The proxy icon is only visible with the native macOS titlebar style. +/// +/// Valid values are: +/// +/// * `visible` - Show the proxy icon. +/// * `hidden` - Hide the proxy icon. +/// +/// The default value is `visible`. +/// +/// This setting can be changed at runtime and will affect all currently +/// open windows but only after their working directory changes again. +/// Therefore, to make this work after changing the setting, you must +/// usually `cd` to a different directory, open a different file in an +/// editor, etc. +@"macos-titlebar-proxy-icon": MacTitlebarProxyIcon = .visible, + +/// Controls the windowing behavior when dropping a file or folder +/// onto the Ghostty icon in the macOS dock. +/// +/// Valid values are: +/// +/// * `new-tab` - Create a new tab in the current window, or open +/// a new window if none exist. +/// * `new-window` - Create a new window unconditionally. +/// +/// The default value is `new-tab`. +/// +/// This setting is only supported on macOS and has no effect on other +/// platforms. +@"macos-dock-drop-behavior": MacOSDockDropBehavior = .@"new-tab", + +/// macOS doesn't have a distinct "alt" key and instead has the "option" +/// key which behaves slightly differently. On macOS by default, the +/// option key plus a character will sometimes produce a Unicode character. +/// For example, on US standard layouts option-b produces "∫". This may be +/// undesirable if you want to use "option" as an "alt" key for keybindings +/// in terminal programs or shells. +/// +/// This configuration lets you change the behavior so that option is treated +/// as alt. +/// +/// The default behavior (unset) will depend on your active keyboard +/// layout. If your keyboard layout is one of the keyboard layouts listed +/// below, then the default value is "true". Otherwise, the default +/// value is "false". Keyboard layouts with a default value of "true" are: +/// +/// - U.S. Standard +/// - U.S. International +/// +/// Note that if an *Option*-sequence doesn't produce a printable character, it +/// will be treated as *Alt* regardless of this setting. (e.g. `alt+ctrl+a`). +/// +/// Explicit values that can be set: +/// +/// If `true`, the *Option* key will be treated as *Alt*. This makes terminal +/// sequences expecting *Alt* to work properly, but will break Unicode input +/// sequences on macOS if you use them via the *Alt* key. +/// +/// You may set this to `false` to restore the macOS *Alt* key unicode +/// sequences but this will break terminal sequences expecting *Alt* to work. +/// +/// The values `left` or `right` enable this for the left or right *Option* +/// key, respectively. +@"macos-option-as-alt": ?inputpkg.OptionAsAlt = null, + +/// Whether to enable the macOS window shadow. The default value is true. +/// With some window managers and window transparency settings, you may +/// find false more visually appealing. +@"macos-window-shadow": bool = true, + +/// If true, the macOS icon in the dock and app switcher will be hidden. This is +/// mainly intended for those primarily using the quick-terminal mode. +/// +/// Note that setting this to true means that keyboard layout changes +/// will no longer be automatic. +/// +/// Control whether macOS app is excluded from the dock and app switcher, +/// a "hidden" state. This is mainly intended for those primarily using +/// quick-terminal mode, but is a general configuration for any use +/// case. +/// +/// Available values: +/// +/// * `never` - The macOS app is never hidden. +/// * `always` - The macOS app is always hidden. +/// +/// Note: When the macOS application is hidden, keyboard layout changes +/// will no longer be automatic. This is a limitation of macOS. +/// +/// Available since: 1.2.0 +@"macos-hidden": MacHidden = .never, + +/// If true, Ghostty on macOS will automatically enable the "Secure Input" +/// feature when it detects that a password prompt is being displayed. +/// +/// "Secure Input" is a macOS security feature that prevents applications from +/// reading keyboard events. This can always be enabled manually using the +/// `Ghostty > Secure Keyboard Entry` menu item. +/// +/// Note that automatic password prompt detection is based on heuristics +/// and may not always work as expected. Specifically, it does not work +/// over SSH connections, but there may be other cases where it also +/// doesn't work. +/// +/// A reason to disable this feature is if you find that it is interfering +/// with legitimate accessibility software (or software that uses the +/// accessibility APIs), since secure input prevents any application from +/// reading keyboard events. +@"macos-auto-secure-input": bool = true, + +/// If true, Ghostty will show a graphical indication when secure input is +/// enabled. This indication is generally recommended to know when secure input +/// is enabled. +/// +/// Normally, secure input is only active when a password prompt is displayed +/// or it is manually (and typically temporarily) enabled. However, if you +/// always have secure input enabled, the indication can be distracting and +/// you may want to disable it. +@"macos-secure-input-indication": bool = true, + +/// If true, Ghostty exposes and handles the built-in AppleScript dictionary +/// on macOS. +/// +/// If false, all AppleScript interactions are disabled. This includes +/// AppleScript commands and AppleScript object lookup for windows, tabs, +/// and terminals. +/// +/// The default is true. +@"macos-applescript": bool = true, + +/// Customize the macOS app icon. +/// +/// This only affects the icon that appears in the dock, application +/// switcher, etc. This does not affect the icon in Finder because +/// that is controlled by a hardcoded value in the signed application +/// bundle and can't be changed at runtime. For more details on what +/// exactly is affected, see the `NSApplication.icon` Apple documentation; +/// that is the API that is being used to set the icon. +/// +/// Valid values: +/// +/// * `official` - Use the official Ghostty icon. +/// * `blueprint`, `chalkboard`, `microchip`, `glass`, `holographic`, +/// `paper`, `retro`, `xray` - Official variants of the Ghostty icon +/// hand-created by artists (no AI). +/// * `custom` - Use a completely custom icon. The location must be specified +/// using the additional `macos-custom-icon` configuration +/// * `custom-style` - Use the official Ghostty icon but with custom +/// styles applied to various layers. The custom styles must be +/// specified using the additional `macos-icon`-prefixed configurations. +/// The `macos-icon-ghost-color` and `macos-icon-screen-color` +/// configurations are required for this style. +/// +/// WARNING: The `custom-style` option is _experimental_. We may change +/// the format of the custom styles in the future. We're still finalizing +/// the exact layers and customization options that will be available. +/// +/// Other caveats: +/// +/// * The icon in the update dialog will always be the official icon. +/// This is because the update dialog is managed through a +/// separate framework and cannot be customized without significant +/// effort. +@"macos-icon": MacAppIcon = .official, + +/// The absolute path to the custom icon file. +/// Supported formats include PNG, JPEG, and ICNS. +/// +/// Defaults to `~/.config/ghostty/Ghostty.icns` +@"macos-custom-icon": ?[:0]const u8 = null, + +/// The material to use for the frame of the macOS app icon. +/// +/// Valid values: +/// +/// * `aluminum` - A brushed aluminum frame. This is the default. +/// * `beige` - A classic 90's computer beige frame. +/// * `plastic` - A glossy, dark plastic frame. +/// * `chrome` - A shiny chrome frame. +/// +/// Note: This configuration is required when `macos-icon` is set to +/// `custom-style`. +@"macos-icon-frame": MacAppIconFrame = .aluminum, + +/// The color of the ghost in the macOS app icon. +/// +/// Note: This configuration is required when `macos-icon` is set to +/// `custom-style`. +/// +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. +@"macos-icon-ghost-color": ?Color = null, + +/// The color of the screen in the macOS app icon. +/// +/// The screen is a linear gradient so you can specify multiple colors +/// that make up the gradient. Up to 64 comma-separated colors may be +/// specified as either hex (`#RRGGBB` or `RRGGBB`) or as named X11 +/// colors. The first color is the bottom of the gradient and the last +/// color is the top of the gradient. +/// +/// Note: This configuration is required when `macos-icon` is set to +/// `custom-style`. +@"macos-icon-screen-color": ?ColorList = null, + +/// Whether macOS Shortcuts are allowed to control Ghostty. +/// +/// Ghostty exposes a number of actions that allow Shortcuts to +/// control and interact with Ghostty. This includes creating new +/// terminals, sending text to terminals, running commands, invoking +/// any keybind action, etc. +/// +/// This is a powerful feature but can be a security risk if a malicious +/// shortcut is able to be installed and executed. Therefore, this +/// configuration allows you to disable this feature. +/// +/// Valid values are: +/// +/// * `ask` - Ask the user whether for permission. Ghostty will remember +/// this choice and never ask again. This is similar to other macOS +/// permissions such as microphone access, camera access, etc. +/// +/// * `allow` - Allow Shortcuts to control Ghostty without asking. +/// +/// * `deny` - Deny Shortcuts from controlling Ghostty. +/// +/// Available since: 1.2.0 +@"macos-shortcuts": MacShortcuts = .ask, + +/// Put every surface (tab, split, window) into a transient `systemd` scope. +/// +/// This allows per-surface resource management. For example, if a shell program +/// is using too much memory, only that shell will be killed by the oom monitor +/// instead of the entire Ghostty process. Similarly, if a shell program is +/// using too much CPU, only that surface will be CPU-throttled. +/// +/// This will cause startup times to be slower (a hundred milliseconds or so), +/// so the default value is "single-instance." In single-instance mode, only +/// one instance of Ghostty is running (see gtk-single-instance) so the startup +/// time is a one-time cost. Additionally, single instance Ghostty is much +/// more likely to have many windows, tabs, etc. so cgroup isolation is a +/// big benefit. +/// +/// This feature requires `systemd`. If `systemd` is unavailable, cgroup +/// initialization will fail. By default, this will not prevent Ghostty from +/// working (see `linux-cgroup-hard-fail`). +/// +/// Changing this value and reloading the config will not affect existing +/// surfaces. +/// +/// Valid values are: +/// +/// * `never` - Never use cgroups. +/// * `always` - Always use cgroups. +/// * `single-instance` - Enable cgroups only for Ghostty instances launched +/// as single-instance applications (see gtk-single-instance). +@"linux-cgroup": LinuxCgroup = if (builtin.os.tag == .linux) + .@"single-instance" +else + .never, + +/// Memory limit for any individual terminal process (tab, split, window, +/// etc.) in bytes. If this is unset then no memory limit will be set. +/// +/// Note that this sets the `MemoryHigh` setting on the transient `systemd` +/// scope, which is a soft limit. You should configure something like +/// `systemd-oom` to handle killing processes that have too much memory +/// pressure. +/// +/// Changing this value and reloading the config will not affect existing +/// surfaces. +/// +/// See the `systemd.resource-control` manual page for more information: +/// https://www.freedesktop.org/software/systemd/man/latest/systemd.resource-control.html +@"linux-cgroup-memory-limit": ?u64 = null, + +/// Number of processes limit for any individual terminal process (tab, split, +/// window, etc.). If this is unset then no limit will be set. +/// +/// Note that this sets the `TasksMax` setting on the transient `systemd` scope, +/// which is a hard limit. +/// +/// Changing this value and reloading the config will not affect existing +/// surfaces. +/// +/// See the `systemd.resource-control` manual page for more information: +/// https://www.freedesktop.org/software/systemd/man/latest/systemd.resource-control.html +@"linux-cgroup-processes-limit": ?u64 = null, + +/// If this is false, then creating a transient `systemd` scope (for +/// `linux-cgroup`) will be allowed to fail and the failure is ignored. This is +/// useful if you view cgroup isolation as a "nice to have" and not a critical +/// resource management feature, because surface creation will not fail if +/// `systemd` APIs fail. +/// +/// If this is true, then any transient `systemd` scope creation failure will +/// cause surface creation to fail. +/// +/// Changing this value and reloading the config will not affect existing +/// surfaces. +@"linux-cgroup-hard-fail": bool = false, + +/// Enable or disable GTK's OpenGL debugging logs. The default is `true` for +/// debug builds, `false` for all others. +/// +/// Available since: 1.1.0 +@"gtk-opengl-debug": bool = builtin.mode == .Debug, + +/// If `true`, 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. +/// +/// If `detect`, Ghostty will assume true (single instance) unless one of +/// the following scenarios is found: +/// +/// 1. TERM_PROGRAM environment variable is a non-empty value. In this +/// case, we assume Ghostty is being launched from a graphical terminal +/// session and you want a dedicated instance. +/// +/// 2. Any CLI arguments exist. In this case, we assume you are passing +/// custom Ghostty configuration. Single instance mode inherits the +/// configuration from when it was launched, so we must disable single +/// instance to load the new configuration. +/// +/// If either of these scenarios is producing a false positive, you can +/// set this configuration explicitly to the behavior you want. +/// +/// The pre-1.2 option `desktop` has been deprecated. Please replace +/// this with `detect`. +/// +/// The default value is `detect`. +/// +/// Note that debug builds of Ghostty have a separate single-instance ID +/// so you can test single instance without conflicting with release builds. +@"gtk-single-instance": GtkSingleInstance = .default, + +/// When enabled, the full GTK titlebar is displayed instead of your window +/// manager's simple titlebar. The behavior of this option will vary with your +/// window manager. +/// +/// This option does nothing when `window-decoration` is none or when running +/// under macOS. +@"gtk-titlebar": bool = true, + +/// Determines the side of the screen that the GTK tab bar will stick to. +/// Top, bottom, and hidden are supported. The default is top. +/// +/// When `hidden` is set, a tab button displaying the number of tabs will appear +/// in the title bar. It has the ability to open a tab overview for displaying +/// tabs. Alternatively, you can use the `toggle_tab_overview` action in a +/// keybind if your window doesn't have a title bar, or you can switch tabs +/// with keybinds. +@"gtk-tabs-location": GtkTabsLocation = .top, + +/// If this is `true`, the titlebar will be hidden when the window is maximized, +/// and shown when the titlebar is unmaximized. GTK only. +/// +/// Available since: 1.1.0 +@"gtk-titlebar-hide-when-maximized": bool = false, + +/// Determines the appearance of the top and bottom bars tab bar. +/// +/// Valid values are: +/// +/// * `flat` - Top and bottom bars are flat with the terminal window. +/// * `raised` - Top and bottom bars cast a shadow on the terminal area. +/// * `raised-border` - Similar to `raised` but the shadow is replaced with a +/// more subtle border. +@"gtk-toolbar-style": GtkToolbarStyle = .raised, + +/// The style of the GTK titlebar. Available values are `native` and `tabs`. +/// +/// The `native` titlebar style is a traditional titlebar with a title, a few +/// buttons and window controls. A separate tab bar will show up below the +/// titlebar if you have multiple tabs open in the window. +/// +/// The `tabs` titlebar merges the tab bar and the traditional titlebar. +/// This frees up vertical space on your screen if you use multiple tabs. One +/// limitation of the `tabs` titlebar is that you cannot drag the titlebar +/// by the titles any longer (as they are tab titles now). Other areas of the +/// `tabs` title bar can be used to drag the window around. +/// +/// The default style is `native`. +@"gtk-titlebar-style": GtkTitlebarStyle = .native, + +/// If `true` (default), then the Ghostty GTK tabs will be "wide." Wide tabs +/// are the new typical Gnome style where tabs fill their available space. +/// If you set this to `false` then tabs will only take up space they need, +/// which is the old style. +@"gtk-wide-tabs": bool = true, + +/// Custom CSS files to be loaded. +/// +/// GTK CSS documentation can be found at the following links: +/// +/// * https://docs.gtk.org/gtk4/css-overview.html - An overview of GTK CSS. +/// * https://docs.gtk.org/gtk4/css-properties.html - A comprehensive list +/// of supported CSS properties. +/// +/// Launch Ghostty with `env GTK_DEBUG=interactive ghostty` to tweak Ghostty's +/// CSS in real time using the GTK Inspector. Errors in your CSS files would +/// also be reported in the terminal you started Ghostty from. See +/// https://developer.gnome.org/documentation/tools/inspector.html for more +/// information about the GTK Inspector. +/// +/// This configuration can be repeated multiple times to load multiple files. +/// Prepend a ? character to the file path to suppress errors if the file does +/// not exist. If you want to include a file that begins with a literal ? +/// character, surround the file path in double quotes ("). +/// The file size limit for a single stylesheet is 5MiB. +/// +/// Available since: 1.1.0 +@"gtk-custom-css": RepeatablePath = .{}, + +/// If `true` (default), applications running in the terminal can show desktop +/// notifications using certain escape sequences such as OSC 9 or OSC 777. +@"desktop-notifications": bool = true, + +/// If `true` (default), applications running in the terminal can show +/// graphical progress bars using the ConEmu OSC 9;4 escape sequence. +/// If `false`, progress bar sequences are silently ignored. +@"progress-style": bool = true, + +/// Modifies the color used for bold text in the terminal. +/// +/// This can be set to a specific color, using the same format as +/// `background` or `foreground` (e.g. `#RRGGBB` but other formats +/// are also supported; see the aforementioned documentation). If a +/// specific color is set, this color will always be used for the default +/// bold text color. It will set the rest of the bold colors to `bright`. +/// +/// This can also be set to `bright`, which uses the bright color palette +/// for bold text. For example, if the text is red, then the bold will +/// use the bright red color. The terminal palette is set with `palette` +/// but can also be overridden by the terminal application itself using +/// escape sequences such as OSC 4. (Since Ghostty 1.2.0, the previous +/// configuration `bold-is-bright` is deprecated and replaced by this +/// usage). +/// +/// Available since Ghostty 1.2.0. +@"bold-color": ?BoldColor = null, + +/// The opacity level (opposite of transparency) of the faint text. 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. +/// +/// Available since Ghostty 1.2.0. +@"faint-opacity": f64 = 0.5, + +/// This will be used to set the `TERM` environment variable. +/// HACK: We set this with an `xterm` prefix because vim uses that to enable key +/// protocols (specifically this will enable `modifyOtherKeys`), among other +/// features. An option exists in vim to modify this: `:set +/// keyprotocol=ghostty:kitty`, however a bug in the implementation prevents it +/// from working properly. https://github.com/vim/vim/pull/13211 fixes this. +term: []const u8 = "xterm-ghostty", + +/// String to send when we receive `ENQ` (`0x05`) from the command that we are +/// running. Defaults to an empty string if not set. +@"enquiry-response": []const u8 = "", + +/// Configures the low-level API to use for async IO, eventing, etc. +/// +/// Most users should leave this set to `auto`. This will automatically detect +/// scenarios where APIs may not be available (for example `io_uring` on +/// certain hardened kernels) and fall back to a different API. However, if +/// you want to force a specific backend for any reason, you can set this +/// here. +/// +/// Based on various benchmarks, we haven't found a statistically significant +/// difference between the backends with regards to memory, CPU, or latency. +/// The choice of backend is more about compatibility and features. +/// +/// Available options: +/// +/// * `auto` - Automatically choose the best backend for the platform +/// based on available options. +/// * `epoll` - Use the `epoll` API +/// * `io_uring` - Use the `io_uring` API +/// +/// If the selected backend is not available on the platform, Ghostty will +/// fall back to an automatically chosen backend that is available. +/// +/// Changing this value requires a full application restart to take effect. +/// +/// This is only supported on Linux, since this is the only platform +/// where we have multiple options. On macOS, we always use `kqueue`. +/// +/// Available since: 1.2.0 +@"async-backend": AsyncBackend = .auto, + +/// Control the auto-update functionality of Ghostty. This is only supported +/// on macOS currently, since Linux builds are distributed via package +/// managers that are not centrally controlled by Ghostty. +/// +/// Checking or downloading an update does not send any information to +/// the project beyond standard network information mandated by the +/// underlying protocols. To put it another way: Ghostty doesn't explicitly +/// add any tracking to the update process. The update process works by +/// downloading information about the latest version and comparing it +/// client-side to the current version. +/// +/// Valid values are: +/// +/// * `off` - Disable auto-updates. +/// * `check` - Check for updates and notify the user if an update is +/// available, but do not automatically download or install the update. +/// * `download` - Check for updates, automatically download the update, +/// notify the user, but do not automatically install the update. +/// +/// If unset, we defer to Sparkle's default behavior, which respects the +/// preference stored in the standard user defaults (`defaults(1)`). +/// +/// Changing this value at runtime works after a small delay. +@"auto-update": ?AutoUpdate = null, + +/// The release channel to use for auto-updates. +/// +/// The default value of this matches the release channel of the currently +/// running Ghostty version. If you download a pre-release version of Ghostty +/// then this will be set to `tip` and you will receive pre-release updates. +/// If you download a stable version of Ghostty then this will be set to +/// `stable` and you will receive stable updates. +/// +/// Valid values are: +/// +/// * `stable` - Stable, tagged releases such as "1.0.0". +/// * `tip` - Pre-release versions generated from each commit to the +/// main branch. This is the version that was in use during private +/// beta testing by thousands of people. It is generally stable but +/// will likely have more bugs than the stable channel. +/// +/// Changing this configuration requires a full restart of +/// Ghostty to take effect. +/// +/// This only works on macOS since only macOS has an auto-update feature. +@"auto-update-channel": ?build_config.ReleaseChannel = null, + +/// This is set by the CLI parser for deinit. +_arena: ?ArenaAllocator = null, + +/// List of diagnostics that were generated during the loading of +/// the configuration. +_diagnostics: cli.DiagnosticList = .{}, + +/// The conditional truths for the configuration. This is used to +/// determine if a conditional configuration matches or not. +_conditional_state: conditional.State = .{}, + +/// The conditional keys that are used at any point during the configuration +/// loading. This is used to speed up the conditional evaluation process. +_conditional_set: std.EnumSet(conditional.Key) = .{}, + +/// The steps we can use to reload the configuration after it has been loaded +/// without reopening the files. This is used in very specific cases such +/// as loadTheme which has more details on why. +_replay_steps: std.ArrayListUnmanaged(Replay.Step) = .{}, + +/// Set to true if Ghostty was executed as xdg-terminal-exec on Linux. +@"_xdg-terminal-exec": bool = false, + +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 dir +/// 3. "Application Support" directory (macOS only) +/// 4. CLI flags +/// 5. 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 = .init(alloc_gpa), + }; + errdefer result.deinit(); + const alloc = result._arena.?.allocator(); + + // Add our default keybindings + try result.keybind.init(alloc); + + // Add our default command palette entries + try result.@"command-palette-entry".init(alloc); + + // Add our default link for URL detection + try result.link.links.append(alloc, .{ + .regex = url.regex, + .action = .{ .open = {} }, + .highlight = .{ .hover_mods = inputpkg.ctrlOrSuper(.{}) }, + }); + + return result; +} + +/// Load configuration from an iterator that yields values that look like +/// command-line arguments, i.e. `--key=value`. +pub fn loadIter( + self: *Config, + alloc: Allocator, + iter: anytype, +) !void { + try cli.args.parse(Config, alloc, self, iter); +} + +/// Load configuration from the target config file at `path`. +/// +/// `path` must be resolved and absolute. +pub fn loadFile(self: *Config, alloc: Allocator, path: []const u8) !void { + assert(std.fs.path.isAbsolute(path)); + var file = file_load.open(path) catch |err| switch (err) { + error.NotAFile => { + log.warn( + "config-file {s}: not reading because it is not a file", + .{path}, + ); + return; + }, + + else => return err, + }; + defer file.close(); + + try self.loadFsFile(alloc, &file, path); +} + +/// Load config from the given File. +fn loadFsFile(self: *Config, alloc: Allocator, file: *std.fs.File, path: []const u8) !void { + std.log.info("reading configuration file path={s}", .{path}); + var buf: [2048]u8 = undefined; + var file_reader = file.reader(&buf); + const reader = &file_reader.interface; + try self.loadReader(alloc, reader, path); +} + +/// Load config from the given Reader. +fn loadReader(self: *Config, alloc: Allocator, reader: *std.Io.Reader, path: []const u8) !void { + bom: { + // If the file starts with a UTF-8 byte order mark, skip it. + // https://en.wikipedia.org/wiki/Byte_order_mark#UTF-8 + const bom: []const u8 = &.{ 0xef, 0xbb, 0xbf }; + const str = reader.peek(bom.len) catch break :bom; + if (std.mem.eql(u8, str, bom)) { + log.info("skipping UTF-8 byte order mark", .{}); + reader.toss(bom.len); + } + } + var iter: cli.args.LineIterator = .{ .r = reader, .filepath = path }; + try self.loadIter(alloc, &iter); + try self.expandPaths(std.fs.path.dirname(path).?); +} + +test "handle bom in config files" { + const testing = std.testing; + const alloc = testing.allocator; + + { + const data = "\xef\xbb\xbfabnormal-command-exit-runtime = 2500\n"; + var reader: std.Io.Reader = .fixed(data); + var cfg = try Config.default(alloc); + defer cfg.deinit(); + try cfg.loadReader( + alloc, + &reader, + "/home/ghostty/.config/ghostty/config.ghostty", + ); + try cfg.finalize(); + + try testing.expect(cfg._diagnostics.empty()); + try testing.expectEqual( + 2500, + cfg.@"abnormal-command-exit-runtime", + ); + } + + { + const data = "abnormal-command-exit-runtime = 2500\n"; + var reader: std.Io.Reader = .fixed(data); + var cfg = try Config.default(alloc); + defer cfg.deinit(); + try cfg.loadReader( + alloc, + &reader, + "/home/ghostty/.config/ghostty/config.ghostty", + ); + try cfg.finalize(); + + try testing.expect(cfg._diagnostics.empty()); + try testing.expectEqual( + 2500, + cfg.@"abnormal-command-exit-runtime", + ); + } +} + +pub const OptionalFileAction = enum { loaded, not_found, @"error" }; + +/// Load optional configuration file from `path`. All errors are ignored. +/// +/// Returns the action that was taken. +pub fn loadOptionalFile( + self: *Config, + alloc: Allocator, + path: []const u8, +) OptionalFileAction { + if (self.loadFile(alloc, path)) { + return .loaded; + } else |err| switch (err) { + error.FileNotFound => return .not_found, + else => { + std.log.warn( + "error reading optional config file, not loading err={} path={s}", + .{ err, path }, + ); + + return .@"error"; + }, + } +} + +fn writeConfigTemplate(path: []const u8) !void { + log.info("creating template config file: path={s}", .{path}); + if (std.fs.path.dirname(path)) |dir_path| { + try std.fs.cwd().makePath(dir_path); + } + const file = try std.fs.createFileAbsolute(path, .{}); + defer file.close(); + var buf: [4096]u8 = undefined; + var file_writer = file.writer(&buf); + const writer = &file_writer.interface; + try writer.print( + @embedFile("./config-template"), + .{ .path = path }, + ); +} + +/// Load configurations from the default configuration files. The default +/// configuration file is at `$XDG_CONFIG_HOME/ghostty/config.ghostty`. +/// +/// On macOS, `$HOME/Library/Application Support/$CFBundleIdentifier/` +/// is also loaded. +/// +/// The legacy `config` file (without extension) is first loaded, +/// then `config.ghostty`. +pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void { + // Load XDG first + const legacy_xdg_path = try file_load.legacyDefaultXdgPath(alloc); + defer alloc.free(legacy_xdg_path); + const xdg_path = try file_load.defaultXdgPath(alloc); + defer alloc.free(xdg_path); + const xdg_loaded: bool = xdg_loaded: { + const legacy_xdg_action = self.loadOptionalFile(alloc, legacy_xdg_path); + const xdg_action = self.loadOptionalFile(alloc, xdg_path); + if (xdg_action != .not_found and legacy_xdg_action != .not_found) { + log.warn("both config files `{s}` and `{s}` exist.", .{ legacy_xdg_path, xdg_path }); + log.warn("loading them both in that order", .{}); + break :xdg_loaded true; + } + + break :xdg_loaded xdg_action != .not_found or + legacy_xdg_action != .not_found; + }; + + // On macOS load the app support directory as well + if (comptime builtin.os.tag == .macos) { + const legacy_app_support_path = try file_load.legacyDefaultAppSupportPath(alloc); + defer alloc.free(legacy_app_support_path); + const app_support_path = try file_load.preferredAppSupportPath(alloc); + defer alloc.free(app_support_path); + const app_support_loaded: bool = loaded: { + const legacy_app_support_action = self.loadOptionalFile( + alloc, + legacy_app_support_path, + ); + + // The app support path and legacy may be the same, since we + // use the `preferred` call above. If its the same, avoid + // a double-load. + const app_support_action: OptionalFileAction = if (!std.mem.eql( + u8, + legacy_app_support_path, + app_support_path, + )) self.loadOptionalFile( + alloc, + app_support_path, + ) else .not_found; + + if (app_support_action != .not_found and legacy_app_support_action != .not_found) { + log.warn( + "both config files `{s}` and `{s}` exist.", + .{ legacy_app_support_path, app_support_path }, + ); + log.warn("loading them both in that order", .{}); + break :loaded true; + } + + break :loaded app_support_action != .not_found or + legacy_app_support_action != .not_found; + }; + + // If both files are not found, then we create a template file. + // For macOS, we only create the template file in the app support + if (!app_support_loaded and !xdg_loaded) { + writeConfigTemplate(app_support_path) catch |err| { + log.warn("error creating template config file err={}", .{err}); + }; + } + } else { + if (!xdg_loaded) { + writeConfigTemplate(xdg_path) catch |err| { + log.warn("error creating template config file err={}", .{err}); + }; + } + } +} + +/// 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 Linux/BSD and have no args. + .linux, .freebsd => if (std.os.argv.len <= 1) return, + + // Everything else we have to at least try because it may + // not use std.os.argv. + else => {}, + } + + // On Linux, we have a special case where if the executing + // program is "xdg-terminal-exec" then we treat all CLI + // args as if they are a command to execute. + // + // In this mode, we also behave slightly differently: + // + // - The initial window title is set to the full command. This + // can be used with window managers to modify positioning, + // styling, etc. based on the command. + // + // See: https://github.com/Vladimir-csp/xdg-terminal-exec + if ((comptime builtin.os.tag == .linux) or (comptime builtin.os.tag == .freebsd)) { + if (internal_os.xdg.parseTerminalExec(std.os.argv)) |args| { + const arena_alloc = self._arena.?.allocator(); + + // First, we add an artificial "-e" so that if we + // replay the inputs to rebuild the config (i.e. if + // a theme is set) then we will get the same behavior. + try self._replay_steps.append(arena_alloc, .@"-e"); + + // Next, take all remaining args and use that to build up + // a command to execute. + var builder: std.ArrayList([:0]const u8) = .empty; + errdefer builder.deinit(arena_alloc); + for (args) |arg_raw| { + const arg = std.mem.sliceTo(arg_raw, 0); + const copy = try arena_alloc.dupeZ(u8, arg); + try self._replay_steps.append(arena_alloc, .{ .arg = copy }); + try builder.append(arena_alloc, copy); + } + + self.@"_xdg-terminal-exec" = true; + self.@"initial-command" = .{ .direct = try builder.toOwnedSlice(arena_alloc) }; + return; + } + } + + // We set config-default-files to true here because this + // should always be reset so we can detect if it is set + // in the CLI since it is documented as having no affect + // from files. + self.@"config-default-files" = true; + + // Keep track of the replay steps up to this point so we + // can replay if we are discarding the default files. + const replay_len_start = self._replay_steps.items.len; + + // font-family settings set via the CLI overwrite any prior values + // rather than append. This avoids a UX oddity where you have to + // specify `font-family=""` to clear the font families. + const fields = &[_][]const u8{ + "font-family", + "font-family-bold", + "font-family-italic", + "font-family-bold-italic", + }; + inline for (fields) |field| @field(self, field).overwrite_next = true; + defer { + inline for (fields) |field| @field(self, field).overwrite_next = false; + } + + // Initialize our CLI iterator. + var iter = try cli.args.argsIterator(alloc_gpa); + defer iter.deinit(); + try self.loadIter(alloc_gpa, &iter); + + // If we are not loading the default files, then we need to + // replay the steps up to this point so that we can rebuild + // the config without it. + if (!self.@"config-default-files") reload: { + const replay_len_end = self._replay_steps.items.len; + if (replay_len_end == replay_len_start) break :reload; + log.info("config-default-files unset, discarding configuration from default files", .{}); + + var new_config = try self.cloneEmpty(alloc_gpa); + errdefer new_config.deinit(); + var it = Replay.iterator( + self._replay_steps.items[replay_len_start..replay_len_end], + &new_config, + ); + try new_config.loadIter(alloc_gpa, &it); + self.deinit(); + self.* = new_config; + } + + // Any paths referenced from the CLI are relative to the current working + // directory. + var buf: [std.fs.max_path_bytes]u8 = undefined; + try self.expandPaths(try std.fs.cwd().realpath(".", &buf)); +} + +/// Load and parse the config files that were added in the "config-file" key. +pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void { + if (self.@"config-file".value.items.len == 0) return; + const arena_alloc = self._arena.?.allocator(); + + // Keeps track of loaded files to prevent cycles. + var loaded = std.StringHashMap(void).init(alloc_gpa); + defer loaded.deinit(); + + // We need to insert all of our loaded config-file values + // PRIOR to the "-e" in our replay steps, since everything + // after "-e" becomes an "initial-command". To do this, we + // dupe the values if we find it. + var replay_suffix: std.ArrayList(Replay.Step) = .empty; + defer replay_suffix.deinit(alloc_gpa); + for (self._replay_steps.items, 0..) |step, i| if (step == .@"-e") { + // We don't need to clone the steps because they should + // all be allocated in our arena and we're keeping our + // arena. + try replay_suffix.appendSlice(alloc_gpa, self._replay_steps.items[i..]); + + // Remove our old values. Again, don't need to free any + // memory here because its all part of our arena. + self._replay_steps.shrinkRetainingCapacity(i); + break; + }; + + // We must use a while below and not a for(items) because we + // may add items to the list while iterating for recursive + // config-file entries. + var i: usize = 0; + while (i < self.@"config-file".value.items.len) : (i += 1) { + const path, const optional = switch (self.@"config-file".value.items[i]) { + .optional => |path| .{ path, true }, + .required => |path| .{ path, false }, + }; + + // Error paths + if (path.len == 0) continue; + + // All paths should already be absolute at this point because + // they're fixed up after each load. + assert(std.fs.path.isAbsolute(path)); + + // We must only load a unique file once + if (try loaded.fetchPut(path, {}) != null) { + const diag: cli.Diagnostic = .{ + .message = try std.fmt.allocPrintSentinel( + arena_alloc, + "config-file {s}: cycle detected", + .{path}, + 0, + ), + }; + + try self._diagnostics.append(arena_alloc, diag); + try self._replay_steps.append(arena_alloc, .{ .diagnostic = diag }); + continue; + } + + var file = std.fs.openFileAbsolute(path, .{}) catch |err| { + if (err != error.FileNotFound or !optional) { + const diag: cli.Diagnostic = .{ + .message = try std.fmt.allocPrintSentinel( + arena_alloc, + "error opening config-file {s}: {}", + .{ path, err }, + 0, + ), + }; + + try self._diagnostics.append(arena_alloc, diag); + try self._replay_steps.append(arena_alloc, .{ .diagnostic = diag }); + } + continue; + }; + defer file.close(); + + const stat = try file.stat(); + switch (stat.kind) { + .file => {}, + else => |kind| { + const diag: cli.Diagnostic = .{ + .message = try std.fmt.allocPrintSentinel( + arena_alloc, + "config-file {s}: not reading because file type is {s}", + .{ path, @tagName(kind) }, + 0, + ), + }; + + try self._diagnostics.append(arena_alloc, diag); + try self._replay_steps.append(arena_alloc, .{ .diagnostic = diag }); + continue; + }, + } + + try self.loadFsFile(arena_alloc, &file, path); + } + + // If we have a suffix, add that back. + if (replay_suffix.items.len > 0) { + try self._replay_steps.appendSlice( + arena_alloc, + replay_suffix.items, + ); + } +} + +/// Get the arena allocator associated with the configuration. +pub fn arenaAlloc(self: *Config) Allocator { + return self._arena.?.allocator(); +} + +/// Change the state of conditionals and reload the configuration +/// based on the new state. This returns a new configuration based +/// on the new state. The caller must free the old configuration if they +/// wish. +/// +/// This returns null if the conditional state would result in no changes +/// to the configuration. In this case, the caller can continue to use +/// the existing configuration or clone if they want a copy. +/// +/// This doesn't re-read any files, it just re-applies the same +/// configuration with the new conditional state. Importantly, this means +/// that if you change the conditional state and the user in the interim +/// deleted a file that was referenced in the configuration, then the +/// configuration can still be reloaded. +pub fn changeConditionalState( + self: *const Config, + new: conditional.State, +) !?Config { + // If the conditional state between the old and new is the same, + // then we don't need to do anything. + relevant: { + inline for (@typeInfo(conditional.Key).@"enum".fields) |field| { + const key: conditional.Key = @field(conditional.Key, field.name); + + // Conditional set contains the keys that this config uses. So we + // only continue if we use this key. + if (self._conditional_set.contains(key) and !deepEqual( + @TypeOf(@field(self._conditional_state, field.name)), + @field(self._conditional_state, field.name), + @field(new, field.name), + )) { + break :relevant; + } + } + + // If we got here, then we didn't find any differences between + // the old and new conditional state that would affect the + // configuration. + return null; + } + + // Create our new configuration + const alloc_gpa = self._arena.?.child_allocator; + var new_config = try self.cloneEmpty(alloc_gpa); + errdefer new_config.deinit(); + + // Set our conditional state so the replay below can use it + new_config._conditional_state = new; + + // Replay all of our steps to rebuild the configuration + var it = Replay.iterator(self._replay_steps.items, &new_config); + try new_config.loadIter(alloc_gpa, &it); + try new_config.finalize(); + + return new_config; +} + +/// Expand the relative paths in config-files to be absolute paths +/// relative to the base directory. +fn expandPaths(self: *Config, base: []const u8) !void { + const arena_alloc = self._arena.?.allocator(); + + // Keep track of this step for replays + try self._replay_steps.append( + arena_alloc, + .{ .expand = try arena_alloc.dupe(u8, base) }, + ); + + // Expand all of our paths + inline for (@typeInfo(Config).@"struct".fields) |field| { + switch (field.type) { + RepeatablePath, Path => { + try @field(self, field.name).expand( + arena_alloc, + base, + &self._diagnostics, + ); + }, + ?RepeatablePath, ?Path => { + if (@field(self, field.name)) |*path| { + try path.expand( + arena_alloc, + base, + &self._diagnostics, + ); + } + }, + else => {}, + } + } +} + +fn loadTheme(self: *Config, theme: Theme) !void { + // Load the correct theme depending on the conditional state. + // Dark/light themes were programmed prior to conditional configuration + // so when we introduce that we probably want to replace this. + const name: []const u8 = switch (self._conditional_state.theme) { + .light => theme.light, + .dark => theme.dark, + }; + + // Find our theme file and open it. See the open function for details. + const themefile = (try themepkg.open( + self._arena.?.allocator(), + name, + &self._diagnostics, + )) orelse return; + const path = themefile.path; + const file = themefile.file; + defer file.close(); + + // From this point onwards, we load the theme and do a bit of a dance + // to achieve two separate goals: + // + // (1) We want the theme to be loaded and our existing config to + // override the theme. So we need to load the theme and apply + // our config on top of it. + // + // (2) We want to free existing memory that we aren't using anymore + // as a result of reloading the configuration. + // + // Point 2 is strictly a result of aur approach to point 1, but it is + // a nice property to have to limit memory bloat as much as possible. + + // Load into a new configuration so that we can free the existing memory. + const alloc_gpa = self._arena.?.child_allocator; + var new_config = try self.cloneEmpty(alloc_gpa); + errdefer new_config.deinit(); + + // Load our theme + var buf: [2048]u8 = undefined; + var file_reader = file.reader(&buf); + const reader = &file_reader.interface; + var iter: cli.args.LineIterator = .{ .r = reader, .filepath = path }; + try new_config.loadIter(alloc_gpa, &iter); + + // Setup our replay to be conditional. + conditional: for (new_config._replay_steps.items) |*item| { + switch (item.*) { + .expand, .diagnostic => {}, + + // If we see "-e" then we do NOT make the following arguments + // conditional since they are supposed to be part of the + // initial command. + .@"-e" => break :conditional, + + // Change our arg to be conditional on our theme. + .arg => |v| { + const alloc_arena = new_config._arena.?.allocator(); + const conds = try alloc_arena.alloc(Conditional, 1); + conds[0] = .{ + .key = .theme, + .op = .eq, + .value = @tagName(self._conditional_state.theme), + }; + item.* = .{ .conditional_arg = .{ + .conditions = conds, + .arg = v, + } }; + }, + + .conditional_arg => |v| { + const alloc_arena = new_config._arena.?.allocator(); + const conds = try alloc_arena.alloc(Conditional, v.conditions.len + 1); + conds[0] = .{ + .key = .theme, + .op = .eq, + .value = @tagName(self._conditional_state.theme), + }; + @memcpy(conds[1..], v.conditions); + item.* = .{ .conditional_arg = .{ + .conditions = conds, + .arg = v.arg, + } }; + }, + } + } + + // Replay our previous inputs so that we can override values + // from the theme. + var slice_it = Replay.iterator(self._replay_steps.items, &new_config); + try new_config.loadIter(alloc_gpa, &slice_it); + + // Success, swap our new config in and free the old. + self.deinit(); + self.* = new_config; +} + +/// Call this once after you are done setting configuration. This +/// is idempotent but will waste memory if called multiple times. +pub fn finalize(self: *Config) !void { + // We always load the theme first because it may set other fields + // in our config. + if (self.theme) |theme| { + const different = !std.mem.eql(u8, theme.light, theme.dark); + + // Warning: loadTheme will deinit our existing config and replace + // it so all memory from self prior to this point will be freed. + try self.loadTheme(theme); + + // If we have different light vs dark mode themes, disable + // window-theme = auto since that breaks it. + if (different) { + // This setting doesn't make sense with different light/dark themes + // because it'll force the theme based on the Ghostty theme. + if (self.@"window-theme" == .auto) self.@"window-theme" = .system; + + // Mark that we use a conditional theme + self._conditional_set.insert(.theme); + } + } + + const alloc = self._arena.?.allocator(); + + // Used for a variety of defaults. See the function docs as well the + // specific variable use sites for more details. + const probable_cli = probableCliEnvironment(); + + // 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".count() > 0) { + const fields = &[_][]const u8{ + "font-family-bold", + "font-family-italic", + "font-family-bold-italic", + }; + inline for (fields) |field| { + if (@field(self, field).count() == 0) { + @field(self, field) = try self.@"font-family".clone(alloc); + } + } + } + + // Prevent setting TERM to an empty string + if (self.term.len == 0) { + // HACK: See comment above at definition + self.term = "xterm-ghostty"; + } + + // The default for the working directory depends on the system. + var wd: WorkingDirectory = self.@"working-directory" orelse if (probable_cli) + .inherit + else + .home; + + // 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. + if ((comptime !builtin.target.cpu.arch.isWasm()) and + (comptime !builtin.is_test)) + { + if (self.command == null or wd == .home) command: { + // 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={}", .{cmd}) + else shell_env: { + // Flatpak always gets its shell from outside the sandbox + if (internal_os.isFlatpak()) break :shell_env; + + // If we were launched from the desktop, our SHELL env var + // will represent our SHELL at login time. We only want to + // read from SHELL if we're in a probable CLI environment. + if (!probable_cli) break :shell_env; + + if (std.process.getEnvVarOwned(alloc, "SHELL")) |value| { + log.info("default shell source=env value={s}", .{value}); + + const copy = try alloc.dupeZ(u8, value); + self.command = .{ .shell = copy }; + + // If we don't need the working directory, then we can exit now. + if (wd != .home) break :command; + } else |_| {} + } + + switch (builtin.os.tag) { + .windows => { + if (self.command == null) { + log.warn("no default shell found, will default to using cmd", .{}); + self.command = .{ .shell = "cmd.exe" }; + } + + if (wd == .home) { + var buf: [std.fs.max_path_bytes]u8 = undefined; + if (try internal_os.home(&buf)) |home| { + wd = .{ .path = try alloc.dupe(u8, home) }; + } else { + wd = .inherit; + } + } + }, + + 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 = .{ .shell = sh }; + } + } + + if (wd == .home) { + if (pw.home) |home| { + log.info("default working directory src=passwd value={s}", .{home}); + wd = .{ .path = home }; + } else { + wd = .inherit; + } + } + + if (self.command == null) { + log.warn("no default shell found, will default to using sh", .{}); + } + }, + } + } + } + try wd.finalize(alloc); + self.@"working-directory" = wd; + + // Apprt-specific defaults + switch (build_config.app_runtime) { + .none => {}, + .gtk => { + switch (self.@"gtk-single-instance") { + .true, .false => {}, + + // For detection, we assume single instance unless we're + // in a CLI environment, then we disable single instance. + .detect => self.@"gtk-single-instance" = if (probable_cli) + .false + else + .true, + } + }, + } + + // Default our click interval + if (self.@"click-repeat-interval" == 0 and + (comptime !builtin.is_test)) + { + self.@"click-repeat-interval" = internal_os.clickInterval() orelse 500; + } + + // Clamp our mouse scroll multiplier + self.@"mouse-scroll-multiplier".precision = @min(10_000.0, @max(0.01, self.@"mouse-scroll-multiplier".precision)); + self.@"mouse-scroll-multiplier".discrete = @min(10_000.0, @max(0.01, self.@"mouse-scroll-multiplier".discrete)); + + // Clamp our split opacity + self.@"unfocused-split-opacity" = @min(1.0, @max(0.15, self.@"unfocused-split-opacity")); + + // Clamp our contrast + self.@"minimum-contrast" = @min(21, @max(1, self.@"minimum-contrast")); + + // Minimum window size + if (self.@"window-width" > 0) self.@"window-width" = @max(10, self.@"window-width"); + if (self.@"window-height" > 0) self.@"window-height" = @max(4, self.@"window-height"); + + // If URLs are disabled, cut off the first link. The first link is + // always the URL matcher. + if (!self.@"link-url") self.link.links.items = self.link.links.items[1..]; + + // We warn when the quit-after-last-window-closed-delay is set to a very + // short value because it can cause Ghostty to quit before the first + // window is even shown. + if (self.@"quit-after-last-window-closed-delay") |duration| { + if (duration.duration < 5 * std.time.ns_per_s) { + log.warn( + "quit-after-last-window-closed-delay is set to a very short value ({f}), which might cause problems", + .{duration}, + ); + } + } + + // We can't set this as a struct default because our config is + // loaded in environments where a build config isn't available. + if (self.@"auto-update-channel" == null) { + self.@"auto-update-channel" = build_config.release_channel; + } + + self.@"faint-opacity" = std.math.clamp(self.@"faint-opacity", 0.0, 1.0); + + // Finalize key remapping set for efficient lookups + self.@"key-remap".finalize(); +} + +/// Callback for src/cli/args.zig to allow us to handle special cases +/// like `--help` or `-e`. Returns "false" if the CLI parsing should halt. +pub fn parseManuallyHook( + self: *Config, + alloc: Allocator, + arg: []const u8, + iter: anytype, +) !bool { + if (std.mem.eql(u8, arg, "-e")) { + // Add the special -e marker. This prevents: + // (1) config-file from adding args to the end (see #2908) + // (2) dark/light theme from making this conditional + try self._replay_steps.append(alloc, .@"-e"); + + // Build up the command. We don't clean this up because we take + // ownership in our allocator. + var command: std.ArrayList([:0]const u8) = .empty; + errdefer command.deinit(alloc); + + while (iter.next()) |param| { + const copy = try alloc.dupeZ(u8, param); + try self._replay_steps.append(alloc, .{ .arg = copy }); + try command.append(alloc, copy); + } + + if (command.items.len == 0) { + try self._diagnostics.append(alloc, .{ + .location = try cli.Location.fromIter(iter, alloc), + .message = try std.fmt.allocPrintSentinel( + alloc, + "missing command after {s}", + .{arg}, + 0, + ), + }); + + return false; + } + + // See "command" docs for the implied configurations and why. + self.@"initial-command" = .{ .direct = command.items }; + self.@"gtk-single-instance" = .false; + self.@"quit-after-last-window-closed" = true; + self.@"quit-after-last-window-closed-delay" = null; + if (self.@"shell-integration" != .none) { + self.@"shell-integration" = .detect; + } + + // Do not continue, we consumed everything. + return false; + } + + // Keep track of our input args for replay + try self._replay_steps.append( + alloc, + .{ .arg = try alloc.dupeZ(u8, arg) }, + ); + + // If we didn't find a special case, continue parsing normally + return true; +} + +fn compatGtkTabsLocation( + self: *Config, + alloc: Allocator, + key: []const u8, + value: ?[]const u8, +) bool { + _ = alloc; + assert(std.mem.eql(u8, key, "gtk-tabs-location")); + + if (std.mem.eql(u8, value orelse "", "hidden")) { + self.@"window-show-tab-bar" = .never; + return true; + } + + return false; +} + +fn compatGtkSingleInstance( + self: *Config, + alloc: Allocator, + key: []const u8, + value: ?[]const u8, +) bool { + _ = alloc; + assert(std.mem.eql(u8, key, "gtk-single-instance")); + + if (std.mem.eql(u8, value orelse "", "desktop")) { + self.@"gtk-single-instance" = .detect; + return true; + } + + return false; +} + +fn compatCursorInvertFgBg( + self: *Config, + alloc: Allocator, + key: []const u8, + value_: ?[]const u8, +) bool { + _ = alloc; + assert(std.mem.eql(u8, key, "cursor-invert-fg-bg")); + + // We don't do anything if the value is unset, which is technically + // not EXACTLY the same as prior behavior since it would fallback + // to doing whatever cursor-color/cursor-text were set to, but + // I don't want to store what that is separately so this is close + // enough. + // + // Realistically, these fields were mutually exclusive so anyone + // relying on that behavior should just upgrade to the new + // cursor-color/cursor-text fields. + const set = cli.args.parseBool(value_ orelse "t") catch return false; + if (set) { + self.@"cursor-color" = .@"cell-foreground"; + self.@"cursor-text" = .@"cell-background"; + } + + return true; +} + +fn compatSelectionInvertFgBg( + self: *Config, + alloc: Allocator, + key: []const u8, + value_: ?[]const u8, +) bool { + _ = alloc; + assert(std.mem.eql(u8, key, "selection-invert-fg-bg")); + + const set = cli.args.parseBool(value_ orelse "t") catch return false; + if (set) { + self.@"selection-foreground" = .@"cell-background"; + self.@"selection-background" = .@"cell-foreground"; + } + + return true; +} + +fn compatBoldIsBright( + self: *Config, + alloc: Allocator, + key: []const u8, + value_: ?[]const u8, +) bool { + _ = alloc; + assert(std.mem.eql(u8, key, "bold-is-bright")); + + const isset = cli.args.parseBool(value_ orelse "t") catch return false; + if (isset) { + self.@"bold-color" = .bright; + } + + return true; +} + +fn compatMacOSDockDropBehavior( + self: *Config, + alloc: Allocator, + key: []const u8, + value: ?[]const u8, +) bool { + _ = alloc; + assert(std.mem.eql(u8, key, "macos-dock-drop-behavior")); + + if (std.mem.eql(u8, value orelse "", "window")) { + self.@"macos-dock-drop-behavior" = .@"new-window"; + return true; + } + + return false; +} + +/// Add a diagnostic message to the config with the given string. +/// This is always added with a location of "none". +pub fn addDiagnosticFmt( + self: *Config, + comptime fmt: []const u8, + args: anytype, +) Allocator.Error!void { + const alloc = self._arena.?.allocator(); + try self._diagnostics.append(alloc, .{ + .message = try std.fmt.allocPrintSentinel( + alloc, + fmt, + args, + 0, + ), + }); +} + +/// 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 = .init(alloc_gpa); + return result; +} + +/// Create a copy of the metadata of this configuration but without +/// the actual values. Metadata includes conditional state. +pub fn cloneEmpty( + self: *const Config, + alloc_gpa: Allocator, +) Allocator.Error!Config { + var result = try default(alloc_gpa); + result._conditional_state = self._conditional_state; + return result; +} + +/// Create a copy of this configuration. +/// +/// This will not re-read referenced configuration files and operates +/// purely in-memory. +pub fn clone( + self: *const Config, + alloc_gpa: Allocator, +) Allocator.Error!Config { + // Start with an empty config + var result = try self.cloneEmpty(alloc_gpa); + errdefer result.deinit(); + const alloc_arena = result._arena.?.allocator(); + + // Copy our values + inline for (@typeInfo(Config).@"struct".fields) |field| { + if (!@hasField(Key, field.name)) continue; + @field(result, field.name) = try cloneValue( + alloc_arena, + field.type, + @field(self, field.name), + ); + } + + // Copy our diagnostics + result._diagnostics = try self._diagnostics.clone(alloc_arena); + + // Preserve our replay steps. We copy them exactly to also preserve + // the exact conditionals required for some steps. + try result._replay_steps.ensureTotalCapacity( + alloc_arena, + self._replay_steps.items.len, + ); + for (self._replay_steps.items) |item| { + result._replay_steps.appendAssumeCapacity( + try item.clone(alloc_arena), + ); + } + assert(result._replay_steps.items.len == self._replay_steps.items.len); + + // Copy the conditional set + result._conditional_set = self._conditional_set; + + return result; +} + +fn cloneValue( + alloc: Allocator, + comptime T: type, + src: T, +) Allocator.Error!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 => {}, + } + + // If we're a type that can have decls and we have clone, then + // call clone and be done. + const t = @typeInfo(T); + if (t == .@"struct" or t == .@"enum" or t == .@"union") { + if (@hasDecl(T, "clone")) return try src.clone(alloc); + } + + // Back into types of types + switch (t) { + inline .bool, + .int, + .float, + .@"enum", + .@"union", + => return src, + + .optional => |info| return try cloneValue( + alloc, + info.child, + src orelse return null, + ), + + .@"struct" => |info| { + // Packed structs we can return directly as copies. + assert(info.layout == .@"packed"); + return src; + }, + + 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 !deepEqual(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; + } +}; + +/// This runs a heuristic to determine if we are likely running +/// Ghostty in a CLI environment. We need this to change some behaviors. +/// We should keep the set of behaviors that depend on this as small +/// as possible because magic sucks, but each place is well documented. +fn probableCliEnvironment() bool { + switch (builtin.os.tag) { + // Windows has its own problems, just ignore it for now since + // its not a real supported target and GTK via WSL2 assuming + // single instance is probably fine. + .windows => return false, + + // On macOS, we don't want to detect `open` calls as CLI envs. + // Our desktop detection on macOS is very accurate due to how + // processes are launched on macOS, so if we detect we're launched + // from the app bundle then we're not in a CLI environment. + .macos => if (internal_os.launchedFromDesktop()) return false, + + else => {}, + } + + // If we have TERM_PROGRAM set to a non-empty value, we assume + // a graphical terminal environment. + if (std.posix.getenv("TERM_PROGRAM")) |v| { + if (v.len > 0) return true; + } + + // CLI arguments makes things probable + if (std.os.argv.len > 1) return true; + + // Unlikely CLI environment + return false; +} + +/// This is used to "replay" the configuration. See loadTheme for details. +const Replay = struct { + const Step = union(enum) { + /// An argument to parse as if it came from the CLI or file. + arg: [:0]const u8, + + /// A base path to expand relative paths against. + expand: []const u8, + + /// A conditional argument. This arg is parsed only if all + /// conditions match (an "AND"). An "OR" can be achieved by + /// having multiple conditional arg entries. + conditional_arg: struct { + conditions: []const Conditional, + arg: []const u8, + }, + + /// A diagnostic to be added to the new configuration when + /// replayed. This should only be used for diagnostics that won't + /// be reproduced during playback. For example, `config-file` + /// errors are not reloaded so they should be added here. + /// + /// Diagnostics cannot be conditional. They are always present + /// even if the conditionals don't match. This helps users find + /// errors in their configuration. + diagnostic: cli.Diagnostic, + + /// The start of a "-e" argument. This marks the end of + /// traditional configuration and the beginning of the + /// "-e" initial command magic. This is separate from "arg" + /// because there are some behaviors unique to this (i.e. + /// we want to keep this at the end for config-file). + /// + /// Note: when "-e" is used, ONLY this is present and + /// not an additional "arg" with "-e" value. + @"-e", + + fn clone( + self: Step, + alloc: Allocator, + ) Allocator.Error!Step { + return switch (self) { + .@"-e" => self, + .diagnostic => |v| .{ .diagnostic = try v.clone(alloc) }, + .arg => |v| .{ .arg = try alloc.dupeZ(u8, v) }, + .expand => |v| .{ .expand = try alloc.dupe(u8, v) }, + .conditional_arg => |v| conditional: { + var conds = try alloc.alloc(Conditional, v.conditions.len); + for (v.conditions, 0..) |cond, i| conds[i] = try cond.clone(alloc); + break :conditional .{ .conditional_arg = .{ + .conditions = conds, + .arg = try alloc.dupe(u8, v.arg), + } }; + }, + }; + } + }; + + const Iterator = struct { + const Self = @This(); + + config: *Config, + slice: []const Replay.Step, + idx: usize = 0, + + pub fn next(self: *Self) ?[]const u8 { + while (true) { + if (self.idx >= self.slice.len) return null; + defer self.idx += 1; + switch (self.slice[self.idx]) { + .expand => |base| self.config.expandPaths(base) catch |err| { + // This shouldn't happen because to reach this step + // means that it succeeded before. Its possible since + // expanding paths is a side effect process that the + // world state changed and we can't expand anymore. + // In that really unfortunate case, we log a warning. + log.warn("error expanding paths err={}", .{err}); + }, + + .diagnostic => |diag| diag: { + // Best effort to clone and append the diagnostic. + // If it fails we log a warning and continue. + const arena_alloc = self.config._arena.?.allocator(); + const cloned = diag.clone(arena_alloc) catch |err| { + log.warn("error cloning diagnostic err={}", .{err}); + break :diag; + }; + self.config._diagnostics.append(arena_alloc, cloned) catch |err| { + log.warn("error appending diagnostic err={}", .{err}); + break :diag; + }; + }, + + .conditional_arg => |v| conditional: { + // All conditions must match. + for (v.conditions) |cond| { + if (!self.config._conditional_state.match(cond)) { + break :conditional; + } + } + + return v.arg; + }, + + .arg => |arg| return arg, + .@"-e" => return "-e", + } + } + } + }; + + /// Construct a Replay iterator from a slice of replay elements. + /// This can be used with args.parse and handles intermediate + /// steps such as expanding relative paths. + fn iterator(slice: []const Replay.Step, dst: *Config) Iterator { + return .{ .slice = slice, .config = dst }; + } +}; + +/// Valid values for confirm-close-surface +/// c_int because it needs to be extern compatible +/// If this is changed, you must also update ghostty.h +pub const ConfirmCloseSurface = enum(c_int) { + false, + true, + always, +}; + +/// Valid values for custom-shader-animation +/// c_int because it needs to be extern compatible +/// If this is changed, you must also update ghostty.h +pub const CustomShaderAnimation = enum(c_int) { + false, + true, + always, +}; + +/// 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", + @"padded-notch", +}; + +/// Valid values for fullscreen config option +/// c_int because it needs to be extern compatible +/// If this is changed, you must also update ghostty.h +pub const Fullscreen = enum(c_int) { + false, + true, + @"non-native", + @"non-native-visible-menu", + @"non-native-padded-notch", +}; + +pub const WindowPaddingColor = enum { + background, + extend, + @"extend-always", +}; + +pub const WindowSubtitle = enum { + false, + @"working-directory", +}; + +pub const LinkPreviews = enum { + false, + true, + osc8, +}; + +/// See working-directory +pub const WorkingDirectory = union(enum) { + const Self = @This(); + + /// Resolve to the current user's home directory during config finalize. + home, + + /// Inherit the working directory from the launching process. + inherit, + + /// Use an explicit working directory path. This may be not be + /// expanded until finalize is called. + path: []const u8, + + pub fn parseCLI(self: *Self, alloc: Allocator, input_: ?[]const u8) !void { + var input = input_ orelse return error.ValueRequired; + input = std.mem.trim(u8, input, &std.ascii.whitespace); + if (input.len == 0) return error.ValueRequired; + + // Match path.zig behavior for quoted values. + if (input.len >= 2 and input[0] == '"' and input[input.len - 1] == '"') { + input = input[1 .. input.len - 1]; + } + + if (std.mem.eql(u8, input, "home")) { + self.* = .home; + return; + } + + if (std.mem.eql(u8, input, "inherit")) { + self.* = .inherit; + return; + } + + self.* = .{ .path = try alloc.dupe(u8, input) }; + } + + /// Expand tilde paths in .path values. + pub fn finalize(self: *Self, alloc: Allocator) Allocator.Error!void { + const path = switch (self.*) { + .path => |path| path, + else => return, + }; + + if (!std.mem.startsWith(u8, path, "~/")) return; + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const expanded = internal_os.expandHome(path, &buf) catch |err| { + log.warn( + "error expanding home directory for working-directory path={s}: {}", + .{ path, err }, + ); + return; + }; + + if (std.mem.eql(u8, expanded, path)) return; + self.* = .{ .path = try alloc.dupe(u8, expanded) }; + } + + pub fn value(self: Self) ?[]const u8 { + return switch (self) { + .path => |path| path, + .home, .inherit => null, + }; + } + + pub fn clone(self: Self, alloc: Allocator) Allocator.Error!Self { + return switch (self) { + .path => |path| .{ .path = try alloc.dupe(u8, path) }, + else => self, + }; + } + + pub fn formatEntry(self: Self, formatter: formatterpkg.EntryFormatter) !void { + switch (self) { + .home, .inherit => try formatter.formatEntry([]const u8, @tagName(self)), + .path => |path| try formatter.formatEntry([]const u8, path), + } + } + + test "WorkingDirectory parseCLI" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var wd: Self = .inherit; + + try wd.parseCLI(alloc, "inherit"); + try testing.expectEqual(.inherit, wd); + + try wd.parseCLI(alloc, "home"); + try testing.expectEqual(.home, wd); + + try wd.parseCLI(alloc, "~/projects/ghostty"); + try testing.expectEqualStrings("~/projects/ghostty", wd.path); + + try wd.parseCLI(alloc, "\"/tmp path\""); + try testing.expectEqualStrings("/tmp path", wd.path); + } + + test "WorkingDirectory finalize" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + { + var wd: Self = .{ .path = "~/projects/ghostty" }; + try wd.finalize(alloc); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const expected = internal_os.expandHome( + "~/projects/ghostty", + &buf, + ) catch "~/projects/ghostty"; + try testing.expectEqualStrings(expected, wd.value().?); + } + } +}; + +/// Color represents a color using RGB. +/// +/// This is a packed struct so that the C API to read color values just +/// works by setting it to a C integer. +pub const Color = struct { + r: u8, + g: u8, + b: u8, + + /// ghostty_config_color_s + pub const C = extern struct { + r: u8, + g: u8, + b: u8, + }; + + pub fn cval(self: Color) Color.C { + return .{ .r = self.r, .g = self.g, .b = self.b }; + } + + /// 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 { + const input = input_ orelse return error.ValueRequired; + // Trim any whitespace before processing + const trimmed = std.mem.trim(u8, input, " \t"); + + if (terminal.x11_color.map.get(trimmed)) |rgb| return .{ + .r = rgb.r, + .g = rgb.g, + .b = rgb.b, + }; + + return fromHex(trimmed); + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: Color, _: Allocator) error{}!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); + } + + /// Used by Formatter + pub fn formatEntry(self: Color, formatter: formatterpkg.EntryFormatter) !void { + var buf: [128]u8 = undefined; + try formatter.formatEntry( + []const u8, + try self.formatBuf(&buf), + ); + } + + /// Format the color as a string. + pub fn formatBuf(self: Color, buf: []u8) Allocator.Error![]const u8 { + return std.fmt.bufPrint( + buf, + "#{x:0>2}{x:0>2}{x:0>2}", + .{ self.r, self.g, self.b }, + ) catch error.OutOfMemory; + } + + /// 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; + if (trimmed.len != 6 and trimmed.len != 3) return error.InvalidValue; + + // Expand short hex values to full hex values + const rgb: []const u8 = if (trimmed.len == 3) &.{ + trimmed[0], trimmed[0], + trimmed[1], trimmed[1], + trimmed[2], trimmed[2], + } else trimmed; + + // 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(rgb[i], 16)) * 16) + + try std.fmt.charToDigit(rgb[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")); + try testing.expectEqual(Color{ .r = 255, .g = 255, .b = 255 }, try Color.fromHex("FFF")); + try testing.expectEqual(Color{ .r = 51, .g = 68, .b = 85 }, try Color.fromHex("#345")); + } + + test "parseCLI from name" { + try std.testing.expectEqual(Color{ .r = 0, .g = 0, .b = 0 }, try Color.parseCLI("black")); + } + + test "formatConfig" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var color: Color = .{ .r = 10, .g = 11, .b = 12 }; + try color.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); + try std.testing.expectEqualSlices(u8, "a = #0a0b0c\n", buf.written()); + } + + test "parseCLI with whitespace" { + const testing = std.testing; + try testing.expectEqual( + Color{ .r = 0xAA, .g = 0xBB, .b = 0xCC }, + try Color.parseCLI(" #AABBCC "), + ); + try testing.expectEqual( + Color{ .r = 0, .g = 0, .b = 0 }, + try Color.parseCLI(" black "), + ); + } +}; + +/// Represents color values that can also reference special color +/// values such as "cell-foreground" or "cell-background". +pub const TerminalColor = union(enum) { + color: Color, + @"cell-foreground", + @"cell-background", + + pub fn parseCLI(input_: ?[]const u8) !TerminalColor { + const input = input_ orelse return error.ValueRequired; + if (std.mem.eql(u8, input, "cell-foreground")) return .@"cell-foreground"; + if (std.mem.eql(u8, input, "cell-background")) return .@"cell-background"; + return .{ .color = try Color.parseCLI(input) }; + } + + pub fn toTerminalRGB(self: TerminalColor) ?terminal.color.RGB { + return switch (self) { + .color => |v| v.toTerminalRGB(), + .@"cell-foreground", .@"cell-background" => null, + }; + } + + /// Used by Formatter + pub fn formatEntry(self: TerminalColor, formatter: formatterpkg.EntryFormatter) !void { + switch (self) { + .color => try self.color.formatEntry(formatter), + + .@"cell-foreground", + .@"cell-background", + => try formatter.formatEntry([:0]const u8, @tagName(self)), + } + } + + test "parseCLI" { + const testing = std.testing; + + try testing.expectEqual( + TerminalColor{ .color = Color{ .r = 78, .g = 42, .b = 132 } }, + try TerminalColor.parseCLI("#4e2a84"), + ); + try testing.expectEqual( + TerminalColor{ .color = Color{ .r = 0, .g = 0, .b = 0 } }, + try TerminalColor.parseCLI("black"), + ); + try testing.expectEqual( + TerminalColor.@"cell-foreground", + try TerminalColor.parseCLI("cell-foreground"), + ); + try testing.expectEqual( + TerminalColor.@"cell-background", + try TerminalColor.parseCLI("cell-background"), + ); + + try testing.expectError(error.InvalidValue, TerminalColor.parseCLI("a")); + } + + test "formatConfig" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var sc: TerminalColor = .@"cell-foreground"; + try sc.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); + try testing.expectEqualSlices(u8, "a = cell-foreground\n", buf.written()); + } +}; + +/// Represents color values that can be used for bold. See `bold-color`. +pub const BoldColor = union(enum) { + color: Color, + bright, + + /// Convert to the terminal-native BoldColor type. + pub fn toTerminal(self: BoldColor) terminal.style.Style.BoldColor { + return switch (self) { + .color => |col| .{ .color = col.toTerminalRGB() }, + .bright => .bright, + }; + } + + pub fn parseCLI(input_: ?[]const u8) !BoldColor { + const input = input_ orelse return error.ValueRequired; + if (std.mem.eql(u8, input, "bright")) return .bright; + return .{ .color = try Color.parseCLI(input) }; + } + + /// Used by Formatter + pub fn formatEntry(self: BoldColor, formatter: formatterpkg.EntryFormatter) !void { + switch (self) { + .color => try self.color.formatEntry(formatter), + .bright => try formatter.formatEntry( + [:0]const u8, + @tagName(self), + ), + } + } + + test "parseCLI" { + const testing = std.testing; + + try testing.expectEqual( + BoldColor{ .color = Color{ .r = 78, .g = 42, .b = 132 } }, + try BoldColor.parseCLI("#4e2a84"), + ); + try testing.expectEqual( + BoldColor{ .color = Color{ .r = 0, .g = 0, .b = 0 } }, + try BoldColor.parseCLI("black"), + ); + try testing.expectEqual( + BoldColor.bright, + try BoldColor.parseCLI("bright"), + ); + + try testing.expectError(error.InvalidValue, BoldColor.parseCLI("a")); + } + + test "formatConfig" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var sc: BoldColor = .bright; + try sc.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); + try testing.expectEqualSlices(u8, "a = bright\n", buf.written()); + } +}; + +pub const ColorList = struct { + const Self = @This(); + + colors: std.ArrayListUnmanaged(Color) = .{}, + colors_c: std.ArrayListUnmanaged(Color.C) = .{}, + + /// ghostty_config_color_list_s + pub const C = extern struct { + colors: [*]Color.C, + len: usize, + }; + + pub fn cval(self: *const Self) C { + return .{ + .colors = self.colors_c.items.ptr, + .len = self.colors_c.items.len, + }; + } + + pub fn parseCLI( + self: *Self, + alloc: Allocator, + input_: ?[]const u8, + ) !void { + const input = input_ orelse return error.ValueRequired; + if (input.len == 0) return error.ValueRequired; + + // Always reset on parse + self.* = .{}; + + // Split the input by commas and parse each color + var it = std.mem.tokenizeScalar(u8, input, ','); + var count: usize = 0; + while (it.next()) |raw| { + count += 1; + if (count > 64) return error.InvalidValue; + + // Trim whitespace from each color value + const trimmed = std.mem.trim(u8, raw, " \t"); + const color = try Color.parseCLI(trimmed); + try self.colors.append(alloc, color); + try self.colors_c.append(alloc, color.cval()); + } + + // If no colors were parsed, we need to return an error + if (self.colors.items.len == 0) return error.InvalidValue; + + assert(self.colors.items.len == self.colors_c.items.len); + } + + pub fn clone( + self: *const Self, + alloc: Allocator, + ) Allocator.Error!Self { + return .{ + .colors = try self.colors.clone(alloc), + .colors_c = try self.colors_c.clone(alloc), + }; + } + + /// Compare if two of our value are requal. Required by Config. + pub fn equal(self: Self, other: Self) bool { + const itemsA = self.colors.items; + const itemsB = other.colors.items; + if (itemsA.len != itemsB.len) return false; + for (itemsA, itemsB) |a, b| { + if (!a.equal(b)) return false; + } else return true; + } + + /// Used by Formatter + pub fn formatEntry( + self: Self, + formatter: anytype, + ) !void { + // If no items, we want to render an empty field. + if (self.colors.items.len == 0) { + try formatter.formatEntry(void, {}); + return; + } + + // Build up the value of our config. Our buffer size should be + // sized to contain all possible maximum values. + var buf: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + for (self.colors.items, 0..) |color, i| { + var color_buf: [128]u8 = undefined; + const color_str = try color.formatBuf(&color_buf); + if (i != 0) writer.writeByte(',') catch return error.OutOfMemory; + writer.writeAll(color_str) catch return error.OutOfMemory; + } + + try formatter.formatEntry( + []const u8, + writer.buffered(), + ); + } + + test "parseCLI" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var p: Self = .{}; + try p.parseCLI(alloc, "black,white"); + try testing.expectEqual(2, p.colors.items.len); + + // Test whitespace handling + try p.parseCLI(alloc, "black, white"); // space after comma + try testing.expectEqual(2, p.colors.items.len); + try p.parseCLI(alloc, "black , white"); // spaces around comma + try testing.expectEqual(2, p.colors.items.len); + try p.parseCLI(alloc, " black , white "); // extra spaces at ends + try testing.expectEqual(2, p.colors.items.len); + + // Error cases + try testing.expectError(error.ValueRequired, p.parseCLI(alloc, null)); + try testing.expectError(error.InvalidValue, p.parseCLI(alloc, " ")); + } + + test "format" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var p: Self = .{}; + try p.parseCLI(alloc, "black,white"); + try p.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); + try std.testing.expectEqualSlices(u8, "a = #000000,#ffffff\n", buf.written()); + } + + test "clone" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var source: Self = .{}; + try source.parseCLI(alloc, "#ff0000,#00ff00,#0000ff"); + + const cloned = try source.clone(alloc); + + try testing.expect(source.equal(cloned)); + try testing.expectEqual(source.colors_c.items.len, cloned.colors_c.items.len); + for (source.colors_c.items, cloned.colors_c.items) |src_c, clone_c| { + try testing.expectEqual(src_c.r, clone_c.r); + try testing.expectEqual(src_c.g, clone_c.g); + try testing.expectEqual(src_c.b, clone_c.b); + } + } +}; + +/// Palette is the 256 color palette for 256-color mode. +pub const Palette = struct { + const Self = @This(); + + /// The actual value that is updated as we parse. + value: terminal.color.Palette = terminal.color.default, + + /// Keep track of which indexes were manually set by the user. + mask: terminal.color.PaletteMask = .initEmpty(), + + /// ghostty_config_palette_s + pub const C = extern struct { + colors: [256]Color.C, + }; + + pub fn cval(self: Self) Palette.C { + var result: Palette.C = undefined; + for (self.value, 0..) |color, i| { + result.colors[i] = Color.C{ + .r = color.r, + .g = color.g, + .b = color.b, + }; + } + + return result; + } + + 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.InvalidValue; + + // Parse the key part (trim whitespace) + const key = try std.fmt.parseInt( + u8, + std.mem.trim(u8, value[0..eqlIdx], " \t"), + 0, + ); + + // Parse the color part (Color.parseCLI will handle whitespace) + const rgb = try Color.parseCLI(value[eqlIdx + 1 ..]); + self.value[key] = .{ .r = rgb.r, .g = rgb.g, .b = rgb.b }; + self.mask.set(key); + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: Self, _: Allocator) error{}!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); + } + + /// Used by Formatter + pub fn formatEntry(self: Self, formatter: formatterpkg.EntryFormatter) !void { + var buf: [128]u8 = undefined; + for (0.., self.value) |k, v| { + try formatter.formatEntry( + []const u8, + std.fmt.bufPrint( + &buf, + "{d}=#{x:0>2}{x:0>2}{x:0>2}", + .{ k, v.r, v.g, v.b }, + ) catch return error.OutOfMemory, + ); + } + } + + 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); + try testing.expect(p.mask.isSet(0)); + try testing.expect(!p.mask.isSet(1)); + } + + test "parseCLI base" { + const testing = std.testing; + + var p: Self = .{}; + + try p.parseCLI("0b1=#014589"); + try p.parseCLI("0o7=#234567"); + try p.parseCLI("0xF=#ABCDEF"); + + try testing.expect(p.value[0b1].r == 0x01); + try testing.expect(p.value[0b1].g == 0x45); + try testing.expect(p.value[0b1].b == 0x89); + + try testing.expect(p.value[0o7].r == 0x23); + try testing.expect(p.value[0o7].g == 0x45); + try testing.expect(p.value[0o7].b == 0x67); + + try testing.expect(p.value[0xF].r == 0xAB); + try testing.expect(p.value[0xF].g == 0xCD); + try testing.expect(p.value[0xF].b == 0xEF); + + try testing.expect(p.mask.isSet(0b1)); + try testing.expect(p.mask.isSet(0o7)); + try testing.expect(p.mask.isSet(0xF)); + try testing.expect(!p.mask.isSet(0)); + try testing.expect(!p.mask.isSet(2)); + } + + test "parseCLI overflow" { + const testing = std.testing; + + var p: Self = .{}; + try testing.expectError(error.Overflow, p.parseCLI("256=#AABBCC")); + // Mask should remain empty since parsing failed. + try testing.expectEqual(@as(usize, 0), p.mask.count()); + } + + test "formatConfig" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var list: Self = .{}; + try list.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); + try std.testing.expectEqualSlices(u8, "a = 0=#1d1f21\n", buf.written()[0..14]); + } + + test "parseCLI with whitespace" { + const testing = std.testing; + + var p: Self = .{}; + try p.parseCLI("0 = #AABBCC"); + try p.parseCLI(" 1= #DDEEFF "); + try p.parseCLI(" 2 = #123456 "); + + try testing.expect(p.value[0].r == 0xAA); + try testing.expect(p.value[0].g == 0xBB); + try testing.expect(p.value[0].b == 0xCC); + + try testing.expect(p.value[1].r == 0xDD); + try testing.expect(p.value[1].g == 0xEE); + try testing.expect(p.value[1].b == 0xFF); + + try testing.expect(p.value[2].r == 0x12); + try testing.expect(p.value[2].g == 0x34); + try testing.expect(p.value[2].b == 0x56); + + try testing.expect(p.mask.isSet(0)); + try testing.expect(p.mask.isSet(1)); + try testing.expect(p.mask.isSet(2)); + try testing.expect(!p.mask.isSet(3)); + } +}; + +/// 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([:0]const u8) = .{}, + + // If true, then the next value will clear the list and start over + // rather than append. This is a bit of a hack but is here to make + // the font-family set of configurations work with CLI parsing. + overwrite_next: bool = false, + + pub fn parseCLI(self: *Self, alloc: Allocator, input: ?[]const u8) !void { + const value = input orelse return error.ValueRequired; + + // Empty value resets the list + if (value.len == 0) { + self.list.clearRetainingCapacity(); + return; + } + + // If we're overwriting then we clear before appending + if (self.overwrite_next) { + self.list.clearRetainingCapacity(); + self.overwrite_next = false; + } + + const copy = try alloc.dupeZ(u8, value); + try self.list.append(alloc, copy); + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self { + // Copy the list and all the strings in the list. + var list = try std.ArrayListUnmanaged([:0]const u8).initCapacity( + alloc, + self.list.items.len, + ); + errdefer { + for (list.items) |item| alloc.free(item); + list.deinit(alloc); + } + for (self.list.items) |item| { + const copy = try alloc.dupeZ(u8, item); + list.appendAssumeCapacity(copy); + } + + return .{ .list = list }; + } + + /// The number of items in the list + pub fn count(self: Self) usize { + return self.list.items.len; + } + + /// 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; + } + + /// Used by Formatter + pub fn formatEntry(self: Self, formatter: formatterpkg.EntryFormatter) !void { + // If no items, we want to render an empty field. + if (self.list.items.len == 0) { + try formatter.formatEntry(void, {}); + return; + } + + for (self.list.items) |value| { + try formatter.formatEntry([]const u8, value); + } + } + + 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); + + try list.parseCLI(alloc, ""); + try testing.expectEqual(@as(usize, 0), list.list.items.len); + } + + test "parseCLI overwrite" { + 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"); + + // Set our overwrite flag + list.overwrite_next = true; + + try list.parseCLI(alloc, "B"); + try testing.expectEqual(@as(usize, 1), list.list.items.len); + try list.parseCLI(alloc, "C"); + try testing.expectEqual(@as(usize, 2), list.list.items.len); + } + + test "formatConfig empty" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var list: Self = .{}; + try list.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); + try std.testing.expectEqualSlices(u8, "a = \n", buf.written()); + } + + test "formatConfig single item" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "A"); + try list.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); + try std.testing.expectEqualSlices(u8, "a = A\n", buf.written()); + } + + test "formatConfig multiple items" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + 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 list.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); + try std.testing.expectEqualSlices(u8, "a = A\na = B\n", buf.written()); + } +}; + +/// SelectionWordChars stores the parsed codepoints for word boundary +/// characters used during text selection. The string is parsed once +/// during configuration and stored as u21 codepoints for efficient +/// lookup during selection operations. +pub const SelectionWordChars = struct { + const Self = @This(); + + /// Default boundary characters: ` \t'"│`|:;,()[]{}<>$` + const default_codepoints = [_]u21{ + 0, // null + ' ', // space + '\t', // tab + '\'', // single quote + '"', // double quote + '│', // U+2502 box drawing + '`', // backtick + '|', // pipe + ':', // colon + ';', // semicolon + ',', // comma + '(', // left paren + ')', // right paren + '[', // left bracket + ']', // right bracket + '{', // left brace + '}', // right brace + '<', // less than + '>', // greater than + '$', // dollar + }; + + /// The parsed codepoints. Always includes null (U+0000) at index 0. + codepoints: []const u21 = &default_codepoints, + + pub fn parseCLI(self: *Self, alloc: Allocator, input: ?[]const u8) !void { + const value = input orelse return error.ValueRequired; + + // Parse string with Zig escape sequence support into codepoints + var list: std.ArrayList(u21) = .empty; + defer list.deinit(alloc); + + // Always include null as first boundary + try list.append(alloc, 0); + + var it = string.codepointIterator(value); + while (it.next() catch return error.InvalidValue) |codepoint| { + try list.append(alloc, codepoint); + } + + self.codepoints = try list.toOwnedSlice(alloc); + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self { + const copy = try alloc.dupe(u21, self.codepoints); + return .{ .codepoints = copy }; + } + + /// Compare if two values are equal. Required by Config. + pub fn equal(self: Self, other: Self) bool { + return std.mem.eql(u21, self.codepoints, other.codepoints); + } + + /// Used by Formatter + pub fn formatEntry(self: Self, formatter: formatterpkg.EntryFormatter) !void { + // Convert codepoints back to UTF-8 string for display + var buf: [4096]u8 = undefined; + var pos: usize = 0; + + // Skip the null character at index 0 + for (self.codepoints[1..]) |codepoint| { + var utf8_buf: [4]u8 = undefined; + const len = std.unicode.utf8Encode(codepoint, &utf8_buf) catch continue; + if (pos + len > buf.len) break; + @memcpy(buf[pos..][0..len], utf8_buf[0..len]); + pos += len; + } + + try formatter.formatEntry([]const u8, buf[0..pos]); + } + + test "parseCLI" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var chars: Self = .{}; + try chars.parseCLI(alloc, " \t;,"); + + // Should have null + 4 characters + try testing.expectEqual(@as(usize, 5), chars.codepoints.len); + try testing.expectEqual(@as(u21, 0), chars.codepoints[0]); + try testing.expectEqual(@as(u21, ' '), chars.codepoints[1]); + try testing.expectEqual(@as(u21, '\t'), chars.codepoints[2]); + try testing.expectEqual(@as(u21, ';'), chars.codepoints[3]); + try testing.expectEqual(@as(u21, ','), chars.codepoints[4]); + } + + test "parseCLI escape sequences" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // \t escape should be parsed as tab + var chars: Self = .{}; + try chars.parseCLI(alloc, " \\t;,"); + + try testing.expectEqual(@as(usize, 5), chars.codepoints.len); + try testing.expectEqual(@as(u21, 0), chars.codepoints[0]); + try testing.expectEqual(@as(u21, ' '), chars.codepoints[1]); + try testing.expectEqual(@as(u21, '\t'), chars.codepoints[2]); + try testing.expectEqual(@as(u21, ';'), chars.codepoints[3]); + try testing.expectEqual(@as(u21, ','), chars.codepoints[4]); + } + + test "parseCLI backslash escape" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // \\ should be parsed as a single backslash + var chars: Self = .{}; + try chars.parseCLI(alloc, "\\\\;"); + + try testing.expectEqual(@as(usize, 3), chars.codepoints.len); + try testing.expectEqual(@as(u21, 0), chars.codepoints[0]); + try testing.expectEqual(@as(u21, '\\'), chars.codepoints[1]); + try testing.expectEqual(@as(u21, ';'), chars.codepoints[2]); + } + + test "parseCLI unicode escape" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // \u{2502} should be parsed as │ + var chars: Self = .{}; + try chars.parseCLI(alloc, "\\u{2502};"); + + try testing.expectEqual(@as(usize, 3), chars.codepoints.len); + try testing.expectEqual(@as(u21, 0), chars.codepoints[0]); + try testing.expectEqual(@as(u21, '│'), chars.codepoints[1]); + try testing.expectEqual(@as(u21, ';'), chars.codepoints[2]); + } +}; + +/// 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.InvalidValue; + 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.InvalidValue; + try self.list.append(alloc, .{ + .id = fontpkg.face.Variation.Id.init(@ptrCast(key.ptr)), + .value = std.fmt.parseFloat(f64, value) catch return error.InvalidValue, + }); + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!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; + } + + /// Used by Formatter + pub fn formatEntry( + self: Self, + formatter: anytype, + ) !void { + if (self.list.items.len == 0) { + try formatter.formatEntry(void, {}); + return; + } + + var buf: [128]u8 = undefined; + for (self.list.items) |value| { + const str = std.fmt.bufPrint(&buf, "{s}={d}", .{ + value.id.str(), + value.value, + }) catch return error.OutOfMemory; + try formatter.formatEntry([]const u8, str); + } + } + + 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]); + } + + test "formatConfig single" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "wght = 200"); + try list.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); + try std.testing.expectEqualSlices(u8, "a = wght=200\n", buf.written()); + } +}; + +/// Stores a set of keybinds. +pub const Keybinds = struct { + set: inputpkg.Binding.Set = .{}, + + /// Defined key tables. The default key table is always the root "set", + /// which allows all table names to be available without reservation. + tables: std.StringArrayHashMapUnmanaged(inputpkg.Binding.Set) = .empty, + + /// The most recent binding target for `chain=` additions. + /// + /// This is intentionally tracked at the Keybinds level so that chains can + /// apply across table boundaries according to parse order. + chain_target: union(enum) { + root, + table: []const u8, + } = .root, + + pub fn init(self: *Keybinds, alloc: Allocator) !void { + // We don't clear the memory because it's in the arena and unlikely + // to be free-able anyways (since arenas can only clear the last + // allocated value). This isn't a memory leak because the arena + // will be freed when the config is freed. + self.set = .{}; + self.tables = .empty; + self.chain_target = .root; + + // keybinds for opening and reloading config + try self.set.put( + alloc, + .{ .key = .{ .unicode = ',' }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, + .{ .reload_config = {} }, + ); + try self.set.put( + alloc, + .{ .key = .{ .unicode = ',' }, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .open_config = {} }, + ); + + { + try self.set.put( + alloc, + .{ .key = .{ .physical = .copy } }, + .{ .copy_to_clipboard = .mixed }, + ); + try self.set.put( + alloc, + .{ .key = .{ .physical = .paste } }, + .paste_from_clipboard, + ); + + // On non-MacOS desktop envs (Windows, KDE, Gnome, Xfce), ctrl+insert is an + // alt keybinding for Copy and shift+ins is an alt keybinding for Paste + // + // The order of these blocks is important. The *last* added keybind for a given action is + // what will display in the menu. We want the more typical keybinds after this block to be + // the standard + if (!builtin.target.os.tag.isDarwin()) { + try self.set.put( + alloc, + .{ .key = .{ .physical = .insert }, .mods = .{ .ctrl = true } }, + .{ .copy_to_clipboard = .mixed }, + ); + try self.set.put( + alloc, + .{ .key = .{ .physical = .insert }, .mods = .{ .shift = true } }, + .{ .paste_from_clipboard = {} }, + ); + } + + // 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.os.tag.isDarwin()) + .{ .super = true } + else + .{ .ctrl = true, .shift = true }; + + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'c' }, .mods = mods }, + .{ .copy_to_clipboard = .mixed }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'v' }, .mods = mods }, + .paste_from_clipboard, + .{ .performable = true }, + ); + } + + // Increase font size mapping for keyboards with dedicated plus keys (like german) + // Note: this order matters below because the C API will only return + // the last keybinding for a given action. The macOS app uses this to + // set the expected keybind for the menu. + try self.set.put( + alloc, + .{ .key = .{ .unicode = '=' }, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .increase_font_size = 1 }, + ); + try self.set.put( + alloc, + .{ .key = .{ .unicode = '+' }, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .increase_font_size = 1 }, + ); + + try self.set.put( + alloc, + .{ .key = .{ .unicode = '-' }, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .decrease_font_size = 1 }, + ); + try self.set.put( + alloc, + .{ .key = .{ .unicode = '0' }, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .reset_font_size = {} }, + ); + + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'j' }, .mods = .{ .shift = true, .ctrl = true, .super = true } }, + .{ .write_screen_file = .copy }, + ); + + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'j' }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, + .{ .write_screen_file = .paste }, + ); + + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'j' }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true, .alt = true }) }, + .{ .write_screen_file = .open }, + ); + + // Expand Selection + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .arrow_left }, .mods = .{ .shift = true } }, + .{ .adjust_selection = .left }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .arrow_right }, .mods = .{ .shift = true } }, + .{ .adjust_selection = .right }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .arrow_up }, .mods = .{ .shift = true } }, + .{ .adjust_selection = .up }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .arrow_down }, .mods = .{ .shift = true } }, + .{ .adjust_selection = .down }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .page_up }, .mods = .{ .shift = true } }, + .{ .adjust_selection = .page_up }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .page_down }, .mods = .{ .shift = true } }, + .{ .adjust_selection = .page_down }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .home }, .mods = .{ .shift = true } }, + .{ .adjust_selection = .home }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .end }, .mods = .{ .shift = true } }, + .{ .adjust_selection = .end }, + .{ .performable = true }, + ); + + // Tabs common to all platforms + try self.set.put( + alloc, + .{ .key = .{ .physical = .tab }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .previous_tab = {} }, + ); + try self.set.put( + alloc, + .{ .key = .{ .physical = .tab }, .mods = .{ .ctrl = true } }, + .{ .next_tab = {} }, + ); + + // Windowing + if (comptime !builtin.target.os.tag.isDarwin()) { + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'n' }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .new_window = {} }, + ); + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'w' }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .close_surface = {} }, + ); + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'q' }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .quit = {} }, + ); + try self.set.put( + alloc, + .{ .key = .{ .physical = .f4 }, .mods = .{ .alt = true } }, + .{ .close_window = {} }, + ); + try self.set.put( + alloc, + .{ .key = .{ .unicode = 't' }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .new_tab = {} }, + ); + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'w' }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .close_tab = .this }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .arrow_left }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .previous_tab = {} }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .arrow_right }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .next_tab = {} }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .page_up }, .mods = .{ .ctrl = true } }, + .{ .previous_tab = {} }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .page_down }, .mods = .{ .ctrl = true } }, + .{ .next_tab = {} }, + .{ .performable = true }, + ); + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'o' }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .new_split = .right }, + ); + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'e' }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .new_split = .down }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = '[' }, .mods = .{ .ctrl = true, .super = true } }, + .{ .goto_split = .previous }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = ']' }, .mods = .{ .ctrl = true, .super = true } }, + .{ .goto_split = .next }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .arrow_up }, .mods = .{ .ctrl = true, .alt = true } }, + .{ .goto_split = .up }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .arrow_down }, .mods = .{ .ctrl = true, .alt = true } }, + .{ .goto_split = .down }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .arrow_left }, .mods = .{ .ctrl = true, .alt = true } }, + .{ .goto_split = .left }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .arrow_right }, .mods = .{ .ctrl = true, .alt = true } }, + .{ .goto_split = .right }, + .{ .performable = true }, + ); + + // Resizing splits + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .arrow_up }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, + .{ .resize_split = .{ .up, 10 } }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .arrow_down }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, + .{ .resize_split = .{ .down, 10 } }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .arrow_left }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, + .{ .resize_split = .{ .left, 10 } }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .arrow_right }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, + .{ .resize_split = .{ .right, 10 } }, + .{ .performable = true }, + ); + + // Viewport scrolling + try self.set.put( + alloc, + .{ .key = .{ .physical = .home }, .mods = .{ .shift = true } }, + .{ .scroll_to_top = {} }, + ); + try self.set.put( + alloc, + .{ .key = .{ .physical = .end }, .mods = .{ .shift = true } }, + .{ .scroll_to_bottom = {} }, + ); + try self.set.put( + alloc, + .{ .key = .{ .physical = .page_up }, .mods = .{ .shift = true } }, + .{ .scroll_page_up = {} }, + ); + try self.set.put( + alloc, + .{ .key = .{ .physical = .page_down }, .mods = .{ .shift = true } }, + .{ .scroll_page_down = {} }, + ); + + // Semantic prompts + try self.set.put( + alloc, + .{ .key = .{ .physical = .page_up }, .mods = .{ .shift = true, .ctrl = true } }, + .{ .jump_to_prompt = -1 }, + ); + try self.set.put( + alloc, + .{ .key = .{ .physical = .page_down }, .mods = .{ .shift = true, .ctrl = true } }, + .{ .jump_to_prompt = 1 }, + ); + + // Search + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'f' }, .mods = .{ .ctrl = true, .shift = true } }, + .start_search, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .escape } }, + .end_search, + .{ .performable = true }, + ); + + // Inspector, matching Chromium + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'i' }, .mods = .{ .shift = true, .ctrl = true } }, + .{ .inspector = .toggle }, + ); + + // Terminal + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'a' }, .mods = .{ .shift = true, .ctrl = true } }, + .{ .select_all = {} }, + ); + + // Selection clipboard paste + try self.set.put( + alloc, + .{ .key = .{ .physical = .insert }, .mods = .{ .shift = true } }, + .{ .paste_from_selection = {} }, + ); + } + { + // On macOS we default to super but everywhere else + // is alt. + const mods: inputpkg.Mods = if (builtin.target.os.tag.isDarwin()) + .{ .super = true } + else + .{ .alt = true }; + + // Cmd/Alt+N for goto tab N + const start: u21 = '1'; + const end: u21 = '8'; + comptime var i: u21 = start; + inline while (i <= end) : (i += 1) { + // We register BOTH the physical `digit_N` key and the unicode + // `N` key. This allows most keyboard layouts to work with + // this shortcut. Namely, AZERTY doesn't produce unicode `N` + // for their digit keys (they're on shifted keys on the same + // physical keys). + + try self.set.putFlags( + alloc, + .{ + .key = .{ .physical = @field( + inputpkg.Key, + std.fmt.comptimePrint("digit_{u}", .{i}), + ) }, + .mods = mods, + }, + .{ .goto_tab = (i - start) + 1 }, + .{ + // On macOS we keep this not performable so that the + // keyboard shortcuts in tabs work. In the future the + // correct fix is to fix the reverse mapping lookup + // to allow us to lookup performable keybinds + // conditionally. + .performable = !builtin.target.os.tag.isDarwin(), + }, + ); + + // Important: this must be the LAST binding set so that the + // libghostty trigger API returns this one for the action, + // so that things like the macOS tab bar key equivalent label + // work properly. + try self.set.putFlags( + alloc, + .{ + .key = .{ .unicode = i }, + .mods = mods, + }, + .{ .goto_tab = (i - start) + 1 }, + .{ + .performable = !builtin.target.os.tag.isDarwin(), + }, + ); + } + try self.set.putFlags( + alloc, + .{ + .key = .{ .unicode = '9' }, + .mods = mods, + }, + .{ .last_tab = {} }, + .{ + // See comment above with the numeric goto_tab + .performable = !builtin.target.os.tag.isDarwin(), + }, + ); + } + + // Toggle fullscreen + try self.set.put( + alloc, + .{ .key = .{ .physical = .enter }, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .toggle_fullscreen = {} }, + ); + + // Toggle zoom a split + try self.set.put( + alloc, + .{ .key = .{ .physical = .enter }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, + .{ .toggle_split_zoom = {} }, + ); + + // Toggle command palette, matches VSCode + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'p' }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, + .toggle_command_palette, + ); + + // Mac-specific keyboard bindings. + if (comptime builtin.target.os.tag.isDarwin()) { + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'q' }, .mods = .{ .super = true } }, + .{ .quit = {} }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'k' }, .mods = .{ .super = true } }, + .{ .clear_screen = {} }, + .{ .performable = true }, + ); + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'a' }, .mods = .{ .super = true } }, + .{ .select_all = {} }, + ); + + // Undo/redo + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 't' }, .mods = .{ .super = true, .shift = true } }, + .{ .undo = {} }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'z' }, .mods = .{ .super = true } }, + .{ .undo = {} }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'z' }, .mods = .{ .super = true, .shift = true } }, + .{ .redo = {} }, + .{ .performable = true }, + ); + + // Viewport scrolling + try self.set.put( + alloc, + .{ .key = .{ .physical = .home }, .mods = .{ .super = true } }, + .{ .scroll_to_top = {} }, + ); + try self.set.put( + alloc, + .{ .key = .{ .physical = .end }, .mods = .{ .super = true } }, + .{ .scroll_to_bottom = {} }, + ); + try self.set.put( + alloc, + .{ .key = .{ .physical = .page_up }, .mods = .{ .super = true } }, + .{ .scroll_page_up = {} }, + ); + try self.set.put( + alloc, + .{ .key = .{ .physical = .page_down }, .mods = .{ .super = true } }, + .{ .scroll_page_down = {} }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'j' }, .mods = .{ .super = true } }, + .{ .scroll_to_selection = {} }, + .{ .performable = true }, + ); + + // Semantic prompts + try self.set.put( + alloc, + .{ .key = .{ .physical = .arrow_up }, .mods = .{ .super = true, .shift = true } }, + .{ .jump_to_prompt = -1 }, + ); + try self.set.put( + alloc, + .{ .key = .{ .physical = .arrow_down }, .mods = .{ .super = true, .shift = true } }, + .{ .jump_to_prompt = 1 }, + ); + + // Mac windowing + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'n' }, .mods = .{ .super = true } }, + .{ .new_window = {} }, + ); + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'w' }, .mods = .{ .super = true } }, + .{ .close_surface = {} }, + ); + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'w' }, .mods = .{ .super = true, .alt = true } }, + .{ .close_tab = .this }, + ); + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'w' }, .mods = .{ .super = true, .shift = true } }, + .{ .close_window = {} }, + ); + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'w' }, .mods = .{ .super = true, .shift = true, .alt = true } }, + .{ .close_all_windows = {} }, + ); + try self.set.put( + alloc, + .{ .key = .{ .unicode = 't' }, .mods = .{ .super = true } }, + .{ .new_tab = {} }, + ); + try self.set.put( + alloc, + .{ .key = .{ .unicode = '[' }, .mods = .{ .super = true, .shift = true } }, + .{ .previous_tab = {} }, + ); + try self.set.put( + alloc, + .{ .key = .{ .unicode = ']' }, .mods = .{ .super = true, .shift = true } }, + .{ .next_tab = {} }, + ); + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'd' }, .mods = .{ .super = true } }, + .{ .new_split = .right }, + ); + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'd' }, .mods = .{ .super = true, .shift = true } }, + .{ .new_split = .down }, + ); + try self.set.put( + alloc, + .{ .key = .{ .unicode = '[' }, .mods = .{ .super = true } }, + .{ .goto_split = .previous }, + ); + try self.set.put( + alloc, + .{ .key = .{ .unicode = ']' }, .mods = .{ .super = true } }, + .{ .goto_split = .next }, + ); + try self.set.put( + alloc, + .{ .key = .{ .physical = .arrow_up }, .mods = .{ .super = true, .alt = true } }, + .{ .goto_split = .up }, + ); + try self.set.put( + alloc, + .{ .key = .{ .physical = .arrow_down }, .mods = .{ .super = true, .alt = true } }, + .{ .goto_split = .down }, + ); + try self.set.put( + alloc, + .{ .key = .{ .physical = .arrow_left }, .mods = .{ .super = true, .alt = true } }, + .{ .goto_split = .left }, + ); + try self.set.put( + alloc, + .{ .key = .{ .physical = .arrow_right }, .mods = .{ .super = true, .alt = true } }, + .{ .goto_split = .right }, + ); + try self.set.put( + alloc, + .{ .key = .{ .physical = .arrow_up }, .mods = .{ .super = true, .ctrl = true } }, + .{ .resize_split = .{ .up, 10 } }, + ); + try self.set.put( + alloc, + .{ .key = .{ .physical = .arrow_down }, .mods = .{ .super = true, .ctrl = true } }, + .{ .resize_split = .{ .down, 10 } }, + ); + try self.set.put( + alloc, + .{ .key = .{ .physical = .arrow_left }, .mods = .{ .super = true, .ctrl = true } }, + .{ .resize_split = .{ .left, 10 } }, + ); + try self.set.put( + alloc, + .{ .key = .{ .physical = .arrow_right }, .mods = .{ .super = true, .ctrl = true } }, + .{ .resize_split = .{ .right, 10 } }, + ); + try self.set.put( + alloc, + .{ .key = .{ .unicode = '=' }, .mods = .{ .super = true, .ctrl = true } }, + .{ .equalize_splits = {} }, + ); + + // Jump to prompt, matches Terminal.app + try self.set.put( + alloc, + .{ .key = .{ .physical = .arrow_up }, .mods = .{ .super = true } }, + .{ .jump_to_prompt = -1 }, + ); + try self.set.put( + alloc, + .{ .key = .{ .physical = .arrow_down }, .mods = .{ .super = true } }, + .{ .jump_to_prompt = 1 }, + ); + + // Search + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'f' }, .mods = .{ .super = true } }, + .start_search, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'e' }, .mods = .{ .super = true } }, + .search_selection, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'f' }, .mods = .{ .super = true, .shift = true } }, + .end_search, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .escape } }, + .end_search, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'g' }, .mods = .{ .super = true } }, + .{ .navigate_search = .next }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'g' }, .mods = .{ .super = true, .shift = true } }, + .{ .navigate_search = .previous }, + .{ .performable = true }, + ); + + // Inspector, matching Chromium + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'i' }, .mods = .{ .alt = true, .super = true } }, + .{ .inspector = .toggle }, + ); + + // Alternate keybind, common to Mac programs + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'f' }, .mods = .{ .super = true, .ctrl = true } }, + .{ .toggle_fullscreen = {} }, + ); + + // Selection clipboard paste, matches Terminal.app + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'v' }, .mods = .{ .super = true, .shift = true } }, + .{ .paste_from_selection = {} }, + ); + + // "Natural text editing" keybinds. This forces these keys to go back + // to legacy encoding (not fixterms). It seems macOS users more than + // others are used to these keys so we set them as defaults. If + // people want to get back to the fixterm encoding they can set + // the keybinds to `unbind`. + try self.set.put( + alloc, + .{ .key = .{ .physical = .arrow_right }, .mods = .{ .super = true } }, + .{ .text = "\\x05" }, + ); + try self.set.put( + alloc, + .{ .key = .{ .physical = .arrow_left }, .mods = .{ .super = true } }, + .{ .text = "\\x01" }, + ); + try self.set.put( + alloc, + .{ .key = .{ .physical = .backspace }, .mods = .{ .super = true } }, + .{ .text = "\\x15" }, + ); + try self.set.put( + alloc, + .{ .key = .{ .physical = .arrow_left }, .mods = .{ .alt = true } }, + .{ .esc = "b" }, + ); + try self.set.put( + alloc, + .{ .key = .{ .physical = .arrow_right }, .mods = .{ .alt = true } }, + .{ .esc = "f" }, + ); + } + } + + pub fn parseCLI(self: *Keybinds, alloc: Allocator, input: ?[]const u8) !void { + var copy: ?[]u8 = null; + const 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; + + @memcpy(buf, value); + break :value buf; + }; + errdefer if (copy) |v| alloc.free(v); + + // Check for special values + if (value.len == 0) { + log.info("config has 'keybind =', using default keybinds", .{}); + try self.init(alloc); + return; + } + + if (std.mem.eql(u8, value, "clear")) { + // We don't clear the memory because its in the arena and unlikely + // to be free-able anyways (since arenas can only clear the last + // allocated value). This isn't a memory leak because the arena + // will be freed when the config is freed. + log.info("config has 'keybind = clear', all keybinds cleared", .{}); + self.set = .{}; + self.tables = .empty; + self.chain_target = .root; + return; + } + + // Check for table syntax: "name/" or "name/binding" + // We look for '/' only before the first '=' to avoid matching + // action arguments like "foo=text:/hello". + const eq_idx = std.mem.indexOfScalar(u8, value, '=') orelse value.len; + if (std.mem.indexOfScalar(u8, value[0..eq_idx], '/')) |slash_idx| table: { + const table_name = value[0..slash_idx]; + + // Length zero is valid, so you can set `/=action` for the slash key + if (table_name.len == 0) break :table; + + // Ignore '+', '>' because they can be part of sequences and + // triggers. This lets things like `ctrl+/=action` work. + if (std.mem.indexOfAny( + u8, + table_name, + "+>", + ) != null) break :table; + + const binding = value[slash_idx + 1 ..]; + + // Get or create the table + const gop = try self.tables.getOrPut(alloc, table_name); + if (!gop.found_existing) { + // We need to copy our table name into the arena + // for valid lookups later. + gop.key_ptr.* = try alloc.dupe(u8, table_name); + gop.value_ptr.* = .{}; + } + + // If there's no binding after the slash, this is a table + // definition/clear command + if (binding.len == 0) { + log.debug("config has 'keybind = {s}/', table cleared", .{table_name}); + gop.value_ptr.* = .{}; + self.chain_target = .root; + return; + } + + // Chains are only allowed at the root level. Their target is + // tracked globally by parse order in `self.chain_target`. + if (std.mem.startsWith(u8, binding, "chain=")) { + return error.InvalidFormat; + } + + // Parse and add the binding to the table + try gop.value_ptr.parseAndPut(alloc, binding); + self.chain_target = .{ .table = gop.key_ptr.* }; + return; + } + + if (std.mem.startsWith(u8, value, "chain=")) { + switch (self.chain_target) { + .root => try self.set.parseAndPut(alloc, value), + .table => |table_name| { + const table = self.tables.getPtr(table_name) orelse { + self.chain_target = .root; + return error.InvalidFormat; + }; + try table.parseAndPut(alloc, value); + }, + } + return; + } + + // Parse into default set + try self.set.parseAndPut(alloc, value); + self.chain_target = .root; + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const Keybinds, alloc: Allocator) Allocator.Error!Keybinds { + var tables: std.StringArrayHashMapUnmanaged(inputpkg.Binding.Set) = .empty; + try tables.ensureTotalCapacity(alloc, @intCast(self.tables.count())); + var it = self.tables.iterator(); + while (it.next()) |entry| { + const key = try alloc.dupe(u8, entry.key_ptr.*); + tables.putAssumeCapacity(key, try entry.value_ptr.clone(alloc)); + } + + return .{ + .set = try self.set.clone(alloc), + .tables = tables, + }; + } + + /// Compare if two of our value are requal. Required by Config. + pub fn equal(self: Keybinds, other: Keybinds) bool { + if (!equalSet(&self.set, &other.set)) return false; + + // Compare tables + if (self.tables.count() != other.tables.count()) return false; + + var it = self.tables.iterator(); + while (it.next()) |entry| { + const other_set = other.tables.get(entry.key_ptr.*) orelse return false; + if (!equalSet(entry.value_ptr, &other_set)) return false; + } + + return true; + } + + fn equalSet( + self: *const inputpkg.Binding.Set, + other: *const inputpkg.Binding.Set, + ) bool { + // Two keybinds are considered equal if their primary bindings + // are the same. We don't compare reverse mappings and such. + const self_map = &self.bindings; + const other_map = &other.bindings; + + // If the count of mappings isn't identical they can't be equal + if (self_map.count() != other_map.count()) return false; + + var it = self_map.iterator(); + while (it.next()) |self_entry| { + // If the trigger isn't in the other map, they can't be equal + const other_entry = other_map.getEntry(self_entry.key_ptr.*) orelse + return false; + + // If the entry types are different, they can't be equal + if (std.meta.activeTag(self_entry.value_ptr.*) != + std.meta.activeTag(other_entry.value_ptr.*)) return false; + + switch (self_entry.value_ptr.*) { + // They're equal if both leader sets are equal. + .leader => if (!equalSet( + self_entry.value_ptr.*.leader, + other_entry.value_ptr.*.leader, + )) return false, + + // Actions are compared by field directly + .leaf => { + const self_leaf = self_entry.value_ptr.*.leaf; + const other_leaf = other_entry.value_ptr.*.leaf; + + if (!deepEqual( + inputpkg.Binding.Set.Leaf, + self_leaf, + other_leaf, + )) return false; + }, + + .leaf_chained => { + const self_chain = self_entry.value_ptr.*.leaf_chained; + const other_chain = other_entry.value_ptr.*.leaf_chained; + + if (self_chain.flags != other_chain.flags) return false; + if (self_chain.actions.items.len != other_chain.actions.items.len) return false; + for (self_chain.actions.items, other_chain.actions.items) |a1, a2| { + if (!deepEqual( + inputpkg.Binding.Action, + a1, + a2, + )) return false; + } + }, + } + } + + return true; + } + + /// Like formatEntry but has an option to include docs. + pub fn formatEntryDocs(self: Keybinds, formatter: formatterpkg.EntryFormatter, docs: bool) !void { + if (self.set.bindings.count() == 0 and self.tables.count() == 0) { + try formatter.formatEntry(void, {}); + return; + } + + var buf: [1024]u8 = undefined; + + // Format root set bindings + var iter = self.set.bindings.iterator(); + while (iter.next()) |next| { + const k = next.key_ptr.*; + const v = next.value_ptr.*; + if (docs) { + try formatter.writer.writeAll("\n"); + const name = @tagName(v); + inline for (@typeInfo(help_strings.KeybindAction).@"struct".decls) |decl| { + if (std.mem.eql(u8, decl.name, name)) { + const help = @field(help_strings.KeybindAction, decl.name); + try formatter.writer.writeAll("# " ++ decl.name ++ "\n"); + var lines = std.mem.splitScalar(u8, help, '\n'); + while (lines.next()) |line| { + try formatter.writer.writeAll("# "); + try formatter.writer.writeAll(line); + try formatter.writer.writeAll("\n"); + } + break; + } + } + } + + var writer: std.Io.Writer = .fixed(&buf); + writer.print("{f}", .{k}) catch return error.OutOfMemory; + try v.formatEntries(&writer, formatter); + } + + // Format table bindings + var table_iter = self.tables.iterator(); + while (table_iter.next()) |table_entry| { + const table_name = table_entry.key_ptr.*; + const table_set = table_entry.value_ptr.*; + + var binding_iter = table_set.bindings.iterator(); + while (binding_iter.next()) |next| { + const k = next.key_ptr.*; + const v = next.value_ptr.*; + + var writer: std.Io.Writer = .fixed(&buf); + writer.print("{s}/{f}", .{ table_name, k }) catch return error.OutOfMemory; + try v.formatEntries(&writer, formatter); + } + } + } + + /// Used by Formatter + pub fn formatEntry(self: Keybinds, formatter: formatterpkg.EntryFormatter) !void { + try self.formatEntryDocs(formatter, false); + } + + 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"); + } + + test "formatConfig single" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Keybinds = .{}; + try list.parseCLI(alloc, "shift+a=csi:hello"); + try list.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); + try std.testing.expectEqualSlices(u8, "a = shift+a=csi:hello\n", buf.written()); + } + + // Regression test for https://github.com/ghostty-org/ghostty/issues/2734 + test "formatConfig multiple items" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Keybinds = .{}; + try list.parseCLI(alloc, "ctrl+z>1=goto_tab:1"); + try list.parseCLI(alloc, "ctrl+z>2=goto_tab:2"); + try list.formatEntry(formatterpkg.entryFormatter("keybind", &buf.writer)); + + // Note they turn into translated keys because they match + // their ASCII mapping. + const want = + \\keybind = ctrl+z>1=goto_tab:1 + \\keybind = ctrl+z>2=goto_tab:2 + \\ + ; + try std.testing.expectEqualStrings(want, buf.written()); + } + + test "formatConfig multiple items nested" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Keybinds = .{}; + try list.parseCLI(alloc, "ctrl+a>ctrl+b>n=new_window"); + try list.parseCLI(alloc, "ctrl+a>ctrl+b>w=close_window"); + try list.parseCLI(alloc, "ctrl+a>ctrl+c>t=new_tab"); + try list.parseCLI(alloc, "ctrl+b>ctrl+d>a=previous_tab"); + try list.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); + + // NB: This does not currently retain the order of the keybinds. + const want = + \\a = ctrl+a>ctrl+b>n=new_window + \\a = ctrl+a>ctrl+b>w=close_window + \\a = ctrl+a>ctrl+c>t=new_tab + \\a = ctrl+b>ctrl+d>a=previous_tab + \\ + ; + try std.testing.expectEqualStrings(want, buf.written()); + } + + test "parseCLI table definition" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // Define a table by adding a binding to it + try keybinds.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + try testing.expectEqual(1, keybinds.tables.count()); + try testing.expect(keybinds.tables.contains("foo")); + + const table = keybinds.tables.get("foo").?; + try testing.expectEqual(1, table.bindings.count()); + } + + test "parseCLI table clear" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // Add a binding to a table + try keybinds.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + try testing.expectEqual(1, keybinds.tables.get("foo").?.bindings.count()); + + // Clear the table with "foo/" + try keybinds.parseCLI(alloc, "foo/"); + try testing.expectEqual(0, keybinds.tables.get("foo").?.bindings.count()); + } + + test "parseCLI table multiple bindings" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + try keybinds.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + try keybinds.parseCLI(alloc, "foo/shift+b=paste_from_clipboard"); + try keybinds.parseCLI(alloc, "bar/ctrl+c=close_window"); + + try testing.expectEqual(2, keybinds.tables.count()); + try testing.expectEqual(2, keybinds.tables.get("foo").?.bindings.count()); + try testing.expectEqual(1, keybinds.tables.get("bar").?.bindings.count()); + } + + test "parseCLI table does not affect root set" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + try keybinds.parseCLI(alloc, "shift+a=copy_to_clipboard"); + try keybinds.parseCLI(alloc, "foo/shift+b=paste_from_clipboard"); + + // Root set should have the first binding + try testing.expectEqual(1, keybinds.set.bindings.count()); + // Table should have the second binding + try testing.expectEqual(1, keybinds.tables.get("foo").?.bindings.count()); + } + + test "parseCLI table empty name is invalid" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + try testing.expectError(error.InvalidFormat, keybinds.parseCLI(alloc, "/shift+a=copy_to_clipboard")); + } + + test "parseCLI table with key sequence" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // Key sequences should work within tables + try keybinds.parseCLI(alloc, "foo/ctrl+a>ctrl+b=new_window"); + + const table = keybinds.tables.get("foo").?; + try testing.expectEqual(1, table.bindings.count()); + } + + test "parseCLI slash in action argument is not a table" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // A slash after the = should not be interpreted as a table delimiter + try keybinds.parseCLI(alloc, "ctrl+a=text:/hello"); + + // Should be in root set, not a table + try testing.expectEqual(1, keybinds.set.bindings.count()); + try testing.expectEqual(0, keybinds.tables.count()); + } + + test "parseCLI slash as key with modifier is not a table" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // ctrl+/ should be parsed as a keybind with '/' as the key, not a table + try keybinds.parseCLI(alloc, "ctrl+/=text:foo"); + + // Should be in root set, not a table + try testing.expectEqual(1, keybinds.set.bindings.count()); + try testing.expectEqual(0, keybinds.tables.count()); + } + + test "parseCLI shift+slash as key is not a table" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // shift+/ should be parsed as a keybind, not a table + try keybinds.parseCLI(alloc, "shift+/=ignore"); + + // Should be in root set, not a table + try testing.expectEqual(1, keybinds.set.bindings.count()); + try testing.expectEqual(0, keybinds.tables.count()); + } + + test "parseCLI bare slash as key is not a table" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // Bare / as a key should work (empty table name is rejected) + try keybinds.parseCLI(alloc, "/=text:foo"); + + // Should be in root set, not a table + try testing.expectEqual(1, keybinds.set.bindings.count()); + try testing.expectEqual(0, keybinds.tables.count()); + } + + test "parseCLI slash in key sequence is not a table" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // Key sequence ending with / should work + try keybinds.parseCLI(alloc, "ctrl+a>ctrl+/=new_window"); + + // Should be in root set, not a table + try testing.expectEqual(1, keybinds.set.bindings.count()); + try testing.expectEqual(0, keybinds.tables.count()); + } + + test "parseCLI table with slash in binding" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // Table with a binding that uses / as the key + try keybinds.parseCLI(alloc, "mytable//=text:foo"); + + // Should be in the table + try testing.expectEqual(0, keybinds.set.bindings.count()); + try testing.expectEqual(1, keybinds.tables.count()); + try testing.expect(keybinds.tables.contains("mytable")); + try testing.expectEqual(1, keybinds.tables.get("mytable").?.bindings.count()); + } + + test "parseCLI table with sequence containing slash" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // Table with a key sequence that ends with / + try keybinds.parseCLI(alloc, "mytable/a>/=new_window"); + + // Should be in the table + try testing.expectEqual(0, keybinds.set.bindings.count()); + try testing.expectEqual(1, keybinds.tables.count()); + try testing.expect(keybinds.tables.contains("mytable")); + } + + test "parseCLI chain without prior parsed binding is invalid" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + try testing.expectError( + error.InvalidFormat, + keybinds.parseCLI(alloc, "chain=new_tab"), + ); + } + + test "parseCLI table chain syntax is invalid" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + try keybinds.parseCLI(alloc, "foo/a=text:hello"); + try testing.expectError( + error.InvalidFormat, + keybinds.parseCLI(alloc, "foo/chain=deactivate_key_table"), + ); + } + + test "parseCLI chain applies to most recent table binding" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + try keybinds.parseCLI(alloc, "ctrl+n=activate_key_table:foo"); + try keybinds.parseCLI(alloc, "foo/a=text:hello"); + try keybinds.parseCLI(alloc, "chain=deactivate_key_table"); + + const root_entry = keybinds.set.get(.{ + .mods = .{ .ctrl = true }, + .key = .{ .unicode = 'n' }, + }).?.value_ptr.*; + try testing.expect(root_entry == .leaf); + try testing.expect(root_entry.leaf.action == .activate_key_table); + + const foo_entry = keybinds.tables.get("foo").?.get(.{ + .key = .{ .unicode = 'a' }, + }).?.value_ptr.*; + try testing.expect(foo_entry == .leaf_chained); + try testing.expectEqual(@as(usize, 2), foo_entry.leaf_chained.actions.items.len); + try testing.expect(foo_entry.leaf_chained.actions.items[0] == .text); + try testing.expect(foo_entry.leaf_chained.actions.items[1] == .deactivate_key_table); + } + + test "clone with tables" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + try keybinds.parseCLI(alloc, "shift+a=copy_to_clipboard"); + try keybinds.parseCLI(alloc, "foo/shift+b=paste_from_clipboard"); + try keybinds.parseCLI(alloc, "bar/ctrl+c=close_window"); + + const cloned = try keybinds.clone(alloc); + + // Verify the clone has the same structure + try testing.expectEqual(keybinds.set.bindings.count(), cloned.set.bindings.count()); + try testing.expectEqual(keybinds.tables.count(), cloned.tables.count()); + try testing.expectEqual( + keybinds.tables.get("foo").?.bindings.count(), + cloned.tables.get("foo").?.bindings.count(), + ); + try testing.expectEqual( + keybinds.tables.get("bar").?.bindings.count(), + cloned.tables.get("bar").?.bindings.count(), + ); + } + + test "equal with tables" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds1: Keybinds = .{}; + try keybinds1.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + + var keybinds2: Keybinds = .{}; + try keybinds2.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + + try testing.expect(keybinds1.equal(keybinds2)); + } + + test "equal with tables different table count" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds1: Keybinds = .{}; + try keybinds1.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + + var keybinds2: Keybinds = .{}; + try keybinds2.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + try keybinds2.parseCLI(alloc, "bar/shift+b=paste_from_clipboard"); + + try testing.expect(!keybinds1.equal(keybinds2)); + } + + test "equal with tables different table names" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds1: Keybinds = .{}; + try keybinds1.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + + var keybinds2: Keybinds = .{}; + try keybinds2.parseCLI(alloc, "bar/shift+a=copy_to_clipboard"); + + try testing.expect(!keybinds1.equal(keybinds2)); + } + + test "equal with tables different bindings" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds1: Keybinds = .{}; + try keybinds1.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + + var keybinds2: Keybinds = .{}; + try keybinds2.parseCLI(alloc, "foo/shift+b=paste_from_clipboard"); + + try testing.expect(!keybinds1.equal(keybinds2)); + } + + test "formatEntry with tables" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + try keybinds.parseCLI(alloc, "foo/shift+a=csi:hello"); + try keybinds.formatEntry(formatterpkg.entryFormatter("keybind", &buf.writer)); + + try testing.expectEqualStrings("keybind = foo/shift+a=csi:hello\n", buf.written()); + } + + test "formatEntry with tables and root set" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + try keybinds.parseCLI(alloc, "shift+b=csi:world"); + try keybinds.parseCLI(alloc, "foo/shift+a=csi:hello"); + try keybinds.formatEntry(formatterpkg.entryFormatter("keybind", &buf.writer)); + + const output = buf.written(); + try testing.expect(std.mem.indexOf(u8, output, "keybind = shift+b=csi:world\n") != null); + try testing.expect(std.mem.indexOf(u8, output, "keybind = foo/shift+a=csi:hello\n") != null); + } + + test "parseCLI clear clears tables" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // Add bindings to root set and tables + try keybinds.parseCLI(alloc, "shift+a=copy_to_clipboard"); + try keybinds.parseCLI(alloc, "foo/shift+b=paste_from_clipboard"); + try keybinds.parseCLI(alloc, "bar/ctrl+c=close_window"); + + try testing.expectEqual(1, keybinds.set.bindings.count()); + try testing.expectEqual(2, keybinds.tables.count()); + + // Clear all keybinds + try keybinds.parseCLI(alloc, "clear"); + + // Both root set and tables should be cleared + try testing.expectEqual(0, keybinds.set.bindings.count()); + try testing.expectEqual(0, keybinds.tables.count()); + } + + test "parseCLI reset clears tables" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // Add bindings to tables + try keybinds.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + try keybinds.parseCLI(alloc, "bar/shift+b=paste_from_clipboard"); + + try testing.expectEqual(2, keybinds.tables.count()); + + // Reset to defaults (empty value) + try keybinds.parseCLI(alloc, ""); + + // Tables should be cleared, root set has defaults + try testing.expectEqual(0, keybinds.tables.count()); + try testing.expect(keybinds.set.bindings.count() > 0); + } +}; + +/// See "font-codepoint-map" for documentation. +pub const RepeatableCodepointMap = struct { + const Self = @This(); + + map: fontpkg.CodepointMap = .{}, + + 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.InvalidValue; + 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); + const valueZ = try alloc.dupeZ(u8, value); + + var p: UnicodeRangeParser = .{ .input = key }; + while (try p.next()) |range| { + try self.map.add(alloc, .{ + .range = range, + .descriptor = .{ + .family = valueZ, + .monospace = false, // we allow any font + }, + }); + } + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self { + return .{ .map = try self.map.clone(alloc) }; + } + + /// Compare if two of our value are equal. Required by Config. + pub fn equal(self: Self, other: Self) bool { + const itemsA = self.map.list.slice(); + const itemsB = other.map.list.slice(); + if (itemsA.len != itemsB.len) return false; + for (0..itemsA.len) |i| { + const a = itemsA.get(i); + const b = itemsB.get(i); + if (!std.meta.eql(a, b)) return false; + } else return true; + } + + /// Used by Formatter + pub fn formatEntry( + self: Self, + formatter: anytype, + ) !void { + if (self.map.list.len == 0) { + try formatter.formatEntry(void, {}); + return; + } + + var buf: [1024]u8 = undefined; + const ranges = self.map.list.items(.range); + const descriptors = self.map.list.items(.descriptor); + for (ranges, descriptors) |range, descriptor| { + if (range[0] == range[1]) { + try formatter.formatEntry( + []const u8, + std.fmt.bufPrint( + &buf, + "U+{X:0>4}={s}", + .{ + range[0], + descriptor.family orelse "", + }, + ) catch return error.OutOfMemory, + ); + } else { + try formatter.formatEntry( + []const u8, + std.fmt.bufPrint( + &buf, + "U+{X:0>4}-U+{X:0>4}={s}", + .{ + range[0], + range[1], + descriptor.family orelse "", + }, + ) catch return error.OutOfMemory, + ); + } + } + } + + /// Parses the list of Unicode codepoint ranges. Valid syntax: + /// + /// "" (empty returns null) + /// U+1234 + /// U+1234-5678 + /// U+1234,U+5678 + /// U+1234-5678,U+5678 + /// U+1234,U+5678-U+9ABC + /// + /// etc. + const UnicodeRangeParser = struct { + input: []const u8, + i: usize = 0, + + pub fn next(self: *UnicodeRangeParser) !?[2]u21 { + // Once we're EOF then we're done without an error. + if (self.eof()) return null; + + // One codepoint no matter what + const start = try self.parseCodepoint(); + if (self.eof()) return .{ start, start }; + + // We're allowed to have any whitespace here + self.consumeWhitespace(); + + // Otherwise we expect either a range or a comma + switch (self.input[self.i]) { + // Comma means we have another codepoint but in a different + // range so we return our current codepoint. + ',' => { + self.advance(); + self.consumeWhitespace(); + if (self.eof()) return error.InvalidValue; + return .{ start, start }; + }, + + // Hyphen means we have a range. + '-' => { + self.advance(); + self.consumeWhitespace(); + if (self.eof()) return error.InvalidValue; + const end = try self.parseCodepoint(); + self.consumeWhitespace(); + if (!self.eof() and self.input[self.i] != ',') return error.InvalidValue; + self.advance(); + self.consumeWhitespace(); + if (start > end) return error.InvalidValue; + return .{ start, end }; + }, + + else => return error.InvalidValue, + } + } + + fn consumeWhitespace(self: *UnicodeRangeParser) void { + while (!self.eof()) { + switch (self.input[self.i]) { + ' ', '\t' => self.advance(), + else => return, + } + } + } + + fn parseCodepoint(self: *UnicodeRangeParser) !u21 { + if (self.input[self.i] != 'U') return error.InvalidValue; + self.advance(); + if (self.eof()) return error.InvalidValue; + if (self.input[self.i] != '+') return error.InvalidValue; + self.advance(); + if (self.eof()) return error.InvalidValue; + + const start_i = self.i; + while (true) { + const current = self.input[self.i]; + const is_hex = (current >= '0' and current <= '9') or + (current >= 'A' and current <= 'F') or + (current >= 'a' and current <= 'f'); + if (!is_hex) break; + + // Advance but break on EOF + self.advance(); + if (self.eof()) break; + } + + // If we didn't consume a single character, we have an error. + if (start_i == self.i) return error.InvalidValue; + + return std.fmt.parseInt(u21, self.input[start_i..self.i], 16) catch + return error.InvalidValue; + } + + fn advance(self: *UnicodeRangeParser) void { + self.i += 1; + } + + fn eof(self: *const UnicodeRangeParser) bool { + return self.i >= self.input.len; + } + }; + + 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, "U+ABCD=Comic Sans"); + try list.parseCLI(alloc, "U+0001 - U+0005=Verdana"); + try list.parseCLI(alloc, "U+0006-U+0009, U+ABCD=Courier"); + + try testing.expectEqual(@as(usize, 4), list.map.list.len); + { + const entry = list.map.list.get(0); + try testing.expectEqual([2]u21{ 0xABCD, 0xABCD }, entry.range); + try testing.expectEqualStrings("Comic Sans", entry.descriptor.family.?); + } + { + const entry = list.map.list.get(1); + try testing.expectEqual([2]u21{ 1, 5 }, entry.range); + try testing.expectEqualStrings("Verdana", entry.descriptor.family.?); + } + { + const entry = list.map.list.get(2); + try testing.expectEqual([2]u21{ 6, 9 }, entry.range); + try testing.expectEqualStrings("Courier", entry.descriptor.family.?); + } + { + const entry = list.map.list.get(3); + try testing.expectEqual([2]u21{ 0xABCD, 0xABCD }, entry.range); + try testing.expectEqualStrings("Courier", entry.descriptor.family.?); + } + } + + test "formatConfig single" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "U+ABCD=Comic Sans"); + try list.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); + try std.testing.expectEqualSlices(u8, "a = U+ABCD=Comic Sans\n", buf.written()); + } + + test "formatConfig range" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "U+0001 - U+0005=Verdana"); + try list.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); + try std.testing.expectEqualSlices(u8, "a = U+0001-U+0005=Verdana\n", buf.written()); + } + + test "formatConfig multiple" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "U+0006-U+0009, U+ABCD=Courier"); + try list.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); + try std.testing.expectEqualSlices(u8, + \\a = U+0006-U+0009=Courier + \\a = U+ABCD=Courier + \\ + , buf.written()); + } +}; + +/// See "clipboard-codepoint-map" for documentation. +pub const RepeatableClipboardCodepointMap = struct { + const Self = @This(); + + map: ClipboardCodepointMap = .{}, + + 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.InvalidValue; + 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); + + // Parse the replacement value - either a codepoint or string + const replacement: ClipboardCodepointMap.Replacement = if (std.mem.startsWith(u8, value, "U+")) blk: { + // Parse as codepoint + const cp_str = value[2..]; // Skip "U+" + const cp = std.fmt.parseInt(u21, cp_str, 16) catch return error.InvalidValue; + break :blk .{ .codepoint = cp }; + } else blk: { + // Parse as UTF-8 string - validate it's valid UTF-8 + if (!std.unicode.utf8ValidateSlice(value)) return error.InvalidValue; + const value_copy = try alloc.dupe(u8, value); + break :blk .{ .string = value_copy }; + }; + + var p: UnicodeRangeParser = .{ .input = key }; + while (try p.next()) |range| { + try self.map.add(alloc, .{ + .range = range, + .replacement = replacement, + }); + } + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self { + return .{ .map = try self.map.clone(alloc) }; + } + + /// Compare if two of our value are equal. Required by Config. + pub fn equal(self: Self, other: Self) bool { + const itemsA = self.map.list.slice(); + const itemsB = other.map.list.slice(); + if (itemsA.len != itemsB.len) return false; + for (0..itemsA.len) |i| { + const a = itemsA.get(i); + const b = itemsB.get(i); + if (!std.meta.eql(a.range, b.range)) return false; + switch (a.replacement) { + .codepoint => |cp_a| switch (b.replacement) { + .codepoint => |cp_b| if (cp_a != cp_b) return false, + .string => return false, + }, + .string => |str_a| switch (b.replacement) { + .string => |str_b| if (!std.mem.eql(u8, str_a, str_b)) return false, + .codepoint => return false, + }, + } + } + return true; + } + + /// Used by Formatter + pub fn formatEntry( + self: Self, + formatter: anytype, + ) !void { + if (self.map.list.len == 0) { + try formatter.formatEntry(void, {}); + return; + } + + var buf: [1024]u8 = undefined; + var value_buf: [32]u8 = undefined; + const ranges = self.map.list.items(.range); + const replacements = self.map.list.items(.replacement); + for (ranges, replacements) |range, replacement| { + const value_str = switch (replacement) { + .codepoint => |cp| try std.fmt.bufPrint(&value_buf, "U+{X:0>4}", .{cp}), + .string => |s| s, + }; + + if (range[0] == range[1]) { + try formatter.formatEntry( + []const u8, + std.fmt.bufPrint( + &buf, + "U+{X:0>4}={s}", + .{ range[0], value_str }, + ) catch return error.OutOfMemory, + ); + } else { + try formatter.formatEntry( + []const u8, + std.fmt.bufPrint( + &buf, + "U+{X:0>4}-U+{X:0>4}={s}", + .{ range[0], range[1], value_str }, + ) catch return error.OutOfMemory, + ); + } + } + } + + /// Reuse the same UnicodeRangeParser from RepeatableCodepointMap + const UnicodeRangeParser = RepeatableCodepointMap.UnicodeRangeParser; + + test "parseCLI codepoint replacement" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "U+2500=U+002D"); // box drawing → hyphen + + try testing.expectEqual(@as(usize, 1), list.map.list.len); + const entry = list.map.list.get(0); + try testing.expectEqual([2]u21{ 0x2500, 0x2500 }, entry.range); + try testing.expect(entry.replacement == .codepoint); + try testing.expectEqual(@as(u21, 0x002D), entry.replacement.codepoint); + } + + test "parseCLI string replacement" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "U+03A3=SUM"); // Greek sigma → "SUM" + + try testing.expectEqual(@as(usize, 1), list.map.list.len); + const entry = list.map.list.get(0); + try testing.expectEqual([2]u21{ 0x03A3, 0x03A3 }, entry.range); + try testing.expect(entry.replacement == .string); + try testing.expectEqualStrings("SUM", entry.replacement.string); + } + + test "parseCLI range replacement" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "U+2500-U+2503=|"); // box drawing range → pipe + + try testing.expectEqual(@as(usize, 1), list.map.list.len); + const entry = list.map.list.get(0); + try testing.expectEqual([2]u21{ 0x2500, 0x2503 }, entry.range); + try testing.expect(entry.replacement == .string); + try testing.expectEqualStrings("|", entry.replacement.string); + } + + test "formatConfig codepoint" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "U+2500=U+002D"); + try list.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); + try std.testing.expectEqualSlices(u8, "a = U+2500=U+002D\n", buf.written()); + } + + test "formatConfig string" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "U+03A3=SUM"); + try list.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); + try std.testing.expectEqualSlices(u8, "a = U+03A3=SUM\n", buf.written()); + } +}; + +pub const FontStyle = union(enum) { + const Self = @This(); + + /// Use the default font style that font discovery finds. + default: void, + + /// Disable this font style completely. This will fall back to using + /// the regular font when this style is encountered. + false: void, + + /// A specific named font style to use for this style. + name: [:0]const u8, + + pub fn parseCLI(self: *Self, alloc: Allocator, input: ?[]const u8) !void { + const value = input orelse return error.ValueRequired; + + if (std.mem.eql(u8, value, "default")) { + self.* = .{ .default = {} }; + return; + } + + if (std.mem.eql(u8, value, "false")) { + self.* = .{ .false = {} }; + return; + } + + const nameZ = try alloc.dupeZ(u8, value); + self.* = .{ .name = nameZ }; + } + + /// Returns the string name value that can be used with a font + /// descriptor. + pub fn nameValue(self: Self) ?[:0]const u8 { + return switch (self) { + .default, .false => null, + .name => self.name, + }; + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: Self, alloc: Allocator) Allocator.Error!Self { + return switch (self) { + .default, .false => self, + .name => |v| .{ .name = try alloc.dupeZ(u8, v) }, + }; + } + + /// Used by Formatter + pub fn formatEntry(self: Self, formatter: formatterpkg.EntryFormatter) !void { + switch (self) { + .default, .false => try formatter.formatEntry( + []const u8, + @tagName(self), + ), + + .name => |name| { + try formatter.formatEntry([:0]const u8, name); + }, + } + } + + test "parseCLI" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var p: Self = .{ .default = {} }; + try p.parseCLI(alloc, "default"); + try testing.expectEqual(Self{ .default = {} }, p); + + try p.parseCLI(alloc, "false"); + try testing.expectEqual(Self{ .false = {} }, p); + + try p.parseCLI(alloc, "bold"); + try testing.expectEqualStrings("bold", p.name); + } + + test "formatConfig default" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var p: Self = .{ .default = {} }; + try p.parseCLI(alloc, "default"); + try p.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); + try std.testing.expectEqualSlices(u8, "a = default\n", buf.written()); + } + + test "formatConfig false" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var p: Self = .{ .default = {} }; + try p.parseCLI(alloc, "false"); + try p.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); + try std.testing.expectEqualSlices(u8, "a = false\n", buf.written()); + } + + test "formatConfig named" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var p: Self = .{ .default = {} }; + try p.parseCLI(alloc, "bold"); + try p.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); + try std.testing.expectEqualSlices(u8, "a = bold\n", buf.written()); + } +}; + +/// See `font-synthetic-style` for documentation. +pub const FontSyntheticStyle = packed struct { + bold: bool = true, + italic: bool = true, + @"bold-italic": bool = true, +}; + +/// See "font-shaping-break" for documentation +pub const FontShapingBreak = packed struct { + cursor: bool = true, +}; + +/// See "link" for documentation. +pub const RepeatableLink = struct { + const Self = @This(); + + links: std.ArrayListUnmanaged(inputpkg.Link) = .{}, + + pub fn parseCLI(self: *Self, alloc: Allocator, input_: ?[]const u8) !void { + _ = self; + _ = alloc; + _ = input_; + return error.NotImplemented; + } + + /// Deep copy of the struct. Required by Config. + pub fn clone( + self: *const Self, + alloc: Allocator, + ) Allocator.Error!Self { + // Note: we don't do any errdefers below since the allocation + // is expected to be arena allocated. + + var list = try std.ArrayListUnmanaged(inputpkg.Link).initCapacity( + alloc, + self.links.items.len, + ); + for (self.links.items) |item| { + const copy = try item.clone(alloc); + list.appendAssumeCapacity(copy); + } + + return .{ .links = list }; + } + + /// Compare if two of our value are requal. Required by Config. + pub fn equal(self: Self, other: Self) bool { + const itemsA = self.links.items; + const itemsB = other.links.items; + if (itemsA.len != itemsB.len) return false; + for (itemsA, itemsB) |*a, *b| { + if (!a.equal(b)) return false; + } else return true; + } + + /// Used by Formatter + pub fn formatEntry(self: Self, formatter: formatterpkg.EntryFormatter) !void { + // This currently can't be set so we don't format anything. + _ = self; + _ = formatter; + } +}; + +/// 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 both the system clipboard + /// and the selection clipboard (for Linux). + clipboard, +}; + +/// Options for right-click actions. +pub const RightClickAction = enum { + /// No action is taken on right-click. + ignore, + + /// Pastes from the system clipboard. + paste, + + /// Copies the selected text to the system clipboard. + copy, + + /// Copies the selected text to the system clipboard and + /// pastes the clipboard if no text is selected. + @"copy-or-paste", + + /// Shows a context menu with options. + @"context-menu", +}; + +/// Shell integration values +pub const ShellIntegration = enum { + none, + detect, + bash, + elvish, + fish, + nushell, + zsh, +}; + +/// Shell integration features +pub const ShellIntegrationFeatures = packed struct { + cursor: bool = true, + sudo: bool = false, + title: bool = true, + @"ssh-env": bool = false, + @"ssh-terminfo": bool = false, + path: bool = true, +}; + +pub const SplitPreserveZoom = packed struct { + navigation: bool = false, +}; + +pub const RepeatableCommand = struct { + const Self = @This(); + + value: std.ArrayListUnmanaged(inputpkg.Command) = .empty, + value_c: std.ArrayListUnmanaged(inputpkg.Command.C) = .empty, + + /// ghostty_config_command_list_s + pub const C = extern struct { + commands: [*]inputpkg.Command.C, + len: usize, + }; + + pub fn cval(self: *const Self) C { + return .{ + .commands = self.value_c.items.ptr, + .len = self.value_c.items.len, + }; + } + + pub fn init(self: *Self, alloc: Allocator) !void { + self.value = .empty; + self.value_c = .empty; + errdefer { + self.value.deinit(alloc); + self.value_c.deinit(alloc); + } + try self.value.appendSlice(alloc, inputpkg.command.defaults); + try self.value_c.appendSlice(alloc, inputpkg.command.defaultsC); + } + + pub fn parseCLI( + self: *Self, + alloc: Allocator, + input_: ?[]const u8, + ) !void { + // Unset or empty input clears the list + const input = input_ orelse ""; + if (input.len == 0) { + self.value.clearRetainingCapacity(); + self.value_c.clearRetainingCapacity(); + return; + } + + // Reserve space in our lists + try self.value.ensureUnusedCapacity(alloc, 1); + try self.value_c.ensureUnusedCapacity(alloc, 1); + + const cmd = try cli.args.parseAutoStruct( + inputpkg.Command, + alloc, + input, + null, + ); + const cmd_c = try cmd.cval(alloc); + self.value.appendAssumeCapacity(cmd); + self.value_c.appendAssumeCapacity(cmd_c); + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self { + const value = try self.value.clone(alloc); + for (value.items) |*item| { + item.* = try item.clone(alloc); + } + + return .{ + .value = value, + .value_c = try self.value_c.clone(alloc), + }; + } + + /// Compare if two of our value are equal. Required by Config. + pub fn equal(self: RepeatableCommand, other: RepeatableCommand) bool { + if (self.value.items.len != other.value.items.len) return false; + for (self.value.items, other.value.items) |a, b| { + if (!a.equal(b)) return false; + } + + return true; + } + + /// Used by Formatter + pub fn formatEntry( + self: RepeatableCommand, + formatter: formatterpkg.EntryFormatter, + ) !void { + if (self.value.items.len == 0) { + try formatter.formatEntry(void, {}); + return; + } + + for (self.value.items) |item| { + var buf: [4096]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + writer.print( + "title:\"{f}\"", + .{std.zig.fmtString(item.title)}, + ) catch return error.OutOfMemory; + + if (item.description.len > 0) { + writer.print( + ",description:\"{f}\"", + .{std.zig.fmtString(item.description)}, + ) catch return error.OutOfMemory; + } + + writer.print(",action:\"{f}\"", .{item.action}) catch return error.OutOfMemory; + + try formatter.formatEntry([]const u8, writer.buffered()); + } + } + + test "RepeatableCommand parseCLI" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: RepeatableCommand = .{}; + try list.parseCLI(alloc, "title:Foo,action:ignore"); + try list.parseCLI(alloc, "title:Bar,description:bobr,action:text:ale bydle"); + try list.parseCLI(alloc, "title:Quux,description:boo,action:increase_font_size:2.5"); + try list.parseCLI(alloc, "title:Baz,description:Raspberry Pie,action:set_font_size:3.14"); + + try testing.expectEqual(@as(usize, 4), list.value.items.len); + + try testing.expectEqual(inputpkg.Binding.Action.ignore, list.value.items[0].action); + try testing.expectEqualStrings("Foo", list.value.items[0].title); + + try testing.expect(list.value.items[1].action == .text); + try testing.expectEqualStrings("ale bydle", list.value.items[1].action.text); + try testing.expectEqualStrings("Bar", list.value.items[1].title); + try testing.expectEqualStrings("bobr", list.value.items[1].description); + + try testing.expectEqual( + inputpkg.Binding.Action{ .increase_font_size = 2.5 }, + list.value.items[2].action, + ); + try testing.expectEqualStrings("Quux", list.value.items[2].title); + try testing.expectEqualStrings("boo", list.value.items[2].description); + + try testing.expectEqual( + inputpkg.Binding.Action{ .set_font_size = 3.14 }, + list.value.items[3].action, + ); + try testing.expectEqualStrings("Baz", list.value.items[3].title); + try testing.expectEqualStrings("Raspberry Pie", list.value.items[3].description); + + try list.parseCLI(alloc, ""); + try testing.expectEqual(@as(usize, 0), list.value.items.len); + } + + test "RepeatableCommand formatConfig empty" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var list: RepeatableCommand = .{}; + try list.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); + try std.testing.expectEqualSlices(u8, "a = \n", buf.written()); + } + + test "RepeatableCommand formatConfig single item" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: RepeatableCommand = .{}; + try list.parseCLI(alloc, "title:Bobr, action:text:Bober"); + try list.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); + try std.testing.expectEqualSlices(u8, "a = title:\"Bobr\",action:\"text:Bober\"\n", buf.written()); + } + + test "RepeatableCommand formatConfig multiple items" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: RepeatableCommand = .{}; + try list.parseCLI(alloc, "title:Bobr, action:text:kurwa"); + try list.parseCLI(alloc, "title:Ja, description: pierdole, action:text:jakie bydle"); + try list.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); + try std.testing.expectEqualSlices(u8, "a = title:\"Bobr\",action:\"text:kurwa\"\na = title:\"Ja\",description:\"pierdole\",action:\"text:jakie bydle\"\n", buf.written()); + } + + test "RepeatableCommand parseCLI commas" { + const testing = std.testing; + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + { + var list: RepeatableCommand = .{}; + try list.parseCLI(alloc, "title:\"Bo,br\",action:\"text:kur,wa\""); + try testing.expectEqual(@as(usize, 1), list.value.items.len); + + const item = list.value.items[0]; + try testing.expectEqualStrings("Bo,br", item.title); + try testing.expectEqualStrings("", item.description); + try testing.expect(item.action == .text); + try testing.expectEqualStrings("kur,wa", item.action.text); + } + { + var list: RepeatableCommand = .{}; + try list.parseCLI(alloc, "title:\"Bo,br\",description:\"abc,def\",action:text:kurwa"); + try testing.expectEqual(@as(usize, 1), list.value.items.len); + + const item = list.value.items[0]; + try testing.expectEqualStrings("Bo,br", item.title); + try testing.expectEqualStrings("abc,def", item.description); + try testing.expect(item.action == .text); + try testing.expectEqualStrings("kurwa", item.action.text); + } + } + + test "RepeatableCommand cval" { + const testing = std.testing; + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: RepeatableCommand = .{}; + try list.parseCLI(alloc, "title:Foo,action:ignore"); + try list.parseCLI(alloc, "title:Bar,description:bobr,action:text:ale bydle"); + + try testing.expectEqual(@as(usize, 2), list.value.items.len); + try testing.expectEqual(@as(usize, 2), list.value_c.items.len); + + const cv = list.cval(); + try testing.expectEqual(@as(usize, 2), cv.len); + + // First entry + try testing.expectEqualStrings("Foo", std.mem.sliceTo(cv.commands[0].title, 0)); + try testing.expectEqualStrings("ignore", std.mem.sliceTo(cv.commands[0].action_key, 0)); + try testing.expectEqualStrings("ignore", std.mem.sliceTo(cv.commands[0].action, 0)); + + // Second entry + try testing.expectEqualStrings("Bar", std.mem.sliceTo(cv.commands[1].title, 0)); + try testing.expectEqualStrings("bobr", std.mem.sliceTo(cv.commands[1].description, 0)); + try testing.expectEqualStrings("text", std.mem.sliceTo(cv.commands[1].action_key, 0)); + try testing.expectEqualStrings("text:ale bydle", std.mem.sliceTo(cv.commands[1].action, 0)); + } + + test "RepeatableCommand cval cleared" { + const testing = std.testing; + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: RepeatableCommand = .{}; + try list.parseCLI(alloc, "title:Foo,action:ignore"); + try testing.expectEqual(@as(usize, 1), list.cval().len); + + try list.parseCLI(alloc, ""); + try testing.expectEqual(@as(usize, 0), list.cval().len); + } +}; + +/// OSC 4, 10, 11, and 12 default color reporting format. +pub const OSCColorReportFormat = enum { + none, + @"8-bit", + @"16-bit", +}; + +/// The default window theme. +pub const WindowTheme = enum { + auto, + system, + light, + dark, + ghostty, +}; + +/// See window-colorspace +pub const WindowColorspace = enum { + srgb, + @"display-p3", +}; + +/// See macos-window-buttons +pub const MacWindowButtons = enum { + visible, + hidden, +}; + +/// See macos-titlebar-style +pub const MacTitlebarStyle = enum { + native, + transparent, + tabs, + hidden, +}; + +/// See macos-titlebar-proxy-icon +pub const MacTitlebarProxyIcon = enum { + visible, + hidden, +}; + +/// See macos-hidden +pub const MacHidden = enum { + never, + always, +}; + +/// See macos-icon +/// +/// Note: future versions of Ghostty can support a custom icon with +/// path by changing this to a tagged union, which doesn't change our +/// format at all. +pub const MacAppIcon = enum { + official, + blueprint, + chalkboard, + microchip, + glass, + holographic, + paper, + retro, + xray, + custom, + @"custom-style", +}; + +/// See macos-icon-frame +pub const MacAppIconFrame = enum { + aluminum, + beige, + plastic, + chrome, +}; + +/// See macos-shortcuts +pub const MacShortcuts = enum { + allow, + deny, + ask, +}; + +/// See gtk-single-instance +pub const GtkSingleInstance = enum { + false, + true, + detect, + + pub const default: GtkSingleInstance = .detect; +}; + +/// See gtk-tabs-location +pub const GtkTabsLocation = enum { + top, + bottom, +}; + +/// See gtk-toolbar-style +pub const GtkToolbarStyle = enum { + flat, + raised, + @"raised-border", +}; + +/// See gtk-titlebar-style +pub const GtkTitlebarStyle = enum(c_int) { + native, + tabs, + + pub const getGObjectType = switch (build_config.app_runtime) { + .gtk => @import("gobject").ext.defineEnum( + GtkTitlebarStyle, + .{ .name = "GhosttyGtkTitlebarStyle" }, + ), + + .none => void, + }; +}; + +/// See app-notifications +pub const AppNotifications = packed struct { + @"clipboard-copy": bool = true, + @"config-reload": bool = true, +}; + +/// See bell-features +pub const BellFeatures = packed struct { + system: bool = false, + audio: bool = false, + attention: bool = true, + title: bool = true, + border: bool = false, +}; + +/// See mouse-shift-capture +pub const MouseShiftCapture = enum { + false, + true, + always, + never, +}; + +/// See mouse-scroll-multiplier +pub const MouseScrollMultiplier = struct { + const Self = @This(); + + precision: f64 = 1, + discrete: f64 = 3, + + pub const default: MouseScrollMultiplier = .{}; + + pub fn parseCLI(self: *Self, alloc: Allocator, input_: ?[]const u8) !void { + const input = input_ orelse return error.ValueRequired; + self.* = cli.args.parseAutoStruct( + MouseScrollMultiplier, + alloc, + input, + self.*, + ) catch |err| switch (err) { + error.InvalidValue => bare: { + const v = std.fmt.parseFloat( + f64, + input, + ) catch return error.InvalidValue; + break :bare .{ + .precision = v, + .discrete = v, + }; + }, + else => return err, + }; + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self { + _ = alloc; + return self.*; + } + + /// Compare if two of our value are equal. Required by Config. + pub fn equal(self: Self, other: Self) bool { + return self.precision == other.precision and self.discrete == other.discrete; + } + + /// Used by Formatter + pub fn formatEntry(self: Self, formatter: formatterpkg.EntryFormatter) !void { + var buf: [4096]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + writer.print( + "precision:{d},discrete:{d}", + .{ self.precision, self.discrete }, + ) catch return error.OutOfMemory; + try formatter.formatEntry([]const u8, writer.buffered()); + } + + test "parse" { + const testing = std.testing; + const alloc = testing.allocator; + const epsilon = 0.00001; + + var args: Self = .{ .precision = 0.1, .discrete = 3 }; + try args.parseCLI(alloc, "3"); + try testing.expectApproxEqAbs(3, args.precision, epsilon); + try testing.expectApproxEqAbs(3, args.discrete, epsilon); + + args = .{ .precision = 0.1, .discrete = 3 }; + try args.parseCLI(alloc, "precision:1"); + try testing.expectApproxEqAbs(1, args.precision, epsilon); + try testing.expectApproxEqAbs(3, args.discrete, epsilon); + + args = .{ .precision = 0.1, .discrete = 3 }; + try args.parseCLI(alloc, "discrete:5"); + try testing.expectApproxEqAbs(0.1, args.precision, epsilon); + try testing.expectApproxEqAbs(5, args.discrete, epsilon); + + args = .{ .precision = 0.1, .discrete = 3 }; + try args.parseCLI(alloc, "precision:3,discrete:7"); + try testing.expectApproxEqAbs(3, args.precision, epsilon); + try testing.expectApproxEqAbs(7, args.discrete, epsilon); + + args = .{ .precision = 0.1, .discrete = 3 }; + try args.parseCLI(alloc, "discrete:8,precision:6"); + try testing.expectApproxEqAbs(6, args.precision, epsilon); + try testing.expectApproxEqAbs(8, args.discrete, epsilon); + + args = .default; + try testing.expectError(error.InvalidValue, args.parseCLI(alloc, "foo:1")); + try testing.expectError(error.InvalidValue, args.parseCLI(alloc, "precision:bar")); + try testing.expectError(error.InvalidValue, args.parseCLI(alloc, "precision:1,discrete:3,foo:5")); + try testing.expectError(error.InvalidValue, args.parseCLI(alloc, "precision:1,,discrete:3")); + try testing.expectError(error.InvalidValue, args.parseCLI(alloc, ",precision:1,discrete:3")); + } + + test "format entry MouseScrollMultiplier" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var args: Self = .{ .precision = 1.5, .discrete = 2.5 }; + try args.formatEntry(formatterpkg.entryFormatter("mouse-scroll-multiplier", &buf.writer)); + try testing.expectEqualSlices(u8, "mouse-scroll-multiplier = precision:1.5,discrete:2.5\n", buf.written()); + } +}; + +/// How to treat requests to write to or read from the clipboard +pub const ClipboardAccess = enum { + allow, + deny, + ask, +}; + +/// See window-save-state +pub const WindowSaveState = enum { + default, + never, + always, +}; + +/// See window-new-tab-position +pub const WindowNewTabPosition = enum { + current, + end, +}; + +/// See macos-dock-drop-behavior +pub const MacOSDockDropBehavior = enum { + @"new-tab", + @"new-window", +}; + +/// See window-show-tab-bar +pub const WindowShowTabBar = enum { + always, + auto, + never, +}; + +/// See resize-overlay +pub const ResizeOverlay = enum { + always, + never, + @"after-first", +}; + +/// See resize-overlay-position +pub const ResizeOverlayPosition = enum { + center, + @"top-left", + @"top-center", + @"top-right", + @"bottom-left", + @"bottom-center", + @"bottom-right", +}; + +/// See quick-terminal-position +pub const QuickTerminalPosition = enum { + top, + bottom, + left, + right, + center, +}; + +/// See quick-terminal-layer +pub const QuickTerminalLayer = enum { + overlay, + top, + bottom, + background, +}; + +/// See quick-terminal-size +pub const QuickTerminalSize = struct { + primary: ?Size = null, + secondary: ?Size = null, + + pub const Size = union(enum) { + percentage: f32, + pixels: u32, + + pub fn toPixels(self: Size, parent_dimensions: u32) u32 { + switch (self) { + .percentage => |v| { + const dim: f32 = @floatFromInt(parent_dimensions); + return @intFromFloat(v / 100.0 * dim); + }, + .pixels => |v| return v, + } + } + + pub fn parse(input: []const u8) !Size { + if (input.len == 0) return error.ValueRequired; + + if (std.mem.endsWith(u8, input, "px")) { + return .{ + .pixels = std.fmt.parseInt( + u32, + input[0 .. input.len - "px".len], + 10, + ) catch return error.InvalidValue, + }; + } + + if (std.mem.endsWith(u8, input, "%")) { + const percentage = std.fmt.parseFloat( + f32, + input[0 .. input.len - "%".len], + ) catch return error.InvalidValue; + + if (percentage < 0) return error.InvalidValue; + return .{ .percentage = percentage }; + } + + return error.MissingUnit; + } + + fn format(self: Size, writer: *std.Io.Writer) !void { + switch (self) { + .percentage => |v| try writer.print("{d}%", .{v}), + .pixels => |v| try writer.print("{}px", .{v}), + } + } + }; + + pub const Dimensions = struct { + width: u32, + height: u32, + }; + + /// C API structure for QuickTerminalSize + pub const C = extern struct { + primary: C.Size, + secondary: C.Size, + + pub const Size = extern struct { + tag: Tag, + value: Value, + + /// c_int because it needs to be extern compatible + pub const Tag = enum(c_int) { none, percentage, pixels }; + + pub const Value = extern union { + percentage: f32, + pixels: u32, + }; + + pub const none: C.Size = .{ .tag = .none, .value = undefined }; + + pub fn percentage(v: f32) C.Size { + return .{ + .tag = .percentage, + .value = .{ .percentage = v }, + }; + } + + pub fn pixels(v: u32) C.Size { + return .{ + .tag = .pixels, + .value = .{ .pixels = v }, + }; + } + }; + }; + + pub fn cval(self: QuickTerminalSize) C { + return .{ + .primary = if (self.primary) |p| switch (p) { + .percentage => |v| .percentage(v), + .pixels => |v| .pixels(v), + } else .none, + .secondary = if (self.secondary) |s| switch (s) { + .percentage => |v| .percentage(v), + .pixels => |v| .pixels(v), + } else .none, + }; + } + + pub fn calculate( + self: QuickTerminalSize, + position: QuickTerminalPosition, + dims: Dimensions, + ) Dimensions { + switch (position) { + .left, .right => return .{ + .width = if (self.primary) |v| v.toPixels(dims.width) else 400, + .height = if (self.secondary) |v| v.toPixels(dims.height) else dims.height, + }, + .top, .bottom => return .{ + .width = if (self.secondary) |v| v.toPixels(dims.width) else dims.width, + .height = if (self.primary) |v| v.toPixels(dims.height) else 400, + }, + .center => if (dims.width >= dims.height) { + return .{ + .width = if (self.primary) |v| v.toPixels(dims.width) else 800, + .height = if (self.secondary) |v| v.toPixels(dims.height) else 400, + }; + } else { + return .{ + .width = if (self.secondary) |v| v.toPixels(dims.width) else 400, + .height = if (self.primary) |v| v.toPixels(dims.height) else 800, + }; + }, + } + } + + pub fn parseCLI(self: *QuickTerminalSize, input: ?[]const u8) !void { + const input_ = input orelse return error.ValueRequired; + var it = std.mem.splitScalar(u8, input_, ','); + + const primary = std.mem.trim( + u8, + it.next() orelse return error.ValueRequired, + cli.args.whitespace, + ); + self.primary = try .parse(primary); + + self.secondary = secondary: { + const secondary = std.mem.trim( + u8, + it.next() orelse break :secondary null, + cli.args.whitespace, + ); + break :secondary try .parse(secondary); + }; + + if (it.next()) |_| return error.TooManyArguments; + } + + pub fn clone(self: *const QuickTerminalSize, _: Allocator) Allocator.Error!QuickTerminalSize { + return .{ + .primary = self.primary, + .secondary = self.secondary, + }; + } + + pub fn formatEntry(self: QuickTerminalSize, formatter: formatterpkg.EntryFormatter) !void { + const primary = self.primary orelse return; + + var buf: [4096]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + primary.format(&writer) catch return error.OutOfMemory; + if (self.secondary) |secondary| { + writer.writeByte(',') catch return error.OutOfMemory; + secondary.format(&writer) catch return error.OutOfMemory; + } + + try formatter.formatEntry([]const u8, writer.buffered()); + } + + test "parse QuickTerminalSize" { + const testing = std.testing; + var v: QuickTerminalSize = undefined; + + try v.parseCLI("50%"); + try testing.expectEqual(50, v.primary.?.percentage); + try testing.expectEqual(null, v.secondary); + + try v.parseCLI("200px"); + try testing.expectEqual(200, v.primary.?.pixels); + try testing.expectEqual(null, v.secondary); + + try v.parseCLI("50%,200px"); + try testing.expectEqual(50, v.primary.?.percentage); + try testing.expectEqual(200, v.secondary.?.pixels); + + try testing.expectError(error.ValueRequired, v.parseCLI(null)); + try testing.expectError(error.ValueRequired, v.parseCLI("")); + try testing.expectError(error.ValueRequired, v.parseCLI("69px,")); + try testing.expectError(error.TooManyArguments, v.parseCLI("69px,42%,69px")); + + try testing.expectError(error.MissingUnit, v.parseCLI("420")); + try testing.expectError(error.MissingUnit, v.parseCLI("bobr")); + try testing.expectError(error.InvalidValue, v.parseCLI("bobr%")); + try testing.expectError(error.InvalidValue, v.parseCLI("-32%")); + try testing.expectError(error.InvalidValue, v.parseCLI("-69px")); + } + test "calculate QuickTerminalSize" { + const testing = std.testing; + const dims_landscape: Dimensions = .{ .width = 2560, .height = 1600 }; + const dims_portrait: Dimensions = .{ .width = 1600, .height = 2560 }; + + { + const size: QuickTerminalSize = .{}; + try testing.expectEqual( + Dimensions{ .width = 2560, .height = 400 }, + size.calculate(.top, dims_landscape), + ); + try testing.expectEqual( + Dimensions{ .width = 400, .height = 1600 }, + size.calculate(.left, dims_landscape), + ); + try testing.expectEqual( + Dimensions{ .width = 800, .height = 400 }, + size.calculate(.center, dims_landscape), + ); + try testing.expectEqual( + Dimensions{ .width = 400, .height = 800 }, + size.calculate(.center, dims_portrait), + ); + } + { + const size: QuickTerminalSize = .{ .primary = .{ .percentage = 20 } }; + try testing.expectEqual( + Dimensions{ .width = 2560, .height = 320 }, + size.calculate(.top, dims_landscape), + ); + try testing.expectEqual( + Dimensions{ .width = 512, .height = 1600 }, + size.calculate(.left, dims_landscape), + ); + try testing.expectEqual( + Dimensions{ .width = 512, .height = 400 }, + size.calculate(.center, dims_landscape), + ); + try testing.expectEqual( + Dimensions{ .width = 400, .height = 512 }, + size.calculate(.center, dims_portrait), + ); + } + { + const size: QuickTerminalSize = .{ .primary = .{ .pixels = 600 } }; + try testing.expectEqual( + Dimensions{ .width = 2560, .height = 600 }, + size.calculate(.top, dims_landscape), + ); + try testing.expectEqual( + Dimensions{ .width = 600, .height = 1600 }, + size.calculate(.left, dims_landscape), + ); + try testing.expectEqual( + Dimensions{ .width = 600, .height = 400 }, + size.calculate(.center, dims_landscape), + ); + try testing.expectEqual( + Dimensions{ .width = 400, .height = 600 }, + size.calculate(.center, dims_portrait), + ); + } + { + const size: QuickTerminalSize = .{ + .primary = .{ .percentage = 69 }, + .secondary = .{ .pixels = 420 }, + }; + try testing.expectEqual( + Dimensions{ .width = 420, .height = 1104 }, + size.calculate(.top, dims_landscape), + ); + try testing.expectEqual( + Dimensions{ .width = 1766, .height = 420 }, + size.calculate(.left, dims_landscape), + ); + try testing.expectEqual( + Dimensions{ .width = 1766, .height = 420 }, + size.calculate(.center, dims_landscape), + ); + try testing.expectEqual( + Dimensions{ .width = 420, .height = 1766 }, + size.calculate(.center, dims_portrait), + ); + } + } +}; + +/// See quick-terminal-screen +pub const QuickTerminalScreen = enum { + main, + mouse, + @"macos-menu-bar", +}; + +// See quick-terminal-space-behavior +pub const QuickTerminalSpaceBehavior = enum { + remain, + move, +}; + +/// See quick-terminal-keyboard-interactivity +pub const QuickTerminalKeyboardInteractivity = enum { + none, + @"on-demand", + exclusive, +}; + +/// See grapheme-width-method +pub const GraphemeWidthMethod = enum { + legacy, + unicode, +}; + +/// See alpha-blending +pub const AlphaBlending = enum { + native, + linear, + @"linear-corrected", + + pub fn isLinear(self: AlphaBlending) bool { + return switch (self) { + .native => false, + .linear, .@"linear-corrected" => true, + }; + } +}; + +/// See background-image-position +pub const BackgroundImagePosition = enum { + @"top-left", + @"top-center", + @"top-right", + @"center-left", + @"center-center", + @"center-right", + @"bottom-left", + @"bottom-center", + @"bottom-right", + center, +}; + +/// See background-image-fit +pub const BackgroundImageFit = enum { + contain, + cover, + stretch, + none, +}; + +/// See freetype-load-flag +pub const FreetypeLoadFlags = packed struct { + // The defaults here at the time of writing this match the defaults + // for Freetype itself. Ghostty hasn't made any opinionated changes + // to these defaults. (Strictly speaking, `light` isn't FreeType's + // own default, but appears to be the effective default with most + // Fontconfig-aware software using FreeType, so until Ghostty + // implements Fontconfig support we default to `light`.) + hinting: bool = true, + @"force-autohint": bool = false, + monochrome: bool = false, + autohint: bool = true, + light: bool = true, +}; + +/// See linux-cgroup +pub const LinuxCgroup = enum { + never, + always, + @"single-instance", +}; + +/// See async-backend +pub const AsyncBackend = enum { + auto, + epoll, + io_uring, +}; + +/// See auto-updates +pub const AutoUpdate = enum { + off, + check, + download, +}; + +/// See background-blur +pub const BackgroundBlur = union(enum) { + false, + true, + @"macos-glass-regular", + @"macos-glass-clear", + radius: u8, + + pub fn parseCLI(self: *BackgroundBlur, input: ?[]const u8) !void { + const input_ = input orelse { + // Emulate behavior for bools + self.* = .true; + return; + }; + + // Try to parse normal bools + if (cli.args.parseBool(input_)) |b| { + self.* = if (b) .true else .false; + return; + } else |_| {} + + // Try to parse enums + if (std.meta.stringToEnum( + std.meta.Tag(BackgroundBlur), + input_, + )) |v| switch (v) { + inline else => |tag| tag: { + // We can only parse void types + const info = std.meta.fieldInfo(BackgroundBlur, tag); + if (info.type != void) break :tag; + self.* = @unionInit( + BackgroundBlur, + @tagName(tag), + {}, + ); + return; + }, + }; + + self.* = .{ .radius = std.fmt.parseInt( + u8, + input_, + 0, + ) catch return error.InvalidValue }; + } + + pub fn enabled(self: BackgroundBlur) bool { + return switch (self) { + .false => false, + .true => true, + .radius => |v| v > 0, + + // We treat these as true because they both imply some blur! + // This has the effect of making the standard blur happen on + // Linux. + .@"macos-glass-regular", .@"macos-glass-clear" => true, + }; + } + + pub fn cval(self: BackgroundBlur) i16 { + return switch (self) { + .false => 0, + .true => 20, + .radius => |v| v, + // I hate sentinel values like this but this is only for + // our macOS application currently. We can switch to a proper + // tagged union if we ever need to. + .@"macos-glass-regular" => -1, + .@"macos-glass-clear" => -2, + }; + } + + pub fn formatEntry( + self: BackgroundBlur, + formatter: anytype, + ) !void { + switch (self) { + .false => try formatter.formatEntry(bool, false), + .true => try formatter.formatEntry(bool, true), + .radius => |v| try formatter.formatEntry(u8, v), + .@"macos-glass-regular" => try formatter.formatEntry([]const u8, "macos-glass-regular"), + .@"macos-glass-clear" => try formatter.formatEntry([]const u8, "macos-glass-clear"), + } + } + + test "parse BackgroundBlur" { + const testing = std.testing; + var v: BackgroundBlur = undefined; + + try v.parseCLI(null); + try testing.expectEqual(.true, v); + + try v.parseCLI("true"); + try testing.expectEqual(.true, v); + + try v.parseCLI("false"); + try testing.expectEqual(.false, v); + + try v.parseCLI("42"); + try testing.expectEqual(42, v.radius); + + try v.parseCLI("macos-glass-regular"); + try testing.expectEqual(.@"macos-glass-regular", v); + + try v.parseCLI("macos-glass-clear"); + try testing.expectEqual(.@"macos-glass-clear", v); + + try testing.expectError(error.InvalidValue, v.parseCLI("")); + try testing.expectError(error.InvalidValue, v.parseCLI("aaaa")); + try testing.expectError(error.InvalidValue, v.parseCLI("420")); + } +}; + +/// See window-decoration +pub const WindowDecoration = enum(c_int) { + auto, + client, + server, + none, + + /// Make this a valid gobject if we're in a GTK environment. + pub const getGObjectType = switch (build_config.app_runtime) { + .gtk => @import("gobject").ext.defineEnum( + WindowDecoration, + .{ .name = "GhosttyConfigWindowDecoration" }, + ), + + .none => void, + }; + + pub fn parseCLI(input_: ?[]const u8) !WindowDecoration { + const input = input_ orelse return .auto; + + return if (cli.args.parseBool(input)) |b| + if (b) .auto else .none + else |_| if (std.meta.stringToEnum(WindowDecoration, input)) |v| + v + else + error.InvalidValue; + } + + test "parse WindowDecoration" { + const testing = std.testing; + + { + const v = try WindowDecoration.parseCLI(null); + try testing.expectEqual(WindowDecoration.auto, v); + } + { + const v = try WindowDecoration.parseCLI("true"); + try testing.expectEqual(WindowDecoration.auto, v); + } + { + const v = try WindowDecoration.parseCLI("false"); + try testing.expectEqual(WindowDecoration.none, v); + } + { + const v = try WindowDecoration.parseCLI("server"); + try testing.expectEqual(WindowDecoration.server, v); + } + { + const v = try WindowDecoration.parseCLI("client"); + try testing.expectEqual(WindowDecoration.client, v); + } + { + const v = try WindowDecoration.parseCLI("auto"); + try testing.expectEqual(WindowDecoration.auto, v); + } + { + const v = try WindowDecoration.parseCLI("none"); + try testing.expectEqual(WindowDecoration.none, v); + } + { + try testing.expectError(error.InvalidValue, WindowDecoration.parseCLI("")); + try testing.expectError(error.InvalidValue, WindowDecoration.parseCLI("aaaa")); + } + } +}; + +/// See theme +pub const Theme = struct { + light: []const u8, + dark: []const u8, + + pub fn parseCLI(self: *Theme, alloc: Allocator, input_: ?[]const u8) !void { + const input = input_ orelse return error.ValueRequired; + if (input.len == 0) return error.ValueRequired; + + // If there is a comma, equal sign, or colon, then we assume that + // we're parsing a light/dark mode theme pair. Note that "=" isn't + // actually valid for setting a light/dark mode pair but I anticipate + // it'll be a common typo. + // + // On Windows, a colon at index 1 is a drive letter (e.g. C:\...) + // and should not trigger light/dark pair parsing. + const has_colon = if (comptime builtin.os.tag == .windows) + if (std.mem.indexOf(u8, input, ":")) |idx| idx != 1 else false + else + std.mem.indexOf(u8, input, ":") != null; + if (std.mem.indexOf(u8, input, ",") != null or + std.mem.indexOf(u8, input, "=") != null or + has_colon) + { + self.* = try cli.args.parseAutoStruct( + Theme, + alloc, + input, + null, + ); + return; + } + + // Trim our value + const trimmed = std.mem.trim(u8, input, cli.args.whitespace); + + // Set the value to the specified value directly. + self.* = .{ + .light = try alloc.dupeZ(u8, trimmed), + .dark = self.light, + }; + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const Theme, alloc: Allocator) Allocator.Error!Theme { + return .{ + .light = try alloc.dupeZ(u8, self.light), + .dark = try alloc.dupeZ(u8, self.dark), + }; + } + + /// Used by Formatter + pub fn formatEntry( + self: Theme, + formatter: anytype, + ) !void { + var buf: [4096]u8 = undefined; + if (std.mem.eql(u8, self.light, self.dark)) { + try formatter.formatEntry([]const u8, self.light); + return; + } + + const str = std.fmt.bufPrint(&buf, "light:{s},dark:{s}", .{ + self.light, + self.dark, + }) catch return error.OutOfMemory; + try formatter.formatEntry([]const u8, str); + } + + test "parse Theme" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // Single + { + var v: Theme = undefined; + try v.parseCLI(alloc, "foo"); + try testing.expectEqualStrings("foo", v.light); + try testing.expectEqualStrings("foo", v.dark); + } + + // Single whitespace + { + var v: Theme = undefined; + try v.parseCLI(alloc, " foo "); + try testing.expectEqualStrings("foo", v.light); + try testing.expectEqualStrings("foo", v.dark); + } + + // Light/dark + { + var v: Theme = undefined; + try v.parseCLI(alloc, " light:foo, dark : bar "); + try testing.expectEqualStrings("foo", v.light); + try testing.expectEqualStrings("bar", v.dark); + } + + var v: Theme = undefined; + try testing.expectError(error.ValueRequired, v.parseCLI(alloc, null)); + try testing.expectError(error.ValueRequired, v.parseCLI(alloc, "")); + try testing.expectError(error.InvalidValue, v.parseCLI(alloc, "light:foo")); + try testing.expectError(error.InvalidValue, v.parseCLI(alloc, "dark:foo")); + } +}; + +pub const Duration = struct { + /// Duration in nanoseconds + duration: u64 = 0, + + const units = [_]struct { + name: []const u8, + factor: u64, + }{ + // The order is important as the first factor that matches will be the + // default unit that is used for formatting. + .{ .name = "y", .factor = 365 * std.time.ns_per_day }, + .{ .name = "w", .factor = std.time.ns_per_week }, + .{ .name = "d", .factor = std.time.ns_per_day }, + .{ .name = "h", .factor = std.time.ns_per_hour }, + .{ .name = "m", .factor = std.time.ns_per_min }, + .{ .name = "s", .factor = std.time.ns_per_s }, + .{ .name = "ms", .factor = std.time.ns_per_ms }, + .{ .name = "µs", .factor = std.time.ns_per_us }, + .{ .name = "us", .factor = std.time.ns_per_us }, + .{ .name = "ns", .factor = 1 }, + }; + + pub fn clone(self: *const Duration, _: Allocator) error{}!Duration { + return .{ .duration = self.duration }; + } + + pub fn equal(self: Duration, other: Duration) bool { + return self.duration == other.duration; + } + + pub fn round(self: Duration, to: u64) Duration { + return .{ .duration = self.duration / to * to }; + } + + pub fn lte(self: Duration, other: Duration) bool { + return self.duration <= other.duration; + } + + pub fn parseCLI(input: ?[]const u8) !Duration { + var remaining = input orelse return error.ValueRequired; + + var value: ?u64 = null; + while (remaining.len > 0) { + // Skip over whitespace before the number + while (remaining.len > 0 and std.ascii.isWhitespace(remaining[0])) { + remaining = remaining[1..]; + } + + // There was whitespace at the end, that's OK + if (remaining.len == 0) break; + + // Find the longest number + const number: u64 = number: { + var prev_number: ?u64 = null; + var prev_remaining: ?[]const u8 = null; + for (1..remaining.len + 1) |index| { + prev_number = std.fmt.parseUnsigned(u64, remaining[0..index], 10) catch { + if (prev_remaining) |prev| remaining = prev; + break :number prev_number; + }; + prev_remaining = remaining[index..]; + } + if (prev_remaining) |prev| remaining = prev; + break :number prev_number; + } orelse return error.InvalidValue; + + // A number without a unit is invalid unless the number is + // exactly zero. In that case, the unit is unambiguous since + // its all the same. + if (remaining.len == 0) { + if (number == 0) { + value = 0; + break; + } + + return error.InvalidValue; + } + + // Find the longest matching unit. Needs to be the longest matching + // to distinguish 'm' from 'ms'. + const factor = factor: { + var prev_factor: ?u64 = null; + var prev_index: ?usize = null; + for (1..remaining.len + 1) |index| { + const next_factor = next: { + for (units) |unit| { + if (std.mem.eql(u8, unit.name, remaining[0..index])) { + break :next unit.factor; + } + } + break :next null; + }; + if (next_factor) |next| { + prev_factor = next; + prev_index = index; + } + } + if (prev_index) |index| { + remaining = remaining[index..]; + } + break :factor prev_factor; + } orelse return error.InvalidValue; + + // Add our time value to the total. Avoid overflow with saturating math. + const diff = std.math.mul(u64, number, factor) catch std.math.maxInt(u64); + value = (value orelse 0) +| diff; + } + + return if (value) |v| .{ .duration = v } else error.ValueRequired; + } + + pub fn formatEntry(self: Duration, formatter: formatterpkg.EntryFormatter) !void { + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try self.format(&writer); + try formatter.formatEntry([]const u8, writer.buffered()); + } + + pub fn format( + self: Duration, + writer: *std.Io.Writer, + ) !void { + var value = self.duration; + var i: usize = 0; + for (units) |unit| { + if (value >= unit.factor) { + if (i > 0) writer.writeAll(" ") catch unreachable; + const remainder = value % unit.factor; + const quotient = (value - remainder) / unit.factor; + writer.print("{d}{s}", .{ quotient, unit.name }) catch unreachable; + value = remainder; + i += 1; + } + } + } + + pub fn cval(self: Duration) usize { + return @intCast(self.asMilliseconds()); + } + + /// Convenience function to convert to milliseconds since many OS and + /// library timing functions operate on that timescale. + pub fn asMilliseconds(self: Duration) c_uint { + const ms: u64 = std.math.divTrunc( + u64, + self.duration, + std.time.ns_per_ms, + ) catch std.math.maxInt(c_uint); + return std.math.cast(c_uint, ms) orelse std.math.maxInt(c_uint); + } +}; + +pub const WindowPadding = struct { + const Self = @This(); + + top_left: u32 = 0, + bottom_right: u32 = 0, + + pub fn clone(self: Self, _: Allocator) error{}!Self { + return self; + } + + pub fn equal(self: Self, other: Self) bool { + return std.meta.eql(self, other); + } + + pub fn parseCLI(input_: ?[]const u8) !WindowPadding { + const input = input_ orelse return error.ValueRequired; + const whitespace = " \t"; + + if (std.mem.indexOf(u8, input, ",")) |idx| { + const input_left = std.mem.trim(u8, input[0..idx], whitespace); + const input_right = std.mem.trim(u8, input[idx + 1 ..], whitespace); + const left = std.fmt.parseInt(u32, input_left, 10) catch + return error.InvalidValue; + const right = std.fmt.parseInt(u32, input_right, 10) catch + return error.InvalidValue; + return .{ .top_left = left, .bottom_right = right }; + } else { + const value = std.fmt.parseInt( + u32, + std.mem.trim(u8, input, whitespace), + 10, + ) catch return error.InvalidValue; + return .{ .top_left = value, .bottom_right = value }; + } + } + + pub fn formatEntry(self: Self, formatter: formatterpkg.EntryFormatter) !void { + var buf: [128]u8 = undefined; + if (self.top_left == self.bottom_right) { + try formatter.formatEntry( + []const u8, + std.fmt.bufPrint( + &buf, + "{}", + .{self.top_left}, + ) catch return error.OutOfMemory, + ); + } else { + try formatter.formatEntry( + []const u8, + std.fmt.bufPrint( + &buf, + "{},{}", + .{ self.top_left, self.bottom_right }, + ) catch return error.OutOfMemory, + ); + } + } + + test "parse WindowPadding" { + const testing = std.testing; + + { + const v = try WindowPadding.parseCLI("100"); + try testing.expectEqual(WindowPadding{ + .top_left = 100, + .bottom_right = 100, + }, v); + } + + { + const v = try WindowPadding.parseCLI("100,200"); + try testing.expectEqual(WindowPadding{ + .top_left = 100, + .bottom_right = 200, + }, v); + } + + // Trim whitespace + { + const v = try WindowPadding.parseCLI(" 100 , 200 "); + try testing.expectEqual(WindowPadding{ + .top_left = 100, + .bottom_right = 200, + }, v); + } + + try testing.expectError(error.ValueRequired, WindowPadding.parseCLI(null)); + try testing.expectError(error.InvalidValue, WindowPadding.parseCLI("")); + try testing.expectError(error.InvalidValue, WindowPadding.parseCLI("a")); + } +}; + +/// See scrollbar +pub const Scrollbar = enum { + system, + never, +}; + +/// See scroll-to-bottom +pub const ScrollToBottom = packed struct { + keystroke: bool = true, + output: bool = false, + + pub const default: ScrollToBottom = .{}; +}; + +/// See notify-on-command-finish +pub const NotifyOnCommandFinish = enum { + never, + unfocused, + always, +}; + +/// See notify-on-command-finish-action +pub const NotifyOnCommandFinishAction = packed struct { + bell: bool = true, + notify: bool = false, +}; + +test "parse duration" { + inline for (Duration.units) |unit| { + var buf: [16]u8 = undefined; + const t = try std.fmt.bufPrint(&buf, "0{s}", .{unit.name}); + const d = try Duration.parseCLI(t); + try std.testing.expectEqual(@as(u64, 0), d.duration); + } + + inline for (Duration.units) |unit| { + var buf: [16]u8 = undefined; + const t = try std.fmt.bufPrint(&buf, "1{s}", .{unit.name}); + const d = try Duration.parseCLI(t); + try std.testing.expectEqual(unit.factor, d.duration); + } + + { + const d = try Duration.parseCLI("0"); + try std.testing.expectEqual(@as(u64, 0), d.duration); + } + + { + const d = try Duration.parseCLI("100ns"); + try std.testing.expectEqual(@as(u64, 100), d.duration); + } + + { + const d = try Duration.parseCLI("1µs"); + try std.testing.expectEqual(@as(u64, 1000), d.duration); + } + + { + const d = try Duration.parseCLI("1µs1ns"); + try std.testing.expectEqual(@as(u64, 1001), d.duration); + } + + { + const d = try Duration.parseCLI("1µs 1ns"); + try std.testing.expectEqual(@as(u64, 1001), d.duration); + } + + { + const d = try Duration.parseCLI(" 1µs1ns"); + try std.testing.expectEqual(@as(u64, 1001), d.duration); + } + + { + const d = try Duration.parseCLI("1µs1ns "); + try std.testing.expectEqual(@as(u64, 1001), d.duration); + } + + { + const d = try Duration.parseCLI("30s"); + try std.testing.expectEqual(@as(u64, 30 * std.time.ns_per_s), d.duration); + } + + { + const d = try Duration.parseCLI("584y 49w 23h 34m 33s 709ms 551µs 615ns"); + try std.testing.expectEqual(std.math.maxInt(u64), d.duration); + } + + // Overflow + { + const d = try Duration.parseCLI("600y"); + try std.testing.expectEqual(std.math.maxInt(u64), d.duration); + } + + // Repeated units + { + const d = try Duration.parseCLI("100ns100ns"); + try std.testing.expectEqual(@as(u64, 200), d.duration); + } + + try std.testing.expectError(error.ValueRequired, Duration.parseCLI(null)); + try std.testing.expectError(error.ValueRequired, Duration.parseCLI("")); + try std.testing.expectError(error.InvalidValue, Duration.parseCLI("1")); + try std.testing.expectError(error.InvalidValue, Duration.parseCLI("s")); + try std.testing.expectError(error.InvalidValue, Duration.parseCLI("1x")); + try std.testing.expectError(error.InvalidValue, Duration.parseCLI("1 ")); +} + +test "test format" { + inline for (Duration.units) |unit| { + const d: Duration = .{ .duration = unit.factor }; + var actual_buf: [16]u8 = undefined; + const actual = try std.fmt.bufPrint(&actual_buf, "{f}", .{d}); + var expected_buf: [16]u8 = undefined; + const expected = if (!std.mem.eql(u8, unit.name, "us")) + try std.fmt.bufPrint(&expected_buf, "1{s}", .{unit.name}) + else + "1µs"; + try std.testing.expectEqualSlices(u8, expected, actual); + } +} + +test "test entryFormatter" { + var buf: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer buf.deinit(); + + var p: Duration = .{ .duration = std.math.maxInt(u64) }; + try p.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); + try std.testing.expectEqualStrings("a = 584y 49w 23h 34m 33s 709ms 551µs 615ns\n", buf.written()); +} + +const TestIterator = struct { + data: []const []const u8, + i: usize = 0, + + pub fn next(self: *TestIterator) ?[]const u8 { + if (self.i >= self.data.len) return null; + const result = self.data[self.i]; + self.i += 1; + return result; + } +}; + +test "parse hook: invalid command" { + const testing = std.testing; + var cfg = try Config.default(testing.allocator); + defer cfg.deinit(); + const alloc = cfg._arena.?.allocator(); + + var it: TestIterator = .{ .data = &.{"foo"} }; + try testing.expect(try cfg.parseManuallyHook(alloc, "--command", &it)); + try testing.expect(cfg.command == null); +} + +test "parse e: command only" { + const testing = std.testing; + var cfg = try Config.default(testing.allocator); + defer cfg.deinit(); + const alloc = cfg._arena.?.allocator(); + + var it: TestIterator = .{ .data = &.{"foo"} }; + try testing.expect(!try cfg.parseManuallyHook(alloc, "-e", &it)); + + const cmd = cfg.@"initial-command".?; + try testing.expect(cmd == .direct); + try testing.expectEqual(cmd.direct.len, 1); + try testing.expectEqualStrings(cmd.direct[0], "foo"); +} + +test "parse e: command and args" { + const testing = std.testing; + var cfg = try Config.default(testing.allocator); + defer cfg.deinit(); + const alloc = cfg._arena.?.allocator(); + + var it: TestIterator = .{ .data = &.{ "echo", "foo", "bar baz" } }; + try testing.expect(!try cfg.parseManuallyHook(alloc, "-e", &it)); + + const cmd = cfg.@"initial-command".?; + try testing.expect(cmd == .direct); + try testing.expectEqual(cmd.direct.len, 3); + try testing.expectEqualStrings(cmd.direct[0], "echo"); + try testing.expectEqualStrings(cmd.direct[1], "foo"); + try testing.expectEqualStrings(cmd.direct[2], "bar baz"); +} + +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 "clone preserves conditional state" { + const testing = std.testing; + const alloc = testing.allocator; + + var a = try Config.default(alloc); + defer a.deinit(); + a._conditional_state.theme = .dark; + try testing.expectEqual(.dark, a._conditional_state.theme); + var dest = try a.clone(alloc); + defer dest.deinit(); + + // Should have no changes + var it = a.changeIterator(&dest); + try testing.expectEqual(@as(?Key, null), it.next()); + + // Should have the same conditional state + try testing.expectEqual(.dark, dest._conditional_state.theme); +} + +test "clone can then change conditional state" { + // This tests a particular bug sequence where: + // 1. Load light + // 2. Convert to dark + // 3. Clone dark + // 4. Convert to light + // 5. Config is still dark (bug) + const testing = std.testing; + const alloc = testing.allocator; + var arena = ArenaAllocator.init(alloc); + defer arena.deinit(); + const alloc_arena = arena.allocator(); + + // Setup our test theme + var td = try internal_os.TempDir.init(); + defer td.deinit(); + var buf: [4096]u8 = undefined; + { + var file = try td.dir.createFile("theme_light", .{}); + defer file.close(); + var writer = file.writer(&buf); + try writer.interface.writeAll(@embedFile("testdata/theme_light")); + try writer.end(); + } + { + var file = try td.dir.createFile("theme_dark", .{}); + defer file.close(); + var writer = file.writer(&buf); + try writer.interface.writeAll(@embedFile("testdata/theme_dark")); + try writer.end(); + } + var light_buf: [std.fs.max_path_bytes]u8 = undefined; + const light = try td.dir.realpath("theme_light", &light_buf); + var dark_buf: [std.fs.max_path_bytes]u8 = undefined; + const dark = try td.dir.realpath("theme_dark", &dark_buf); + + var cfg_light = try Config.default(alloc); + defer cfg_light.deinit(); + var it: TestIterator = .{ .data = &.{ + try std.fmt.allocPrint( + alloc_arena, + "--theme=light:{s},dark:{s}", + .{ light, dark }, + ), + } }; + try cfg_light.loadIter(alloc, &it); + try cfg_light.finalize(); + + var cfg_dark = (try cfg_light.changeConditionalState(.{ .theme = .dark })).?; + defer cfg_dark.deinit(); + + try testing.expectEqual(Color{ + .r = 0xEE, + .g = 0xEE, + .b = 0xEE, + }, cfg_dark.background); + + var cfg_clone = try cfg_dark.clone(alloc); + defer cfg_clone.deinit(); + try testing.expectEqual(Color{ + .r = 0xEE, + .g = 0xEE, + .b = 0xEE, + }, cfg_clone.background); + + var cfg_light2 = (try cfg_clone.changeConditionalState(.{ .theme = .light })).?; + defer cfg_light2.deinit(); + try testing.expectEqual(Color{ + .r = 0xFF, + .g = 0xFF, + .b = 0xFF, + }, cfg_light2.background); +} + +test "clone preserves conditional set" { + const testing = std.testing; + const alloc = testing.allocator; + + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + "--theme=light:foo,dark:bar", + "--window-theme=auto", + } }; + try cfg.loadIter(alloc, &it); + try cfg.finalize(); + + var clone1 = try cfg.clone(alloc); + defer clone1.deinit(); + + try testing.expect(clone1._conditional_set.contains(.theme)); +} + +test "working-directory expands tilde" { + const testing = std.testing; + const alloc = testing.allocator; + + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + "--working-directory=~/projects/ghostty", + } }; + try cfg.loadIter(alloc, &it); + try cfg.finalize(); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const expected = internal_os.expandHome( + "~/projects/ghostty", + &buf, + ) catch "~/projects/ghostty"; + try testing.expectEqualStrings(expected, cfg.@"working-directory".?.value().?); +} + +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-thicken" = true; + + try testing.expect(source.changed(&dest, .@"font-thicken")); + try testing.expect(!source.changed(&dest, .@"font-size")); +} + +test "changeConditionalState ignores irrelevant changes" { + const testing = std.testing; + const alloc = testing.allocator; + + { + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + "--theme=foo", + } }; + try cfg.loadIter(alloc, &it); + try cfg.finalize(); + + try testing.expect(try cfg.changeConditionalState( + .{ .theme = .dark }, + ) == null); + } +} + +test "changeConditionalState applies relevant changes" { + const testing = std.testing; + const alloc = testing.allocator; + + { + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + "--theme=light:foo,dark:bar", + } }; + try cfg.loadIter(alloc, &it); + try cfg.finalize(); + + var cfg2 = (try cfg.changeConditionalState(.{ .theme = .dark })).?; + defer cfg2.deinit(); + + try testing.expect(cfg2._conditional_set.contains(.theme)); + } +} +test "theme loading" { + const testing = std.testing; + const alloc = testing.allocator; + var arena = ArenaAllocator.init(alloc); + defer arena.deinit(); + const alloc_arena = arena.allocator(); + + // Setup our test theme + var td = try internal_os.TempDir.init(); + defer td.deinit(); + var buf: [4096]u8 = undefined; + { + var file = try td.dir.createFile("theme", .{}); + defer file.close(); + var writer = file.writer(&buf); + try writer.interface.writeAll(@embedFile("testdata/theme_simple")); + try writer.end(); + } + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const path = try td.dir.realpath("theme", &path_buf); + + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + try std.fmt.allocPrint(alloc_arena, "--theme={s}", .{path}), + } }; + try cfg.loadIter(alloc, &it); + try cfg.finalize(); + + try testing.expectEqual(Color{ + .r = 0x12, + .g = 0x3A, + .b = 0xBC, + }, cfg.background); + + // Not a conditional theme + try testing.expect(!cfg._conditional_set.contains(.theme)); +} + +test "theme loading preserves conditional state" { + const testing = std.testing; + const alloc = testing.allocator; + var arena = ArenaAllocator.init(alloc); + defer arena.deinit(); + const alloc_arena = arena.allocator(); + + // Setup our test theme + var td = try internal_os.TempDir.init(); + defer td.deinit(); + var buf: [4096]u8 = undefined; + { + var file = try td.dir.createFile("theme", .{}); + defer file.close(); + var writer = file.writer(&buf); + try writer.interface.writeAll(@embedFile("testdata/theme_simple")); + try writer.end(); + } + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const path = try td.dir.realpath("theme", &path_buf); + + var cfg = try Config.default(alloc); + defer cfg.deinit(); + cfg._conditional_state = .{ .theme = .dark }; + var it: TestIterator = .{ .data = &.{ + try std.fmt.allocPrint(alloc_arena, "--theme={s}", .{path}), + } }; + try cfg.loadIter(alloc, &it); + try cfg.finalize(); + + try testing.expect(cfg._conditional_state.theme == .dark); +} + +test "theme priority is lower than config" { + const testing = std.testing; + const alloc = testing.allocator; + var arena = ArenaAllocator.init(alloc); + defer arena.deinit(); + const alloc_arena = arena.allocator(); + + // Setup our test theme + var td = try internal_os.TempDir.init(); + defer td.deinit(); + var buf: [4096]u8 = undefined; + { + var file = try td.dir.createFile("theme", .{}); + defer file.close(); + var writer = file.writer(&buf); + try writer.interface.writeAll(@embedFile("testdata/theme_simple")); + try writer.end(); + } + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const path = try td.dir.realpath("theme", &path_buf); + + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + "--background=#ABCDEF", + try std.fmt.allocPrint(alloc_arena, "--theme={s}", .{path}), + } }; + try cfg.loadIter(alloc, &it); + try cfg.finalize(); + + try testing.expectEqual(Color{ + .r = 0xAB, + .g = 0xCD, + .b = 0xEF, + }, cfg.background); +} + +test "theme loading correct light/dark" { + const testing = std.testing; + const alloc = testing.allocator; + var arena = ArenaAllocator.init(alloc); + defer arena.deinit(); + const alloc_arena = arena.allocator(); + + // Setup our test theme + var td = try internal_os.TempDir.init(); + defer td.deinit(); + var buf: [4096]u8 = undefined; + { + var file = try td.dir.createFile("theme_light", .{}); + defer file.close(); + var writer = file.writer(&buf); + try writer.interface.writeAll(@embedFile("testdata/theme_light")); + try writer.end(); + } + { + var file = try td.dir.createFile("theme_dark", .{}); + defer file.close(); + var writer = file.writer(&buf); + try writer.interface.writeAll(@embedFile("testdata/theme_dark")); + try writer.end(); + } + var light_buf: [std.fs.max_path_bytes]u8 = undefined; + const light = try td.dir.realpath("theme_light", &light_buf); + var dark_buf: [std.fs.max_path_bytes]u8 = undefined; + const dark = try td.dir.realpath("theme_dark", &dark_buf); + + // Light + { + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + try std.fmt.allocPrint( + alloc_arena, + "--theme=light:{s},dark:{s}", + .{ light, dark }, + ), + } }; + try cfg.loadIter(alloc, &it); + try cfg.finalize(); + + try testing.expectEqual(Color{ + .r = 0xFF, + .g = 0xFF, + .b = 0xFF, + }, cfg.background); + } + + // Dark + { + var cfg = try Config.default(alloc); + defer cfg.deinit(); + cfg._conditional_state = .{ .theme = .dark }; + var it: TestIterator = .{ .data = &.{ + try std.fmt.allocPrint( + alloc_arena, + "--theme=light:{s},dark:{s}", + .{ light, dark }, + ), + } }; + try cfg.loadIter(alloc, &it); + try cfg.finalize(); + + try testing.expectEqual(Color{ + .r = 0xEE, + .g = 0xEE, + .b = 0xEE, + }, cfg.background); + } + + // Light to Dark + { + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + try std.fmt.allocPrint( + alloc_arena, + "--theme=light:{s},dark:{s}", + .{ light, dark }, + ), + } }; + try cfg.loadIter(alloc, &it); + try cfg.finalize(); + + var new = (try cfg.changeConditionalState(.{ .theme = .dark })).?; + defer new.deinit(); + try testing.expectEqual(Color{ + .r = 0xEE, + .g = 0xEE, + .b = 0xEE, + }, new.background); + } +} + +test "theme specifying light/dark changes window-theme from auto" { + const testing = std.testing; + const alloc = testing.allocator; + + { + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + "--theme=light:foo,dark:bar", + "--window-theme=auto", + } }; + try cfg.loadIter(alloc, &it); + try cfg.finalize(); + + try testing.expect(cfg.@"window-theme" == .system); + } +} + +test "theme specifying light/dark sets theme usage in conditional state" { + const testing = std.testing; + const alloc = testing.allocator; + + { + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + "--theme=light:foo,dark:bar", + "--window-theme=auto", + } }; + try cfg.loadIter(alloc, &it); + try cfg.finalize(); + + try testing.expect(cfg.@"window-theme" == .system); + try testing.expect(cfg._conditional_set.contains(.theme)); + } +} + +test "compatibility: gtk-single-instance desktop" { + const testing = std.testing; + const alloc = testing.allocator; + + { + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + "--gtk-single-instance=desktop", + } }; + try cfg.loadIter(alloc, &it); + + // We need to test this BEFORE finalize, because finalize will + // convert our detect to a real value. + try testing.expectEqual( + GtkSingleInstance.detect, + cfg.@"gtk-single-instance", + ); + } +} + +test "compatibility: removed cursor-invert-fg-bg" { + const testing = std.testing; + const alloc = testing.allocator; + + { + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + "--cursor-invert-fg-bg", + } }; + try cfg.loadIter(alloc, &it); + try cfg.finalize(); + + try testing.expectEqual( + TerminalColor.@"cell-foreground", + cfg.@"cursor-color", + ); + try testing.expectEqual( + TerminalColor.@"cell-background", + cfg.@"cursor-text", + ); + } +} + +test "compatibility: removed selection-invert-fg-bg" { + const testing = std.testing; + const alloc = testing.allocator; + + { + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + "--selection-invert-fg-bg", + } }; + try cfg.loadIter(alloc, &it); + try cfg.finalize(); + + try testing.expectEqual( + TerminalColor.@"cell-background", + cfg.@"selection-foreground", + ); + try testing.expectEqual( + TerminalColor.@"cell-foreground", + cfg.@"selection-background", + ); + } +} + +test "compatibility: removed bold-is-bright" { + const testing = std.testing; + const alloc = testing.allocator; + + { + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + "--bold-is-bright", + } }; + try cfg.loadIter(alloc, &it); + try cfg.finalize(); + + try testing.expectEqual( + BoldColor.bright, + cfg.@"bold-color", + ); + } +} + +test "compatibility: window new-window" { + const testing = std.testing; + const alloc = testing.allocator; + + { + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + "--macos-dock-drop-behavior=window", + } }; + try cfg.loadIter(alloc, &it); + try cfg.finalize(); + try testing.expectEqual( + MacOSDockDropBehavior.@"new-window", + cfg.@"macos-dock-drop-behavior", + ); + } +} |
