Merge branch 'main' into localize-nautilus-script

This commit is contained in:
David Matos
2026-02-17 23:16:33 +01:00
55 changed files with 2464 additions and 457 deletions

View File

@@ -632,6 +632,7 @@ pub fn init(
.env_override = config.env,
.shell_integration = config.@"shell-integration",
.shell_integration_features = config.@"shell-integration-features",
.cursor_blink = config.@"cursor-style-blink",
.working_directory = config.@"working-directory",
.resources_dir = global_state.resources_dir.host(),
.term = config.term,
@@ -5398,20 +5399,11 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
return false;
},
.copy_title_to_clipboard => {
const title = self.rt_surface.getTitle() orelse return false;
if (title.len == 0) return false;
self.rt_surface.setClipboard(.standard, &.{.{
.mime = "text/plain",
.data = title,
}}, false) catch |err| {
log.err("error copying title to clipboard err={}", .{err});
return true;
};
return true;
},
.copy_title_to_clipboard => return try self.rt_app.performAction(
.{ .surface = self },
.copy_title_to_clipboard,
{},
),
.paste_from_clipboard => return try self.startClipboardRequest(
.standard,

View File

@@ -330,6 +330,11 @@ pub const Action = union(Key) {
/// The readonly state of the surface has changed.
readonly: Readonly,
/// Copy the effective title of the surface to the clipboard.
/// The effective title is the user-overridden title if set,
/// otherwise the terminal-set title.
copy_title_to_clipboard,
/// Sync with: ghostty_action_tag_e
pub const Key = enum(c_int) {
quit,
@@ -395,6 +400,7 @@ pub const Action = union(Key) {
search_total,
search_selected,
readonly,
copy_title_to_clipboard,
};
/// Sync with: ghostty_action_u

View File

@@ -49,9 +49,9 @@ pub const blueprints: []const Blueprint = &.{
.{ .major = 1, .minor = 5, .name = "split-tree-split" },
.{ .major = 1, .minor = 2, .name = "surface" },
.{ .major = 1, .minor = 5, .name = "surface-scrolled-window" },
.{ .major = 1, .minor = 5, .name = "surface-title-dialog" },
.{ .major = 1, .minor = 3, .name = "surface-child-exited" },
.{ .major = 1, .minor = 5, .name = "tab" },
.{ .major = 1, .minor = 5, .name = "title-dialog" },
.{ .major = 1, .minor = 5, .name = "window" },
.{ .major = 1, .minor = 5, .name = "command-palette" },
};

View File

@@ -36,6 +36,7 @@ const Config = @import("config.zig").Config;
const Surface = @import("surface.zig").Surface;
const SplitTree = @import("split_tree.zig").SplitTree;
const Window = @import("window.zig").Window;
const Tab = @import("tab.zig").Tab;
const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog;
const ConfigErrorsDialog = @import("config_errors_dialog.zig").ConfigErrorsDialog;
const GlobalShortcuts = @import("global_shortcuts.zig").GlobalShortcuts;
@@ -674,6 +675,8 @@ pub const Application = extern struct {
.close_tab => return Action.closeTab(target, value),
.close_window => return Action.closeWindow(target),
.copy_title_to_clipboard => return Action.copyTitleToClipboard(target),
.config_change => try Action.configChange(
self,
target,
@@ -1921,6 +1924,13 @@ const Action = struct {
}
}
pub fn copyTitleToClipboard(target: apprt.Target) bool {
return switch (target) {
.app => false,
.surface => |v| v.rt_surface.gobj().copyTitleToClipboard(),
};
}
pub fn configChange(
self: *Application,
target: apprt.Target,
@@ -2356,8 +2366,21 @@ const Action = struct {
},
},
.tab => {
// GTK does not yet support tab title prompting
return false;
switch (target) {
.app => return false,
.surface => |v| {
const surface = v.rt_surface.surface;
const tab = ext.getAncestor(
Tab,
surface.as(gtk.Widget),
) orelse {
log.warn("surface is not in a tab, ignoring prompt_tab_title", .{});
return false;
};
tab.promptTabTitle();
return true;
},
}
},
}
}

View File

@@ -30,7 +30,7 @@ const SearchOverlay = @import("search_overlay.zig").SearchOverlay;
const KeyStateOverlay = @import("key_state_overlay.zig").KeyStateOverlay;
const ChildExited = @import("surface_child_exited.zig").SurfaceChildExited;
const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog;
const TitleDialog = @import("surface_title_dialog.zig").SurfaceTitleDialog;
const TitleDialog = @import("title_dialog.zig").TitleDialog;
const Window = @import("window.zig").Window;
const InspectorWindow = @import("inspector_window.zig").InspectorWindow;
const i18n = @import("../../../os/i18n.zig");
@@ -1404,12 +1404,7 @@ pub const Surface = extern struct {
/// Prompt for a manual title change for the surface.
pub fn promptTitle(self: *Self) void {
const priv = self.private();
const dialog = gobject.ext.newInstance(
TitleDialog,
.{
.@"initial-value" = priv.title_override orelse priv.title,
},
);
const dialog = TitleDialog.new(.surface, priv.title_override orelse priv.title);
_ = TitleDialog.signals.set.connect(
dialog,
*Self,
@@ -1989,6 +1984,24 @@ pub const Surface = extern struct {
return self.private().title;
}
/// Returns the effective title: the user-overridden title if set,
/// otherwise the terminal-set title.
pub fn getEffectiveTitle(self: *Self) ?[:0]const u8 {
const priv = self.private();
return priv.title_override orelse priv.title;
}
/// Copies the effective title to the clipboard.
pub fn copyTitleToClipboard(self: *Self) bool {
const title = self.getEffectiveTitle() orelse return false;
if (title.len == 0) return false;
self.setClipboard(.standard, &.{.{
.mime = "text/plain",
.data = title,
}}, false);
return true;
}
/// Set the title for this surface, copies the value. This should always
/// be the title as set by the terminal program, not any manually set
/// title. For manually set titles see `setTitleOverride`.

View File

@@ -14,6 +14,7 @@ const Config = @import("config.zig").Config;
const Application = @import("application.zig").Application;
const SplitTree = @import("split_tree.zig").SplitTree;
const Surface = @import("surface.zig").Surface;
const TitleDialog = @import("title_dialog.zig").TitleDialog;
const log = std.log.scoped(.gtk_ghostty_window);
@@ -125,6 +126,18 @@ pub const Tab = extern struct {
},
);
};
pub const @"title-override" = struct {
pub const name = "title-override";
const impl = gobject.ext.defineProperty(
name,
Self,
?[:0]const u8,
.{
.default = null,
.accessor = C.privateStringFieldAccessor("title_override"),
},
);
};
};
pub const signals = struct {
@@ -148,6 +161,9 @@ pub const Tab = extern struct {
/// The title of this tab. This is usually bound to the active surface.
title: ?[:0]const u8 = null,
/// The manually overridden title from `promptTabTitle`.
title_override: ?[:0]const u8 = null,
/// The tooltip of this tab. This is usually bound to the active surface.
tooltip: ?[:0]const u8 = null,
@@ -204,6 +220,7 @@ pub const Tab = extern struct {
.init("ring-bell", actionRingBell, null),
.init("next-page", actionNextPage, null),
.init("previous-page", actionPreviousPage, null),
.init("prompt-tab-title", actionPromptTabTitle, null),
};
_ = ext.actions.addAsGroup(Self, self, "tab", &actions);
@@ -212,6 +229,37 @@ pub const Tab = extern struct {
//---------------------------------------------------------------
// Properties
/// Overridden title. This will be generally be shown over the title
/// unless this is unset (null).
pub fn setTitleOverride(self: *Self, title: ?[:0]const u8) void {
const priv = self.private();
if (priv.title_override) |v| glib.free(@ptrCast(@constCast(v)));
priv.title_override = null;
if (title) |v| priv.title_override = glib.ext.dupeZ(u8, v);
self.as(gobject.Object).notifyByPspec(properties.@"title-override".impl.param_spec);
}
fn titleDialogSet(
_: *TitleDialog,
title_ptr: [*:0]const u8,
self: *Self,
) callconv(.c) void {
const title = std.mem.span(title_ptr);
self.setTitleOverride(if (title.len == 0) null else title);
}
pub fn promptTabTitle(self: *Self) void {
const priv = self.private();
const dialog = TitleDialog.new(.tab, priv.title_override orelse priv.title);
_ = TitleDialog.signals.set.connect(
dialog,
*Self,
titleDialogSet,
self,
.{},
);
dialog.present(self.as(gtk.Widget));
}
/// Get the currently active surface. See the "active-surface" property.
/// This does not ref the value.
pub fn getActiveSurface(self: *Self) ?*Surface {
@@ -358,6 +406,14 @@ pub const Tab = extern struct {
}
}
fn actionPromptTabTitle(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Self,
) callconv(.c) void {
self.promptTabTitle();
}
fn actionRingBell(
_: *gio.SimpleAction,
_: ?*glib.Variant,
@@ -399,7 +455,8 @@ pub const Tab = extern struct {
_: *Self,
config_: ?*Config,
terminal_: ?[*:0]const u8,
override_: ?[*:0]const u8,
surface_override_: ?[*:0]const u8,
tab_override_: ?[*:0]const u8,
zoomed_: c_int,
bell_ringing_: c_int,
_: *gobject.ParamSpec,
@@ -407,7 +464,8 @@ pub const Tab = extern struct {
const zoomed = zoomed_ != 0;
const bell_ringing = bell_ringing_ != 0;
// Our plain title is the overridden title if it exists, otherwise
// Our plain title is the manually tab overridden title if it exists,
// otherwise the overridden title if it exists, otherwise
// the terminal title if it exists, otherwise a default string.
const plain = plain: {
const default = "Ghostty";
@@ -416,7 +474,8 @@ pub const Tab = extern struct {
break :title config.get().title orelse null;
};
const plain = override_ orelse
const plain = tab_override_ orelse
surface_override_ orelse
terminal_ orelse
config_title orelse
break :plain default;
@@ -480,6 +539,7 @@ pub const Tab = extern struct {
properties.@"split-tree".impl,
properties.@"surface-tree".impl,
properties.title.impl,
properties.@"title-override".impl,
properties.tooltip.impl,
});

View File

@@ -6,17 +6,19 @@ const gobject = @import("gobject");
const gtk = @import("gtk");
const gresource = @import("../build/gresource.zig");
const i18n = @import("../../../os/main.zig").i18n;
const ext = @import("../ext.zig");
const Common = @import("../class.zig").Common;
const Dialog = @import("dialog.zig").Dialog;
const log = std.log.scoped(.gtk_ghostty_surface_title_dialog);
const log = std.log.scoped(.gtk_ghostty_title_dialog);
pub const SurfaceTitleDialog = extern struct {
pub const TitleDialog = extern struct {
const Self = @This();
parent_instance: Parent,
pub const Parent = adw.AlertDialog;
pub const getGObjectType = gobject.ext.defineClass(Self, .{
.name = "GhosttySurfaceTitleDialog",
.name = "GhosttyTitleDialog",
.instanceInit = &init,
.classInit = &Class.init,
.parent_class = &Class.parent,
@@ -24,6 +26,24 @@ pub const SurfaceTitleDialog = extern struct {
});
pub const properties = struct {
pub const target = struct {
pub const name = "target";
const impl = gobject.ext.defineProperty(
name,
Self,
Target,
.{
.default = .surface,
.accessor = gobject.ext
.privateFieldAccessor(
Self,
Private,
&Private.offset,
"target",
),
},
);
};
pub const @"initial-value" = struct {
pub const name = "initial-value";
pub const get = impl.get;
@@ -59,6 +79,7 @@ pub const SurfaceTitleDialog = extern struct {
initial_value: ?[:0]const u8 = null,
// Template bindings
target: Target,
entry: *gtk.Entry,
pub var offset: c_int = 0;
@@ -68,6 +89,10 @@ pub const SurfaceTitleDialog = extern struct {
gtk.Widget.initTemplate(self.as(gtk.Widget));
}
pub fn new(target: Target, initial_value: ?[:0]const u8) *Self {
return gobject.ext.newInstance(Self, .{ .target = target, .@"initial-value" = initial_value });
}
pub fn present(self: *Self, parent_: *gtk.Widget) void {
// If we have a window we can attach to, we prefer that.
const parent: *gtk.Widget = if (ext.getAncestor(
@@ -89,6 +114,9 @@ pub const SurfaceTitleDialog = extern struct {
priv.entry.getBuffer().setText(v, -1);
}
// Set the title for the dialog
self.as(Dialog.Parent).setHeading(priv.target.title());
// Show it. We could also just use virtual methods to bind to
// response but this is pretty simple.
self.as(adw.AlertDialog).choose(
@@ -162,7 +190,7 @@ pub const SurfaceTitleDialog = extern struct {
comptime gresource.blueprint(.{
.major = 1,
.minor = 5,
.name = "surface-title-dialog",
.name = "title-dialog",
}),
);
@@ -175,6 +203,7 @@ pub const SurfaceTitleDialog = extern struct {
// Properties
gobject.ext.registerProperties(class, &.{
properties.@"initial-value".impl,
properties.target.impl,
});
// Virtual methods
@@ -187,3 +216,19 @@ pub const SurfaceTitleDialog = extern struct {
pub const bindTemplateCallback = C.Class.bindTemplateCallback;
};
};
pub const Target = enum(c_int) {
surface,
tab,
pub fn title(self: Target) [*:0]const u8 {
return switch (self) {
.surface => i18n._("Change Terminal Title"),
.tab => i18n._("Change Tab Title"),
};
}
pub const getGObjectType = gobject.ext.defineEnum(
Target,
.{ .name = "GhosttyTitleDialogTarget" },
);
};

View File

@@ -252,6 +252,10 @@ pub const Window = extern struct {
/// A weak reference to a command palette.
command_palette: WeakRef(CommandPalette) = .empty,
/// Tab page that the context menu was opened for.
/// setup by `setup-menu`.
context_menu_page: ?*adw.TabPage = null,
// Template bindings
tab_overview: *adw.TabOverview,
tab_bar: *adw.TabBar,
@@ -335,6 +339,8 @@ pub const Window = extern struct {
.init("close-tab", actionCloseTab, s_variant_type),
.init("new-tab", actionNewTab, null),
.init("new-window", actionNewWindow, null),
.init("prompt-tab-title", actionPromptTabTitle, null),
.init("prompt-context-tab-title", actionPromptContextTabTitle, null),
.init("ring-bell", actionRingBell, null),
.init("split-right", actionSplitRight, null),
.init("split-left", actionSplitLeft, null),
@@ -1531,6 +1537,13 @@ pub const Window = extern struct {
self.as(gtk.Window).close();
}
}
fn setupTabMenu(
_: *adw.TabView,
page: ?*adw.TabPage,
self: *Self,
) callconv(.c) void {
self.private().context_menu_page = page;
}
fn surfaceClipboardWrite(
_: *Surface,
@@ -1774,6 +1787,26 @@ pub const Window = extern struct {
self.performBindingAction(.new_tab);
}
fn actionPromptContextTabTitle(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Self,
) callconv(.c) void {
const priv = self.private();
const page = priv.context_menu_page orelse return;
const child = page.getChild();
const tab = gobject.ext.cast(Tab, child) orelse return;
tab.promptTabTitle();
}
fn actionPromptTabTitle(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Window,
) callconv(.c) void {
self.performBindingAction(.prompt_tab_title);
}
fn actionSplitRight(
_: *gio.SimpleAction,
_: ?*glib.Variant,
@@ -1999,6 +2032,7 @@ pub const Window = extern struct {
class.bindTemplateCallback("close_page", &tabViewClosePage);
class.bindTemplateCallback("page_attached", &tabViewPageAttached);
class.bindTemplateCallback("page_detached", &tabViewPageDetached);
class.bindTemplateCallback("setup_tab_menu", &setupTabMenu);
class.bindTemplateCallback("tab_create_window", &tabViewCreateWindow);
class.bindTemplateCallback("notify_n_pages", &tabViewNPages);
class.bindTemplateCallback("notify_selected_page", &tabViewSelectedPage);

View File

@@ -321,6 +321,11 @@ menu context_menu_model {
submenu {
label: _("Tab");
item {
label: _("Change Tab Title…");
action: "tab.prompt-tab-title";
}
item {
label: _("New Tab");
action: "win.new-tab";

View File

@@ -8,7 +8,7 @@ template $GhosttyTab: Box {
orientation: vertical;
hexpand: true;
vexpand: true;
title: bind $computed_title(template.config, split_tree.active-surface as <$GhosttySurface>.title, split_tree.active-surface as <$GhosttySurface>.title-override, split_tree.is-zoomed, split_tree.active-surface as <$GhosttySurface>.bell-ringing) as <string>;
title: bind $computed_title(template.config, split_tree.active-surface as <$GhosttySurface>.title, split_tree.active-surface as <$GhosttySurface>.title-override, template.title-override, split_tree.is-zoomed, split_tree.active-surface as <$GhosttySurface>.bell-ringing) as <string>;
tooltip: bind split_tree.active-surface as <$GhosttySurface>.pwd;
$GhosttySplitTree split_tree {

View File

@@ -1,8 +1,7 @@
using Gtk 4.0;
using Adw 1;
template $GhosttySurfaceTitleDialog: Adw.AlertDialog {
heading: _("Change Terminal Title");
template $GhosttyTitleDialog: Adw.AlertDialog {
body: _("Leave blank to restore the default title.");
responses [

View File

@@ -162,6 +162,8 @@ template $GhosttyWindow: Adw.ApplicationWindow {
page-attached => $page_attached();
page-detached => $page_detached();
create-window => $tab_create_window();
setup-menu => $setup_tab_menu();
menu-model: tab_context_menu;
shortcuts: none;
}
}
@@ -218,6 +220,11 @@ menu main_menu {
}
section {
item {
label: _("Change Tab Title…");
action: "win.prompt-tab-title";
}
item {
label: _("New Tab");
action: "win.new-tab";
@@ -307,3 +314,10 @@ menu main_menu {
}
}
}
menu tab_context_menu {
item {
label: _("Change Tab Title…");
action: "win.prompt-context-tab-title";
}
}

View File

@@ -784,8 +784,30 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF },
///
/// 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 (015) since the
/// rest of the palette (16255) will be automatically generated by
/// default (see `palette-generate` for more details).
palette: Palette = .{},
/// Whether to automatically generate the extended 256 color palette
/// (indices 16255) 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.
///
/// 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 = true,
/// 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`)
@@ -2731,7 +2753,7 @@ keybind: Keybinds = .{},
///
/// Available features:
///
/// * `cursor` - Set the cursor to a blinking bar at the prompt.
/// * `cursor` - Set the cursor to a bar at the prompt.
///
/// * `sudo` - Set sudo wrapper to preserve terminfo.
///
@@ -5530,14 +5552,16 @@ pub const ColorList = struct {
}
};
/// Palette is the 256 color palette for 256-color mode. This is still
/// used by many terminal applications.
/// 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: [265]Color.C,
@@ -5574,6 +5598,7 @@ pub const Palette = struct {
// 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.
@@ -5609,6 +5634,8 @@ pub const Palette = struct {
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" {
@@ -5631,6 +5658,12 @@ pub const Palette = struct {
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" {
@@ -5638,6 +5671,8 @@ pub const Palette = struct {
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" {
@@ -5669,6 +5704,11 @@ pub const Palette = struct {
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));
}
};

View File

@@ -1,15 +1,17 @@
const std = @import("std");
const oni = @import("oniguruma");
/// Default URL regex. This is used to detect URLs in terminal output.
/// Default URL/path regex. This is used to detect URLs and file paths in
/// terminal output.
///
/// This is here in the config package because one day the matchers will be
/// configurable and this will be a default.
///
/// This regex is liberal in what it accepts after the scheme, with exceptions
/// for URLs ending with . or ). Although such URLs are perfectly valid, it is
/// common for text to contain URLs surrounded by parentheses (such as in
/// Markdown links) or at the end of sentences. Therefore, this regex excludes
/// them as follows:
/// For scheme URLs, this regex is liberal in what it accepts after the scheme,
/// with exceptions for URLs ending with . or ). Although such URLs are
/// perfectly valid, it is common for text to contain URLs surrounded by
/// parentheses (such as in Markdown links) or at the end of sentences.
/// Therefore, this regex excludes them as follows:
///
/// 1. Do not match regexes ending with .
/// 2. Do not match regexes ending with ), except for ones which contain a (
@@ -22,12 +24,6 @@ const oni = @import("oniguruma");
///
/// There are many complicated cases where these heuristics break down, but
/// handling them well requires a non-regex approach.
pub const regex =
"(?:" ++ url_schemes ++
\\)(?:
++ ipv6_url_pattern ++
\\|[\w\-.~:/?#@!$&*+,;=%]+(?:[\(\[]\w*[\)\]])?)+(?<![,.])|(?:\.\.\/|\.\/|(?<!\w)\/)(?:(?=[\w\-.~:\/?#@!$&*+,;=%]*\.)[\w\-.~:\/?#@!$&*+,;=%]+(?: [\w\-.~:\/?#@!$&*+,;=%]*[\/.])*(?: +(?= *$))?|(?![\w\-.~:\/?#@!$&*+,;=%]*\.)[\w\-.~:\/?#@!$&*+,;=%]+(?: [\w\-.~:\/?#@!$&*+,;=%]+)*(?: +(?= *$))?)|[\w][\w\-.]*\/(?=[\w\-.~:\/?#@!$&*+,;=%]*\.)[\w\-.~:\/?#@!$&*+,;=%]+(?: [\w\-.~:\/?#@!$&*+,;=%]*[\/.])*(?: +(?= *$))?
;
const url_schemes =
\\https?://|mailto:|ftp://|file:|ssh:|git://|ssh://|tel:|magnet:|ipfs://|ipns://|gemini://|gopher://|news:
;
@@ -36,6 +32,95 @@ const ipv6_url_pattern =
\\(?:\[[:0-9a-fA-F]+(?:[:0-9a-fA-F]*)+\](?::[0-9]+)?)
;
const scheme_url_chars =
\\[\w\-.~:/?#@!$&*+,;=%]
;
const path_chars =
\\[\w\-.~:\/?#@!$&*+;=%]
;
const optional_bracketed_word_suffix =
\\(?:[\(\[]\w*[\)\]])?
;
const no_trailing_punctuation =
\\(?<![,.])
;
const no_trailing_colon =
\\(?<!:)
;
const trailing_spaces_at_eol =
\\(?: +(?= *$))?
;
const dotted_path_lookahead =
\\(?=[\w\-.~:\/?#@!$&*+;=%]*\.)
;
const non_dotted_path_lookahead =
\\(?![\w\-.~:\/?#@!$&*+;=%]*\.)
;
const dotted_path_space_segments =
\\(?:(?<!:) (?!\w+:\/\/)[\w\-.~:\/?#@!$&*+;=%]*[\/.])*
;
const any_path_space_segments =
\\(?:(?<!:) (?!\w+:\/\/)[\w\-.~:\/?#@!$&*+;=%]+)*
;
// Branch 1: URLs with explicit schemes (http, mailto, ftp, etc.).
const scheme_url_branch =
"(?:" ++ url_schemes ++ ")" ++
"(?:" ++ ipv6_url_pattern ++ "|" ++ scheme_url_chars ++ "+" ++ optional_bracketed_word_suffix ++ ")+" ++
no_trailing_punctuation;
const rooted_or_relative_path_prefix =
\\(?:\.\.\/|\.\/|(?<!\w)~\/|(?:[\w][\w\-.]*\/)*(?<!\w)\$[A-Za-z_]\w*\/|\.[\w][\w\-.]*\/|(?<![\w~\/])\/(?!\/))
;
// Branch 2: Absolute paths and dot-relative paths (/, ./, ../).
// A dotted segment is treated as file-like, while the undotted case stays
// broad to capture directory-like paths with spaces.
const rooted_or_relative_path_branch =
rooted_or_relative_path_prefix ++
"(?:" ++
dotted_path_lookahead ++
path_chars ++ "+" ++
dotted_path_space_segments ++
no_trailing_colon ++
trailing_spaces_at_eol ++
"|" ++
non_dotted_path_lookahead ++
path_chars ++ "+" ++
any_path_space_segments ++
no_trailing_colon ++
trailing_spaces_at_eol ++
")";
// Branch 3: Bare relative paths such as src/config/url.zig.
const bare_relative_path_prefix =
\\(?<!\$\d*)(?<!\w)[\w][\w\-.]*\/
;
const bare_relative_path_branch =
dotted_path_lookahead ++
bare_relative_path_prefix ++
path_chars ++ "+" ++
dotted_path_space_segments ++
no_trailing_colon ++
trailing_spaces_at_eol;
pub const regex =
scheme_url_branch ++
"|" ++
rooted_or_relative_path_branch ++
"|" ++
bare_relative_path_branch;
test "url regex" {
const testing = std.testing;
@@ -77,7 +162,7 @@ test "url regex" {
.expect = "https://example.com",
},
.{
.input = "Link trailing colon https://example.com, more text.",
.input = "Link trailing comma https://example.com, more text.",
.expect = "https://example.com",
},
.{
@@ -148,6 +233,10 @@ test "url regex" {
.input = "match git://example.com git links",
.expect = "git://example.com",
},
.{
.input = "/tmp/test.txt http://www.google.com",
.expect = "/tmp/test.txt",
},
.{
.input = "match tel:+18005551234 tel links",
.expect = "tel:+18005551234",
@@ -291,6 +380,89 @@ test "url regex" {
.input = "some-pkg/src/file.txt more text",
.expect = "some-pkg/src/file.txt",
},
// comma should match substrings
.{
.input = "src/foo.c,baz.txt",
.expect = "src/foo.c",
},
.{
.input = "~/foo/bar.txt",
.expect = "~/foo/bar.txt",
},
.{
.input = "open ~/Documents/notes.md please",
.expect = "~/Documents/notes.md",
},
.{
.input = "~/.config/ghostty/config",
.expect = "~/.config/ghostty/config",
},
.{
.input = "directory: ~/src/ghostty-org/ghostty",
.expect = "~/src/ghostty-org/ghostty",
},
.{
.input = "$HOME/src/config/url.zig",
.expect = "$HOME/src/config/url.zig",
},
.{
.input = "project dir: $PWD/src/ghostty/main.zig",
.expect = "$PWD/src/ghostty/main.zig",
},
// $VAR mid-path should match fully, not partially from the $
.{
.input = "foo/$BAR/baz",
.expect = "foo/$BAR/baz",
},
.{
.input = ".foo/bar/$VAR",
.expect = ".foo/bar/$VAR",
},
.{
.input = ".config/ghostty/config",
.expect = ".config/ghostty/config",
},
.{
.input = "loaded from .local/share/ghostty/state.db now",
.expect = ".local/share/ghostty/state.db",
},
.{
.input = "../some/where",
.expect = "../some/where",
},
// comma-separated file paths
.{
.input = " - shared/src/foo/SomeItem.m:12, shared/src/",
.expect = "shared/src/foo/SomeItem.m:12",
},
// mid-string dot should not partially match but fully
.{
.input = "foo.local/share",
.expect = "foo.local/share",
},
// numeric directory should match fully
.{
.input = "2024/report.txt",
.expect = "2024/report.txt",
},
// comma should stop matching in spaced path segments
.{
.input = "./foo bar,baz",
.expect = "./foo bar",
},
.{
.input = "/tmp/foo bar,baz",
.expect = "/tmp/foo bar",
},
// trailing colon should not be part of the path
.{
.input = "./.config/ghostty: Needs upstream (main)",
.expect = "./.config/ghostty",
},
.{
.input = "./Downloads: Operation not permitted",
.expect = "./Downloads",
},
};
for (cases) |case| {
@@ -306,10 +478,23 @@ test "url regex" {
try testing.expectEqualStrings(case.expect, match);
}
// Bare relative paths without any dot should not match as file paths
const no_match_cases = [_][]const u8{
// bare relative paths without any dot should not match as file paths
"input/output",
"foo/bar",
// $-numeric character should not match
"$10/bar",
"$10/$20",
"$10/bar.txt",
// comma should not let dot detection look past it
"foo/bar,baz.txt",
// $VAR should not match mid-word
"foo$BAR/baz.txt",
// ~ should not match mid-word
"foo~/bar.txt",
// double-slash comments are not paths
"// foo bar",
"//foo",
};
for (no_match_cases) |input| {
var result = re.search(input, .{});

View File

@@ -40,6 +40,12 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
try writer.writeAll(
\\_ghostty() {
\\
\\ # compat: mapfile -t COMPREPLY < <( "$@" )
\\ _compreply() {
\\ COMPREPLY=()
\\ while IFS='' read -r line; do COMPREPLY+=("$line"); done < <( "$@" )
\\ }
\\
\\ # -o nospace requires we add back a space when a completion is finished
\\ # and not part of a --key= completion
\\ _add_spaces() {
@@ -50,16 +56,18 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
\\
\\ _fonts() {
\\ local IFS=$'\n'
\\ mapfile -t COMPREPLY < <( compgen -P '"' -S '"' -W "$($ghostty +list-fonts | grep '^[A-Z]' )" -- "$cur")
\\ COMPREPLY=()
\\ while read -r line; do COMPREPLY+=("$line"); done < <( compgen -P '"' -S '"' -W "$($ghostty +list-fonts | grep '^[A-Z]' )" -- "$cur")
\\ }
\\
\\ _themes() {
\\ local IFS=$'\n'
\\ mapfile -t COMPREPLY < <( compgen -P '"' -S '"' -W "$($ghostty +list-themes | sed -E 's/^(.*) \(.*$/\1/')" -- "$cur")
\\ COMPREPLY=()
\\ while read -r line; do COMPREPLY+=("$line"); done < <( compgen -P '"' -S '"' -W "$($ghostty +list-themes | sed -E 's/^(.*) \(.*$/\1/')" -- "$cur")
\\ }
\\
\\ _files() {
\\ mapfile -t COMPREPLY < <( compgen -o filenames -f -- "$cur" )
\\ _compreply compgen -o filenames -f -- "$cur"
\\ for i in "${!COMPREPLY[@]}"; do
\\ if [[ -d "${COMPREPLY[i]}" ]]; then
\\ COMPREPLY[i]="${COMPREPLY[i]}/";
@@ -71,7 +79,7 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
\\ }
\\
\\ _dirs() {
\\ mapfile -t COMPREPLY < <( compgen -o dirnames -d -- "$cur" )
\\ _compreply compgen -o dirnames -d -- "$cur"
\\ for i in "${!COMPREPLY[@]}"; do
\\ if [[ -d "${COMPREPLY[i]}" ]]; then
\\ COMPREPLY[i]="${COMPREPLY[i]}/";
@@ -115,8 +123,8 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
else if (field.type == Config.RepeatablePath)
try writer.writeAll("_files ;;")
else {
const compgenPrefix = "mapfile -t COMPREPLY < <( compgen -W \"";
const compgenSuffix = "\" -- \"$cur\" ); _add_spaces ;;";
const compgenPrefix = "_compreply compgen -W \"";
const compgenSuffix = "\" -- \"$cur\"; _add_spaces ;;";
switch (@typeInfo(field.type)) {
.bool => try writer.writeAll("return ;;"),
.@"enum" => |info| {
@@ -147,7 +155,7 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
}
try writer.writeAll(
\\ *) mapfile -t COMPREPLY < <( compgen -W "$config" -- "$cur" ) ;;
\\ *) _compreply compgen -W "$config" -- "$cur" ;;
\\ esac
\\
\\ return 0
@@ -206,8 +214,8 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
try writer.writeAll(pad5 ++ "--" ++ opt.name ++ ") ");
const compgenPrefix = "mapfile -t COMPREPLY < <( compgen -W \"";
const compgenSuffix = "\" -- \"$cur\" ); _add_spaces ;;";
const compgenPrefix = "_compreply compgen -W \"";
const compgenSuffix = "\" -- \"$cur\"; _add_spaces ;;";
switch (@typeInfo(opt.type)) {
.bool => try writer.writeAll("return ;;"),
.@"enum" => |info| {
@@ -243,7 +251,7 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
}
try writer.writeAll("\n");
}
try writer.writeAll(pad5 ++ "*) mapfile -t COMPREPLY < <( compgen -W \"$" ++ bashName ++ "\" -- \"$cur\" ) ;;\n");
try writer.writeAll(pad5 ++ "*) _compreply compgen -W \"$" ++ bashName ++ "\" -- \"$cur\" ;;\n");
try writer.writeAll(
\\ esac
\\ ;;
@@ -252,7 +260,7 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
}
try writer.writeAll(
\\ *) mapfile -t COMPREPLY < <( compgen -W "--help" -- "$cur" ) ;;
\\ *) _compreply compgen -W "--help" -- "$cur" ;;
\\ esac
\\
\\ return 0
@@ -298,7 +306,7 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
\\ case "${COMP_WORDS[1]}" in
\\ -e | --help | --version) return 0 ;;
\\ --*) _handle_config ;;
\\ *) mapfile -t COMPREPLY < <( compgen -W "${topLevel}" -- "$cur" ); _add_spaces ;;
\\ *) _compreply compgen -W "${topLevel}" -- "$cur"; _add_spaces ;;
\\ esac
\\ ;;
\\ *)

View File

@@ -39,10 +39,57 @@ pub fn encode(
[]const u8 => Error![3][]const u8,
else => unreachable,
} {
// These are the set of byte values that are always replaced by
// a space (per xterm's behavior) for any text insertion method e.g.
// a paste, drag and drop, etc. These are copied directly from xterm's
// source.
const strip: []const u8 = &.{
0x00, // NUL
0x08, // BS
0x05, // ENQ
0x04, // EOT
0x1B, // ESC
0x7F, // DEL
// These can be overridden by the running terminal program
// via tcsetattr, so they aren't totally safe to hardcode like
// this. In practice, I haven't seen modern programs change these
// and its a much bigger architectural change to pass these through
// so for now they're hardcoded.
0x03, // VINTR (Ctrl+C)
0x1C, // VQUIT (Ctrl+\)
0x15, // VKILL (Ctrl+U)
0x1A, // VSUSP (Ctrl+Z)
0x11, // VSTART (Ctrl+Q)
0x13, // VSTOP (Ctrl+S)
0x17, // VWERASE (Ctrl+W)
0x16, // VLNEXT (Ctrl+V)
0x12, // VREPRINT (Ctrl+R)
0x0F, // VDISCARD (Ctrl+O)
};
const mutable = @TypeOf(data) == []u8;
var result: [3][]const u8 = .{ "", data, "" };
// If we have any of the strip values, then we need to replace them
// with spaces. This is what xterm does and it does it regardless
// of bracketed paste mode. This is a security measure to prevent pastes
// from containing bytes that could be used to inject commands.
if (std.mem.indexOfAny(u8, data, strip) != null) {
if (comptime !mutable) return Error.MutableRequired;
var offset: usize = 0;
while (std.mem.indexOfAny(
u8,
data[offset..],
strip,
)) |idx| {
offset += idx;
data[offset] = ' ';
offset += 1;
}
}
// Bracketed paste mode (mode 2004) wraps pasted data in
// fenceposts so that the terminal can ignore things like newlines.
if (opts.bracketed) {
@@ -143,3 +190,39 @@ test "encode unbracketed windows-stye newline" {
try testing.expectEqualStrings("hello\r\rworld", result[1]);
try testing.expectEqualStrings("", result[2]);
}
test "encode strip unsafe bytes const" {
const testing = std.testing;
try testing.expectError(Error.MutableRequired, encode(
@as([]const u8, "hello\x00world"),
.{ .bracketed = true },
));
}
test "encode strip unsafe bytes mutable bracketed" {
const testing = std.testing;
const data: []u8 = try testing.allocator.dupe(u8, "hel\x1blo\x00world");
defer testing.allocator.free(data);
const result = encode(data, .{ .bracketed = true });
try testing.expectEqualStrings("\x1b[200~", result[0]);
try testing.expectEqualStrings("hel lo world", result[1]);
try testing.expectEqualStrings("\x1b[201~", result[2]);
}
test "encode strip unsafe bytes mutable unbracketed" {
const testing = std.testing;
const data: []u8 = try testing.allocator.dupe(u8, "hel\x03lo");
defer testing.allocator.free(data);
const result = encode(data, .{ .bracketed = false });
try testing.expectEqualStrings("", result[0]);
try testing.expectEqualStrings("hel lo", result[1]);
try testing.expectEqualStrings("", result[2]);
}
test "encode strip multiple unsafe bytes" {
const testing = std.testing;
const data: []u8 = try testing.allocator.dupe(u8, "\x00\x08\x7f");
defer testing.allocator.free(data);
const result = encode(data, .{ .bracketed = true });
try testing.expectEqualStrings(" ", result[1]);
}

View File

@@ -2275,26 +2275,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// std.log.warn("[rebuildCells time] {}\t{}", .{start_micro, end.since(start) / std.time.ns_per_us});
// }
// Determine our x/y range for preedit. We don't want to render anything
// here because we will render the preedit separately.
const preedit_range: ?PreeditRange = if (preedit) |preedit_v| preedit: {
// We base the preedit on the position of the cursor in the
// viewport. If the cursor isn't visible in the viewport we
// don't show it.
const cursor_vp = state.cursor.viewport orelse
break :preedit null;
const range = preedit_v.range(
cursor_vp.x,
state.cols - 1,
);
break :preedit .{
.y = @intCast(cursor_vp.y),
.x = .{ range.start, range.end },
.cp_offset = range.cp_offset,
};
} else null;
const grid_size_diff =
self.cells.size.rows != state.rows or
self.cells.size.columns != state.cols;
@@ -2352,6 +2332,32 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
state.rows,
self.cells.size.rows,
);
// Determine our x/y range for preedit. We don't want to render anything
// here because we will render the preedit separately.
const preedit_range: ?PreeditRange = if (preedit) |preedit_v| preedit: {
// We base the preedit on the position of the cursor in the
// viewport. If the cursor isn't visible in the viewport we
// don't show it.
const cursor_vp = state.cursor.viewport orelse
break :preedit null;
// If our preedit row isn't dirty then we don't need the
// preedit range. This also avoids an issue later where we
// unconditionally add preedit cells when this is set.
if (!rebuild and !row_dirty[cursor_vp.y]) break :preedit null;
const range = preedit_v.range(
cursor_vp.x,
state.cols - 1,
);
break :preedit .{
.y = @intCast(cursor_vp.y),
.x = .{ range.start, range.end },
.cp_offset = range.cp_offset,
};
} else null;
for (
0..,
row_raws[0..row_len],
@@ -2527,14 +2533,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
}
// Setup our preedit text.
if (preedit) |preedit_v| {
const range = preedit_range.?;
if (preedit) |preedit_v| preedit: {
const range = preedit_range orelse break :preedit;
var x = range.x[0];
for (preedit_v.codepoints[range.cp_offset..]) |cp| {
self.addPreeditCell(
cp,
.{ .x = x, .y = range.y },
state.colors.background,
state.colors.foreground,
) catch |err| {
log.warn("error building preedit cell, will be invalid x={} y={}, err={}", .{
@@ -3264,7 +3269,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self: *Self,
cp: renderer.State.Preedit.Codepoint,
coord: terminal.Coordinate,
screen_bg: terminal.color.RGB,
screen_fg: terminal.color.RGB,
) !void {
// Render the glyph for our preedit text
@@ -3283,16 +3287,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
return;
};
// Add our opaque background cell
self.cells.bgCell(coord.y, coord.x).* = .{
screen_bg.r, screen_bg.g, screen_bg.b, 255,
};
if (cp.wide and coord.x < self.cells.size.columns - 1) {
self.cells.bgCell(coord.y, coord.x + 1).* = .{
screen_bg.r, screen_bg.g, screen_bg.b, 255,
};
}
// Add our text
try self.cells.add(self.alloc, .text, .{
.atlas = .grayscale,

View File

@@ -198,7 +198,7 @@ function __ghostty_precmd() {
# Marks. We need to do fresh line (A) at the beginning of the prompt
# since if the cursor is not at the beginning of a line, the terminal
# will emit a newline.
PS1='\[\e]133;A;redraw=last;cl=line\a\]'$PS1'\[\e]133;B\a\]'
PS1='\[\e]133;A;redraw=last;cl=line;aid='"$BASHPID"'\a\]'$PS1'\[\e]133;B\a\]'
PS2='\[\e]133;A;k=s\a\]'$PS2'\[\e]133;B\a\]'
# Bash doesn't redraw the leading lines in a multiline prompt so
@@ -213,7 +213,10 @@ function __ghostty_precmd() {
# Cursor
if [[ "$GHOSTTY_SHELL_FEATURES" == *"cursor"* ]]; then
[[ "$PS1" != *'\[\e[5 q\]'* ]] && PS1=$PS1'\[\e[5 q\]' # input
builtin local cursor=5 # blinking bar
[[ "$GHOSTTY_SHELL_FEATURES" == *"cursor:steady"* ]] && cursor=6 # steady bar
[[ "$PS1" != *"\[\e[${cursor} q\]"* ]] && PS1=$PS1"\[\e[${cursor} q\]"
[[ "$PS0" != *'\[\e[0 q\]'* ]] && PS0=$PS0'\[\e[0 q\]' # reset
fi
@@ -236,8 +239,6 @@ function __ghostty_precmd() {
builtin printf "\e]7;kitty-shell-cwd://%s%s\a" "$HOSTNAME" "$PWD"
fi
# Fresh line and start of prompt.
builtin printf "\e]133;A;redraw=last;cl=line;aid=%s\a" "$BASHPID"
_ghostty_executing=0
}
@@ -278,7 +279,9 @@ if (( BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4) )
__ghostty_hook() {
builtin local ret=$?
__ghostty_precmd "$ret"
PS0=$__ghostty_ps0
if [[ "$PS0" != *"$__ghostty_ps0"* ]]; then
PS0=$PS0"${__ghostty_ps0}"
fi
}
# Append our hook to PROMPT_COMMAND, preserving its existing type.

View File

@@ -154,11 +154,16 @@
set edit:after-readline = (conj $edit:after-readline $mark-output-start~)
set edit:after-command = (conj $edit:after-command $mark-output-end~)
if (has-value $features cursor) {
fn beam { printf "\e[5 q" }
fn block { printf "\e[0 q" }
if (str:contains $E:GHOSTTY_SHELL_FEATURES "cursor") {
var cursor = "5" # blinking bar
if (has-value $features cursor:steady) {
set cursor = "6" # steady bar
}
fn beam { printf "\e["$cursor" q" }
fn reset { printf "\e[0 q" }
set edit:before-readline = (conj $edit:before-readline $beam~)
set edit:after-readline = (conj $edit:after-readline {|_| block })
set edit:after-readline = (conj $edit:after-readline {|_| reset })
}
if (and (has-value $features path) (has-env GHOSTTY_BIN_DIR)) {
if (not (has-value $paths $E:GHOSTTY_BIN_DIR)) {

View File

@@ -72,11 +72,14 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
set -g __ghostty_prompt_start_mark "\e]133;A;click_events=1\a"
end
if contains cursor $features
if string match -q 'cursor*' -- $features
set -l cursor 5 # blinking bar
contains cursor:steady $features && set cursor 6 # steady bar
# Change the cursor to a beam on prompt.
function __ghostty_set_cursor_beam --on-event fish_prompt -d "Set cursor shape"
function __ghostty_set_cursor_beam --on-event fish_prompt -V cursor -d "Set cursor shape"
if not functions -q fish_vi_cursor_handle
echo -en "\e[5 q"
echo -en "\e[$cursor q"
end
end
function __ghostty_reset_cursor --on-event fish_preexec -d "Reset cursor shape"
@@ -233,7 +236,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
set --global fish_handle_reflow 1
# Initial calls for first prompt
if contains cursor $features
if string match -q 'cursor*' -- $features
__ghostty_set_cursor_beam
end
__ghostty_mark_prompt_start

View File

@@ -188,7 +188,7 @@ _ghostty_deferred_init() {
# our own prompt, user prompt, and our own prompt with user additions on
# top. We cannot force prompt_subst on the user though, so we would
# still need this code for the no_prompt_subst case.
PS1=${PS1//$'%{\e]133;A\a%}'}
PS1=${PS1//$'%{\e]133;A;cl=line\a%}'}
PS1=${PS1//$'%{\e]133;A;k=s\a%}'}
PS1=${PS1//$'%{\e]133;B\a%}'}
PS2=${PS2//$'%{\e]133;A;k=s\a%}'}
@@ -227,14 +227,14 @@ _ghostty_deferred_init() {
# 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
builtin local steady=0
[[ "$GHOSTTY_SHELL_FEATURES" == *"cursor:steady"* ]] && steady=1
case ${KEYMAP-} in
vicmd|visual) builtin print -nu "$_ghostty_fd" "\e[$(( 1 + steady )) q" ;; # block
*) builtin print -nu "$_ghostty_fd" "\e[$(( 5 + steady )) q" ;; # bar
esac
}
# Restore the blinking default shape before executing an external command
# Restore the default shape before executing an external command
functions[_ghostty_preexec]+="
builtin print -rnu $_ghostty_fd \$'\\e[0 q'"
fi

View File

@@ -47,6 +47,115 @@ pub const default: Palette = default: {
/// Palette is the 256 color palette.
pub const Palette = [256]RGB;
/// Mask that can be used to set which palette indexes were set.
pub const PaletteMask = std.StaticBitSet(@typeInfo(Palette).array.len);
/// Generate the 256-color palette from the user's base16 theme colors,
/// terminal background, and terminal foreground.
///
/// Motivation: The default 256-color palette uses fixed, fully-saturated
/// colors that clash with custom base16 themes, have poor readability in
/// dark shades (the first non-black shade jumps to 37% intensity instead
/// of the expected 20%), and exhibit inconsistent perceived brightness
/// across hues of the same shade (e.g., blue appears darker than green).
/// By generating the extended palette from the user's chosen colors,
/// programs can use the richer 256-color range without requiring their
/// own theme configuration, and light/dark switching works automatically.
///
/// The 216-color cube (indices 16231) is built via trilinear
/// interpolation in CIELAB space over the 8 base colors. The base16
/// palette maps to the 8 corners of a 6×6×6 RGB cube as follows:
///
/// R=0 edge: bg → base[1] (red)
/// R=5 edge: base[6] → fg
/// G=0 edge: bg/base[6] (via R) → base[2]/base[4] (green/blue via R)
/// G=5 edge: base[1]/fg (via R) → base[3]/base[5] (yellow/magenta via R)
///
/// For each R slice, four corner colors (c0c3) are interpolated along
/// the R axis, then for each G row two edge colors (c4c5) are
/// interpolated along G, and finally each B cell is interpolated along B
/// to produce the final color. CIELAB interpolation ensures perceptually
/// uniform brightness transitions across different hues.
///
/// The 24-step grayscale ramp (indices 232255) is a simple linear
/// interpolation in CIELAB from the background to the foreground,
/// excluding pure black and white (available in the cube at (0,0,0)
/// and (5,5,5)). The interpolation parameter runs from 1/25 to 24/25.
///
/// Fill `skip` with user-defined color indexes to avoid replacing them.
///
/// Reference: https://gist.github.com/jake-stewart/0a8ea46159a7da2c808e5be2177e1783
pub fn generate256Color(
base: Palette,
skip: PaletteMask,
bg: RGB,
fg: RGB,
) Palette {
// Convert the background, foreground, and 8 base theme colors into
// CIELAB space so that all interpolation is perceptually uniform.
const bg_lab: LAB = .fromRgb(bg);
const fg_lab: LAB = .fromRgb(fg);
const base8_lab: [8]LAB = base8: {
var base8: [8]LAB = undefined;
for (0..8) |i| base8[i] = .fromRgb(base[i]);
break :base8 base8;
};
// Start from the base palette so indices 015 are preserved as-is.
var result = base;
// Build the 216-color cube (indices 16231) via trilinear interpolation
// in CIELAB. The three nested loops correspond to the R, G, and B axes
// of a 6×6×6 cube. For each R slice, four corner colors (c0c3) are
// interpolated along R from the 8 base colors, mapping the cube corners
// to theme-aware anchors (see doc comment for the mapping). Then for
// each G row, two edge colors (c4c5) blend along G, and finally each
// B cell interpolates along B to produce the final color.
var idx: usize = 16;
for (0..6) |ri| {
// R-axis corners: blend base colors along the red dimension.
const tr = @as(f32, @floatFromInt(ri)) / 5.0;
const c0: LAB = .lerp(tr, bg_lab, base8_lab[1]);
const c1: LAB = .lerp(tr, base8_lab[2], base8_lab[3]);
const c2: LAB = .lerp(tr, base8_lab[4], base8_lab[5]);
const c3: LAB = .lerp(tr, base8_lab[6], fg_lab);
for (0..6) |gi| {
// G-axis edges: blend the R-interpolated corners along green.
const tg = @as(f32, @floatFromInt(gi)) / 5.0;
const c4: LAB = .lerp(tg, c0, c1);
const c5: LAB = .lerp(tg, c2, c3);
for (0..6) |bi| {
// B-axis: final interpolation along blue, then convert back to RGB.
if (!skip.isSet(idx)) {
const c6: LAB = .lerp(
@as(f32, @floatFromInt(bi)) / 5.0,
c4,
c5,
);
result[idx] = c6.toRgb();
}
idx += 1;
}
}
}
// Build the 24-step grayscale ramp (indices 232255) by linearly
// interpolating in CIELAB from background to foreground. The parameter
// runs from 1/25 to 24/25, excluding the endpoints which are already
// available in the cube at (0,0,0) and (5,5,5).
for (0..24) |i| {
const t = @as(f32, @floatFromInt(i + 1)) / 25.0;
if (!skip.isSet(idx)) {
const c: LAB = .lerp(t, bg_lab, fg_lab);
result[idx] = c.toRgb();
}
idx += 1;
}
return result;
}
/// A palette that can have its colors changed and reset. Purposely built
/// for terminal color operations.
pub const DynamicPalette = struct {
@@ -58,9 +167,7 @@ pub const DynamicPalette = struct {
/// A bitset where each bit represents whether the corresponding
/// palette index has been modified from its default value.
mask: Mask,
const Mask = std.StaticBitSet(@typeInfo(Palette).array.len);
mask: PaletteMask,
pub const default: DynamicPalette = .init(colorpkg.default);
@@ -519,6 +626,101 @@ pub const RGB = packed struct(u24) {
}
};
/// LAB color space
const LAB = struct {
l: f32,
a: f32,
b: f32,
/// RGB to LAB
pub fn fromRgb(rgb: RGB) LAB {
// Step 1: Normalize sRGB channels from [0, 255] to [0.0, 1.0].
var r: f32 = @as(f32, @floatFromInt(rgb.r)) / 255.0;
var g: f32 = @as(f32, @floatFromInt(rgb.g)) / 255.0;
var b: f32 = @as(f32, @floatFromInt(rgb.b)) / 255.0;
// Step 2: Apply the inverse sRGB companding (gamma correction) to
// convert from sRGB to linear RGB. The sRGB transfer function has
// two segments: a linear portion for small values and a power curve
// for the rest.
r = if (r > 0.04045) std.math.pow(f32, (r + 0.055) / 1.055, 2.4) else r / 12.92;
g = if (g > 0.04045) std.math.pow(f32, (g + 0.055) / 1.055, 2.4) else g / 12.92;
b = if (b > 0.04045) std.math.pow(f32, (b + 0.055) / 1.055, 2.4) else b / 12.92;
// Step 3: Convert linear RGB to CIE XYZ using the sRGB to XYZ
// transformation matrix (D65 illuminant). The X and Z values are
// normalized by the D65 white point reference values (Xn=0.95047,
// Zn=1.08883; Yn=1.0 is implicit).
var x = (r * 0.4124564 + g * 0.3575761 + b * 0.1804375) / 0.95047;
var y = r * 0.2126729 + g * 0.7151522 + b * 0.0721750;
var z = (r * 0.0193339 + g * 0.1191920 + b * 0.9503041) / 1.08883;
// Step 4: Apply the CIE f(t) nonlinear transform to each XYZ
// component. Above the threshold (epsilon ≈ 0.008856) the cube
// root is used; below it, a linear approximation avoids numerical
// instability near zero.
x = if (x > 0.008856) std.math.cbrt(x) else 7.787 * x + 16.0 / 116.0;
y = if (y > 0.008856) std.math.cbrt(y) else 7.787 * y + 16.0 / 116.0;
z = if (z > 0.008856) std.math.cbrt(z) else 7.787 * z + 16.0 / 116.0;
// Step 5: Compute the final CIELAB values from the transformed XYZ.
// L* is lightness (0100), a* is greenred, b* is blueyellow.
return .{ .l = 116.0 * y - 16.0, .a = 500.0 * (x - y), .b = 200.0 * (y - z) };
}
/// LAB to RGB
pub fn toRgb(self: LAB) RGB {
// Step 1: Recover the intermediate f(Y), f(X), f(Z) values from
// L*a*b* by inverting the CIELAB formulas.
const y = (self.l + 16.0) / 116.0;
const x = self.a / 500.0 + y;
const z = y - self.b / 200.0;
// Step 2: Apply the inverse CIE f(t) transform to get back to
// XYZ. Above epsilon (≈0.008856) the cube is used; below it the
// linear segment is inverted. Results are then scaled by the D65
// white point reference values (Xn=0.95047, Zn=1.08883; Yn=1.0).
const x3 = x * x * x;
const y3 = y * y * y;
const z3 = z * z * z;
const xf = (if (x3 > 0.008856) x3 else (x - 16.0 / 116.0) / 7.787) * 0.95047;
const yf = if (y3 > 0.008856) y3 else (y - 16.0 / 116.0) / 7.787;
const zf = (if (z3 > 0.008856) z3 else (z - 16.0 / 116.0) / 7.787) * 1.08883;
// Step 3: Convert CIE XYZ back to linear RGB using the XYZ to sRGB
// matrix (inverse of the sRGB to XYZ matrix, D65 illuminant).
var r = xf * 3.2404542 - yf * 1.5371385 - zf * 0.4985314;
var g = -xf * 0.9692660 + yf * 1.8760108 + zf * 0.0415560;
var b = xf * 0.0556434 - yf * 0.2040259 + zf * 1.0572252;
// Step 4: Apply sRGB companding (gamma correction) to convert from
// linear RGB back to sRGB. This is the forward sRGB transfer
// function with the same two-segment split as the inverse.
r = if (r > 0.0031308) 1.055 * std.math.pow(f32, r, 1.0 / 2.4) - 0.055 else 12.92 * r;
g = if (g > 0.0031308) 1.055 * std.math.pow(f32, g, 1.0 / 2.4) - 0.055 else 12.92 * g;
b = if (b > 0.0031308) 1.055 * std.math.pow(f32, b, 1.0 / 2.4) - 0.055 else 12.92 * b;
// Step 5: Clamp to [0.0, 1.0], scale to [0, 255], and round to
// the nearest integer to produce the final 8-bit sRGB values.
return .{
.r = @intFromFloat(@min(@max(r, 0.0), 1.0) * 255.0 + 0.5),
.g = @intFromFloat(@min(@max(g, 0.0), 1.0) * 255.0 + 0.5),
.b = @intFromFloat(@min(@max(b, 0.0), 1.0) * 255.0 + 0.5),
};
}
/// Linearly interpolate between two LAB colors component-wise.
/// `t` is the interpolation factor in [0, 1]: t=0 returns `a`,
/// t=1 returns `b`, and values in between blend proportionally.
pub fn lerp(t: f32, a: LAB, b: LAB) LAB {
return .{
.l = a.l + t * (b.l - a.l),
.a = a.a + t * (b.a - a.a),
.b = a.b + t * (b.b - a.b),
};
}
};
test "palette: default" {
const testing = std.testing;
@@ -683,3 +885,126 @@ test "DynamicPalette: changeDefault with multiple changes" {
try testing.expectEqual(blue, p.current[3]);
try testing.expectEqual(@as(usize, 3), p.mask.count());
}
test "LAB.fromRgb" {
const testing = std.testing;
const epsilon = 0.5;
// White (255, 255, 255) -> L*=100, a*=0, b*=0
const white = LAB.fromRgb(.{ .r = 255, .g = 255, .b = 255 });
try testing.expectApproxEqAbs(@as(f32, 100.0), white.l, epsilon);
try testing.expectApproxEqAbs(@as(f32, 0.0), white.a, epsilon);
try testing.expectApproxEqAbs(@as(f32, 0.0), white.b, epsilon);
// Black (0, 0, 0) -> L*=0, a*=0, b*=0
const black = LAB.fromRgb(.{ .r = 0, .g = 0, .b = 0 });
try testing.expectApproxEqAbs(@as(f32, 0.0), black.l, epsilon);
try testing.expectApproxEqAbs(@as(f32, 0.0), black.a, epsilon);
try testing.expectApproxEqAbs(@as(f32, 0.0), black.b, epsilon);
// Pure red (255, 0, 0) -> L*≈53.23, a*≈80.11, b*≈67.22
const red = LAB.fromRgb(.{ .r = 255, .g = 0, .b = 0 });
try testing.expectApproxEqAbs(@as(f32, 53.23), red.l, epsilon);
try testing.expectApproxEqAbs(@as(f32, 80.11), red.a, epsilon);
try testing.expectApproxEqAbs(@as(f32, 67.22), red.b, epsilon);
// Pure green (0, 128, 0) -> L*≈46.23, a*≈-51.70, b*≈49.90
const green = LAB.fromRgb(.{ .r = 0, .g = 128, .b = 0 });
try testing.expectApproxEqAbs(@as(f32, 46.23), green.l, epsilon);
try testing.expectApproxEqAbs(@as(f32, -51.70), green.a, epsilon);
try testing.expectApproxEqAbs(@as(f32, 49.90), green.b, epsilon);
// Pure blue (0, 0, 255) -> L*≈32.30, a*≈79.20, b*≈-107.86
const blue = LAB.fromRgb(.{ .r = 0, .g = 0, .b = 255 });
try testing.expectApproxEqAbs(@as(f32, 32.30), blue.l, epsilon);
try testing.expectApproxEqAbs(@as(f32, 79.20), blue.a, epsilon);
try testing.expectApproxEqAbs(@as(f32, -107.86), blue.b, epsilon);
}
test "generate256Color: base16 preserved" {
const testing = std.testing;
const bg = RGB{ .r = 0, .g = 0, .b = 0 };
const fg = RGB{ .r = 255, .g = 255, .b = 255 };
const palette = generate256Color(default, .initEmpty(), bg, fg);
// The first 16 colors (base16) must remain unchanged.
for (0..16) |i| {
try testing.expectEqual(default[i], palette[i]);
}
}
test "generate256Color: cube corners match base colors" {
const testing = std.testing;
const bg = RGB{ .r = 0, .g = 0, .b = 0 };
const fg = RGB{ .r = 255, .g = 255, .b = 255 };
const palette = generate256Color(default, .initEmpty(), bg, fg);
// Index 16 is cube (0,0,0) which should equal bg.
try testing.expectEqual(bg, palette[16]);
// Index 231 is cube (5,5,5) which should equal fg.
try testing.expectEqual(fg, palette[231]);
}
test "generate256Color: grayscale ramp monotonic luminance" {
const testing = std.testing;
const bg = RGB{ .r = 0, .g = 0, .b = 0 };
const fg = RGB{ .r = 255, .g = 255, .b = 255 };
const palette = generate256Color(default, .initEmpty(), bg, fg);
// The grayscale ramp (232255) should have monotonically increasing
// luminance from near-black to near-white.
var prev_lum: f64 = 0.0;
for (232..256) |i| {
const lum = palette[i].luminance();
try testing.expect(lum >= prev_lum);
prev_lum = lum;
}
}
test "generate256Color: skip mask preserves original colors" {
const testing = std.testing;
const bg = RGB{ .r = 0, .g = 0, .b = 0 };
const fg = RGB{ .r = 255, .g = 255, .b = 255 };
// Mark a few indices as skipped; they should keep their base value.
var skip: PaletteMask = .initEmpty();
skip.set(20);
skip.set(100);
skip.set(240);
const palette = generate256Color(default, skip, bg, fg);
try testing.expectEqual(default[20], palette[20]);
try testing.expectEqual(default[100], palette[100]);
try testing.expectEqual(default[240], palette[240]);
// A non-skipped index in the cube should differ from the default.
try testing.expect(!palette[21].eql(default[21]));
}
test "LAB.toRgb" {
const testing = std.testing;
// Round-trip: RGB -> LAB -> RGB should recover the original values.
const cases = [_]RGB{
.{ .r = 255, .g = 255, .b = 255 },
.{ .r = 0, .g = 0, .b = 0 },
.{ .r = 255, .g = 0, .b = 0 },
.{ .r = 0, .g = 128, .b = 0 },
.{ .r = 0, .g = 0, .b = 255 },
.{ .r = 128, .g = 128, .b = 128 },
.{ .r = 64, .g = 224, .b = 208 },
};
for (cases) |expected| {
const lab = LAB.fromRgb(expected);
const actual = lab.toRgb();
try testing.expectEqual(expected.r, actual.r);
try testing.expectEqual(expected.g, actual.g);
try testing.expectEqual(expected.b, actual.b);
}
}

View File

@@ -4,6 +4,7 @@ const Allocator = std.mem.Allocator;
const color = @import("color.zig");
const size = @import("size.zig");
const charsets = @import("charsets.zig");
const hyperlink = @import("hyperlink.zig");
const kitty = @import("kitty.zig");
const modespkg = @import("modes.zig");
const Screen = @import("Screen.zig");
@@ -996,6 +997,10 @@ pub const PageFormatter = struct {
// Our style for non-plain formats
var style: Style = .{};
// Track hyperlink state for HTML output. We need to close </a> tags
// when the hyperlink changes or ends.
var current_hyperlink_id: ?hyperlink.Id = null;
for (start_y..end_y + 1) |y_usize| {
const y: size.CellCountInt = @intCast(y_usize);
const row: *Row = self.page.getRow(y);
@@ -1232,6 +1237,63 @@ pub const PageFormatter = struct {
}
}
// Hyperlink state
hyperlink: {
// We currently only emit hyperlinks for HTML. In the
// future we can support emitting OSC 8 hyperlinks for
// VT output as well.
if (self.opts.emit != .html) break :hyperlink;
// Get the hyperlink ID. This ID is our internal ID,
// not necessarily the OSC8 ID.
const link_id_: ?u16 = if (cell.hyperlink)
self.page.lookupHyperlink(cell)
else
null;
// If our hyperlink IDs match (even null) then we have
// identical hyperlink state and we do nothing.
if (current_hyperlink_id == link_id_) break :hyperlink;
// If our prior hyperlink ID was non-null, we need to
// close it because the ID has changed.
if (current_hyperlink_id != null) {
try self.formatHyperlinkClose(writer);
current_hyperlink_id = null;
}
// Set our current hyperlink ID
const link_id = link_id_ orelse break :hyperlink;
current_hyperlink_id = link_id;
// Emit the opening hyperlink tag
const uri = uri: {
const link = self.page.hyperlink_set.get(
self.page.memory,
link_id,
);
break :uri link.uri.offset.ptr(self.page.memory)[0..link.uri.len];
};
try self.formatHyperlinkOpen(
writer,
uri,
);
// If we have a point map, we map the hyperlink to
// this cell.
if (self.point_map) |*map| {
var discarding: std.Io.Writer.Discarding = .init(&.{});
try self.formatHyperlinkOpen(
&discarding.writer,
uri,
);
for (0..discarding.count) |_| map.map.append(map.alloc, .{
.x = x,
.y = y,
}) catch return error.WriteFailed;
}
}
switch (cell.content_tag) {
// We combine codepoint and graphemes because both have
// shared style handling. We use comptime to dup it.
@@ -1266,6 +1328,9 @@ pub const PageFormatter = struct {
// If the style is non-default, we need to close our style tag.
if (!style.default()) try self.formatStyleClose(writer);
// Close any open hyperlink for HTML output
if (current_hyperlink_id != null) try self.formatHyperlinkClose(writer);
// Close the monospace wrapper for HTML output
if (self.opts.emit == .html) {
const closing = "</div>";
@@ -1415,6 +1480,8 @@ pub const PageFormatter = struct {
};
}
/// Write a string with HTML escaping. Used for escaping href attributes
/// and other HTML attribute values.
fn formatStyleOpen(
self: PageFormatter,
writer: *std.Io.Writer,
@@ -1465,6 +1532,49 @@ pub const PageFormatter = struct {
);
}
}
fn formatHyperlinkOpen(
self: PageFormatter,
writer: *std.Io.Writer,
uri: []const u8,
) std.Io.Writer.Error!void {
switch (self.opts.emit) {
.plain, .vt => unreachable,
// layout since we're primarily using it as a CSS wrapper.
.html => {
try writer.writeAll("<a href=\"");
for (uri) |byte| try self.writeCodepoint(
writer,
byte,
);
try writer.writeAll("\">");
},
}
}
fn formatHyperlinkClose(
self: PageFormatter,
writer: *std.Io.Writer,
) std.Io.Writer.Error!void {
const str: []const u8 = switch (self.opts.emit) {
.html => "</a>",
.plain, .vt => return,
};
try writer.writeAll(str);
if (self.point_map) |*m| {
assert(m.map.items.len > 0);
m.map.ensureUnusedCapacity(
m.alloc,
str.len,
) catch return error.WriteFailed;
m.map.appendNTimesAssumeCapacity(
m.map.items[m.map.items.len - 1],
str.len,
);
}
}
};
test "Page plain single line" {
@@ -5937,3 +6047,222 @@ test "Page VT background color on trailing blank cells" {
// This should be true but currently fails due to the bug
try testing.expect(has_red_bg_line1);
}
test "Page HTML with hyperlinks" {
const testing = std.testing;
const alloc = testing.allocator;
var builder: std.Io.Writer.Allocating = .init(alloc);
defer builder.deinit();
var t = try Terminal.init(alloc, .{
.cols = 80,
.rows = 24,
});
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
// Start a hyperlink, write some text, end it
try s.nextSlice("\x1b]8;;https://example.com\x1b\\link text\x1b]8;;\x1b\\ normal");
const pages = &t.screens.active.pages;
const page = &pages.pages.last.?.data;
var formatter: PageFormatter = .init(page, .{ .emit = .html });
try formatter.format(&builder.writer);
const output = builder.writer.buffered();
try testing.expectEqualStrings(
"<div style=\"font-family: monospace; white-space: pre;\">" ++
"<a href=\"https://example.com\">link text</a> normal" ++
"</div>",
output,
);
}
test "Page HTML with multiple hyperlinks" {
const testing = std.testing;
const alloc = testing.allocator;
var builder: std.Io.Writer.Allocating = .init(alloc);
defer builder.deinit();
var t = try Terminal.init(alloc, .{
.cols = 80,
.rows = 24,
});
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
// Two different hyperlinks
try s.nextSlice("\x1b]8;;https://first.com\x1b\\first\x1b]8;;\x1b\\ ");
try s.nextSlice("\x1b]8;;https://second.com\x1b\\second\x1b]8;;\x1b\\");
const pages = &t.screens.active.pages;
const page = &pages.pages.last.?.data;
var formatter: PageFormatter = .init(page, .{ .emit = .html });
try formatter.format(&builder.writer);
const output = builder.writer.buffered();
try testing.expectEqualStrings(
"<div style=\"font-family: monospace; white-space: pre;\">" ++
"<a href=\"https://first.com\">first</a>" ++
" " ++
"<a href=\"https://second.com\">second</a>" ++
"</div>",
output,
);
}
test "Page HTML with hyperlink escaping" {
const testing = std.testing;
const alloc = testing.allocator;
var builder: std.Io.Writer.Allocating = .init(alloc);
defer builder.deinit();
var t = try Terminal.init(alloc, .{
.cols = 80,
.rows = 24,
});
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
// URL with special characters that need escaping
try s.nextSlice("\x1b]8;;https://example.com?a=1&b=2\x1b\\link\x1b]8;;\x1b\\");
const pages = &t.screens.active.pages;
const page = &pages.pages.last.?.data;
var formatter: PageFormatter = .init(page, .{ .emit = .html });
try formatter.format(&builder.writer);
const output = builder.writer.buffered();
try testing.expectEqualStrings(
"<div style=\"font-family: monospace; white-space: pre;\">" ++
"<a href=\"https://example.com?a=1&amp;b=2\">link</a>" ++
"</div>",
output,
);
}
test "Page HTML with styled hyperlink" {
const testing = std.testing;
const alloc = testing.allocator;
var builder: std.Io.Writer.Allocating = .init(alloc);
defer builder.deinit();
var t = try Terminal.init(alloc, .{
.cols = 80,
.rows = 24,
});
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
// Bold hyperlink
try s.nextSlice("\x1b]8;;https://example.com\x1b\\\x1b[1mbold link\x1b[0m\x1b]8;;\x1b\\");
const pages = &t.screens.active.pages;
const page = &pages.pages.last.?.data;
var formatter: PageFormatter = .init(page, .{ .emit = .html });
try formatter.format(&builder.writer);
const output = builder.writer.buffered();
try testing.expectEqualStrings(
"<div style=\"font-family: monospace; white-space: pre;\">" ++
"<div style=\"display: inline;font-weight: bold;\">" ++
"<a href=\"https://example.com\">bold link</div></a>" ++
"</div>",
output,
);
}
test "Page HTML hyperlink closes style before anchor" {
const testing = std.testing;
const alloc = testing.allocator;
var builder: std.Io.Writer.Allocating = .init(alloc);
defer builder.deinit();
var t = try Terminal.init(alloc, .{
.cols = 80,
.rows = 24,
});
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
// Styled hyperlink followed by plain text
try s.nextSlice("\x1b]8;;https://example.com\x1b\\\x1b[1mbold\x1b[0m plain");
const pages = &t.screens.active.pages;
const page = &pages.pages.last.?.data;
var formatter: PageFormatter = .init(page, .{ .emit = .html });
try formatter.format(&builder.writer);
const output = builder.writer.buffered();
try testing.expectEqualStrings(
"<div style=\"font-family: monospace; white-space: pre;\">" ++
"<div style=\"display: inline;font-weight: bold;\">" ++
"<a href=\"https://example.com\">bold</div> plain</a>" ++
"</div>",
output,
);
}
test "Page HTML hyperlink point map maps closing to previous cell" {
const testing = std.testing;
const alloc = testing.allocator;
var builder: std.Io.Writer.Allocating = .init(alloc);
defer builder.deinit();
var t = try Terminal.init(alloc, .{
.cols = 80,
.rows = 24,
});
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\ normal");
const pages = &t.screens.active.pages;
const page = &pages.pages.last.?.data;
var formatter: PageFormatter = .init(page, .{ .emit = .html });
var point_map: std.ArrayList(Coordinate) = .empty;
defer point_map.deinit(alloc);
formatter.point_map = .{ .alloc = alloc, .map = &point_map };
try formatter.format(&builder.writer);
const output = builder.writer.buffered();
const expected_output =
"<div style=\"font-family: monospace; white-space: pre;\">" ++
"<a href=\"https://example.com\">link</a> normal" ++
"</div>";
try testing.expectEqualStrings(expected_output, output);
try testing.expectEqual(expected_output.len, point_map.items.len);
// The </a> closing tag bytes should all map to the last cell of the link
const closing_idx = comptime std.mem.indexOf(u8, expected_output, "</a>").?;
const expected_coord = point_map.items[closing_idx - 1];
for (closing_idx..closing_idx + "</a>".len) |i| {
try testing.expectEqual(expected_coord, point_map.items[i]);
}
}

View File

@@ -153,8 +153,12 @@ pub const Command = union(Key) {
/// Kitty text sizing protocol (OSC 66)
kitty_text_sizing: parsers.kitty_text_sizing.OSC,
kitty_clipboard_protocol: KittyClipboardProtocol,
pub const SemanticPrompt = parsers.semantic_prompt.Command;
pub const KittyClipboardProtocol = parsers.kitty_clipboard_protocol.OSC;
pub const Key = LibEnum(
if (build_options.c_abi) .c else .zig,
// NOTE: Order matters, see LibEnum documentation.
@@ -182,6 +186,7 @@ pub const Command = union(Key) {
"conemu_xterm_emulation",
"conemu_comment",
"kitty_text_sizing",
"kitty_clipboard_protocol",
},
);
@@ -325,6 +330,7 @@ pub const Parser = struct {
@"21",
@"22",
@"52",
@"55",
@"66",
@"77",
@"104",
@@ -339,8 +345,10 @@ pub const Parser = struct {
@"118",
@"119",
@"133",
@"552",
@"777",
@"1337",
@"5522",
};
pub fn init(alloc: ?Allocator) Parser {
@@ -402,6 +410,7 @@ pub const Parser = struct {
.semantic_prompt,
.show_desktop_notification,
.kitty_text_sizing,
.kitty_clipboard_protocol,
=> {},
}
@@ -569,6 +578,7 @@ pub const Parser = struct {
.@"5" => switch (c) {
';' => if (self.ensureAllocator()) self.writeToFixed(),
'2' => self.state = .@"52",
'5' => self.state = .@"55",
else => self.state = .invalid,
},
@@ -584,6 +594,11 @@ pub const Parser = struct {
else => self.state = .invalid,
},
.@"55" => switch (c) {
'2' => self.state = .@"552",
else => self.state = .invalid,
},
.@"7" => switch (c) {
';' => self.writeToFixed(),
'7' => self.state = .@"77",
@@ -602,12 +617,23 @@ pub const Parser = struct {
else => self.state = .invalid,
},
.@"552" => switch (c) {
'2' => self.state = .@"5522",
else => self.state = .invalid,
},
.@"1337",
=> switch (c) {
';' => self.writeToFixed(),
else => self.state = .invalid,
},
.@"5522",
=> switch (c) {
';' => self.writeToAllocating(),
else => self.state = .invalid,
},
.@"0",
.@"22",
.@"777",
@@ -676,6 +702,8 @@ pub const Parser = struct {
.@"52" => parsers.clipboard_operation.parse(self, terminator_ch),
.@"55" => null,
.@"6" => null,
.@"66" => parsers.kitty_text_sizing.parse(self, terminator_ch),
@@ -684,9 +712,13 @@ pub const Parser = struct {
.@"133" => parsers.semantic_prompt.parse(self, terminator_ch),
.@"552" => null,
.@"777" => parsers.rxvt_extension.parse(self, terminator_ch),
.@"1337" => parsers.iterm2.parse(self, terminator_ch),
.@"5522" => parsers.kitty_clipboard_protocol.parse(self, terminator_ch),
};
}
};

View File

@@ -6,6 +6,7 @@ pub const clipboard_operation = @import("parsers/clipboard_operation.zig");
pub const color = @import("parsers/color.zig");
pub const hyperlink = @import("parsers/hyperlink.zig");
pub const iterm2 = @import("parsers/iterm2.zig");
pub const kitty_clipboard_protocol = @import("parsers/kitty_clipboard_protocol.zig");
pub const kitty_color = @import("parsers/kitty_color.zig");
pub const kitty_text_sizing = @import("parsers/kitty_text_sizing.zig");
pub const mouse_shape = @import("parsers/mouse_shape.zig");

View File

@@ -0,0 +1,702 @@
//! Kitty's clipboard protocol (OSC 5522)
//! Specification: https://sw.kovidgoyal.net/kitty/clipboard/
//! https://rockorager.dev/misc/bracketed-paste-mime/
const std = @import("std");
const build_options = @import("terminal_options");
const assert = @import("../../../quirks.zig").inlineAssert;
const Parser = @import("../../osc.zig").Parser;
const Command = @import("../../osc.zig").Command;
const Terminator = @import("../../osc.zig").Terminator;
const encoding = @import("../encoding.zig");
const log = std.log.scoped(.kitty_clipboard_protocol);
pub const OSC = struct {
/// The raw metadata that was received. It can be parsed by using the `readOption` method.
metadata: []const u8,
/// The raw payload. It may be Base64 encoded, check the `e` option.
payload: ?[]const u8,
/// The terminator that was used in case we need to send a response.
terminator: Terminator,
/// Decode an option from the metadata.
pub fn readOption(self: OSC, comptime key: Option) ?key.Type() {
return key.read(self.metadata);
}
};
pub const Location = enum {
primary,
pub fn init(str: []const u8) ?Location {
return std.meta.stringToEnum(Location, str);
}
};
pub const Operation = enum {
read,
walias,
wdata,
write,
pub fn init(str: []const u8) ?Operation {
return std.meta.stringToEnum(Operation, str);
}
};
pub const Status = enum {
DATA,
DONE,
EBUSY,
EINVAL,
EIO,
ENOSYS,
EPERM,
OK,
pub fn init(str: []const u8) ?Status {
return std.meta.stringToEnum(Status, str);
}
};
pub const Option = enum {
id,
loc,
mime,
name,
password,
pw,
status,
type,
pub fn Type(comptime key: Option) type {
return switch (key) {
.id => []const u8,
.loc => Location,
.mime => []const u8,
.name => []const u8,
.password => []const u8,
.pw => []const u8,
.status => Status,
.type => Operation,
};
}
/// Read the option value from the raw metadata string.
pub fn read(
comptime key: Option,
metadata: []const u8,
) ?key.Type() {
const value: []const u8 = value: {
var pos: usize = 0;
while (pos < metadata.len) {
// skip any whitespace
while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1;
// bail if we are out of metadata
if (pos >= metadata.len) return null;
if (!std.mem.startsWith(u8, metadata[pos..], @tagName(key))) {
// this isn't the key we are looking for, skip to the next option, or bail if
// there is no next option
pos = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse return null;
pos += 1;
continue;
}
// skip past the key
pos += @tagName(key).len;
// skip any whitespace
while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1;
// bail if we are out of metadata
if (pos >= metadata.len) return null;
// a valid option has an '='
if (metadata[pos] != '=') return null;
// the end of the value is bounded by a ':' or the end of the metadata
const end = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse metadata.len;
const start = pos + 1;
// strip any leading or trailing whitespace
break :value std.mem.trim(u8, metadata[start..end], &std.ascii.whitespace);
}
// the key was not found
return null;
};
// return the parsed value
return switch (key) {
.id => parseIdentifier(value),
.loc => .init(value),
.mime => value,
.name => value,
.password => value,
.pw => value,
.status => .init(value),
.type => .init(value),
};
}
};
/// Characters that are valid in identifiers.
const valid_identifier_characters: []const u8 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_+.";
fn isValidIdentifier(str: []const u8) bool {
if (str.len == 0) return false;
return std.mem.indexOfNone(u8, str, valid_identifier_characters) == null;
}
fn parseIdentifier(str: []const u8) ?[]const u8 {
if (isValidIdentifier(str)) return str;
return null;
}
pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command {
assert(parser.state == .@"5522");
const writer = parser.writer orelse {
parser.state = .invalid;
return null;
};
const data = writer.buffered();
const metadata: []const u8, const payload: ?[]const u8 = result: {
const start = std.mem.indexOfScalar(u8, data, ';') orelse break :result .{ data, null };
break :result .{ data[0..start], data[start + 1 .. data.len] };
};
parser.command = .{
.kitty_clipboard_protocol = .{
.metadata = metadata,
.payload = payload,
.terminator = .init(terminator_ch),
},
};
return &parser.command;
}
test "OSC: 5522: empty metadata and missing payload" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "5522;";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_clipboard_protocol);
try testing.expectEqualStrings("", cmd.kitty_clipboard_protocol.metadata);
try testing.expect(cmd.kitty_clipboard_protocol.payload == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.mime) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.status) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.type) == null);
}
test "OSC: 5522: empty metadata and empty payload" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "5522;;";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_clipboard_protocol);
try testing.expectEqualStrings("", cmd.kitty_clipboard_protocol.metadata);
try testing.expectEqualStrings("", cmd.kitty_clipboard_protocol.payload.?);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.mime) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.status) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.type) == null);
}
test "OSC: 5522: non-empty metadata and payload" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "5522;type=read;dGV4dC9wbGFpbg==";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_clipboard_protocol);
try testing.expectEqualStrings("type=read", cmd.kitty_clipboard_protocol.metadata);
try testing.expectEqualStrings("dGV4dC9wbGFpbg==", cmd.kitty_clipboard_protocol.payload.?);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.mime) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.status) == null);
try testing.expectEqual(.read, cmd.kitty_clipboard_protocol.readOption(.type));
}
test "OSC: 5522: empty id" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "5522;id=";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_clipboard_protocol);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
}
test "OSC: 5522: valid id" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "5522;id=5c076ad9-d36f-4705-847b-d4dbf356cc0d";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_clipboard_protocol);
try testing.expectEqualStrings("5c076ad9-d36f-4705-847b-d4dbf356cc0d", cmd.kitty_clipboard_protocol.readOption(.id).?);
}
test "OSC: 5522: invalid id" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "5522;id=*42*";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_clipboard_protocol);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
}
test "OSC: 5522: invalid status" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "5522;status=BOBR";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_clipboard_protocol);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.status) == null);
}
test "OSC: 5522: valid status" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "5522;status=DONE";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_clipboard_protocol);
try testing.expectEqual(.DONE, cmd.kitty_clipboard_protocol.readOption(.status).?);
}
test "OSC: 5522: invalid location" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "5522;loc=bobr";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_clipboard_protocol);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
}
test "OSC: 5522: valid location" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "5522;loc=primary";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_clipboard_protocol);
try testing.expectEqual(.primary, cmd.kitty_clipboard_protocol.readOption(.loc).?);
}
test "OSC: 5522: password 1" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "5522;pw=R2hvc3R0eQ==:name=Qk9CUiBLVVJXQQ==";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_clipboard_protocol);
try testing.expectEqualStrings("R2hvc3R0eQ==", cmd.kitty_clipboard_protocol.readOption(.pw).?);
try testing.expectEqualStrings("Qk9CUiBLVVJXQQ==", cmd.kitty_clipboard_protocol.readOption(.name).?);
}
test "OSC: 5522: password 2" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "5522;password=R2hvc3R0eQ==";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_clipboard_protocol);
try testing.expectEqualStrings("R2hvc3R0eQ==", cmd.kitty_clipboard_protocol.readOption(.password).?);
}
test "OSC: 5522: example 1" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "5522;type=read:status=OK";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_clipboard_protocol);
try testing.expect(cmd.kitty_clipboard_protocol.payload == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.mime) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
try testing.expectEqual(.OK, cmd.kitty_clipboard_protocol.readOption(.status).?);
try testing.expectEqual(.read, cmd.kitty_clipboard_protocol.readOption(.type).?);
}
test "OSC: 5522: example 2" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "5522;type=read:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_clipboard_protocol);
try testing.expectEqualStrings("R2hvc3R0eQ==", cmd.kitty_clipboard_protocol.payload.?);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
try testing.expectEqualStrings("dGV4dC9wbGFpbg==", cmd.kitty_clipboard_protocol.readOption(.mime).?);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.status) == null);
try testing.expectEqual(.read, cmd.kitty_clipboard_protocol.readOption(.type).?);
}
test "OSC: 5522: example 3" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "5522;type=read:status=OK";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_clipboard_protocol);
try testing.expect(cmd.kitty_clipboard_protocol.payload == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.mime) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
try testing.expectEqual(.OK, cmd.kitty_clipboard_protocol.readOption(.status).?);
try testing.expectEqual(.read, cmd.kitty_clipboard_protocol.readOption(.type).?);
}
test "OSC: 5522: example 4" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "5522;type=write";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_clipboard_protocol);
try testing.expect(cmd.kitty_clipboard_protocol.payload == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.mime) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.status) == null);
try testing.expectEqual(.write, cmd.kitty_clipboard_protocol.readOption(.type).?);
}
test "OSC: 5522: example 5" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_clipboard_protocol);
try testing.expectEqualStrings("R2hvc3R0eQ==", cmd.kitty_clipboard_protocol.payload.?);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
try testing.expectEqualStrings("dGV4dC9wbGFpbg==", cmd.kitty_clipboard_protocol.readOption(.mime).?);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.status) == null);
try testing.expectEqual(.wdata, cmd.kitty_clipboard_protocol.readOption(.type).?);
}
test "OSC: 5522: example 6" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "5522;type=wdata";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_clipboard_protocol);
try testing.expect(cmd.kitty_clipboard_protocol.payload == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.mime) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.status) == null);
try testing.expectEqual(.wdata, cmd.kitty_clipboard_protocol.readOption(.type).?);
}
test "OSC: 5522: example 7" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "5522;type=write:status=DONE";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_clipboard_protocol);
try testing.expect(cmd.kitty_clipboard_protocol.payload == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.mime) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
try testing.expectEqual(.DONE, cmd.kitty_clipboard_protocol.readOption(.status).?);
try testing.expectEqual(.write, cmd.kitty_clipboard_protocol.readOption(.type).?);
}
test "OSC: 5522: example 8" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "5522;type=write:status=EPERM";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_clipboard_protocol);
try testing.expect(cmd.kitty_clipboard_protocol.payload == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.mime) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
try testing.expectEqual(.EPERM, cmd.kitty_clipboard_protocol.readOption(.status).?);
try testing.expectEqual(.write, cmd.kitty_clipboard_protocol.readOption(.type).?);
}
test "OSC: 5522: example 9" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "5522;type=walias:mime=dGV4dC9wbGFpbg==;dGV4dC9odG1sIGFwcGxpY2F0aW9uL2pzb24=";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_clipboard_protocol);
try testing.expectEqualStrings("dGV4dC9odG1sIGFwcGxpY2F0aW9uL2pzb24=", cmd.kitty_clipboard_protocol.payload.?);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
try testing.expectEqualStrings("dGV4dC9wbGFpbg==", cmd.kitty_clipboard_protocol.readOption(.mime).?);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.status) == null);
try testing.expectEqual(.walias, cmd.kitty_clipboard_protocol.readOption(.type).?);
}
test "OSC: 5522: example 10" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "5522;type=read:status=OK:password=Qk9CUiBLVVJXQQ==";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_clipboard_protocol);
try testing.expect(cmd.kitty_clipboard_protocol.payload == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.mime) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
try testing.expectEqualStrings("Qk9CUiBLVVJXQQ==", cmd.kitty_clipboard_protocol.readOption(.password).?);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
try testing.expectEqual(.OK, cmd.kitty_clipboard_protocol.readOption(.status).?);
try testing.expectEqual(.read, cmd.kitty_clipboard_protocol.readOption(.type).?);
}
test "OSC: 5522: example 11" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_clipboard_protocol);
try testing.expect(cmd.kitty_clipboard_protocol.payload == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
try testing.expectEqualStrings("dGV4dC9wbGFpbg==", cmd.kitty_clipboard_protocol.readOption(.mime).?);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
try testing.expectEqual(.DATA, cmd.kitty_clipboard_protocol.readOption(.status).?);
try testing.expectEqual(.read, cmd.kitty_clipboard_protocol.readOption(.type).?);
}
test "OSC: 5522: example 12" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "5522;type=read:mime=dGV4dC9wbGFpbg==:password=Qk9CUiBLVVJXQQ==";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_clipboard_protocol);
try testing.expect(cmd.kitty_clipboard_protocol.payload == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
try testing.expectEqualStrings("dGV4dC9wbGFpbg==", cmd.kitty_clipboard_protocol.readOption(.mime).?);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
try testing.expectEqualStrings("Qk9CUiBLVVJXQQ==", cmd.kitty_clipboard_protocol.readOption(.password).?);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.status) == null);
try testing.expectEqual(.read, cmd.kitty_clipboard_protocol.readOption(.type).?);
}
test "OSC: 5522: example 13" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "5522;type=read:status=OK";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_clipboard_protocol);
try testing.expect(cmd.kitty_clipboard_protocol.payload == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.mime) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
try testing.expectEqual(.OK, cmd.kitty_clipboard_protocol.readOption(.status).?);
try testing.expectEqual(.read, cmd.kitty_clipboard_protocol.readOption(.type).?);
}
test "OSC: 5522: example 14" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==;Qk9CUiBLVVJXQQ==";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_clipboard_protocol);
try testing.expectEqualStrings("Qk9CUiBLVVJXQQ==", cmd.kitty_clipboard_protocol.payload.?);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
try testing.expectEqualStrings("dGV4dC9wbGFpbg==", cmd.kitty_clipboard_protocol.readOption(.mime).?);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
try testing.expectEqual(.DATA, cmd.kitty_clipboard_protocol.readOption(.status).?);
try testing.expectEqual(.read, cmd.kitty_clipboard_protocol.readOption(.type).?);
}
test "OSC: 5522: example 15" {
const testing = std.testing;
var p: Parser = .init(testing.allocator);
defer p.deinit();
const input = "5522;type=read:status=OK";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .kitty_clipboard_protocol);
try testing.expect(cmd.kitty_clipboard_protocol.payload == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.id) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.loc) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.mime) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.name) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.password) == null);
try testing.expect(cmd.kitty_clipboard_protocol.readOption(.pw) == null);
try testing.expectEqual(.OK, cmd.kitty_clipboard_protocol.readOption(.status).?);
try testing.expectEqual(.read, cmd.kitty_clipboard_protocol.readOption(.type).?);
}

