diff --git a/src/cli/args.zig b/src/cli/args.zig index eb91c43a8..678c44b1d 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -10,6 +10,9 @@ const ErrorList = @import("../config/ErrorList.zig"); // - Only `--long=value` format is accepted. Do we want to allow // `--long value`? Not currently allowed. +// For trimming +const whitespace = " \t"; + /// The base errors for arg parsing. Additional errors can be returned due /// to type-specific parsing but these are always possible. pub const Error = error{ @@ -191,18 +194,6 @@ fn parseIntoField( } } - switch (fieldInfo) { - .Enum => { - @field(dst, field.name) = std.meta.stringToEnum( - Field, - value orelse return error.ValueRequired, - ) orelse return error.InvalidValue; - return; - }, - - else => {}, - } - // No parseCLI, magic the value based on the type @field(dst, field.name) = switch (Field) { []const u8 => value: { @@ -239,7 +230,19 @@ fn parseIntoField( value orelse return error.ValueRequired, ) catch return error.InvalidValue, - else => unreachable, + else => switch (fieldInfo) { + .Enum => std.meta.stringToEnum( + Field, + value orelse return error.ValueRequired, + ) orelse return error.InvalidValue, + + .Struct => try parsePackedStruct( + Field, + value orelse return error.ValueRequired, + ), + + else => unreachable, + }, }; return; @@ -249,6 +252,42 @@ fn parseIntoField( return error.InvalidField; } +fn parsePackedStruct(comptime T: type, v: []const u8) !T { + const info = @typeInfo(T).Struct; + assert(info.layout == .Packed); + + var result: T = .{}; + + // We split each value by "," + var iter = std.mem.splitSequence(u8, v, ","); + loop: while (iter.next()) |part_raw| { + // Determine the field we're looking for and the value. If the + // field is prefixed with "no-" then we set the value to false. + const part, const value = part: { + const negation_prefix = "no-"; + const trimmed = std.mem.trim(u8, part_raw, whitespace); + if (std.mem.startsWith(u8, trimmed, negation_prefix)) { + break :part .{ trimmed[negation_prefix.len..], false }; + } else { + break :part .{ trimmed, true }; + } + }; + + inline for (info.fields) |field| { + assert(field.type == bool); + if (std.mem.eql(u8, field.name, part)) { + @field(result, field.name) = value; + continue :loop; + } + } + + // No field matched + return error.InvalidValue; + } + + return result; +} + fn parseBool(v: []const u8) !bool { const t = &[_][]const u8{ "1", "t", "T", "true" }; const f = &[_][]const u8{ "0", "f", "F", "false" }; @@ -462,6 +501,63 @@ test "parseIntoField: enums" { try testing.expectEqual(Enum.two, data.v); } +test "parseIntoField: packed struct" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const Field = packed struct { + a: bool = false, + b: bool = true, + }; + var data: struct { + v: Field, + } = undefined; + + try parseIntoField(@TypeOf(data), alloc, &data, "v", "b"); + try testing.expect(!data.v.a); + try testing.expect(data.v.b); +} + +test "parseIntoField: packed struct negation" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const Field = packed struct { + a: bool = false, + b: bool = true, + }; + var data: struct { + v: Field, + } = undefined; + + try parseIntoField(@TypeOf(data), alloc, &data, "v", "a,no-b"); + try testing.expect(data.v.a); + try testing.expect(!data.v.b); +} + +test "parseIntoField: packed struct whitespace" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const Field = packed struct { + a: bool = false, + b: bool = true, + }; + var data: struct { + v: Field, + } = undefined; + + try parseIntoField(@TypeOf(data), alloc, &data, "v", " a, no-b "); + try testing.expect(data.v.a); + try testing.expect(!data.v.b); +} + test "parseIntoField: optional field" { const testing = std.testing; var arena = ArenaAllocator.init(testing.allocator); @@ -582,7 +678,6 @@ pub fn LineIterator(comptime ReaderType: type) type { } orelse return null; // Trim any whitespace around it - const whitespace = " \t"; const trim = std.mem.trim(u8, entry, whitespace); if (trim.len != entry.len) { std.mem.copy(u8, entry, trim); diff --git a/src/config.zig b/src/config.zig index a6f4113f0..4fda2e5b5 100644 --- a/src/config.zig +++ b/src/config.zig @@ -9,6 +9,7 @@ pub const Keybinds = Config.Keybinds; pub const MouseShiftCapture = Config.MouseShiftCapture; pub const NonNativeFullscreen = Config.NonNativeFullscreen; pub const OptionAsAlt = Config.OptionAsAlt; +pub const ShellIntegrationFeatures = Config.ShellIntegrationFeatures; // Alternate APIs pub const CAPI = @import("config/CAPI.zig"); diff --git a/src/config/Config.zig b/src/config/Config.zig index 952ca4bf8..83f56def3 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -536,6 +536,19 @@ keybind: Keybinds = .{}, /// The default value is "detect". @"shell-integration": ShellIntegration = .detect, +/// Shell integration features to enable if shell integration itself is enabled. +/// 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. +/// +/// Available features: +/// +/// - "cursor" - Set the cursor to a blinking bar at the prompt. +/// +/// Example: "cursor", "no-cursor" +@"shell-integration-features": ShellIntegrationFeatures = .{}, + /// Sets the reporting format for OSC sequences that request color information. /// Ghostty currently supports OSC 10 (foreground) and OSC 11 (background) queries, /// and by default the reported values are scaled-up RGB values, where each component @@ -2189,6 +2202,11 @@ pub const ShellIntegration = enum { zsh, }; +/// Shell integration features +pub const ShellIntegrationFeatures = packed struct { + cursor: bool = true, +}; + /// OSC 10 and 11 default color reporting format. pub const OSCColorReportFormat = enum { none, diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index b8a7e3d8c..b312a987d 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -41,8 +41,10 @@ function __ghostty_precmd() { PS2=$PS2'\[\e]133;B\a\]' # Cursor + if test "$GHOSTTY_SHELL_INTEGRATION_NO_CURSOR" != "1"; then PS1=$PS1'\[\e[5 q\]' PS0=$PS0'\[\e[0 q\]' + fi # Command PS0=$PS0'$(__ghostty_get_current_command)' diff --git a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish index 1b5ec5e25..f176d80c4 100755 --- a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +++ b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish @@ -51,12 +51,17 @@ status --is-interactive || ghostty_exit function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" functions -e __ghostty_setup - # Change the cursor to a beam on prompt. - function __ghostty_set_cursor_beam --on-event fish_prompt -d "Set cursor shape" - echo -en "\e[5 q" - end - function __ghostty_reset_cursor --on-event fish_preexec -d "Reset cursor shape" - echo -en "\e[0 q" + # Check if we are setting cursors + set --local no_cursor "$GHOSTTY_SHELL_INTEGRATION_NO_CURSOR" + + if test -z $no_cursor + # Change the cursor to a beam on prompt. + function __ghostty_set_cursor_beam --on-event fish_prompt -d "Set cursor shape" + echo -en "\e[5 q" + end + function __ghostty_reset_cursor --on-event fish_preexec -d "Reset cursor shape" + echo -en "\e[0 q" + end end # Setup prompt marking @@ -82,7 +87,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" # Report pwd. This is actually built-in to fish but only for terminals # that match an allowlist and that isn't us. - function __update_cwd_osc --on-variable PWD -d 'Notify capable terminals when $PWD changes' + function __update_cwd_osc --on-variable PWD -d 'Notify capable terminals when $PWD changes' if status --is-command-substitution || set -q INSIDE_EMACS return end @@ -93,7 +98,9 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" set --global fish_handle_reflow 1 # Initial calls for first prompt - __ghostty_set_cursor_beam + if test -z $no_cursor + __ghostty_set_cursor_beam + end __ghostty_mark_prompt_start __update_cwd_osc end diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 8697d59b4..c9611b9ab 100755 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -200,21 +200,23 @@ _ghostty_deferred_init() { functions[_ghostty_preexec]+=" builtin print -rnu $_ghostty_fd \$'\\e]2;'\"\${(V)1}\"\$'\\a'" - # Enable cursor shape changes depending on the current keymap. - # This implementation leaks blinking block cursor into external commands - # executed from zle. For example, users of fzf-based widgets may find - # themselves with a blinking block cursor within fzf. - _ghostty_zle_line_init _ghostty_zle_line_finish _ghostty_zle_keymap_select() { - case ${KEYMAP-} in - # Blinking block cursor. - vicmd|visual) builtin print -nu "$_ghostty_fd" '\e[1 q';; - # Blinking bar cursor. - *) builtin print -nu "$_ghostty_fd" '\e[5 q';; - esac - } - # Restore the blinking default shape before executing an external command - functions[_ghostty_preexec]+=" - builtin print -rnu $_ghostty_fd \$'\\e[0 q'" + if [[ "$GHOSTTY_SHELL_INTEGRATION_NO_CURSOR" != 1 ]]; then + # Enable cursor shape changes depending on the current keymap. + # This implementation leaks blinking block cursor into external commands + # executed from zle. For example, users of fzf-based widgets may find + # themselves with a blinking block cursor within fzf. + _ghostty_zle_line_init _ghostty_zle_line_finish _ghostty_zle_keymap_select() { + case ${KEYMAP-} in + # Blinking block cursor. + vicmd|visual) builtin print -nu "$_ghostty_fd" '\e[1 q';; + # Blinking bar cursor. + *) builtin print -nu "$_ghostty_fd" '\e[5 q';; + esac + } + # Restore the blinking default shape before executing an external command + functions[_ghostty_preexec]+=" + builtin print -rnu $_ghostty_fd \$'\\e[0 q'" + fi # Some zsh users manually run `source ~/.zshrc` in order to apply rc file # changes to the current shell. This is a terrible practice that breaks many diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 5fc3c6c1f..f48dbca0d 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -820,6 +820,7 @@ const Subprocess = struct { final_path, &env, force, + opts.full_config.@"shell-integration-features", ); }; if (shell_integrated) |shell| { diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index 6f8fe3220..08733f6ee 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -1,5 +1,6 @@ const std = @import("std"); const EnvMap = std.process.EnvMap; +const config = @import("../config.zig"); const log = std.log.scoped(.shell_integration); @@ -18,23 +19,31 @@ pub fn setup( command_path: []const u8, env: *EnvMap, force_shell: ?Shell, + features: config.ShellIntegrationFeatures, ) !?Shell { const exe = if (force_shell) |shell| switch (shell) { .fish => "/fish", .zsh => "/zsh", } else std.fs.path.basename(command_path); - if (std.mem.eql(u8, "fish", exe)) { - try setupFish(resource_dir, env); - return .fish; - } + const shell: Shell = shell: { + if (std.mem.eql(u8, "fish", exe)) { + try setupFish(resource_dir, env); + break :shell .fish; + } - if (std.mem.eql(u8, "zsh", exe)) { - try setupZsh(resource_dir, env); - return .zsh; - } + if (std.mem.eql(u8, "zsh", exe)) { + try setupZsh(resource_dir, env); + break :shell .zsh; + } - return null; + return null; + }; + + // Setup our feature env vars + if (!features.cursor) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_CURSOR", "1"); + + return shell; } /// Setup the fish automatic shell integration. This works by