View File

@@ -2047,6 +2047,7 @@ pub fn Stream(comptime Handler: type) type {
.conemu_output_environment_variable,
.conemu_run_process,
.kitty_text_sizing,
.kitty_clipboard_protocol,
=> {
log.debug("unimplemented OSC callback: {}", .{cmd});
},

View File

@@ -562,6 +562,7 @@ pub const Config = struct {
env_override: configpkg.RepeatableStringMap = .{},
shell_integration: configpkg.Config.ShellIntegration = .detect,
shell_integration_features: configpkg.Config.ShellIntegrationFeatures = .{},
cursor_blink: ?bool = null,
working_directory: ?[]const u8 = null,
resources_dir: ?[]const u8,
term: []const u8,
@@ -755,6 +756,7 @@ const Subprocess = struct {
try shell_integration.setupFeatures(
&env,
cfg.shell_integration_features,
cfg.cursor_blink orelse true,
);
const force: ?shell_integration.Shell = switch (cfg.shell_integration) {

View File

@@ -175,8 +175,28 @@ pub const DerivedConfig = struct {
errdefer arena.deinit();
const alloc = arena.allocator();
const palette: terminalpkg.color.Palette = palette: {
if (config.@"palette-generate") generate: {
if (config.palette.mask.findFirstSet() == null) {
// If the user didn't set any values manually, then
// we're using the default palette and we don't need
// to apply the generation code to it.
break :generate;
}
break :palette terminalpkg.color.generate256Color(
config.palette.value,
config.palette.mask,
config.background.toTerminalRGB(),
config.foreground.toTerminalRGB(),
);
}
break :palette config.palette.value;
};
return .{
.palette = config.palette.value,
.palette = palette,
.image_storage_limit = config.@"image-storage-limit",
.cursor_style = config.@"cursor-style",
.cursor_blink = config.@"cursor-style-blink",

View File

@@ -188,11 +188,13 @@ test detectShell {
pub fn setupFeatures(
env: *EnvMap,
features: config.ShellIntegrationFeatures,
cursor_blink: bool,
) !void {
const fields = @typeInfo(@TypeOf(features)).@"struct".fields;
const capacity: usize = capacity: {
comptime var n: usize = fields.len - 1; // commas
inline for (fields) |field| n += field.name.len;
n += ":steady".len; // cursor value
break :capacity n;
};
@@ -221,6 +223,10 @@ pub fn setupFeatures(
if (@field(features, name)) {
if (writer.end > 0) try writer.writeByte(',');
try writer.writeAll(name);
if (std.mem.eql(u8, name, "cursor")) {
try writer.writeAll(if (cursor_blink) ":blink" else ":steady");
}
}
}
@@ -241,8 +247,8 @@ test "setup features" {
var env = EnvMap.init(alloc);
defer env.deinit();
try setupFeatures(&env, .{ .cursor = true, .sudo = true, .title = true, .@"ssh-env" = true, .@"ssh-terminfo" = true, .path = true });
try testing.expectEqualStrings("cursor,path,ssh-env,ssh-terminfo,sudo,title", env.get("GHOSTTY_SHELL_FEATURES").?);
try setupFeatures(&env, .{ .cursor = true, .sudo = true, .title = true, .@"ssh-env" = true, .@"ssh-terminfo" = true, .path = true }, true);
try testing.expectEqualStrings("cursor:blink,path,ssh-env,ssh-terminfo,sudo,title", env.get("GHOSTTY_SHELL_FEATURES").?);
}
// Test: all features disabled
@@ -250,7 +256,7 @@ test "setup features" {
var env = EnvMap.init(alloc);
defer env.deinit();
try setupFeatures(&env, std.mem.zeroes(config.ShellIntegrationFeatures));
try setupFeatures(&env, std.mem.zeroes(config.ShellIntegrationFeatures), true);
try testing.expect(env.get("GHOSTTY_SHELL_FEATURES") == null);
}
@@ -259,9 +265,25 @@ test "setup features" {
var env = EnvMap.init(alloc);
defer env.deinit();
try setupFeatures(&env, .{ .cursor = false, .sudo = true, .title = false, .@"ssh-env" = true, .@"ssh-terminfo" = false, .path = false });
try setupFeatures(&env, .{ .cursor = false, .sudo = true, .title = false, .@"ssh-env" = true, .@"ssh-terminfo" = false, .path = false }, true);
try testing.expectEqualStrings("ssh-env,sudo", env.get("GHOSTTY_SHELL_FEATURES").?);
}
// Test: blinking cursor
{
var env = EnvMap.init(alloc);
defer env.deinit();
try setupFeatures(&env, .{ .cursor = true, .sudo = false, .title = false, .@"ssh-env" = false, .@"ssh-terminfo" = false, .path = false }, true);
try testing.expectEqualStrings("cursor:blink", env.get("GHOSTTY_SHELL_FEATURES").?);
}
// Test: steady cursor
{
var env = EnvMap.init(alloc);
defer env.deinit();
try setupFeatures(&env, .{ .cursor = true, .sudo = false, .title = false, .@"ssh-env" = false, .@"ssh-terminfo" = false, .path = false }, false);
try testing.expectEqualStrings("cursor:steady", env.get("GHOSTTY_SHELL_FEATURES").?);
}
}
/// Setup the bash automatic shell integration. This works by