Merge branch 'ghostty-org:main' into feat-list-themes-write-config

This commit is contained in:
greathongtu
2025-12-17 00:29:24 +08:00
committed by GitHub
487 changed files with 63169 additions and 12623 deletions

View File

@@ -5,21 +5,16 @@ const App = @This();
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const assert = @import("quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const build_config = @import("build_config.zig");
const apprt = @import("apprt.zig");
const Surface = @import("Surface.zig");
const tracy = @import("tracy");
const input = @import("input.zig");
const configpkg = @import("config.zig");
const Config = configpkg.Config;
const BlockingQueue = @import("datastruct/main.zig").BlockingQueue;
const renderer = @import("renderer.zig");
const font = @import("font/main.zig");
const internal_os = @import("os/main.zig");
const macos = @import("macos");
const objc = @import("objc");
const log = std.log.scoped(.app);

File diff suppressed because it is too large Load Diff

View File

@@ -8,8 +8,6 @@
//! The goal is to have different implementations share as much of the core
//! logic as possible, and to only reach out to platform-specific implementation
//! code when absolutely necessary.
const std = @import("std");
const builtin = @import("builtin");
const build_config = @import("build_config.zig");
const structs = @import("apprt/structs.zig");
@@ -28,6 +26,7 @@ pub const Target = action.Target;
pub const ContentScale = structs.ContentScale;
pub const Clipboard = structs.Clipboard;
pub const ClipboardContent = structs.ClipboardContent;
pub const ClipboardRequest = structs.ClipboardRequest;
pub const ClipboardRequestType = structs.ClipboardRequestType;
pub const ColorScheme = structs.ColorScheme;

View File

@@ -1,6 +1,6 @@
const std = @import("std");
const build_config = @import("../build_config.zig");
const assert = std.debug.assert;
const assert = @import("../quirks.zig").inlineAssert;
const apprt = @import("../apprt.zig");
const configpkg = @import("../config.zig");
const input = @import("../input.zig");
@@ -129,6 +129,9 @@ pub const Action = union(Key) {
/// Jump to a specific split.
goto_split: GotoSplit,
/// Jump to next/previous window.
goto_window: GotoWindow,
/// Resize the split in the given direction.
resize_split: ResizeSplit,
@@ -164,6 +167,9 @@ pub const Action = union(Key) {
/// The cell size has changed to the given dimensions in pixels.
cell_size: CellSize,
/// The scrollbar is updating.
scrollbar: terminal.Scrollbar,
/// The target should be re-rendered. This usually has a specific
/// surface target but if the app is targeted then all active
/// surfaces should be redrawn.
@@ -186,8 +192,9 @@ pub const Action = union(Key) {
set_title: SetTitle,
/// Set the title of the target to a prompted value. It is up to
/// the apprt to prompt.
prompt_title,
/// the apprt to prompt. The value specifies whether to prompt for the
/// surface title or the tab title.
prompt_title: PromptTitle,
/// The current working directory has changed for the target terminal.
pwd: Pwd,
@@ -298,6 +305,21 @@ pub const Action = union(Key) {
/// A command has finished,
command_finished: CommandFinished,
/// Start the search overlay with an optional initial needle.
start_search: StartSearch,
/// End the search overlay, clearing the search state and hiding it.
end_search,
/// The total number of matches found by the search.
search_total: SearchTotal,
/// The currently selected search match index (1-based).
search_selected: SearchSelected,
/// The readonly state of the surface has changed.
readonly: Readonly,
/// Sync with: ghostty_action_tag_e
pub const Key = enum(c_int) {
quit,
@@ -316,6 +338,7 @@ pub const Action = union(Key) {
move_tab,
goto_tab,
goto_split,
goto_window,
resize_split,
equalize_splits,
toggle_split_zoom,
@@ -324,6 +347,7 @@ pub const Action = union(Key) {
reset_window_size,
initial_size,
cell_size,
scrollbar,
render,
inspector,
show_gtk_inspector,
@@ -354,6 +378,11 @@ pub const Action = union(Key) {
progress_report,
show_on_screen_keyboard,
command_finished,
start_search,
end_search,
search_total,
search_selected,
readonly,
};
/// Sync with: ghostty_action_u
@@ -449,6 +478,13 @@ pub const GotoSplit = enum(c_int) {
right,
};
// This is made extern (c_int) to make interop easier with our embedded
// runtime. The small size cost doesn't make a difference in our union.
pub const GotoWindow = enum(c_int) {
previous,
next,
};
/// The amount to resize the split by and the direction to resize it in.
pub const ResizeSplit = extern struct {
amount: u16,
@@ -511,11 +547,22 @@ pub const QuitTimer = enum(c_int) {
stop,
};
pub const Readonly = enum(c_int) {
off,
on,
};
pub const MouseVisibility = enum(c_int) {
visible,
hidden,
};
/// Whether to prompt for the surface title or tab title.
pub const PromptTitle = enum(c_int) {
surface,
tab,
};
pub const MouseOverLink = struct {
url: [:0]const u8,
@@ -720,6 +767,9 @@ pub const OpenUrl = struct {
/// should try to open the URL in a text editor or viewer or
/// some equivalent, if possible.
text,
/// The URL is known to contain HTML content.
html,
};
// Sync with: ghostty_action_open_url_s
@@ -744,6 +794,8 @@ pub const CloseTabMode = enum(c_int) {
this,
/// Close all other tabs.
other,
/// Close all tabs to the right of the current tab.
right,
};
pub const CommandFinished = struct {
@@ -763,3 +815,48 @@ pub const CommandFinished = struct {
};
}
};
pub const StartSearch = struct {
needle: [:0]const u8,
// Sync with: ghostty_action_start_search_s
pub const C = extern struct {
needle: [*:0]const u8,
};
pub fn cval(self: StartSearch) C {
return .{
.needle = self.needle.ptr,
};
}
};
pub const SearchTotal = struct {
total: ?usize,
// Sync with: ghostty_action_search_total_s
pub const C = extern struct {
total: isize,
};
pub fn cval(self: SearchTotal) C {
return .{
.total = if (self.total) |t| @intCast(t) else -1,
};
}
};
pub const SearchSelected = struct {
selected: ?usize,
// Sync with: ghostty_action_search_selected_s
pub const C = extern struct {
selected: isize,
};
pub fn cval(self: SearchSelected) C {
return .{
.selected = if (self.selected) |s| @intCast(s) else -1,
};
}
};

View File

@@ -6,7 +6,7 @@
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const assert = @import("../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const objc = @import("objc");
const apprt = @import("../apprt.zig");
@@ -66,7 +66,13 @@ pub const App = struct {
) callconv(.c) void,
/// Write the clipboard value.
write_clipboard: *const fn (SurfaceUD, [*:0]const u8, c_int, bool) callconv(.c) void,
write_clipboard: *const fn (
SurfaceUD,
c_int,
[*]const CAPI.ClipboardContent,
usize,
bool,
) callconv(.c) void,
/// Close the current surface given by this function.
close_surface: ?*const fn (SurfaceUD, bool) callconv(.c) void = null,
@@ -699,16 +705,27 @@ pub const Surface = struct {
alloc.destroy(state);
}
pub fn setClipboardString(
pub fn setClipboard(
self: *const Surface,
val: [:0]const u8,
clipboard_type: apprt.Clipboard,
contents: []const apprt.ClipboardContent,
confirm: bool,
) !void {
const alloc = self.app.core_app.alloc;
const array = try alloc.alloc(CAPI.ClipboardContent, contents.len);
defer alloc.free(array);
for (contents, 0..) |content, i| {
array[i] = .{
.mime = content.mime,
.data = content.data,
};
}
self.app.opts.write_clipboard(
self.userdata,
val.ptr,
@intCast(@intFromEnum(clipboard_type)),
array.ptr,
array.len,
confirm,
);
}
@@ -1211,6 +1228,12 @@ pub const CAPI = struct {
cell_height_px: u32,
};
// ghostty_clipboard_content_s
const ClipboardContent = extern struct {
mime: [*:0]const u8,
data: [*:0]const u8,
};
// ghostty_text_s
const Text = extern struct {
tl_px_x: f64,
@@ -1535,7 +1558,7 @@ pub const CAPI = struct {
defer core_surface.renderer_state.mutex.unlock();
// If we don't have a selection, do nothing.
const core_sel = core_surface.io.terminal.screen.selection orelse return false;
const core_sel = core_surface.io.terminal.screens.active.selection orelse return false;
// Read the text from the selection.
return readTextLocked(surface, core_sel, result);
@@ -1555,7 +1578,7 @@ pub const CAPI = struct {
defer surface.core_surface.renderer_state.mutex.unlock();
const core_sel = sel.core(
&surface.core_surface.renderer_state.terminal.screen,
surface.core_surface.renderer_state.terminal.screens.active,
) orelse return false;
return readTextLocked(surface, core_sel, result);
@@ -2114,7 +2137,7 @@ pub const CAPI = struct {
// Get our word selection
const sel = sel: {
const screen = &surface.renderer_state.terminal.screen;
const screen: *terminal.Screen = surface.renderer_state.terminal.screens.active;
const pos = try ptr.getCursorPos();
const pt_viewport = surface.posToViewport(pos.x, pos.y);
const pin = screen.pages.pin(.{
@@ -2126,7 +2149,7 @@ pub const CAPI = struct {
if (comptime std.debug.runtime_safety) unreachable;
return false;
};
break :sel surface.io.terminal.screen.selectWord(pin) orelse return false;
break :sel surface.io.terminal.screens.active.selectWord(pin) orelse return false;
};
// Read the selection

View File

@@ -1,5 +1,3 @@
const internal_os = @import("../os/main.zig");
// The required comptime API for any apprt.
pub const App = @import("gtk/App.zig");
pub const Surface = @import("gtk/Surface.zig");

View File

@@ -5,18 +5,13 @@ const App = @This();
const std = @import("std");
const builtin = @import("builtin");
const Allocator = std.mem.Allocator;
const adw = @import("adw");
const gio = @import("gio");
const apprt = @import("../../apprt.zig");
const configpkg = @import("../../config.zig");
const internal_os = @import("../../os/main.zig");
const Config = configpkg.Config;
const CoreApp = @import("../../App.zig");
const Application = @import("class/application.zig").Application;
const Surface = @import("Surface.zig");
const gtk_version = @import("gtk_version.zig");
const adw_version = @import("adw_version.zig");
const ipcNewWindow = @import("ipc/new_window.zig").newWindow;
const log = std.log.scoped(.gtk);

View File

@@ -80,15 +80,15 @@ pub fn clipboardRequest(
);
}
pub fn setClipboardString(
pub fn setClipboard(
self: *Self,
val: [:0]const u8,
clipboard_type: apprt.Clipboard,
contents: []const apprt.ClipboardContent,
confirm: bool,
) !void {
self.surface.setClipboardString(
val,
self.surface.setClipboard(
clipboard_type,
contents,
confirm,
);
}

View File

@@ -11,6 +11,20 @@ pub const c = @cImport({
@cInclude("adwaita.h");
});
pub const blueprint_compiler_help =
\\
\\When building from a Git checkout, Ghostty requires
\\version {f} or newer of `blueprint-compiler` as a
\\build-time dependency. Please install it, ensure that it
\\is available on your PATH, and then retry building Ghostty.
\\See `HACKING.md` for more details.
\\
\\This message should *not* appear for normal users, who
\\should build Ghostty from official release tarballs instead.
\\Please consult https://ghostty.org/docs/install/build for
\\more information on the recommended build instructions.
;
const adwaita_version = std.SemanticVersion{
.major = c.ADW_MAJOR_VERSION,
.minor = c.ADW_MINOR_VERSION,
@@ -79,13 +93,9 @@ pub fn main() !void {
error.FileNotFound => {
std.debug.print(
\\`blueprint-compiler` not found.
\\
\\Ghostty requires version {f} or newer of
\\`blueprint-compiler` as a build-time dependency starting
\\from version 1.2. Please install it, ensure that it is
\\available on your PATH, and then retry building Ghostty.
\\
, .{required_blueprint_version});
++ blueprint_compiler_help,
.{required_blueprint_version},
);
std.posix.exit(1);
},
else => return err,
@@ -103,13 +113,9 @@ pub fn main() !void {
if (version.order(required_blueprint_version) == .lt) {
std.debug.print(
\\`blueprint-compiler` is the wrong version.
\\
\\Ghostty requires version {f} or newer of
\\`blueprint-compiler` as a build-time dependency starting
\\from version 1.2. Please install it, ensure that it is
\\available on your PATH, and then retry building Ghostty.
\\
, .{required_blueprint_version});
++ blueprint_compiler_help,
.{required_blueprint_version},
);
std.posix.exit(1);
}
}
@@ -144,13 +150,9 @@ pub fn main() !void {
error.FileNotFound => {
std.debug.print(
\\`blueprint-compiler` not found.
\\
\\Ghostty requires version {f} or newer of
\\`blueprint-compiler` as a build-time dependency starting
\\from version 1.2. Please install it, ensure that it is
\\available on your PATH, and then retry building Ghostty.
\\
, .{required_blueprint_version});
++ blueprint_compiler_help,
.{required_blueprint_version},
);
std.posix.exit(1);
},
else => return err,

View File

@@ -43,9 +43,11 @@ pub const blueprints: []const Blueprint = &.{
.{ .major = 1, .minor = 5, .name = "inspector-widget" },
.{ .major = 1, .minor = 5, .name = "inspector-window" },
.{ .major = 1, .minor = 2, .name = "resize-overlay" },
.{ .major = 1, .minor = 2, .name = "search-overlay" },
.{ .major = 1, .minor = 5, .name = "split-tree" },
.{ .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" },

View File

@@ -1,14 +1,11 @@
/// Contains all the logic for putting the Ghostty process and
/// each individual surface into its own cgroup.
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const gio = @import("gio");
const glib = @import("glib");
const gobject = @import("gobject");
const App = @import("App.zig");
const internal_os = @import("../../os/main.zig");
const log = std.log.scoped(.gtk_systemd_cgroup);

View File

@@ -1,7 +1,6 @@
const std = @import("std");
const assert = std.debug.assert;
const assert = @import("../../../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const builtin = @import("builtin");
const adw = @import("adw");
const gdk = @import("gdk");
const gio = @import("gio");
@@ -10,6 +9,7 @@ const gobject = @import("gobject");
const gtk = @import("gtk");
const build_config = @import("../../../build_config.zig");
const state = &@import("../../../global.zig").state;
const i18n = @import("../../../os/main.zig").i18n;
const apprt = @import("../../../apprt.zig");
const cgroup = @import("../cgroup.zig");
@@ -394,6 +394,14 @@ pub const Application = extern struct {
.{ .detail = "config" },
);
_ = gtk.CssProvider.signals.parsing_error.connect(
css_provider,
*Self,
signalCssParsingError,
self,
.{},
);
// Trigger initial config changes
self.as(gobject.Object).notifyByPspec(properties.config.impl.param_spec);
@@ -530,12 +538,16 @@ pub const Application = extern struct {
}
// If we have no windows attached to our app, also quit.
if (priv.requested_window and @as(
?*glib.List,
self.as(gtk.Application).getWindows(),
) == null) {
log.debug("must_quit due to no app windows", .{});
break :q true;
// We only do this if we don't have the closed delay set,
// because with the closed delay set we'll exit eventually.
if (config.@"quit-after-last-window-closed-delay" == null) {
if (priv.requested_window and @as(
?*glib.List,
self.as(gtk.Application).getWindows(),
) == null) {
log.debug("must_quit due to no app windows", .{});
break :q true;
}
}
// No quit conditions met
@@ -649,6 +661,8 @@ pub const Application = extern struct {
.goto_split => return Action.gotoSplit(target, value),
.goto_window => return Action.gotoWindow(value),
.goto_tab => return Action.gotoTab(target, value),
.initial_size => return Action.initialSize(target, value),
@@ -683,7 +697,7 @@ pub const Application = extern struct {
.progress_report => return Action.progressReport(target, value),
.prompt_title => return Action.promptTitle(target),
.prompt_title => return Action.promptTitle(target, value),
.quit => self.quit(),
@@ -697,6 +711,8 @@ pub const Application = extern struct {
.ring_bell => Action.ringBell(target),
.scrollbar => Action.scrollbar(target, value),
.set_title => Action.setTitle(target, value),
.show_child_exited => return Action.showChildExited(target, value),
@@ -715,6 +731,11 @@ pub const Application = extern struct {
.show_on_screen_keyboard => return Action.showOnScreenKeyboard(target),
.command_finished => return Action.commandFinished(target, value),
.start_search => Action.startSearch(target),
.end_search => Action.endSearch(target),
.search_total => Action.searchTotal(target, value),
.search_selected => Action.searchSelected(target, value),
// Unimplemented
.secure_input,
.close_all_windows,
@@ -729,6 +750,7 @@ pub const Application = extern struct {
.check_for_updates,
.undo,
.redo,
.readonly,
=> {
log.warn("unimplemented action={}", .{action});
return false;
@@ -806,19 +828,19 @@ pub const Application = extern struct {
}
}
fn loadRuntimeCss(self: *Self) Allocator.Error!void {
fn loadRuntimeCss(self: *Self) (Allocator.Error || std.Io.Writer.Error)!void {
const alloc = self.allocator();
const priv: *Private = self.private();
const config = priv.config.get();
const config = self.private().config.get();
var buf: std.Io.Writer.Allocating = try .initCapacity(alloc, 2048);
defer buf.deinit();
var buf: std.ArrayListUnmanaged(u8) = try .initCapacity(alloc, 2048);
defer buf.deinit(alloc);
const writer = buf.writer(alloc);
const writer = &buf.writer;
// Load standard css first as it can override some of the user configured styling.
try loadRuntimeCss414(config, &writer);
try loadRuntimeCss416(config, &writer);
try loadRuntimeCss414(config, writer);
try loadRuntimeCss416(config, writer);
const unfocused_fill: CoreConfig.Color = config.@"unfocused-split-fill" orelse config.background;
@@ -858,25 +880,22 @@ pub const Application = extern struct {
, .{ .font_family = font_family });
}
// ensure that we have a sentinel
try writer.writeByte(0);
const contents = buf.written();
const data = buf.items[0 .. buf.items.len - 1 :0];
log.debug("runtime CSS is {d} bytes", .{contents.len});
log.debug("runtime CSS is {d} bytes", .{data.len + 1});
const bytes = glib.Bytes.new(contents.ptr, contents.len);
defer bytes.unref();
// Clears any previously loaded CSS from this provider
loadCssProviderFromData(
self.private().css_provider,
data,
);
priv.css_provider.loadFromBytes(bytes);
}
/// Load runtime CSS for older than GTK 4.16
fn loadRuntimeCss414(
config: *const CoreConfig,
writer: *const std.ArrayListUnmanaged(u8).Writer,
) Allocator.Error!void {
writer: *std.Io.Writer,
) std.Io.Writer.Error!void {
if (gtk_version.runtimeAtLeast(4, 16, 0)) return;
const window_theme = config.@"window-theme";
@@ -911,8 +930,8 @@ pub const Application = extern struct {
/// Load runtime for GTK 4.16 and newer
fn loadRuntimeCss416(
config: *const CoreConfig,
writer: *const std.ArrayListUnmanaged(u8).Writer,
) Allocator.Error!void {
writer: *std.Io.Writer,
) std.Io.Writer.Error!void {
if (gtk_version.runtimeUntil(4, 16, 0)) return;
const window_theme = config.@"window-theme";
@@ -1008,8 +1027,8 @@ pub const Application = extern struct {
}
}
fn loadCustomCss(self: *Self) !void {
const priv = self.private();
fn loadCustomCss(self: *Self) (std.fs.File.ReadError || Allocator.Error)!void {
const priv: *Private = self.private();
const alloc = self.allocator();
const display = gdk.Display.getDefault() orelse {
log.warn("unable to get display", .{});
@@ -1026,7 +1045,7 @@ pub const Application = extern struct {
}
priv.custom_css_providers.clearRetainingCapacity();
const config = priv.config.getMut();
const config = priv.config.get();
for (config.@"gtk-custom-css".value.items) |p| {
const path, const optional = switch (p) {
.optional => |path| .{ path, true },
@@ -1043,25 +1062,42 @@ pub const Application = extern struct {
};
defer file.close();
const css_file_size_limit = 5 * 1024 * 1024; // 5MB
log.info("loading gtk-custom-css path={s}", .{path});
var buf: [4096]u8 = undefined;
var reader = file.reader(&buf);
const contents = try reader.interface.readAlloc(
const contents = file.readToEndAlloc(
alloc,
5 * 1024 * 1024, // 5MB,
);
css_file_size_limit,
) catch |err| switch (err) {
error.FileTooBig => {
log.warn("gtk-custom-css file {s} was larger than {Bi}", .{ path, css_file_size_limit });
continue;
},
else => |e| return e,
};
defer alloc.free(contents);
const data = try alloc.dupeZ(u8, contents);
defer alloc.free(data);
const bytes = glib.Bytes.new(contents.ptr, contents.len);
defer bytes.unref();
const css_provider = gtk.CssProvider.new();
errdefer css_provider.unref();
_ = gtk.CssProvider.signals.parsing_error.connect(
css_provider,
*Self,
signalCssParsingError,
self,
.{},
);
try priv.custom_css_providers.append(alloc, css_provider);
css_provider.loadFromBytes(bytes);
const provider = gtk.CssProvider.new();
errdefer provider.unref();
try priv.custom_css_providers.append(alloc, provider);
loadCssProviderFromData(provider, data);
gtk.StyleContext.addProviderForDisplay(
display,
provider.as(gtk.StyleProvider),
css_provider.as(gtk.StyleProvider),
gtk.STYLE_PROVIDER_PRIORITY_USER,
);
}
@@ -1083,7 +1119,7 @@ pub const Application = extern struct {
self.syncActionAccelerator("win.split-down", .{ .new_split = .down });
self.syncActionAccelerator("win.split-left", .{ .new_split = .left });
self.syncActionAccelerator("win.split-up", .{ .new_split = .up });
self.syncActionAccelerator("win.copy", .{ .copy_to_clipboard = {} });
self.syncActionAccelerator("win.copy", .{ .copy_to_clipboard = .mixed });
self.syncActionAccelerator("win.paste", .{ .paste_from_clipboard = {} });
self.syncActionAccelerator("win.reset", .{ .reset = {} });
self.syncActionAccelerator("win.clear", .{ .clear_screen = {} });
@@ -1164,7 +1200,7 @@ pub const Application = extern struct {
// just stuck with the old CSS but we don't want to fail the entire
// config change operation.
self.loadRuntimeCss() catch |err| switch (err) {
error.OutOfMemory => log.warn(
error.WriteFailed, error.OutOfMemory => log.warn(
"out of memory loading runtime CSS, no runtime CSS applied",
.{},
),
@@ -1177,6 +1213,37 @@ pub const Application = extern struct {
};
}
/// Log CSS parsing error
fn signalCssParsingError(
_: *gtk.CssProvider,
css_section: *gtk.CssSection,
err: *glib.Error,
_: *Self,
) callconv(.c) void {
const location = css_section.toString();
defer glib.free(location);
if (comptime gtk_version.atLeast(4, 16, 0)) bytes: {
const bytes = css_section.getBytes() orelse break :bytes;
var len: usize = undefined;
const ptr = bytes.getData(&len) orelse break :bytes;
const data = ptr[0..len];
log.warn("css parsing failed at {s}: {s} {d} {s}\n{s}", .{
location,
glib.quarkToString(err.f_domain),
err.f_code,
err.f_message orelse "«unknown»",
data,
});
return;
}
log.warn("css parsing failed at {s}: {s} {d} {s}", .{
location,
glib.quarkToString(err.f_domain),
err.f_code,
err.f_message orelse "«unknown»",
});
}
//---------------------------------------------------------------
// Libghostty Callbacks
@@ -1521,7 +1588,7 @@ pub const Application = extern struct {
.dark;
log.debug("style manager changed scheme={}", .{scheme});
const priv = self.private();
const priv: *Private = self.private();
const core_app = priv.core_app;
core_app.colorSchemeEvent(self.rt(), scheme) catch |err| {
log.warn("error updating app color scheme err={}", .{err});
@@ -1534,6 +1601,26 @@ pub const Application = extern struct {
);
};
}
if (gtk_version.atLeast(4, 20, 0)) {
const gtk_scheme: gtk.InterfaceColorScheme = switch (scheme) {
.light => gtk.InterfaceColorScheme.light,
.dark => gtk.InterfaceColorScheme.dark,
};
var value = gobject.ext.Value.newFrom(gtk_scheme);
gobject.Object.setProperty(
priv.css_provider.as(gobject.Object),
"prefers-color-scheme",
&value,
);
for (priv.custom_css_providers.items) |css_provider| {
gobject.Object.setProperty(
css_provider.as(gobject.Object),
"prefers-color-scheme",
&value,
);
}
}
}
fn handleReloadConfig(
@@ -1931,6 +2018,69 @@ const Action = struct {
}
}
pub fn gotoWindow(direction: apprt.action.GotoWindow) bool {
const glist = gtk.Window.listToplevels();
defer glist.free();
// The window we're starting from is typically our active window.
const starting: *glib.List = @as(?*glib.List, glist.findCustom(
null,
findActiveWindow,
)) orelse glist;
// Go forward or backwards in the list until we find a valid
// window that is visible.
var current_: ?*glib.List = starting;
while (current_) |node| : (current_ = switch (direction) {
.next => node.f_next,
.previous => node.f_prev,
}) {
const data = node.f_data orelse continue;
const gtk_window: *gtk.Window = @ptrCast(@alignCast(data));
if (gotoWindowMaybe(gtk_window)) return true;
}
// If we reached here, we didn't find a valid window to focus.
// Wrap around.
current_ = switch (direction) {
.next => glist,
.previous => last: {
var end: *glib.List = glist;
while (end.f_next) |next| end = next;
break :last end;
},
};
while (current_) |node| : (current_ = switch (direction) {
.next => node.f_next,
.previous => node.f_prev,
}) {
if (current_ == starting) break;
const data = node.f_data orelse continue;
const gtk_window: *gtk.Window = @ptrCast(@alignCast(data));
if (gotoWindowMaybe(gtk_window)) return true;
}
return false;
}
fn gotoWindowMaybe(gtk_window: *gtk.Window) bool {
// If it is already active skip it.
if (gtk_window.isActive() != 0) return false;
// If it is hidden, skip it.
if (gtk_window.as(gtk.Widget).isVisible() == 0) return false;
// If it isn't a Ghostty window, skip it.
const window = gobject.ext.cast(
Window,
gtk_window,
) orelse return false;
// Focus our active surface
const surface = window.getActiveSurface() orelse return false;
gtk.Window.present(gtk_window);
surface.grabFocus();
return true;
}
pub fn initialSize(
target: apprt.Target,
value: apprt.action.InitialSize,
@@ -2168,12 +2318,18 @@ const Action = struct {
};
}
pub fn promptTitle(target: apprt.Target) bool {
switch (target) {
.app => return false,
.surface => |v| {
v.rt_surface.surface.promptTitle();
return true;
pub fn promptTitle(target: apprt.Target, value: apprt.action.PromptTitle) bool {
switch (value) {
.surface => switch (target) {
.app => return false,
.surface => |v| {
v.rt_surface.surface.promptTitle();
return true;
},
},
.tab => {
// GTK does not yet support tab title prompting
return false;
},
}
}
@@ -2270,6 +2426,44 @@ const Action = struct {
}
}
pub fn scrollbar(
target: apprt.Target,
value: apprt.Action.Value(.scrollbar),
) void {
switch (target) {
.app => {},
.surface => |v| v.rt_surface.surface.setScrollbar(value),
}
}
pub fn startSearch(target: apprt.Target) void {
switch (target) {
.app => {},
.surface => |v| v.rt_surface.surface.setSearchActive(true),
}
}
pub fn endSearch(target: apprt.Target) void {
switch (target) {
.app => {},
.surface => |v| v.rt_surface.surface.setSearchActive(false),
}
}
pub fn searchTotal(target: apprt.Target, value: apprt.action.SearchTotal) void {
switch (target) {
.app => {},
.surface => |v| v.rt_surface.surface.setSearchTotal(value.total),
}
}
pub fn searchSelected(target: apprt.Target, value: apprt.action.SearchSelected) void {
switch (target) {
.app => {},
.surface => |v| v.rt_surface.surface.setSearchSelected(value.selected),
}
}
pub fn setTitle(
target: apprt.Target,
value: apprt.action.SetTitle,
@@ -2485,7 +2679,9 @@ fn setGtkEnv(config: *const CoreConfig) error{NoSpaceLeft}!void {
/// disable it.
@"vulkan-disable": bool = false,
} = .{
.opengl = config.@"gtk-opengl-debug",
// `gtk-opengl-debug` dumps logs directly to stderr so both must be true
// to enable OpenGL debugging.
.opengl = state.logging.stderr and config.@"gtk-opengl-debug",
};
var gdk_disable: struct {
@@ -2580,8 +2776,3 @@ fn findActiveWindow(data: ?*const anyopaque, _: ?*const anyopaque) callconv(.c)
// Abusing integers to be enums and booleans is a terrible idea, C.
return if (window.isActive() != 0) 0 else -1;
}
fn loadCssProviderFromData(provider: *gtk.CssProvider, data: [:0]const u8) void {
assert(gtk_version.runtimeAtLeast(4, 12, 0));
provider.loadFromString(data);
}

View File

@@ -1,5 +1,4 @@
const std = @import("std");
const assert = std.debug.assert;
const adw = @import("adw");
const glib = @import("glib");
const gobject = @import("gobject");

View File

@@ -1,13 +1,10 @@
const std = @import("std");
const adw = @import("adw");
const gobject = @import("gobject");
const gtk = @import("gtk");
const gresource = @import("../build/gresource.zig");
const i18n = @import("../../../os/main.zig").i18n;
const adw_version = @import("../adw_version.zig");
const Common = @import("../class.zig").Common;
const Config = @import("config.zig").Config;
const Dialog = @import("dialog.zig").Dialog;
const log = std.log.scoped(.gtk_ghostty_close_confirmation_dialog);

View File

@@ -1,7 +1,5 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const adw = @import("adw");
const glib = @import("glib");
const gobject = @import("gobject");
const gtk = @import("gtk");

View File

@@ -1,10 +1,8 @@
const std = @import("std");
const adw = @import("adw");
const gobject = @import("gobject");
const gtk = @import("gtk");
const gresource = @import("../build/gresource.zig");
const adw_version = @import("../adw_version.zig");
const Common = @import("../class.zig").Common;
const Config = @import("config.zig").Config;
const Dialog = @import("dialog.zig").Dialog;

View File

@@ -1,9 +1,7 @@
const std = @import("std");
const adw = @import("adw");
const gobject = @import("gobject");
const gtk = @import("gtk");
const build_config = @import("../../../build_config.zig");
const adw_version = @import("../adw_version.zig");
const gresource = @import("../build/gresource.zig");
const Common = @import("../class.zig").Common;

View File

@@ -3,10 +3,8 @@ const adw = @import("adw");
const gobject = @import("gobject");
const gtk = @import("gtk");
const gresource = @import("../build/gresource.zig");
const adw_version = @import("../adw_version.zig");
const Common = @import("../class.zig").Common;
const Config = @import("config.zig").Config;
const log = std.log.scoped(.gtk_ghostty_dialog);

View File

@@ -1,14 +1,11 @@
const std = @import("std");
const assert = std.debug.assert;
const assert = @import("../../../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const adw = @import("adw");
const gio = @import("gio");
const glib = @import("glib");
const gobject = @import("gobject");
const gtk = @import("gtk");
const Binding = @import("../../../input.zig").Binding;
const gresource = @import("../build/gresource.zig");
const key = @import("../key.zig");
const Common = @import("../class.zig").Common;
const Application = @import("application.zig").Application;

View File

@@ -1,5 +1,5 @@
const std = @import("std");
const assert = std.debug.assert;
const assert = @import("../../../quirks.zig").inlineAssert;
const cimgui = @import("cimgui");
const gl = @import("opengl");

View File

@@ -1,6 +1,5 @@
const std = @import("std");
const adw = @import("adw");
const gobject = @import("gobject");
const gtk = @import("gtk");

View File

@@ -2,15 +2,12 @@ const std = @import("std");
const build_config = @import("../../../build_config.zig");
const adw = @import("adw");
const gdk = @import("gdk");
const gobject = @import("gobject");
const gtk = @import("gtk");
const gresource = @import("../build/gresource.zig");
const key = @import("../key.zig");
const Common = @import("../class.zig").Common;
const Application = @import("application.zig").Application;
const Surface = @import("surface.zig").Surface;
const DebugWarning = @import("debug_warning.zig").DebugWarning;
const InspectorWidget = @import("inspector_widget.zig").InspectorWidget;

View File

@@ -1,5 +1,4 @@
const std = @import("std");
const assert = std.debug.assert;
const adw = @import("adw");
const glib = @import("glib");
const gobject = @import("gobject");

View File

@@ -0,0 +1,486 @@
const std = @import("std");
const adw = @import("adw");
const glib = @import("glib");
const gobject = @import("gobject");
const gdk = @import("gdk");
const gtk = @import("gtk");
const gresource = @import("../build/gresource.zig");
const Common = @import("../class.zig").Common;
const log = std.log.scoped(.gtk_ghostty_search_overlay);
/// The overlay that shows the current size while a surface is resizing.
/// This can be used generically to show pretty much anything with a
/// disappearing overlay, but we have no other use at this point so it
/// is named specifically for what it does.
///
/// General usage:
///
/// 1. Add it to an overlay
/// 2. Set the label with `setLabel`
/// 3. Schedule to show it with `schedule`
///
/// Set any properties to change the behavior.
pub const SearchOverlay = extern struct {
const Self = @This();
parent_instance: Parent,
pub const Parent = adw.Bin;
pub const getGObjectType = gobject.ext.defineClass(Self, .{
.name = "GhosttySearchOverlay",
.instanceInit = &init,
.classInit = &Class.init,
.parent_class = &Class.parent,
.private = .{ .Type = Private, .offset = &Private.offset },
});
pub const properties = struct {
pub const active = struct {
pub const name = "active";
const impl = gobject.ext.defineProperty(
name,
Self,
bool,
.{
.default = false,
.accessor = gobject.ext.typedAccessor(
Self,
bool,
.{
.getter = getSearchActive,
.setter = setSearchActive,
},
),
},
);
};
pub const @"search-total" = struct {
pub const name = "search-total";
const impl = gobject.ext.defineProperty(
name,
Self,
u64,
.{
.default = 0,
.minimum = 0,
.maximum = std.math.maxInt(u64),
.accessor = gobject.ext.typedAccessor(
Self,
u64,
.{ .getter = getSearchTotal },
),
},
);
};
pub const @"has-search-total" = struct {
pub const name = "has-search-total";
const impl = gobject.ext.defineProperty(
name,
Self,
bool,
.{
.default = false,
.accessor = gobject.ext.typedAccessor(
Self,
bool,
.{ .getter = getHasSearchTotal },
),
},
);
};
pub const @"search-selected" = struct {
pub const name = "search-selected";
const impl = gobject.ext.defineProperty(
name,
Self,
u64,
.{
.default = 0,
.minimum = 0,
.maximum = std.math.maxInt(u64),
.accessor = gobject.ext.typedAccessor(
Self,
u64,
.{ .getter = getSearchSelected },
),
},
);
};
pub const @"has-search-selected" = struct {
pub const name = "has-search-selected";
const impl = gobject.ext.defineProperty(
name,
Self,
bool,
.{
.default = false,
.accessor = gobject.ext.typedAccessor(
Self,
bool,
.{ .getter = getHasSearchSelected },
),
},
);
};
pub const @"halign-target" = struct {
pub const name = "halign-target";
const impl = gobject.ext.defineProperty(
name,
Self,
gtk.Align,
.{
.default = .end,
.accessor = C.privateShallowFieldAccessor("halign_target"),
},
);
};
pub const @"valign-target" = struct {
pub const name = "valign-target";
const impl = gobject.ext.defineProperty(
name,
Self,
gtk.Align,
.{
.default = .start,
.accessor = C.privateShallowFieldAccessor("valign_target"),
},
);
};
};
pub const signals = struct {
/// Emitted when the search is stopped (e.g., Escape pressed).
pub const @"stop-search" = struct {
pub const name = "stop-search";
pub const connect = impl.connect;
const impl = gobject.ext.defineSignal(
name,
Self,
&.{},
void,
);
};
/// Emitted when the search text changes (debounced).
pub const @"search-changed" = struct {
pub const name = "search-changed";
pub const connect = impl.connect;
const impl = gobject.ext.defineSignal(
name,
Self,
&.{?[*:0]const u8},
void,
);
};
/// Emitted when navigating to the next match.
pub const @"next-match" = struct {
pub const name = "next-match";
pub const connect = impl.connect;
const impl = gobject.ext.defineSignal(
name,
Self,
&.{},
void,
);
};
/// Emitted when navigating to the previous match.
pub const @"previous-match" = struct {
pub const name = "previous-match";
pub const connect = impl.connect;
const impl = gobject.ext.defineSignal(
name,
Self,
&.{},
void,
);
};
};
const Private = struct {
/// The search entry widget.
search_entry: *gtk.SearchEntry,
/// True when a search is active, meaning we should show the overlay.
active: bool = false,
/// Total number of search matches (null means unknown/none).
search_total: ?usize = null,
/// Currently selected match index (null means none selected).
search_selected: ?usize = null,
/// Target horizontal alignment for the overlay.
halign_target: gtk.Align = .end,
/// Target vertical alignment for the overlay.
valign_target: gtk.Align = .start,
pub var offset: c_int = 0;
};
fn init(self: *Self, _: *Class) callconv(.c) void {
gtk.Widget.initTemplate(self.as(gtk.Widget));
}
/// Grab focus on the search entry and select all text.
pub fn grabFocus(self: *Self) void {
const priv = self.private();
_ = priv.search_entry.as(gtk.Widget).grabFocus();
// Select all text in the search entry field. -1 is distance from
// the end, causing the entire text to be selected.
priv.search_entry.as(gtk.Editable).selectRegion(0, -1);
}
// Set active status, and update search on activation
fn setSearchActive(self: *Self, active: bool) void {
const priv = self.private();
if (!priv.active and active) {
const text = priv.search_entry.as(gtk.Editable).getText();
signals.@"search-changed".impl.emit(self, null, .{text}, null);
}
priv.active = active;
}
/// Set the total number of search matches.
pub fn setSearchTotal(self: *Self, total: ?usize) void {
const priv = self.private();
const had_total = priv.search_total != null;
if (priv.search_total == total) return;
priv.search_total = total;
self.as(gobject.Object).notifyByPspec(properties.@"search-total".impl.param_spec);
if (had_total != (total != null)) {
self.as(gobject.Object).notifyByPspec(properties.@"has-search-total".impl.param_spec);
}
}
/// Set the currently selected match index.
pub fn setSearchSelected(self: *Self, selected: ?usize) void {
const priv = self.private();
const had_selected = priv.search_selected != null;
if (priv.search_selected == selected) return;
priv.search_selected = selected;
self.as(gobject.Object).notifyByPspec(properties.@"search-selected".impl.param_spec);
if (had_selected != (selected != null)) {
self.as(gobject.Object).notifyByPspec(properties.@"has-search-selected".impl.param_spec);
}
}
fn getSearchActive(self: *Self) bool {
return self.private().active;
}
fn getSearchTotal(self: *Self) u64 {
return self.private().search_total orelse 0;
}
fn getHasSearchTotal(self: *Self) bool {
return self.private().search_total != null;
}
fn getSearchSelected(self: *Self) u64 {
return self.private().search_selected orelse 0;
}
fn getHasSearchSelected(self: *Self) bool {
return self.private().search_selected != null;
}
fn closureMatchLabel(
_: *Self,
has_selected: bool,
selected: u64,
has_total: bool,
total: u64,
) callconv(.c) ?[*:0]const u8 {
if (!has_total or total == 0) return glib.ext.dupeZ(u8, "0/0");
var buf: [32]u8 = undefined;
const label = std.fmt.bufPrintZ(&buf, "{}/{}", .{
if (has_selected) selected + 1 else 0,
total,
}) catch return null;
return glib.ext.dupeZ(u8, label);
}
//---------------------------------------------------------------
// Template callbacks
fn searchChanged(entry: *gtk.SearchEntry, self: *Self) callconv(.c) void {
const text = entry.as(gtk.Editable).getText();
signals.@"search-changed".impl.emit(self, null, .{text}, null);
}
// NOTE: The callbacks below use anyopaque for the first parameter
// because they're shared with multiple widgets in the template.
fn stopSearch(_: *anyopaque, self: *Self) callconv(.c) void {
signals.@"stop-search".impl.emit(self, null, .{}, null);
}
fn nextMatch(_: *anyopaque, self: *Self) callconv(.c) void {
signals.@"next-match".impl.emit(self, null, .{}, null);
}
fn previousMatch(_: *anyopaque, self: *Self) callconv(.c) void {
signals.@"previous-match".impl.emit(self, null, .{}, null);
}
fn searchEntryKeyPressed(
_: *gtk.EventControllerKey,
keyval: c_uint,
_: c_uint,
gtk_mods: gdk.ModifierType,
self: *Self,
) callconv(.c) c_int {
if (keyval == gdk.KEY_Return or keyval == gdk.KEY_KP_Enter) {
if (gtk_mods.shift_mask) {
signals.@"previous-match".impl.emit(self, null, .{}, null);
} else {
signals.@"next-match".impl.emit(self, null, .{}, null);
}
return 1;
}
return 0;
}
fn onDragEnd(
_: *gtk.GestureDrag,
offset_x: f64,
offset_y: f64,
self: *Self,
) callconv(.c) void {
// On drag end, we want to move our halign/valign if we crossed
// the midpoint on either axis. This lets the search overlay be
// moved to different corners of the parent container.
const priv = self.private();
const widget = self.as(gtk.Widget);
const parent = widget.getParent() orelse return;
const parent_width: f64 = @floatFromInt(parent.getAllocatedWidth());
const parent_height: f64 = @floatFromInt(parent.getAllocatedHeight());
const self_width: f64 = @floatFromInt(widget.getAllocatedWidth());
const self_height: f64 = @floatFromInt(widget.getAllocatedHeight());
const self_x: f64 = if (priv.halign_target == .start) 0 else parent_width - self_width;
const self_y: f64 = if (priv.valign_target == .start) 0 else parent_height - self_height;
const new_x = self_x + offset_x + (self_width / 2);
const new_y = self_y + offset_y + (self_height / 2);
const new_halign: gtk.Align = if (new_x > parent_width / 2) .end else .start;
const new_valign: gtk.Align = if (new_y > parent_height / 2) .end else .start;
var changed = false;
if (new_halign != priv.halign_target) {
priv.halign_target = new_halign;
self.as(gobject.Object).notifyByPspec(properties.@"halign-target".impl.param_spec);
changed = true;
}
if (new_valign != priv.valign_target) {
priv.valign_target = new_valign;
self.as(gobject.Object).notifyByPspec(properties.@"valign-target".impl.param_spec);
changed = true;
}
if (changed) self.as(gtk.Widget).queueResize();
}
//---------------------------------------------------------------
// Virtual methods
fn dispose(self: *Self) callconv(.c) void {
const priv = self.private();
_ = priv;
gtk.Widget.disposeTemplate(
self.as(gtk.Widget),
getGObjectType(),
);
gobject.Object.virtual_methods.dispose.call(
Class.parent,
self.as(Parent),
);
}
fn finalize(self: *Self) callconv(.c) void {
const priv = self.private();
_ = priv;
gobject.Object.virtual_methods.finalize.call(
Class.parent,
self.as(Parent),
);
}
const C = Common(Self, Private);
pub const as = C.as;
pub const ref = C.ref;
pub const unref = C.unref;
const private = C.private;
pub const Class = extern struct {
parent_class: Parent.Class,
var parent: *Parent.Class = undefined;
pub const Instance = Self;
fn init(class: *Class) callconv(.c) void {
gtk.Widget.Class.setTemplateFromResource(
class.as(gtk.Widget.Class),
comptime gresource.blueprint(.{
.major = 1,
.minor = 2,
.name = "search-overlay",
}),
);
// Bindings
class.bindTemplateChildPrivate("search_entry", .{});
// Template Callbacks
class.bindTemplateCallback("stop_search", &stopSearch);
class.bindTemplateCallback("search_changed", &searchChanged);
class.bindTemplateCallback("match_label_closure", &closureMatchLabel);
class.bindTemplateCallback("next_match", &nextMatch);
class.bindTemplateCallback("previous_match", &previousMatch);
class.bindTemplateCallback("search_entry_key_pressed", &searchEntryKeyPressed);
class.bindTemplateCallback("on_drag_end", &onDragEnd);
// Properties
gobject.ext.registerProperties(class, &.{
properties.active.impl,
properties.@"search-total".impl,
properties.@"has-search-total".impl,
properties.@"search-selected".impl,
properties.@"has-search-selected".impl,
properties.@"halign-target".impl,
properties.@"valign-target".impl,
});
// Signals
signals.@"stop-search".impl.register(.{});
signals.@"search-changed".impl.register(.{});
signals.@"next-match".impl.register(.{});
signals.@"previous-match".impl.register(.{});
// Virtual methods
gobject.Object.virtual_methods.dispose.implement(class, &dispose);
gobject.Object.virtual_methods.finalize.implement(class, &finalize);
}
pub const as = C.Class.as;
pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
pub const bindTemplateCallback = C.Class.bindTemplateCallback;
};
};

View File

@@ -1,6 +1,5 @@
const std = @import("std");
const build_config = @import("../../../build_config.zig");
const assert = std.debug.assert;
const assert = @import("../../../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const adw = @import("adw");
const gio = @import("gio");
@@ -8,20 +7,15 @@ const glib = @import("glib");
const gobject = @import("gobject");
const gtk = @import("gtk");
const i18n = @import("../../../os/main.zig").i18n;
const apprt = @import("../../../apprt.zig");
const input = @import("../../../input.zig");
const CoreSurface = @import("../../../Surface.zig");
const gtk_version = @import("../gtk_version.zig");
const adw_version = @import("../adw_version.zig");
const ext = @import("../ext.zig");
const gresource = @import("../build/gresource.zig");
const Common = @import("../class.zig").Common;
const WeakRef = @import("../weak_ref.zig").WeakRef;
const Config = @import("config.zig").Config;
const Application = @import("application.zig").Application;
const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog;
const Surface = @import("surface.zig").Surface;
const SurfaceScrolledWindow = @import("surface_scrolled_window.zig").SurfaceScrolledWindow;
const log = std.log.scoped(.gtk_ghostty_split_tree);
@@ -874,7 +868,9 @@ pub const SplitTree = extern struct {
current: Surface.Tree.Node.Handle,
) *gtk.Widget {
return switch (tree.nodes[current.idx()]) {
.leaf => |v| v.as(gtk.Widget),
.leaf => |v| gobject.ext.newInstance(SurfaceScrolledWindow, .{
.surface = v,
}).as(gtk.Widget),
.split => |s| SplitTreeSplit.new(
current,
&s,

View File

@@ -1,5 +1,5 @@
const std = @import("std");
const assert = std.debug.assert;
const assert = @import("../../../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const adw = @import("adw");
const gdk = @import("gdk");
@@ -19,18 +19,17 @@ const terminal = @import("../../../terminal/main.zig");
const CoreSurface = @import("../../../Surface.zig");
const gresource = @import("../build/gresource.zig");
const ext = @import("../ext.zig");
const adw_version = @import("../adw_version.zig");
const gtk_key = @import("../key.zig");
const ApprtSurface = @import("../Surface.zig");
const Common = @import("../class.zig").Common;
const Application = @import("application.zig").Application;
const Config = @import("config.zig").Config;
const ResizeOverlay = @import("resize_overlay.zig").ResizeOverlay;
const SearchOverlay = @import("search_overlay.zig").SearchOverlay;
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 Window = @import("window.zig").Window;
const WeakRef = @import("../weak_ref.zig").WeakRef;
const InspectorWindow = @import("inspector_window.zig").InspectorWindow;
const i18n = @import("../../../os/i18n.zig");
@@ -40,12 +39,16 @@ pub const Surface = extern struct {
const Self = @This();
parent_instance: Parent,
pub const Parent = adw.Bin;
pub const Implements = [_]type{gtk.Scrollable};
pub const getGObjectType = gobject.ext.defineClass(Self, .{
.name = "GhosttySurface",
.instanceInit = &init,
.classInit = &Class.init,
.parent_class = &Class.parent,
.private = .{ .Type = Private, .offset = &Private.offset },
.implements = &.{
gobject.ext.implement(gtk.Scrollable, .{}),
},
});
/// A SplitTree implementation that stores surfaces.
@@ -301,6 +304,62 @@ pub const Surface = extern struct {
},
);
};
pub const hadjustment = struct {
pub const name = "hadjustment";
const impl = gobject.ext.defineProperty(
name,
Self,
?*gtk.Adjustment,
.{
.accessor = .{
.getter = getHAdjustmentValue,
.setter = setHAdjustmentValue,
},
},
);
};
pub const vadjustment = struct {
pub const name = "vadjustment";
const impl = gobject.ext.defineProperty(
name,
Self,
?*gtk.Adjustment,
.{
.accessor = .{
.getter = getVAdjustmentValue,
.setter = setVAdjustmentValue,
},
},
);
};
pub const @"hscroll-policy" = struct {
pub const name = "hscroll-policy";
const impl = gobject.ext.defineProperty(
name,
Self,
gtk.ScrollablePolicy,
.{
.default = .natural,
.accessor = C.privateShallowFieldAccessor("hscroll_policy"),
},
);
};
pub const @"vscroll-policy" = struct {
pub const name = "vscroll-policy";
const impl = gobject.ext.defineProperty(
name,
Self,
gtk.ScrollablePolicy,
.{
.default = .natural,
.accessor = C.privateShallowFieldAccessor("vscroll_policy"),
},
);
};
};
pub const signals = struct {
@@ -491,6 +550,9 @@ pub const Surface = extern struct {
/// The resize overlay
resize_overlay: *ResizeOverlay,
/// The search overlay
search_overlay: *SearchOverlay,
/// The apprt Surface.
rt_surface: ApprtSurface = undefined,
@@ -548,6 +610,13 @@ pub const Surface = extern struct {
action_group: ?*gio.SimpleActionGroup = null,
// Gtk.Scrollable interface adjustments
hadj: ?*gtk.Adjustment = null,
vadj: ?*gtk.Adjustment = null,
hscroll_policy: gtk.ScrollablePolicy = .natural,
vscroll_policy: gtk.ScrollablePolicy = .natural,
vadj_signal_group: ?*gobject.SignalGroup = null,
// Template binds
child_exited_overlay: *ChildExited,
context_menu: *gtk.PopoverMenu,
@@ -714,6 +783,47 @@ pub const Surface = extern struct {
return priv.im_context.as(gtk.IMContext).activateOsk(event) != 0;
}
/// Set the scrollbar state for this surface. This will setup the
/// properties for our Gtk.Scrollable interface properly.
pub fn setScrollbar(self: *Self, scrollbar: terminal.Scrollbar) void {
// Update existing adjustment in-place. If we don't have an
// adjustment then we do nothing because we're not part of a
// scrolled window.
const vadj = self.getVAdjustment() orelse return;
// Check if values match existing adjustment and skip update if so
const value: f64 = @floatFromInt(scrollbar.offset);
const upper: f64 = @floatFromInt(scrollbar.total);
const page_size: f64 = @floatFromInt(scrollbar.len);
if (std.math.approxEqAbs(f64, vadj.getValue(), value, 0.001) and
std.math.approxEqAbs(f64, vadj.getUpper(), upper, 0.001) and
std.math.approxEqAbs(f64, vadj.getPageSize(), page_size, 0.001))
{
return;
}
// If we have a vadjustment we MUST have the signal group since
// it is setup in the prop handler.
const priv = self.private();
const group = priv.vadj_signal_group.?;
// During manual scrollbar changes from Ghostty core we don't
// want to emit value-changed signals so we block them. This would
// cause a waste of resources at best and infinite loops at worst.
group.block();
defer group.unblock();
vadj.configure(
value, // value: current scroll position
0, // lower: minimum value
upper, // upper: maximum value (total scrollable area)
1, // step_increment: amount to scroll on arrow click
page_size, // page_increment: amount to scroll on page up/down
page_size, // page_size: size of visible area
);
}
/// Set the current progress report state.
pub fn setProgressReport(
self: *Self,
@@ -838,6 +948,8 @@ pub const Surface = extern struct {
if (cfg.@"notify-on-command-finish" == .unfocused and self.getFocused()) return true;
}
if (value.duration.lte(cfg.@"notify-on-command-finish-after")) return true;
const action = cfg.@"notify-on-command-finish-action";
if (action.bell) self.setBellRinging(true);
@@ -1027,13 +1139,14 @@ pub const Surface = extern struct {
if (entry.native == keycode) break :w3c entry.key;
} else .unidentified;
// If the key should be remappable, then consult the pre-remapped
// XKB keyval/keysym to get the (possibly) remapped key.
// Consult the pre-remapped XKB keyval/keysym to get the (possibly)
// remapped key. If the W3C key or the remapped key
// is eligible for remapping, we use it.
//
// See the docs for `shouldBeRemappable` for why we even have to
// do this in the first place.
if (w3c_key.shouldBeRemappable()) {
if (gtk_key.keyFromKeyval(keyval)) |remapped|
if (gtk_key.keyFromKeyval(keyval)) |remapped| {
if (w3c_key.shouldBeRemappable() or remapped.shouldBeRemappable())
break :keycode remapped;
}
@@ -1355,6 +1468,10 @@ pub const Surface = extern struct {
// EnvMap is a bit annoying so I'm punting it.
if (ext.getAncestor(Window, self.as(gtk.Widget))) |window| {
try window.winproto().addSubprocessEnv(&env);
if (window.isQuickTerminal()) {
try env.put("GHOSTTY_QUICK_TERMINAL", "1");
}
}
return env;
@@ -1443,16 +1560,16 @@ pub const Surface = extern struct {
);
}
pub fn setClipboardString(
pub fn setClipboard(
self: *Self,
val: [:0]const u8,
clipboard_type: apprt.Clipboard,
contents: []const apprt.ClipboardContent,
confirm: bool,
) void {
Clipboard.set(
self,
val,
clipboard_type,
contents,
confirm,
);
}
@@ -1466,6 +1583,12 @@ pub const Surface = extern struct {
pub fn sendDesktopNotification(self: *Self, title: [:0]const u8, body: [:0]const u8) void {
const app = Application.default();
const priv: *Private = self.private();
const core_surface = priv.core_surface orelse {
log.warn("can't send notification because there is no core surface", .{});
return;
};
const t = switch (title.len) {
0 => "Ghostty",
@@ -1480,7 +1603,7 @@ pub const Surface = extern struct {
defer icon.unref();
notification.setIcon(icon.as(gio.Icon));
const pointer = glib.Variant.newUint64(@intFromPtr(self));
const pointer = glib.Variant.newUint64(@intFromPtr(core_surface));
notification.setDefaultActionAndTargetValue(
"app.present-surface",
pointer,
@@ -1511,6 +1634,7 @@ pub const Surface = extern struct {
priv.mouse_hidden = false;
priv.focused = true;
priv.size = .{ .width = 0, .height = 0 };
priv.vadj_signal_group = null;
// If our configuration is null then we get the configuration
// from the application.
@@ -1575,6 +1699,22 @@ pub const Surface = extern struct {
priv.config = null;
}
if (priv.vadj_signal_group) |group| {
group.setTarget(null);
group.as(gobject.Object).unref();
priv.vadj_signal_group = null;
}
if (priv.hadj) |v| {
v.as(gobject.Object).unref();
priv.hadj = null;
}
if (priv.vadj) |v| {
v.as(gobject.Object).unref();
priv.vadj = null;
}
if (priv.progress_bar_timer) |timer| {
if (glib.Source.remove(timer) == 0) {
log.warn("unable to remove progress bar timer", .{});
@@ -1816,6 +1956,29 @@ pub const Surface = extern struct {
self.as(gobject.Object).notifyByPspec(properties.@"error".impl.param_spec);
}
pub fn setSearchActive(self: *Self, active: bool) void {
const priv = self.private();
var value = gobject.ext.Value.newFrom(active);
defer value.unset();
gobject.Object.setProperty(
priv.search_overlay.as(gobject.Object),
SearchOverlay.properties.active.name,
&value,
);
if (active) {
priv.search_overlay.grabFocus();
}
}
pub fn setSearchTotal(self: *Self, total: ?usize) void {
self.private().search_overlay.setSearchTotal(total);
}
pub fn setSearchSelected(self: *Self, selected: ?usize) void {
self.private().search_overlay.setSearchSelected(selected);
}
fn propConfig(
self: *Self,
_: *gobject.ParamSpec,
@@ -1988,6 +2151,43 @@ pub const Surface = extern struct {
self.as(gtk.Widget).setCursorFromName(name.ptr);
}
fn vadjValueChanged(adj: *gtk.Adjustment, self: *Self) callconv(.c) void {
// This will trigger for every single pixel change in the adjustment,
// but our core surface handles the noise from this so that identical
// rows are cheap.
const core_surface = self.core() orelse return;
const row: usize = @intFromFloat(@round(adj.getValue()));
_ = core_surface.performBindingAction(.{ .scroll_to_row = row }) catch |err| {
log.err("error performing scroll_to_row action err={}", .{err});
};
}
fn propVAdjustment(
self: *Self,
_: *gobject.ParamSpec,
_: ?*anyopaque,
) callconv(.c) void {
const priv = self.private();
// When vadjustment is first set, we setup the signal group lazily.
// This makes it so that if we don't use scrollbars, we never
// pay the memory cost of this.
const group: *gobject.SignalGroup = priv.vadj_signal_group orelse group: {
const group = gobject.SignalGroup.new(gtk.Adjustment.getGObjectType());
group.connect(
"value-changed",
@ptrCast(&vadjValueChanged),
self,
);
priv.vadj_signal_group = group;
break :group group;
};
// Setup our signal group target
group.setTarget(if (priv.vadj) |v| v.as(gobject.Object) else null);
}
/// Handle bell features that need to happen every time a BEL is received
/// Currently this is audio and system but this could change in the future.
fn ringBell(self: *Self) void {
@@ -2052,6 +2252,66 @@ pub const Surface = extern struct {
}
}
//---------------------------------------------------------------
// Gtk.Scrollable interface implementation
pub fn getHAdjustment(self: *Self) ?*gtk.Adjustment {
return self.private().hadj;
}
pub fn setHAdjustment(self: *Self, adj_: ?*gtk.Adjustment) void {
self.as(gobject.Object).freezeNotify();
defer self.as(gobject.Object).thawNotify();
self.as(gobject.Object).notifyByPspec(properties.hadjustment.impl.param_spec);
const priv = self.private();
if (priv.hadj) |old| {
old.as(gobject.Object).unref();
priv.hadj = null;
}
const adj = adj_ orelse return;
_ = adj.as(gobject.Object).ref();
priv.hadj = adj;
}
fn getHAdjustmentValue(self: *Self, value: *gobject.Value) void {
gobject.ext.Value.set(value, self.getHAdjustment());
}
fn setHAdjustmentValue(self: *Self, value: *const gobject.Value) void {
self.setHAdjustment(gobject.ext.Value.get(value, ?*gtk.Adjustment));
}
pub fn getVAdjustment(self: *Self) ?*gtk.Adjustment {
return self.private().vadj;
}
pub fn setVAdjustment(self: *Self, adj_: ?*gtk.Adjustment) void {
self.as(gobject.Object).freezeNotify();
defer self.as(gobject.Object).thawNotify();
self.as(gobject.Object).notifyByPspec(properties.vadjustment.impl.param_spec);
const priv = self.private();
if (priv.vadj) |old| {
old.as(gobject.Object).unref();
priv.vadj = null;
}
const adj = adj_ orelse return;
_ = adj.as(gobject.Object).ref();
priv.vadj = adj;
}
fn getVAdjustmentValue(self: *Self, value: *gobject.Value) void {
gobject.ext.Value.set(value, self.getVAdjustment());
}
fn setVAdjustmentValue(self: *Self, value: *const gobject.Value) void {
self.setVAdjustment(gobject.ext.Value.get(value, ?*gtk.Adjustment));
}
//---------------------------------------------------------------
// Signal Handlers
@@ -2938,6 +3198,35 @@ pub const Surface = extern struct {
self.setTitleOverride(if (title.len == 0) null else title);
}
fn searchStop(_: *SearchOverlay, self: *Self) callconv(.c) void {
const surface = self.core() orelse return;
_ = surface.performBindingAction(.end_search) catch |err| {
log.warn("unable to perform end_search action err={}", .{err});
};
_ = self.private().gl_area.as(gtk.Widget).grabFocus();
}
fn searchChanged(_: *SearchOverlay, needle: ?[*:0]const u8, self: *Self) callconv(.c) void {
const surface = self.core() orelse return;
_ = surface.performBindingAction(.{ .search = std.mem.sliceTo(needle orelse "", 0) }) catch |err| {
log.warn("unable to perform search action err={}", .{err});
};
}
fn searchNextMatch(_: *SearchOverlay, self: *Self) callconv(.c) void {
const surface = self.core() orelse return;
_ = surface.performBindingAction(.{ .navigate_search = .next }) catch |err| {
log.warn("unable to perform navigate_search action err={}", .{err});
};
}
fn searchPreviousMatch(_: *SearchOverlay, self: *Self) callconv(.c) void {
const surface = self.core() orelse return;
_ = surface.performBindingAction(.{ .navigate_search = .previous }) catch |err| {
log.warn("unable to perform navigate_search action err={}", .{err});
};
}
const C = Common(Self, Private);
pub const as = C.as;
pub const ref = C.ref;
@@ -2952,6 +3241,7 @@ pub const Surface = extern struct {
fn init(class: *Class) callconv(.c) void {
gobject.ext.ensureType(ResizeOverlay);
gobject.ext.ensureType(SearchOverlay);
gobject.ext.ensureType(ChildExited);
gtk.Widget.Class.setTemplateFromResource(
class.as(gtk.Widget.Class),
@@ -2971,6 +3261,7 @@ pub const Surface = extern struct {
class.bindTemplateChildPrivate("error_page", .{});
class.bindTemplateChildPrivate("progress_bar_overlay", .{});
class.bindTemplateChildPrivate("resize_overlay", .{});
class.bindTemplateChildPrivate("search_overlay", .{});
class.bindTemplateChildPrivate("terminal_page", .{});
class.bindTemplateChildPrivate("drop_target", .{});
class.bindTemplateChildPrivate("im_context", .{});
@@ -3005,8 +3296,13 @@ pub const Surface = extern struct {
class.bindTemplateCallback("notify_mouse_hover_url", &propMouseHoverUrl);
class.bindTemplateCallback("notify_mouse_hidden", &propMouseHidden);
class.bindTemplateCallback("notify_mouse_shape", &propMouseShape);
class.bindTemplateCallback("notify_vadjustment", &propVAdjustment);
class.bindTemplateCallback("should_border_be_shown", &closureShouldBorderBeShown);
class.bindTemplateCallback("should_unfocused_split_be_shown", &closureShouldUnfocusedSplitBeShown);
class.bindTemplateCallback("search_stop", &searchStop);
class.bindTemplateCallback("search_changed", &searchChanged);
class.bindTemplateCallback("search_next_match", &searchNextMatch);
class.bindTemplateCallback("search_previous_match", &searchPreviousMatch);
// Properties
gobject.ext.registerProperties(class, &.{
@@ -3026,6 +3322,12 @@ pub const Surface = extern struct {
properties.@"title-override".impl,
properties.zoom.impl,
properties.@"is-split".impl,
// For Gtk.Scrollable
properties.hadjustment.impl,
properties.vadjustment.impl,
properties.@"hscroll-policy".impl,
properties.@"vscroll-policy".impl,
});
// Signals
@@ -3097,24 +3399,80 @@ const Clipboard = struct {
/// Set the clipboard contents.
pub fn set(
self: *Surface,
val: [:0]const u8,
clipboard_type: apprt.Clipboard,
contents: []const apprt.ClipboardContent,
confirm: bool,
) void {
const priv = self.private();
// Grab our plaintext content for use in confirmation dialogs
// and signals. We always expect one to exist.
const text: [:0]const u8 = for (contents) |content| {
if (std.mem.eql(u8, content.mime, "text/plain")) {
break content.data;
}
} else return;
// If no confirmation is necessary, set the clipboard.
if (!confirm) {
const clipboard = get(
priv.gl_area.as(gtk.Widget),
clipboard_type,
) orelse return;
clipboard.setText(val);
const alloc = Application.default().allocator();
if (alloc.alloc(*gdk.ContentProvider, contents.len)) |providers| {
// Note: we don't need to unref the individual providers
// because new_union takes ownership of them.
defer alloc.free(providers);
for (contents, 0..) |content, i| {
const bytes = glib.Bytes.new(content.data.ptr, content.data.len);
defer bytes.unref();
if (std.mem.eql(u8, content.mime, "text/plain")) {
// Add an explicit UTF-8 encoding parameter to the
// text/plain type. The default charset when there is
// none is ASCII, and lots of things look for UTF-8
// specifically.
// The specs are not clear about the order here, but
// some clients apparently pick the first match in the
// order we set here then garble up bare 'text/plain'
// with non-ASCII UTF-8 content, so offer UTF-8 first.
//
// Note that under X11, GTK automatically adds the
// UTF8_STRING atom when this is present.
const text_provider_atoms = [_][:0]const u8{
"text/plain;charset=utf-8",
"text/plain",
};
var text_providers: [text_provider_atoms.len]*gdk.ContentProvider = undefined;
for (text_provider_atoms, 0..) |atom, j| {
const provider = gdk.ContentProvider.newForBytes(atom, bytes);
text_providers[j] = provider;
}
const text_union = gdk.ContentProvider.newUnion(
&text_providers,
text_providers.len,
);
providers[i] = text_union;
} else {
const provider = gdk.ContentProvider.newForBytes(content.mime, bytes);
providers[i] = provider;
}
}
const all = gdk.ContentProvider.newUnion(providers.ptr, providers.len);
defer all.unref();
_ = clipboard.setContent(all);
} else |_| {
// If we fail to alloc, we can at least set the text content.
clipboard.setText(text);
}
Surface.signals.@"clipboard-write".impl.emit(
self,
null,
.{ clipboard_type, val.ptr },
.{ clipboard_type, text.ptr },
null,
);
@@ -3124,7 +3482,7 @@ const Clipboard = struct {
showClipboardConfirmation(
self,
.{ .osc_52_write = clipboard_type },
val,
text,
);
}

View File

@@ -1,5 +1,4 @@
const std = @import("std");
const assert = std.debug.assert;
const adw = @import("adw");
const glib = @import("glib");
const gobject = @import("gobject");

View File

@@ -0,0 +1,208 @@
const std = @import("std");
const adw = @import("adw");
const gobject = @import("gobject");
const gtk = @import("gtk");
const gresource = @import("../build/gresource.zig");
const Common = @import("../class.zig").Common;
const Surface = @import("surface.zig").Surface;
const Config = @import("config.zig").Config;
const log = std.log.scoped(.gtk_ghostty_surface_scrolled_window);
/// A wrapper widget that embeds a Surface inside a GtkScrolledWindow.
/// This provides scrollbar functionality for the terminal surface.
/// The surface property can be set during initialization or changed
/// dynamically via the surface property.
pub const SurfaceScrolledWindow = extern struct {
const Self = @This();
parent_instance: Parent,
pub const Parent = adw.Bin;
pub const getGObjectType = gobject.ext.defineClass(Self, .{
.name = "GhostttySurfaceScrolledWindow",
.instanceInit = &init,
.classInit = &Class.init,
.parent_class = &Class.parent,
.private = .{ .Type = Private, .offset = &Private.offset },
});
pub const properties = struct {
pub const config = struct {
pub const name = "config";
const impl = gobject.ext.defineProperty(
name,
Self,
?*Config,
.{
.accessor = C.privateObjFieldAccessor("config"),
},
);
};
pub const surface = struct {
pub const name = "surface";
const impl = gobject.ext.defineProperty(
name,
Self,
?*Surface,
.{
.accessor = .{
.getter = getSurfaceValue,
.setter = setSurfaceValue,
},
},
);
};
};
const Private = struct {
config: ?*Config = null,
config_binding: ?*gobject.Binding = null,
surface: ?*Surface = null,
pub var offset: c_int = 0;
};
fn init(self: *Self, _: *Class) callconv(.c) void {
gtk.Widget.initTemplate(self.as(gtk.Widget));
}
fn dispose(self: *Self) callconv(.c) void {
const priv = self.private();
if (priv.config_binding) |binding| {
binding.as(gobject.Object).unref();
priv.config_binding = null;
}
if (priv.config) |v| {
v.unref();
priv.config = null;
}
gtk.Widget.disposeTemplate(
self.as(gtk.Widget),
getGObjectType(),
);
gobject.Object.virtual_methods.dispose.call(
Class.parent,
self.as(Parent),
);
}
fn finalize(self: *Self) callconv(.c) void {
gobject.Object.virtual_methods.finalize.call(
Class.parent,
self.as(Parent),
);
}
fn getSurfaceValue(self: *Self, value: *gobject.Value) void {
gobject.ext.Value.set(
value,
self.private().surface,
);
}
fn setSurfaceValue(self: *Self, value: *const gobject.Value) void {
self.setSurface(gobject.ext.Value.get(
value,
?*Surface,
));
}
pub fn getSurface(self: *Self) ?*Surface {
return self.private().surface;
}
pub fn setSurface(self: *Self, surface_: ?*Surface) void {
const priv = self.private();
if (surface_ == priv.surface) return;
self.as(gobject.Object).freezeNotify();
defer self.as(gobject.Object).thawNotify();
self.as(gobject.Object).notifyByPspec(properties.surface.impl.param_spec);
priv.surface = surface_;
}
fn closureScrollbarPolicy(
_: *Self,
config_: ?*Config,
) callconv(.c) gtk.PolicyType {
const config = if (config_) |c| c.get() else return .automatic;
return switch (config.scrollbar) {
.never => .never,
.system => .automatic,
};
}
fn propSurface(
self: *Self,
_: *gobject.ParamSpec,
_: ?*anyopaque,
) callconv(.c) void {
const priv = self.private();
const child: *gtk.Widget = self.as(Parent).getChild().?;
const scrolled_window = gobject.ext.cast(gtk.ScrolledWindow, child).?;
scrolled_window.setChild(if (priv.surface) |s| s.as(gtk.Widget) else null);
// Unbind old config binding if it exists
if (priv.config_binding) |binding| {
binding.as(gobject.Object).unref();
priv.config_binding = null;
}
// Bind config from surface to our config property
if (priv.surface) |surface| {
priv.config_binding = surface.as(gobject.Object).bindProperty(
properties.config.name,
self.as(gobject.Object),
properties.config.name,
.{ .sync_create = true },
);
}
}
const C = Common(Self, Private);
pub const as = C.as;
pub const ref = C.ref;
pub const unref = C.unref;
const private = C.private;
pub const Class = extern struct {
parent_class: Parent.Class,
var parent: *Parent.Class = undefined;
pub const Instance = Self;
fn init(class: *Class) callconv(.c) void {
gtk.Widget.Class.setTemplateFromResource(
class.as(gtk.Widget.Class),
comptime gresource.blueprint(.{
.major = 1,
.minor = 5,
.name = "surface-scrolled-window",
}),
);
// Bindings
class.bindTemplateCallback("scrollbar_policy", &closureScrollbarPolicy);
class.bindTemplateCallback("notify_surface", &propSurface);
// Properties
gobject.ext.registerProperties(class, &.{
properties.config.impl,
properties.surface.impl,
});
// Virtual methods
gobject.Object.virtual_methods.dispose.implement(class, &dispose);
gobject.Object.virtual_methods.finalize.implement(class, &finalize);
}
pub const as = C.Class.as;
pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
pub const bindTemplateCallback = C.Class.bindTemplateCallback;
};
};

View File

@@ -6,7 +6,6 @@ const gobject = @import("gobject");
const gtk = @import("gtk");
const gresource = @import("../build/gresource.zig");
const adw_version = @import("../adw_version.zig");
const ext = @import("../ext.zig");
const Common = @import("../class.zig").Common;

View File

@@ -1,19 +1,13 @@
const std = @import("std");
const build_config = @import("../../../build_config.zig");
const assert = std.debug.assert;
const adw = @import("adw");
const gio = @import("gio");
const glib = @import("glib");
const gobject = @import("gobject");
const gtk = @import("gtk");
const i18n = @import("../../../os/main.zig").i18n;
const apprt = @import("../../../apprt.zig");
const input = @import("../../../input.zig");
const CoreSurface = @import("../../../Surface.zig");
const ext = @import("../ext.zig");
const gtk_version = @import("../gtk_version.zig");
const adw_version = @import("../adw_version.zig");
const gresource = @import("../build/gresource.zig");
const Common = @import("../class.zig").Common;
const Config = @import("config.zig").Config;
@@ -353,6 +347,7 @@ pub const Tab = extern struct {
switch (mode) {
.this => tab_view.closePage(page),
.other => tab_view.closeOtherPages(page),
.right => tab_view.closePagesAfter(page),
}
}
@@ -389,8 +384,14 @@ pub const Tab = extern struct {
// the terminal title if it exists, otherwise a default string.
const plain = plain: {
const default = "Ghostty";
const config_title: ?[*:0]const u8 = title: {
const config = config_ orelse break :title null;
break :title config.get().title orelse null;
};
const plain = override_ orelse
terminal_ orelse
config_title orelse
break :plain default;
break :plain std.mem.span(plain);
};

View File

@@ -1,6 +1,6 @@
const std = @import("std");
const build_config = @import("../../../build_config.zig");
const assert = std.debug.assert;
const assert = @import("../../../quirks.zig").inlineAssert;
const adw = @import("adw");
const gdk = @import("gdk");
const gio = @import("gio");
@@ -28,7 +28,6 @@ const Surface = @import("surface.zig").Surface;
const Tab = @import("tab.zig").Tab;
const DebugWarning = @import("debug_warning.zig").DebugWarning;
const CommandPalette = @import("command_palette.zig").CommandPalette;
const InspectorWindow = @import("inspector_window.zig").InspectorWindow;
const WeakRef = @import("../weak_ref.zig").WeakRef;
const log = std.log.scoped(.gtk_ghostty_window);
@@ -794,7 +793,7 @@ pub const Window = extern struct {
/// Get the currently active surface. See the "active-surface" property.
/// This does not ref the value.
fn getActiveSurface(self: *Self) ?*Surface {
pub fn getActiveSurface(self: *Self) ?*Surface {
const tab = self.getSelectedTab() orelse return null;
return tab.getActiveSurface();
}
@@ -1015,6 +1014,15 @@ pub const Window = extern struct {
_: *gobject.ParamSpec,
self: *Self,
) callconv(.c) void {
// Hide quick-terminal if set to autohide
if (self.isQuickTerminal()) {
if (self.getConfig()) |cfg| {
if (cfg.get().@"quick-terminal-autohide" and self.as(gtk.Window).isActive() == 0) {
self.toggleVisibility();
}
}
}
// Don't change urgency if we're not the active window.
if (self.as(gtk.Window).isActive() == 0) return;
@@ -1585,6 +1593,9 @@ pub const Window = extern struct {
// Grab focus
surface.grabFocus();
// Bring the window to the front.
self.as(gtk.Window).present();
}
fn surfaceToggleFullscreen(
@@ -1789,7 +1800,7 @@ pub const Window = extern struct {
_: ?*glib.Variant,
self: *Window,
) callconv(.c) void {
self.performBindingAction(.copy_to_clipboard);
self.performBindingAction(.{ .copy_to_clipboard = .mixed });
}
fn actionPaste(

View File

@@ -34,6 +34,18 @@ label.url-overlay.right {
border-radius: 6px 0px 0px 0px;
}
/*
* GhosttySurface search overlay
*/
.search-overlay {
padding: 6px 8px;
margin: 8px;
border-radius: 8px;
outline-style: solid;
outline-color: #555555;
outline-width: 1px;
}
/*
* GhosttySurface resize overlay
*/

View File

@@ -4,10 +4,9 @@
//! helpers.
const std = @import("std");
const assert = std.debug.assert;
const assert = @import("../../quirks.zig").inlineAssert;
const testing = std.testing;
const gio = @import("gio");
const glib = @import("glib");
const gobject = @import("gobject");
const gtk = @import("gtk");

View File

@@ -1,6 +1,6 @@
const std = @import("std");
const assert = std.debug.assert;
const assert = @import("../../../quirks.zig").inlineAssert;
const testing = std.testing;
const gio = @import("gio");

View File

@@ -1,5 +1,4 @@
const std = @import("std");
const build_options = @import("build_options");
const gdk = @import("gdk");
const glib = @import("glib");

View File

@@ -0,0 +1,94 @@
using Gtk 4.0;
using Gdk 4.0;
using Adw 1;
template $GhosttySearchOverlay: Adw.Bin {
visible: bind template.active;
halign-target: end;
valign-target: start;
halign: bind template.halign-target;
valign: bind template.valign-target;
GestureDrag {
button: 1;
propagation-phase: capture;
drag-end => $on_drag_end();
}
Adw.Bin {
Box container {
styles [
"background",
"search-overlay",
]
orientation: horizontal;
spacing: 6;
SearchEntry search_entry {
placeholder-text: _("Find…");
width-chars: 20;
hexpand: true;
stop-search => $stop_search();
search-changed => $search_changed();
next-match => $next_match();
previous-match => $previous_match();
EventControllerKey {
// We need this so we capture before the SearchEntry.
propagation-phase: capture;
key-pressed => $search_entry_key_pressed();
}
}
Label {
styles [
"dim-label",
]
label: bind $match_label_closure(template.has-search-selected, template.search-selected, template.has-search-total, template.search-total) as <string>;
width-chars: 6;
xalign: 1.0;
}
Box button_box {
orientation: horizontal;
spacing: 1;
styles [
"linked",
]
Button prev_button {
icon-name: "go-up-symbolic";
tooltip-text: _("Previous Match");
clicked => $next_match();
cursor: Gdk.Cursor {
name: "pointer";
};
}
Button next_button {
icon-name: "go-down-symbolic";
tooltip-text: _("Next Match");
clicked => $previous_match();
cursor: Gdk.Cursor {
name: "pointer";
};
}
}
Button close_button {
icon-name: "window-close-symbolic";
tooltip-text: _("Close");
clicked => $stop_search();
cursor: Gdk.Cursor {
name: "pointer";
};
}
}
}
}

View File

@@ -41,6 +41,34 @@ Overlay terminal_page {
halign: start;
has-arrow: false;
}
EventControllerFocus {
enter => $focus_enter();
leave => $focus_leave();
}
EventControllerKey {
key-pressed => $key_pressed();
key-released => $key_released();
}
EventControllerScroll {
scroll => $scroll();
scroll-begin => $scroll_begin();
scroll-end => $scroll_end();
flags: both_axes;
}
EventControllerMotion {
motion => $mouse_motion();
leave => $mouse_leave();
}
GestureClick {
pressed => $mouse_down();
released => $mouse_up();
button: 0;
}
};
[overlay]
@@ -64,6 +92,10 @@ Overlay terminal_page {
reveal-child: bind $should_border_be_shown(template.config, template.bell-ringing) as <bool>;
transition-type: crossfade;
transition-duration: 500;
// Revealers take up the full size, we need this to not capture events.
can-focus: false;
can-target: false;
focusable: false;
Box bell_overlay {
styles [
@@ -115,12 +147,26 @@ Overlay terminal_page {
label: bind template.mouse-hover-url;
}
[overlay]
$GhosttySearchOverlay search_overlay {
stop-search => $search_stop();
search-changed => $search_changed();
next-match => $search_next_match();
previous-match => $search_previous_match();
}
[overlay]
// Apply unfocused-split-fill and unfocused-split-opacity to current surface
// this is only applied when a tab has more than one surface
Revealer {
reveal-child: bind $should_unfocused_split_be_shown(template.focused, template.is-split) as <bool>;
transition-duration: 0;
// This is all necessary so that the Revealer itself doesn't override
// any input events from the other overlays. Namely, if you don't have
// these then the search overlay won't get mouse events.
can-focus: false;
can-target: false;
focusable: false;
DrawingArea {
styles [
@@ -129,35 +175,6 @@ Overlay terminal_page {
}
}
// Event controllers for interactivity
EventControllerFocus {
enter => $focus_enter();
leave => $focus_leave();
}
EventControllerKey {
key-pressed => $key_pressed();
key-released => $key_released();
}
EventControllerMotion {
motion => $mouse_motion();
leave => $mouse_leave();
}
EventControllerScroll {
scroll => $scroll();
scroll-begin => $scroll_begin();
scroll-end => $scroll_end();
flags: both_axes;
}
GestureClick {
pressed => $mouse_down();
released => $mouse_up();
button: 0;
}
DropTarget drop_target {
drop => $drop();
actions: copy;
@@ -174,6 +191,7 @@ template $GhosttySurface: Adw.Bin {
notify::mouse-hover-url => $notify_mouse_hover_url();
notify::mouse-hidden => $notify_mouse_hidden();
notify::mouse-shape => $notify_mouse_shape();
notify::vadjustment => $notify_vadjustment();
// Some history: we used to use a Stack here and swap between the
// terminal and error pages as needed. But a Stack doesn't play nice
// with our SplitTree and Gtk.Paned usage[^1]. Replacing this with

View File

@@ -0,0 +1,11 @@
using Gtk 4.0;
using Adw 1;
template $GhostttySurfaceScrolledWindow: Adw.Bin {
notify::surface => $notify_surface();
Gtk.ScrolledWindow {
hscrollbar-policy: never;
vscrollbar-policy: bind $scrollbar_policy(template.config) as <Gtk.PolicyType>;
}
}

View File

@@ -1,7 +1,6 @@
//! Wayland protocol implementation for the Ghostty GTK apprt.
const std = @import("std");
const Allocator = std.mem.Allocator;
const build_options = @import("build_options");
const gdk = @import("gdk");
const gdk_wayland = @import("gdk_wayland");

View File

@@ -1,10 +1,8 @@
//! X11 window protocol implementation for the Ghostty GTK apprt.
const std = @import("std");
const builtin = @import("builtin");
const build_options = @import("build_options");
const Allocator = std.mem.Allocator;
const adw = @import("adw");
const gdk = @import("gdk");
const gdk_x11 = @import("gdk_x11");
const glib = @import("glib");
@@ -175,6 +173,12 @@ pub const Window = struct {
blur_region: Region = .{},
// Cache last applied values to avoid redundant X11 property updates.
// Redundant property updates seem to cause some visual glitches
// with some window managers: https://github.com/ghostty-org/ghostty/pull/8075
last_applied_blur_region: ?Region = null,
last_applied_decoration_hints: ?MotifWMHints = null,
pub fn init(
alloc: Allocator,
app: *App,
@@ -257,30 +261,42 @@ pub const Window = struct {
const gtk_widget = self.apprt_window.as(gtk.Widget);
const config = if (self.apprt_window.getConfig()) |v| v.get() else return;
// When blur is disabled, remove the property if it was previously set
const blur = config.@"background-blur";
if (!blur.enabled()) {
if (self.last_applied_blur_region != null) {
try self.deleteProperty(self.app.atoms.kde_blur);
self.last_applied_blur_region = null;
}
return;
}
// Transform surface coordinates to device coordinates.
const scale = gtk_widget.getScaleFactor();
self.blur_region.width = gtk_widget.getWidth() * scale;
self.blur_region.height = gtk_widget.getHeight() * scale;
const blur = config.@"background-blur";
// Only update X11 properties when the blur region actually changes
if (self.last_applied_blur_region) |last| {
if (std.meta.eql(self.blur_region, last)) return;
}
log.debug("set blur={}, window xid={}, region={}", .{
blur,
self.x11_surface.getXid(),
self.blur_region,
});
if (blur.enabled()) {
try self.changeProperty(
Region,
self.app.atoms.kde_blur,
c.XA_CARDINAL,
._32,
.{ .mode = .replace },
&self.blur_region,
);
} else {
try self.deleteProperty(self.app.atoms.kde_blur);
}
try self.changeProperty(
Region,
self.app.atoms.kde_blur,
c.XA_CARDINAL,
._32,
.{ .mode = .replace },
&self.blur_region,
);
self.last_applied_blur_region = self.blur_region;
}
fn syncDecorations(self: *Window) !void {
@@ -309,6 +325,11 @@ pub const Window = struct {
.auto, .client, .none => false,
};
// Only update decoration hints when they actually change
if (self.last_applied_decoration_hints) |last| {
if (std.meta.eql(hints, last)) return;
}
try self.changeProperty(
MotifWMHints,
self.app.atoms.motif_wm_hints,
@@ -317,6 +338,7 @@ pub const Window = struct {
.{ .mode = .replace },
&hints,
);
self.last_applied_decoration_hints = hints;
}
pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void {

View File

@@ -2,7 +2,7 @@
//! process.
const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const assert = @import("../quirks.zig").inlineAssert;
pub const Errors = error{
/// The IPC failed. If a function returns this error, it's expected that

View File

@@ -54,6 +54,11 @@ pub const Clipboard = enum(Backing) {
};
};
pub const ClipboardContent = struct {
mime: [:0]const u8,
data: [:0]const u8,
};
pub const ClipboardRequestType = enum(u8) {
paste,
osc_52_read,

View File

@@ -6,15 +6,15 @@ const build_config = @import("../build_config.zig");
const App = @import("../App.zig");
const Surface = @import("../Surface.zig");
const renderer = @import("../renderer.zig");
const termio = @import("../termio.zig");
const terminal = @import("../terminal/main.zig");
const Config = @import("../config.zig").Config;
const MessageData = @import("../datastruct/main.zig").MessageData;
/// The message types that can be sent to a single surface.
pub const Message = union(enum) {
/// Represents a write request. Magic number comes from the max size
/// we want this union to be.
pub const WriteReq = termio.MessageData(u8, 255);
pub const WriteReq = MessageData(u8, 255);
/// Set the title of the surface.
/// TODO: we should change this to a "WriteReq" style structure in
@@ -104,6 +104,15 @@ pub const Message = union(enum) {
/// of the command.
stop_command: ?u8,
/// The scrollbar state changed for the surface.
scrollbar: terminal.Scrollbar,
/// Search progress update
search_total: ?usize,
/// Selected search index change
search_selected: ?usize,
pub const ReportTitleStyle = enum {
csi_21_t,

View File

@@ -107,7 +107,7 @@ fn stepWcwidth(ptr: *anyopaque) Benchmark.Error!void {
const self: *CodepointWidth = @ptrCast(@alignCast(ptr));
const f = self.data_f orelse return;
var read_buf: [4096]u8 = undefined;
var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
var f_reader = f.reader(&read_buf);
var r = &f_reader.interface;
@@ -134,7 +134,7 @@ fn stepTable(ptr: *anyopaque) Benchmark.Error!void {
const self: *CodepointWidth = @ptrCast(@alignCast(ptr));
const f = self.data_f orelse return;
var read_buf: [4096]u8 = undefined;
var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
var f_reader = f.reader(&read_buf);
var r = &f_reader.interface;
@@ -166,7 +166,7 @@ fn stepSimd(ptr: *anyopaque) Benchmark.Error!void {
const self: *CodepointWidth = @ptrCast(@alignCast(ptr));
const f = self.data_f orelse return;
var read_buf: [4096]u8 = undefined;
var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
var f_reader = f.reader(&read_buf);
var r = &f_reader.interface;

View File

@@ -90,7 +90,7 @@ fn stepNoop(ptr: *anyopaque) Benchmark.Error!void {
const self: *GraphemeBreak = @ptrCast(@alignCast(ptr));
const f = self.data_f orelse return;
var read_buf: [4096]u8 = undefined;
var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
var f_reader = f.reader(&read_buf);
var r = &f_reader.interface;
@@ -113,7 +113,7 @@ fn stepTable(ptr: *anyopaque) Benchmark.Error!void {
const self: *GraphemeBreak = @ptrCast(@alignCast(ptr));
const f = self.data_f orelse return;
var read_buf: [4096]u8 = undefined;
var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
var f_reader = f.reader(&read_buf);
var r = &f_reader.interface;

View File

@@ -4,7 +4,6 @@
const IsSymbol = @This();
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const Benchmark = @import("Benchmark.zig");
@@ -90,13 +89,15 @@ fn stepUucode(ptr: *anyopaque) Benchmark.Error!void {
const self: *IsSymbol = @ptrCast(@alignCast(ptr));
const f = self.data_f orelse return;
var read_buf: [4096]u8 = undefined;
var r = f.reader(&read_buf);
var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
var f_reader = f.reader(&read_buf);
var r = &f_reader.interface;
var d: UTF8Decoder = .{};
var buf: [4096]u8 align(std.atomic.cache_line) = undefined;
while (true) {
const n = r.read(&buf) catch |err| {
log.warn("error reading data file err={}", .{err});
const n = r.readSliceShort(&buf) catch {
log.warn("error reading data file err={?}", .{f_reader.err});
return error.BenchmarkFailed;
};
if (n == 0) break; // EOF reached
@@ -115,13 +116,15 @@ fn stepTable(ptr: *anyopaque) Benchmark.Error!void {
const self: *IsSymbol = @ptrCast(@alignCast(ptr));
const f = self.data_f orelse return;
var read_buf: [4096]u8 = undefined;
var r = f.reader(&read_buf);
var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
var f_reader = f.reader(&read_buf);
var r = &f_reader.interface;
var d: UTF8Decoder = .{};
var buf: [4096]u8 align(std.atomic.cache_line) = undefined;
while (true) {
const n = r.read(&buf) catch |err| {
log.warn("error reading data file err={}", .{err});
const n = r.readSliceShort(&buf) catch {
log.warn("error reading data file err={?}", .{f_reader.err});
return error.BenchmarkFailed;
};
if (n == 0) break; // EOF reached

118
src/benchmark/OscParser.zig Normal file
View File

@@ -0,0 +1,118 @@
//! This benchmark tests the throughput of the OSC parser.
const OscParser = @This();
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const Benchmark = @import("Benchmark.zig");
const options = @import("options.zig");
const Parser = @import("../terminal/osc.zig").Parser;
const log = std.log.scoped(.@"osc-parser-bench");
opts: Options,
/// The file, opened in the setup function.
data_f: ?std.fs.File = null,
parser: Parser,
pub const Options = struct {
/// The data to read as a filepath. If this is "-" then
/// we will read stdin. If this is unset, then we will
/// do nothing (benchmark is a noop). It'd be more unixy to
/// use stdin by default but I find that a hanging CLI command
/// with no interaction is a bit annoying.
data: ?[]const u8 = null,
};
/// Create a new terminal stream handler for the given arguments.
pub fn create(
alloc: Allocator,
opts: Options,
) !*OscParser {
const ptr = try alloc.create(OscParser);
errdefer alloc.destroy(ptr);
ptr.* = .{
.opts = opts,
.data_f = null,
.parser = .init(alloc),
};
return ptr;
}
pub fn destroy(self: *OscParser, alloc: Allocator) void {
self.parser.deinit();
alloc.destroy(self);
}
pub fn benchmark(self: *OscParser) Benchmark {
return .init(self, .{
.stepFn = step,
.setupFn = setup,
.teardownFn = teardown,
});
}
fn setup(ptr: *anyopaque) Benchmark.Error!void {
const self: *OscParser = @ptrCast(@alignCast(ptr));
// Open our data file to prepare for reading. We can do more
// validation here eventually.
assert(self.data_f == null);
self.data_f = options.dataFile(self.opts.data) catch |err| {
log.warn("error opening data file err={}", .{err});
return error.BenchmarkFailed;
};
self.parser.reset();
}
fn teardown(ptr: *anyopaque) void {
const self: *OscParser = @ptrCast(@alignCast(ptr));
if (self.data_f) |f| {
f.close();
self.data_f = null;
}
}
fn step(ptr: *anyopaque) Benchmark.Error!void {
const self: *OscParser = @ptrCast(@alignCast(ptr));
const f = self.data_f orelse return;
var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
var r = f.reader(&read_buf);
var osc_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
while (true) {
r.interface.fill(@bitSizeOf(usize) / 8) catch |err| switch (err) {
error.EndOfStream => return,
error.ReadFailed => return error.BenchmarkFailed,
};
const len = r.interface.takeInt(usize, .little) catch |err| switch (err) {
error.EndOfStream => return,
error.ReadFailed => return error.BenchmarkFailed,
};
if (len > osc_buf.len) return error.BenchmarkFailed;
r.interface.readSliceAll(osc_buf[0..len]) catch |err| switch (err) {
error.EndOfStream => return,
error.ReadFailed => return error.BenchmarkFailed,
};
for (osc_buf[0..len]) |c| self.parser.next(c);
_ = self.parser.end(std.ascii.control_code.bel);
self.parser.reset();
}
}
test OscParser {
const testing = std.testing;
const alloc = testing.allocator;
const impl: *OscParser = try .create(alloc, .{});
defer impl.destroy(alloc);
const bench = impl.benchmark();
_ = try bench.run(.once);
}

View File

@@ -0,0 +1,196 @@
//! This benchmark tests the performance of the Screen.clone
//! function. This is useful because it is one of the primary lock
//! holders that impact IO performance when the renderer is active.
//! We do this very frequently.
const ScreenClone = @This();
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const terminalpkg = @import("../terminal/main.zig");
const Benchmark = @import("Benchmark.zig");
const options = @import("options.zig");
const Terminal = terminalpkg.Terminal;
const log = std.log.scoped(.@"terminal-stream-bench");
opts: Options,
terminal: Terminal,
pub const Options = struct {
/// The type of codepoint width calculation to use.
mode: Mode = .clone,
/// The size of the terminal. This affects benchmarking when
/// dealing with soft line wrapping and the memory impact
/// of page sizes.
@"terminal-rows": u16 = 80,
@"terminal-cols": u16 = 120,
/// The data to read as a filepath. If this is "-" then
/// we will read stdin. If this is unset, then we will
/// do nothing (benchmark is a noop). It'd be more unixy to
/// use stdin by default but I find that a hanging CLI command
/// with no interaction is a bit annoying.
///
/// This will be used to initialize the terminal screen state before
/// cloning. This data can switch to alt screen if it wants. The time
/// to read this is not part of the benchmark.
data: ?[]const u8 = null,
};
pub const Mode = enum {
/// The baseline mode copies the screen by value.
noop,
/// Full clone
clone,
/// RenderState rather than a screen clone.
render,
};
pub fn create(
alloc: Allocator,
opts: Options,
) !*ScreenClone {
const ptr = try alloc.create(ScreenClone);
errdefer alloc.destroy(ptr);
ptr.* = .{
.opts = opts,
.terminal = try .init(alloc, .{
.rows = opts.@"terminal-rows",
.cols = opts.@"terminal-cols",
}),
};
return ptr;
}
pub fn destroy(self: *ScreenClone, alloc: Allocator) void {
self.terminal.deinit(alloc);
alloc.destroy(self);
}
pub fn benchmark(self: *ScreenClone) Benchmark {
return .init(self, .{
.stepFn = switch (self.opts.mode) {
.noop => stepNoop,
.clone => stepClone,
.render => stepRender,
},
.setupFn = setup,
.teardownFn = teardown,
});
}
fn setup(ptr: *anyopaque) Benchmark.Error!void {
const self: *ScreenClone = @ptrCast(@alignCast(ptr));
// Always reset our terminal state
self.terminal.fullReset();
// Force a style on every single row, which
var s = self.terminal.vtStream();
defer s.deinit();
s.nextSlice("\x1b[48;2;20;40;60m") catch unreachable;
for (0..self.terminal.rows - 1) |_| s.nextSlice("hello\r\n") catch unreachable;
s.nextSlice("hello") catch unreachable;
// Setup our terminal state
const data_f: std.fs.File = (options.dataFile(
self.opts.data,
) catch |err| {
log.warn("error opening data file err={}", .{err});
return error.BenchmarkFailed;
}) orelse return;
var stream = self.terminal.vtStream();
defer stream.deinit();
var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
var f_reader = data_f.reader(&read_buf);
const r = &f_reader.interface;
var buf: [4096]u8 = undefined;
while (true) {
const n = r.readSliceShort(&buf) catch {
log.warn("error reading data file err={?}", .{f_reader.err});
return error.BenchmarkFailed;
};
if (n == 0) break; // EOF reached
stream.nextSlice(buf[0..n]) catch |err| {
log.warn("error processing data file chunk err={}", .{err});
return error.BenchmarkFailed;
};
}
}
fn teardown(ptr: *anyopaque) void {
const self: *ScreenClone = @ptrCast(@alignCast(ptr));
_ = self;
}
fn stepNoop(ptr: *anyopaque) Benchmark.Error!void {
const self: *ScreenClone = @ptrCast(@alignCast(ptr));
// We loop because its so fast that a single benchmark run doesn't
// properly capture our speeds.
for (0..1000) |_| {
const s: terminalpkg.Screen = self.terminal.screens.active.*;
std.mem.doNotOptimizeAway(s);
}
}
fn stepClone(ptr: *anyopaque) Benchmark.Error!void {
const self: *ScreenClone = @ptrCast(@alignCast(ptr));
// We loop because its so fast that a single benchmark run doesn't
// properly capture our speeds.
for (0..1000) |_| {
const s: *terminalpkg.Screen = self.terminal.screens.active;
const copy = s.clone(
s.alloc,
.{ .viewport = .{} },
null,
) catch |err| {
log.warn("error cloning screen err={}", .{err});
return error.BenchmarkFailed;
};
std.mem.doNotOptimizeAway(copy);
// Note: we purposely do not free memory because we don't want
// to benchmark that. We'll free when the benchmark exits.
}
}
fn stepRender(ptr: *anyopaque) Benchmark.Error!void {
const self: *ScreenClone = @ptrCast(@alignCast(ptr));
// We do this once out of the loop because a significant slowdown
// on the first run is allocation. After that first run, even with
// a full rebuild, it is much faster. Let's ignore that first run
// slowdown.
const alloc = self.terminal.screens.active.alloc;
var state: terminalpkg.RenderState = .empty;
state.update(alloc, &self.terminal) catch |err| {
log.warn("error cloning screen err={}", .{err});
return error.BenchmarkFailed;
};
// We loop because its so fast that a single benchmark run doesn't
// properly capture our speeds.
for (0..1000) |_| {
// Forces a full rebuild because it thinks our screen changed
state.screen = .alternate;
state.update(alloc, &self.terminal) catch |err| {
log.warn("error cloning screen err={}", .{err});
return error.BenchmarkFailed;
};
std.mem.doNotOptimizeAway(state);
// Note: we purposely do not free memory because we don't want
// to benchmark that. We'll free when the benchmark exits.
}
}

View File

@@ -75,7 +75,7 @@ fn step(ptr: *anyopaque) Benchmark.Error!void {
// the benchmark results and... I know writing this that we
// aren't currently IO bound.
const f = self.data_f orelse return;
var read_buf: [4096]u8 = undefined;
var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
var f_reader = f.reader(&read_buf);
var r = &f_reader.interface;

View File

@@ -114,7 +114,7 @@ fn step(ptr: *anyopaque) Benchmark.Error!void {
// aren't currently IO bound.
const f = self.data_f orelse return;
var read_buf: [4096]u8 = undefined;
var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
var f_reader = f.reader(&read_buf);
const r = &f_reader.interface;
@@ -138,8 +138,15 @@ fn step(ptr: *anyopaque) Benchmark.Error!void {
const Handler = struct {
t: *Terminal,
pub fn print(self: *Handler, cp: u21) !void {
try self.t.print(cp);
pub fn vt(
self: *Handler,
comptime action: Stream.Action.Tag,
value: Stream.Action.Value(action),
) !void {
switch (action) {
.print => try self.t.print(value.cp),
else => {},
}
}
};

View File

@@ -8,9 +8,11 @@ const cli = @import("../cli.zig");
pub const Action = enum {
@"codepoint-width",
@"grapheme-break",
@"screen-clone",
@"terminal-parser",
@"terminal-stream",
@"is-symbol",
@"osc-parser",
/// Returns the struct associated with the action. The struct
/// should have a few decls:
@@ -22,11 +24,13 @@ pub const Action = enum {
/// See TerminalStream for an example.
pub fn Struct(comptime action: Action) type {
return switch (action) {
.@"screen-clone" => @import("ScreenClone.zig"),
.@"terminal-stream" => @import("TerminalStream.zig"),
.@"codepoint-width" => @import("CodepointWidth.zig"),
.@"grapheme-break" => @import("GraphemeBreak.zig"),
.@"terminal-parser" => @import("TerminalParser.zig"),
.@"is-symbol" => @import("IsSymbol.zig"),
.@"osc-parser" => @import("OscParser.zig"),
};
}
};

View File

@@ -4,6 +4,7 @@ pub const CApi = @import("CApi.zig");
pub const TerminalStream = @import("TerminalStream.zig");
pub const CodepointWidth = @import("CodepointWidth.zig");
pub const GraphemeBreak = @import("GraphemeBreak.zig");
pub const ScreenClone = @import("ScreenClone.zig");
pub const TerminalParser = @import("TerminalParser.zig");
pub const IsSymbol = @import("IsSymbol.zig");

View File

@@ -16,13 +16,6 @@ const expandPath = @import("../os/path.zig").expand;
const gtk = @import("gtk.zig");
const GitVersion = @import("GitVersion.zig");
/// The version of the next release.
///
/// TODO: When Zig 0.14 is released, derive this from build.zig.zon directly.
/// Until then this MUST match build.zig.zon and should always be the
/// _next_ version to release.
const app_version: std.SemanticVersion = .{ .major = 1, .minor = 2, .patch = 1 };
/// Standard build configuration options.
optimize: std.builtin.OptimizeMode,
target: std.Build.ResolvedTarget,
@@ -62,6 +55,7 @@ emit_macos_app: bool = false,
emit_terminfo: bool = false,
emit_termcap: bool = false,
emit_test_exe: bool = false,
emit_themes: bool = false,
emit_xcframework: bool = false,
emit_webdata: bool = false,
emit_unicode_table_gen: bool = false,
@@ -69,7 +63,7 @@ emit_unicode_table_gen: bool = false,
/// Environmental properties
env: std.process.EnvMap,
pub fn init(b: *std.Build) !Config {
pub fn init(b: *std.Build, appVersion: []const u8) !Config {
// Setup our standard Zig target and optimize options, i.e.
// `-Doptimize` and `-Dtarget`.
const optimize = b.standardOptimizeOption(.{});
@@ -179,7 +173,13 @@ pub fn init(b: *std.Build) !Config {
bool,
"simd",
"Build with SIMD-accelerated code paths. Results in significant performance improvements.",
) orelse true;
) orelse simd: {
// We can't build our SIMD dependencies for Wasm. Note that we may
// still use SIMD features in the Wasm-builds.
if (target.result.cpu.arch.isWasm()) break :simd false;
break :simd true;
};
config.wayland = b.option(
bool,
@@ -217,6 +217,17 @@ pub fn init(b: *std.Build) !Config {
// If an explicit version is given, we always use it.
try std.SemanticVersion.parse(v)
else version: {
const app_version = try std.SemanticVersion.parse(appVersion);
// Is ghostty a dependency? If so, skip git detection.
// @src().file won't resolve from b.build_root unless ghostty
// is the project being built.
b.build_root.handle.access(@src().file, .{}) catch break :version .{
.major = app_version.major,
.minor = app_version.minor,
.patch = app_version.patch,
};
// If no explicit version is given, we try to detect it from git.
const vsn = GitVersion.detect(b) catch |err| switch (err) {
// If Git isn't available we just make an unknown dev version.
@@ -374,6 +385,12 @@ pub fn init(b: *std.Build) !Config {
.ReleaseSafe, .ReleaseFast, .ReleaseSmall => false,
};
config.emit_themes = b.option(
bool,
"emit-themes",
"Install bundled iTerm2-Color-Schemes Ghostty themes",
) orelse true;
config.emit_webdata = b.option(
bool,
"emit-webdata",

View File

@@ -2,7 +2,6 @@
const GhosttyBench = @This();
const std = @import("std");
const Config = @import("Config.zig");
const SharedDeps = @import("SharedDeps.zig");
steps: []*std.Build.Step.Compile,

View File

@@ -170,11 +170,11 @@ pub const Resource = struct {
/// Returns true if the dist path exists at build time.
pub fn exists(self: *const Resource, b: *std.Build) bool {
if (std.fs.accessAbsolute(b.pathFromRoot(self.dist), .{})) {
if (b.build_root.handle.access(self.dist, .{})) {
// If we have a ".git" directory then we're a git checkout
// and we never want to use the dist path. This shouldn't happen
// so show a warning to the user.
if (std.fs.accessAbsolute(b.pathFromRoot(".git"), .{})) {
if (b.build_root.handle.access(".git", .{})) {
std.log.warn(
"dist resource '{s}' should not be in a git checkout",
.{self.dist},

View File

@@ -3,8 +3,6 @@
const GhosttyFrameData = @This();
const std = @import("std");
const Config = @import("Config.zig");
const SharedDeps = @import("SharedDeps.zig");
const DistResource = @import("GhosttyDist.zig").Resource;
/// The output path for the compressed framedata zig file
@@ -43,7 +41,6 @@ pub fn distResources(b: *std.Build) struct {
.root_module = b.createModule(.{
.target = b.graph.host,
}),
.use_llvm = true,
});
exe.addCSourceFile(.{
.file = b.path("src/build/framegen/main.c"),

View File

@@ -1,12 +1,9 @@
const GhosttyLibVt = @This();
const std = @import("std");
const assert = std.debug.assert;
const RunStep = std.Build.Step.Run;
const Config = @import("Config.zig");
const GhosttyZig = @import("GhosttyZig.zig");
const SharedDeps = @import("SharedDeps.zig");
const LibtoolStep = @import("LibtoolStep.zig");
const LipoStep = @import("LipoStep.zig");
/// The step that generates the file.
step: *std.Build.Step,
@@ -17,7 +14,35 @@ artifact: *std.Build.Step.InstallArtifact,
/// The final library file
output: std.Build.LazyPath,
dsym: ?std.Build.LazyPath,
pkg_config: std.Build.LazyPath,
pkg_config: ?std.Build.LazyPath,
pub fn initWasm(
b: *std.Build,
zig: *const GhosttyZig,
) !GhosttyLibVt {
const target = zig.vt.resolved_target.?;
assert(target.result.cpu.arch.isWasm());
const exe = b.addExecutable(.{
.name = "ghostty-vt",
.root_module = zig.vt_c,
.version = std.SemanticVersion{ .major = 0, .minor = 1, .patch = 0 },
});
// Allow exported symbols to actually be exported.
exe.rdynamic = true;
// There is no entrypoint for this wasm module.
exe.entry = .disabled;
return .{
.step = &exe.step,
.artifact = b.addInstallArtifact(exe, .{}),
.output = exe.getEmittedBin(),
.dsym = null,
.pkg_config = null,
};
}
pub fn initShared(
b: *std.Build,
@@ -30,9 +55,10 @@ pub fn initShared(
.root_module = zig.vt_c,
.version = std.SemanticVersion{ .major = 0, .minor = 1, .patch = 0 },
});
lib.installHeader(
b.path("include/ghostty/vt.h"),
"ghostty/vt.h",
lib.installHeadersDirectory(
b.path("include/ghostty"),
"ghostty",
.{ .include_extensions = &.{".h"} },
);
// Get our debug symbols
@@ -81,9 +107,11 @@ pub fn install(
) void {
const b = step.owner;
step.dependOn(&self.artifact.step);
step.dependOn(&b.addInstallFileWithDir(
self.pkg_config,
.prefix,
"share/pkgconfig/libghostty-vt.pc",
).step);
if (self.pkg_config) |pkg_config| {
step.dependOn(&b.addInstallFileWithDir(
pkg_config,
.prefix,
"share/pkgconfig/libghostty-vt.pc",
).step);
}
}

View File

@@ -1,15 +1,14 @@
const GhosttyResources = @This();
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const buildpkg = @import("main.zig");
const Config = @import("Config.zig");
const RunStep = std.Build.Step.Run;
const SharedDeps = @import("SharedDeps.zig");
steps: []*std.Build.Step,
pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources {
pub fn init(b: *std.Build, cfg: *const Config, deps: *const SharedDeps) !GhosttyResources {
var steps: std.ArrayList(*std.Build.Step) = .empty;
errdefer steps.deinit(b.allocator);
@@ -26,6 +25,8 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources {
});
build_data_exe.linkLibC();
deps.help_strings.addImport(build_data_exe);
// Terminfo
terminfo: {
const os_tag = cfg.target.result.os.tag;
@@ -125,14 +126,16 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources {
}
// Themes
if (b.lazyDependency("iterm2_themes", .{})) |upstream| {
const install_step = b.addInstallDirectory(.{
.source_dir = upstream.path(""),
.install_dir = .{ .custom = "share" },
.install_subdir = b.pathJoin(&.{ "ghostty", "themes" }),
.exclude_extensions = &.{".md"},
});
try steps.append(b.allocator, &install_step.step);
if (cfg.emit_themes) {
if (b.lazyDependency("iterm2_themes", .{})) |upstream| {
const install_step = b.addInstallDirectory(.{
.source_dir = upstream.path(""),
.install_dir = .{ .custom = "share" },
.install_subdir = b.pathJoin(&.{ "ghostty", "themes" }),
.exclude_extensions = &.{".md"},
});
try steps.append(b.allocator, &install_step.step);
}
}
// Fish shell completions
@@ -225,7 +228,7 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources {
// 'ghostty.sublime-syntax' file from zig-out to the '~.config/bat/syntaxes'
// directory. The syntax then needs to be mapped to the correct language in
// the config file within the '~.config/bat' directory
// (ex: --map-syntax "/Users/user/.config/ghostty/config:Ghostty Config").
// (ex: --map-syntax "/Users/user/.config/ghostty/config.ghostty:Ghostty Config").
{
const run = b.addRunArtifact(build_data_exe);
run.addArg("+sublime");

View File

@@ -3,7 +3,6 @@
const GhosttyWebdata = @This();
const std = @import("std");
const Config = @import("Config.zig");
const SharedDeps = @import("SharedDeps.zig");
steps: []*std.Build.Step,

View File

@@ -31,7 +31,7 @@ pub fn init(
.ReleaseSafe,
.ReleaseSmall,
.ReleaseFast,
=> "Release",
=> "ReleaseLocal",
};
const xc_arch: ?[]const u8 = switch (deps.xcframework.target) {
@@ -151,7 +151,7 @@ pub fn init(
// This overrides our default behavior and forces logs to show
// up on stderr (in addition to the centralized macOS log).
open.setEnvironmentVariable("GHOSTTY_LOG", "1");
open.setEnvironmentVariable("GHOSTTY_LOG", "stderr,macos");
// Configure how we're launching
open.setEnvironmentVariable("GHOSTTY_MAC_LAUNCH_SOURCE", "zig_run");

View File

@@ -719,15 +719,19 @@ pub fn addSimd(
}
// Highway
if (b.lazyDependency("highway", .{
.target = target,
.optimize = optimize,
})) |highway_dep| {
m.linkLibrary(highway_dep.artifact("highway"));
if (static_libs) |v| try v.append(
b.allocator,
highway_dep.artifact("highway").getEmittedBin(),
);
if (b.systemIntegrationOption("highway", .{ .default = false })) {
m.linkSystemLibrary("libhwy", dynamic_link_opts);
} else {
if (b.lazyDependency("highway", .{
.target = target,
.optimize = optimize,
})) |highway_dep| {
m.linkLibrary(highway_dep.artifact("highway"));
if (static_libs) |v| try v.append(
b.allocator,
highway_dep.artifact("highway").getEmittedBin(),
);
}
}
// utfcpp - This is used as a dependency on our hand-written C++ code
@@ -746,6 +750,7 @@ pub fn addSimd(
m.addIncludePath(b.path("src"));
{
// From hwy/detect_targets.h
const HWY_AVX10_2: c_int = 1 << 3;
const HWY_AVX3_SPR: c_int = 1 << 4;
const HWY_AVX3_ZEN4: c_int = 1 << 6;
const HWY_AVX3_DL: c_int = 1 << 7;
@@ -756,7 +761,7 @@ pub fn addSimd(
// The performance difference between AVX2 and AVX512 is not
// significant for our use case and AVX512 is very rare on consumer
// hardware anyways.
const HWY_DISABLED_TARGETS: c_int = HWY_AVX3_SPR | HWY_AVX3_ZEN4 | HWY_AVX3_DL | HWY_AVX3;
const HWY_DISABLED_TARGETS: c_int = HWY_AVX10_2 | HWY_AVX3_SPR | HWY_AVX3_ZEN4 | HWY_AVX3_DL | HWY_AVX3;
m.addCSourceFiles(.{
.files = &.{

View File

@@ -1,7 +1,6 @@
const UnicodeTables = @This();
const std = @import("std");
const Config = @import("Config.zig");
/// The exe.
props_exe: *std.Build.Step.Compile,

View File

@@ -24,12 +24,12 @@ RUN DEBIAN_FRONTEND="noninteractive" apt-get -qq update && \
WORKDIR /src
COPY ./build.zig /src
COPY ./build.zig ./build.zig.zon /src/
# Install zig
# https://ziglang.org/download/
RUN export ZIG_VERSION=$(sed -n -e 's/^.*requireZig("\(.*\)").*$/\1/p' build.zig) && curl -L -o /tmp/zig.tar.xz "https://ziglang.org/download/$ZIG_VERSION/zig-$(uname -m)-linux-$ZIG_VERSION.tar.xz" && \
RUN export ZIG_VERSION=$(sed -n -E 's/^\s*\.?minimum_zig_version\s*=\s*"([^"]+)".*/\1/p' build.zig.zon) && curl -L -o /tmp/zig.tar.xz "https://ziglang.org/download/$ZIG_VERSION/zig-$(uname -m)-linux-$ZIG_VERSION.tar.xz" && \
tar -xf /tmp/zig.tar.xz -C /opt && \
rm /tmp/zig.tar.xz && \
ln -s "/opt/zig-$(uname -m)-linux-$ZIG_VERSION/zig" /usr/local/bin/zig
@@ -41,4 +41,3 @@ RUN zig build \
-Dcpu=baseline
RUN ./zig-out/bin/ghostty +version

View File

@@ -1,17 +1,22 @@
#--------------------------------------------------------------------
# Generate documentation with Doxygen
#--------------------------------------------------------------------
FROM ubuntu:24.04 AS builder
FROM --platform=linux/amd64 archlinux:latest AS builder
# Build argument for noindex header
ARG ADD_NOINDEX_HEADER=false
RUN apt-get update && apt-get install -y \
RUN pacman -Syu --noconfirm && \
pacman -S --noconfirm \
doxygen \
graphviz \
&& rm -rf /var/lib/apt/lists/*
graphviz && \
pacman -Scc --noconfirm
WORKDIR /ghostty
COPY include/ ./include/
COPY images/ ./images/
COPY dist/doxygen/ ./dist/doxygen/
COPY example/ ./example/
COPY Doxyfile ./
COPY DoxygenLayout.xml ./
RUN mkdir -p zig-out/share/ghostty/doc/libghostty
RUN doxygen

View File

@@ -6,11 +6,27 @@ server {
location / {
root /usr/share/nginx/html;
index index.html;
etag on;
add_header Cache-Control "no-cache" always;
add_header X-Robots-Tag "noindex, nofollow" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;" always;
}
}
EOF
# Remove default server config
rm -f /etc/nginx/conf.d/default.conf
else
cat > /etc/nginx/conf.d/default.conf << 'EOF'
server {
listen 80;
location / {
root /usr/share/nginx/html;
index index.html;
etag on;
add_header Cache-Control "no-cache" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;" always;
}
}
EOF
fi
exec nginx -g "daemon off;"

View File

@@ -1,15 +1,15 @@
# FILES
_\$XDG_CONFIG_HOME/ghostty/config_
_\$XDG_CONFIG_HOME/ghostty/config.ghostty_
: Location of the default configuration file.
_\$HOME/Library/Application Support/com.mitchellh.ghostty/config_
_\$HOME/Library/Application Support/com.mitchellh.ghostty/config.ghostty_
: **On macOS**, location of the default configuration file. This location takes
precedence over the XDG environment locations.
_\$LOCALAPPDATA/ghostty/config_
_\$LOCALAPPDATA/ghostty/config.ghostty_
: **On Windows**, if _\$XDG_CONFIG_HOME_ is not set, _\$LOCALAPPDATA_ will be searched
for configuration files.
@@ -37,6 +37,19 @@ precedence over the XDG environment locations.
: **WINDOWS ONLY:** alternate location to search for configuration files.
**GHOSTTY_LOG**
: The `GHOSTTY_LOG` environment variable can be used to control which
destinations receive logs. Ghostty currently defines two destinations:
: - `stderr` - logging to `stderr`.
: - `macos` - logging to macOS's unified log (has no effect on non-macOS platforms).
: Combine values with a comma to enable multiple destinations. Prefix a
destination with `no-` to disable it. Enabling and disabling destinations
can be done at the same time. Setting `GHOSTTY_LOG` to `true` will enable all
destinations. Setting `GHOSTTY_LOG` to `false` will disable all destinations.
# BUGS
See GitHub issues: <https://github.com/ghostty-org/ghostty/issues>

View File

@@ -1,15 +1,15 @@
# FILES
_\$XDG_CONFIG_HOME/ghostty/config_
_\$XDG_CONFIG_HOME/ghostty/config.ghostty_
: Location of the default configuration file.
_\$HOME/Library/Application Support/com.mitchellh.ghostty/config_
_\$HOME/Library/Application Support/com.mitchellh.ghostty/config.ghostty_
: **On macOS**, location of the default configuration file. This location takes
precedence over the XDG environment locations.
_\$LOCALAPPDATA/ghostty/config_
_\$LOCALAPPDATA/ghostty/config.ghostty_
: **On Windows**, if _\$XDG_CONFIG_HOME_ is not set, _\$LOCALAPPDATA_ will be searched
for configuration files.

View File

@@ -8,11 +8,11 @@
To configure Ghostty, you must use a configuration file. GUI-based configuration
is on the roadmap but not yet supported. The configuration file must be placed
at `$XDG_CONFIG_HOME/ghostty/config`, which defaults to `~/.config/ghostty/config`
at `$XDG_CONFIG_HOME/ghostty/config.ghostty`, which defaults to `~/.config/ghostty/config.ghostty`
if the [XDG environment is not set](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html).
**If you are using macOS, the configuration file can also be placed at
`$HOME/Library/Application Support/com.mitchellh.ghostty/config`.** This is the
`$HOME/Library/Application Support/com.mitchellh.ghostty/config.ghostty`.** This is the
default configuration location for macOS. It will be searched before any of the
XDG environment locations listed above.
@@ -73,16 +73,12 @@ the public config files of many Ghostty users for examples and inspiration.
## Configuration Errors
If your configuration file has any errors, Ghostty does its best to ignore
them and move on. Configuration errors currently show up in the log. The log
is written directly to stderr, so it is up to you to figure out how to access
that for your system (for now). On macOS, you can also use the system `log` CLI
utility with `log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"'`.
them and move on. Configuration errors will be logged.
## Debugging Configuration
You can verify that configuration is being properly loaded by looking at the
debug output of Ghostty. Documentation for how to view the debug output is in
the "building Ghostty" section at the end of the README.
debug output of Ghostty.
In the debug output, you should see in the first 20 lines or so messages about
loading (or not loading) a configuration file, as well as any errors it may have
@@ -93,3 +89,34 @@ will fall back to default values for erroneous keys.
You can also view the full configuration Ghostty is loading using `ghostty
+show-config` from the command-line. Use the `--help` flag to additional options
for that command.
## Logging
Ghostty can write logs to a number of destinations. On all platforms, logging to
`stderr` is available. Depending on the platform and how Ghostty was launched,
logs sent to `stderr` may be stored by the system and made available for later
retrieval.
On Linux if Ghostty is launched by the default `systemd` user service, you can use
`journald` to see Ghostty's logs: `journalctl --user --unit app-com.mitchellh.ghostty.service`.
On macOS logging to the macOS unified log is available and enabled by default.
--Use the system `log` CLI to view Ghostty's logs: `sudo log stream level debug
--predicate 'subsystem=="com.mitchellh.ghostty"'`.
Ghostty's logging can be configured in two ways. The first is by what
optimization level Ghostty is compiled with. If Ghostty is compiled with `Debug`
optimizations debug logs will be output to `stderr`. If Ghostty is compiled with
any other optimization the debug logs will not be output to `stderr`.
Ghostty also checks the `GHOSTTY_LOG` environment variable. It can be used
to control which destinations receive logs. Ghostty currently defines two
destinations:
- `stderr` - logging to `stderr`.
- `macos` - logging to macOS's unified log (has no effect on non-macOS platforms).
Combine values with a comma to enable multiple destinations. Prefix a
destination with `no-` to disable it. Enabling and disabling destinations
can be done at the same time. Setting `GHOSTTY_LOG` to `true` will enable all
destinations. Setting `GHOSTTY_LOG` to `false` will disable all destinations.

View File

@@ -1,4 +1,5 @@
const std = @import("std");
const assert = std.debug.assert;
const config = @import("config.zig");
const config_x = @import("config.x.zig");
const d = config.default;
@@ -17,11 +18,25 @@ fn computeWidth(
_ = cp;
_ = backing;
_ = tracking;
data.width = @intCast(@min(2, @max(0, data.wcwidth)));
// This condition is to get the previous behavior of uucode's `wcwidth`,
// returning the width of a code point in a grapheme cluster but with the
// exception to treat emoji modifiers as width 2 so they can be displayed
// in isolation. PRs to follow will take advantage of the new uucode
// `wcwidth_standalone` vs `wcwidth_zero_in_grapheme` split.
if (data.wcwidth_zero_in_grapheme and !data.is_emoji_modifier) {
data.width = 0;
} else {
data.width = @min(2, data.wcwidth_standalone);
}
}
const width = config.Extension{
.inputs = &.{"wcwidth"},
.inputs = &.{
"wcwidth_standalone",
"wcwidth_zero_in_grapheme",
"is_emoji_modifier",
},
.compute = &computeWidth,
.fields = &.{
.{ .name = "width", .type = u2 },
@@ -41,6 +56,7 @@ fn computeIsSymbol(
_ = tracking;
const block = data.block;
data.is_symbol = data.general_category == .other_private_use or
block == .arrows or
block == .dingbats or
block == .emoticons or
block == .miscellaneous_symbols or
@@ -74,8 +90,7 @@ pub const tables = [_]config.Table{
width.field("width"),
d.field("grapheme_break"),
is_symbol.field("is_symbol"),
d.field("is_emoji_modifier"),
d.field("is_emoji_modifier_base"),
d.field("is_emoji_vs_base"),
},
},
};

View File

@@ -1,5 +1,4 @@
const std = @import("std");
const help_strings = @import("help_strings");
const helpgen_actions = @import("../../input/helpgen_actions.zig");
pub fn main() !void {

View File

@@ -7,10 +7,11 @@ pub fn requireZig(comptime required_zig: []const u8) void {
const current_vsn = builtin.zig_version;
const required_vsn = std.SemanticVersion.parse(required_zig) catch unreachable;
if (current_vsn.major != required_vsn.major or
current_vsn.minor != required_vsn.minor)
current_vsn.minor != required_vsn.minor or
current_vsn.patch < required_vsn.patch)
{
@compileError(std.fmt.comptimePrint(
"Your Zig version v{} does not meet the required build version of v{}",
"Your Zig version v{f} does not meet the required build version of v{f}",
.{ current_vsn, required_vsn },
));
}

View File

@@ -9,7 +9,6 @@ const assert = std.debug.assert;
const apprt = @import("apprt.zig");
const font = @import("font/main.zig");
const rendererpkg = @import("renderer.zig");
const WasmTarget = @import("os/wasm/target.zig").Target;
const BuildConfig = @import("build/Config.zig");
pub const ReleaseChannel = BuildConfig.ReleaseChannel;

View File

@@ -1,6 +1,6 @@
const std = @import("std");
const mem = std.mem;
const assert = std.debug.assert;
const assert = @import("../quirks.zig").inlineAssert;
const Allocator = mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const diags = @import("diagnostics.zig");
@@ -604,7 +604,7 @@ pub fn parseAutoStruct(
return result;
}
fn parsePackedStruct(comptime T: type, v: []const u8) !T {
pub fn parsePackedStruct(comptime T: type, v: []const u8) !T {
const info = @typeInfo(T).@"struct";
comptime assert(info.layout == .@"packed");
@@ -1427,7 +1427,12 @@ pub const LineIterator = struct {
//
// This will also optimize reads down the line as we're
// more likely to beworking with buffered data.
self.r.fillMore() catch {};
//
// fillMore asserts that the buffer has available capacity,
// so skip this if it's full.
if (self.r.bufferedLen() < self.r.buffer.len) {
self.r.fillMore() catch {};
}
var writer: std.Io.Writer = .fixed(self.entry[2..]);
@@ -1590,3 +1595,33 @@ test "LineIterator with CRLF line endings" {
try testing.expectEqual(@as(?[]const u8, null), iter.next());
try testing.expectEqual(@as(?[]const u8, null), iter.next());
}
test "LineIterator with buffered reader" {
const testing = std.testing;
var f: std.Io.Reader = .fixed("A\nB = C\n");
var buf: [2]u8 = undefined;
var r = f.limited(.unlimited, &buf);
const reader = &r.interface;
var iter: LineIterator = .init(reader);
try testing.expectEqualStrings("--A", iter.next().?);
try testing.expectEqualStrings("--B=C", iter.next().?);
try testing.expectEqual(@as(?[]const u8, null), iter.next());
try testing.expectEqual(@as(?[]const u8, null), iter.next());
}
test "LineIterator with buffered and primed reader" {
const testing = std.testing;
var f: std.Io.Reader = .fixed("A\nB = C\n");
var buf: [2]u8 = undefined;
var r = f.limited(.unlimited, &buf);
const reader = &r.interface;
try reader.fill(buf.len);
var iter: LineIterator = .init(reader);
try testing.expectEqualStrings("--A", iter.next().?);
try testing.expectEqualStrings("--B=C", iter.next().?);
try testing.expectEqual(@as(?[]const u8, null), iter.next());
try testing.expectEqual(@as(?[]const u8, null), iter.next());
}

View File

@@ -3,10 +3,9 @@ const builtin = @import("builtin");
const args = @import("args.zig");
const Action = @import("ghostty.zig").Action;
const Allocator = std.mem.Allocator;
const help_strings = @import("help_strings");
const vaxis = @import("vaxis");
const framedata = @embedFile("framedata");
const framedata = @import("framedata").compressed;
const vxfw = vaxis.vxfw;

View File

@@ -1,6 +1,6 @@
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const assert = @import("../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const build_config = @import("../build_config.zig");

View File

@@ -1,6 +1,6 @@
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const assert = @import("../quirks.zig").inlineAssert;
const args = @import("args.zig");
const Allocator = std.mem.Allocator;
const Action = @import("ghostty.zig").Action;
@@ -30,9 +30,9 @@ pub const Options = struct {
/// this yet.
///
/// The filepath opened is the default user-specific configuration
/// file, which is typically located at `$XDG_CONFIG_HOME/ghostty/config`.
/// file, which is typically located at `$XDG_CONFIG_HOME/ghostty/config.ghostty`.
/// On macOS, this may also be located at
/// `~/Library/Application Support/com.mitchellh.ghostty/config`.
/// `~/Library/Application Support/com.mitchellh.ghostty/config.ghostty`.
/// On macOS, whichever path exists and is non-empty will be prioritized,
/// prioritizing the Application Support directory if neither are
/// non-empty.
@@ -73,7 +73,7 @@ fn runInner(alloc: Allocator, stderr: *std.Io.Writer) !u8 {
defer config.deinit();
// Find the preferred path.
const path = try Config.preferredDefaultFilePath(alloc);
const path = try configpkg.preferredDefaultFilePath(alloc);
defer alloc.free(path);
// We don't currently support Windows because we use the exec syscall.

View File

@@ -1,11 +1,9 @@
const std = @import("std");
const inputpkg = @import("../input.zig");
const args = @import("args.zig");
const Action = @import("ghostty.zig").Action;
const Config = @import("../config/Config.zig");
const themepkg = @import("../config/theme.zig");
const tui = @import("tui.zig");
const internal_os = @import("../os/main.zig");
const global_state = &@import("../global.zig").state;
const vaxis = @import("vaxis");
@@ -180,7 +178,13 @@ pub fn run(gpa_alloc: std.mem.Allocator) !u8 {
return 0;
}
var theme_config = try Config.default(gpa_alloc);
defer theme_config.deinit();
for (themes.items) |theme| {
try theme_config.loadFile(theme_config._arena.?.allocator(), theme.path);
if (!shouldIncludeTheme(opts.color, theme_config)) {
continue;
}
if (opts.path)
try stdout.print("{s} ({t}) {s}\n", .{ theme.theme, theme.location, theme.path })
else
@@ -266,7 +270,7 @@ const Preview = struct {
.hex = false,
.mode = .normal,
.color_scheme = .light,
.text_input = .init(allocator, &self.vx.unicode),
.text_input = .init(allocator),
.theme_filter = theme_filter,
};

View File

@@ -5,10 +5,11 @@ const DiskCache = @This();
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const assert = @import("../../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const xdg = @import("../../os/main.zig").xdg;
const TempDir = @import("../../os/main.zig").TempDir;
const internal_os = @import("../../os/main.zig");
const xdg = internal_os.xdg;
const TempDir = internal_os.TempDir;
const Entry = @import("Entry.zig");
// 512KB - sufficient for approximately 10k entries
@@ -69,7 +70,7 @@ pub fn add(
// Create cache directory if needed
if (std.fs.path.dirname(self.path)) |dir| {
std.fs.makeDirAbsolute(dir) catch |err| switch (err) {
std.fs.cwd().makePath(dir) catch |err| switch (err) {
error.PathAlreadyExists => {},
else => return err,
};
@@ -180,13 +181,12 @@ pub fn contains(
// Open our file
const file = std.fs.openFileAbsolute(
self.path,
.{ .mode = .read_write },
.{},
) catch |err| switch (err) {
error.FileNotFound => return false,
else => return err,
};
defer file.close();
try fixupPermissions(file);
// Read existing entries
var entries = try readEntries(alloc, file);
@@ -327,55 +327,34 @@ fn readEntries(
// Supports both standalone hostnames and user@hostname format
fn isValidCacheKey(key: []const u8) bool {
// 253 + 1 + 64 for user@hostname
if (key.len == 0 or key.len > 320) return false;
if (key.len == 0) return false;
// Check for user@hostname format
if (std.mem.indexOf(u8, key, "@")) |at_pos| {
if (std.mem.indexOfScalar(u8, key, '@')) |at_pos| {
const user = key[0..at_pos];
const hostname = key[at_pos + 1 ..];
return isValidUser(user) and isValidHostname(hostname);
return isValidUser(user) and isValidHost(hostname);
}
return isValidHostname(key);
return isValidHost(key);
}
// Basic hostname validation - accepts domains and IPs
// (including IPv6 in brackets)
fn isValidHostname(host: []const u8) bool {
if (host.len == 0 or host.len > 253) return false;
// Handle IPv6 addresses in brackets
if (host.len >= 4 and host[0] == '[' and host[host.len - 1] == ']') {
const ipv6_part = host[1 .. host.len - 1];
if (ipv6_part.len == 0) return false;
var has_colon = false;
for (ipv6_part) |c| {
switch (c) {
'a'...'f', 'A'...'F', '0'...'9' => {},
':' => has_colon = true,
else => return false,
}
}
return has_colon;
// Checks if a host is a valid hostname or IP address
fn isValidHost(host: []const u8) bool {
// First check for valid hostnames because this is assumed to be the more
// likely ssh host format.
if (internal_os.hostname.isValid(host)) {
return true;
}
// Standard hostname/domain validation
for (host) |c| {
switch (c) {
'a'...'z', 'A'...'Z', '0'...'9', '.', '-' => {},
else => return false,
}
}
// No leading/trailing dots or hyphens, no consecutive dots
if (host[0] == '.' or host[0] == '-' or
host[host.len - 1] == '.' or host[host.len - 1] == '-')
{
// We also accept valid IP addresses. In practice, IPv4 addresses are also
// considered valid hostnames due to their overlapping syntax, so we can
// simplify this check to be IPv6-specific.
if (std.net.Address.parseIp6(host, 0)) |_| {
return true;
} else |_| {
return false;
}
return std.mem.indexOf(u8, host, "..") == null;
}
fn isValidUser(user: []const u8) bool {
@@ -474,98 +453,73 @@ test "disk cache operations" {
);
}
// Tests
test "hostname validation - valid cases" {
test isValidHost {
const testing = std.testing;
try testing.expect(isValidHostname("example.com"));
try testing.expect(isValidHostname("sub.example.com"));
try testing.expect(isValidHostname("host-name.domain.org"));
try testing.expect(isValidHostname("192.168.1.1"));
try testing.expect(isValidHostname("a"));
try testing.expect(isValidHostname("1"));
// Valid hostnames
try testing.expect(isValidHost("localhost"));
try testing.expect(isValidHost("example.com"));
try testing.expect(isValidHost("sub.example.com"));
// IPv4 addresses
try testing.expect(isValidHost("127.0.0.1"));
try testing.expect(isValidHost("192.168.1.1"));
// IPv6 addresses
try testing.expect(isValidHost("::1"));
try testing.expect(isValidHost("2001:db8::1"));
try testing.expect(isValidHost("2001:db8:0:1:1:1:1:1"));
try testing.expect(!isValidHost("fe80::1%eth0")); // scopes not supported
// Invalid hosts
try testing.expect(!isValidHost(""));
try testing.expect(!isValidHost("host\nname"));
try testing.expect(!isValidHost(".example.com"));
try testing.expect(!isValidHost("host..domain"));
try testing.expect(!isValidHost("-hostname"));
try testing.expect(!isValidHost("hostname-"));
try testing.expect(!isValidHost("host name"));
try testing.expect(!isValidHost("host_name"));
try testing.expect(!isValidHost("host@domain"));
try testing.expect(!isValidHost("host:port"));
}
test "hostname validation - IPv6 addresses" {
test isValidUser {
const testing = std.testing;
try testing.expect(isValidHostname("[::1]"));
try testing.expect(isValidHostname("[2001:db8::1]"));
try testing.expect(!isValidHostname("[fe80::1%eth0]")); // Interface notation not supported
try testing.expect(!isValidHostname("[]")); // Empty IPv6
try testing.expect(!isValidHostname("[invalid]")); // No colons
}
test "hostname validation - invalid cases" {
const testing = std.testing;
try testing.expect(!isValidHostname(""));
try testing.expect(!isValidHostname("host\nname"));
try testing.expect(!isValidHostname(".example.com"));
try testing.expect(!isValidHostname("example.com."));
try testing.expect(!isValidHostname("host..domain"));
try testing.expect(!isValidHostname("-hostname"));
try testing.expect(!isValidHostname("hostname-"));
try testing.expect(!isValidHostname("host name"));
try testing.expect(!isValidHostname("host_name"));
try testing.expect(!isValidHostname("host@domain"));
try testing.expect(!isValidHostname("host:port"));
// Too long
const long_host = "a" ** 254;
try testing.expect(!isValidHostname(long_host));
}
test "user validation - valid cases" {
const testing = std.testing;
// Valid
try testing.expect(isValidUser("user"));
try testing.expect(isValidUser("deploy"));
try testing.expect(isValidUser("test-user"));
try testing.expect(isValidUser("user-user"));
try testing.expect(isValidUser("user_name"));
try testing.expect(isValidUser("user.name"));
try testing.expect(isValidUser("user123"));
try testing.expect(isValidUser("a"));
}
test "user validation - complex realistic cases" {
const testing = std.testing;
try testing.expect(isValidUser("git"));
try testing.expect(isValidUser("ubuntu"));
try testing.expect(isValidUser("root"));
try testing.expect(isValidUser("service.account"));
try testing.expect(isValidUser("user-with-dashes"));
}
test "user validation - invalid cases" {
const testing = std.testing;
// Invalid
try testing.expect(!isValidUser(""));
try testing.expect(!isValidUser("user name"));
try testing.expect(!isValidUser("user@domain"));
try testing.expect(!isValidUser("user@example"));
try testing.expect(!isValidUser("user:group"));
try testing.expect(!isValidUser("user\nname"));
// Too long
const long_user = "a" ** 65;
try testing.expect(!isValidUser(long_user));
try testing.expect(!isValidUser("a" ** 65)); // too long
}
test "cache key validation - hostname format" {
test isValidCacheKey {
const testing = std.testing;
// Valid
try testing.expect(isValidCacheKey("example.com"));
try testing.expect(isValidCacheKey("sub.example.com"));
try testing.expect(isValidCacheKey("192.168.1.1"));
try testing.expect(isValidCacheKey("[::1]"));
try testing.expect(!isValidCacheKey(""));
try testing.expect(!isValidCacheKey(".invalid.com"));
}
test "cache key validation - user@hostname format" {
const testing = std.testing;
try testing.expect(isValidCacheKey("::1"));
try testing.expect(isValidCacheKey("user@example.com"));
try testing.expect(isValidCacheKey("deploy@prod.server.com"));
try testing.expect(isValidCacheKey("test-user@192.168.1.1"));
try testing.expect(isValidCacheKey("user_name@host.domain.org"));
try testing.expect(isValidCacheKey("git@github.com"));
try testing.expect(isValidCacheKey("ubuntu@[::1]"));
try testing.expect(isValidCacheKey("user@192.168.1.1"));
try testing.expect(isValidCacheKey("user@::1"));
// Invalid
try testing.expect(!isValidCacheKey(""));
try testing.expect(!isValidCacheKey(".example.com"));
try testing.expect(!isValidCacheKey("@example.com"));
try testing.expect(!isValidCacheKey("user@"));
try testing.expect(!isValidCacheKey("user@@host"));
try testing.expect(!isValidCacheKey("user@.invalid.com"));
try testing.expect(!isValidCacheKey("user@@example"));
try testing.expect(!isValidCacheKey("user@.example.com"));
}

View File

@@ -1,7 +1,6 @@
const std = @import("std");
const fs = std.fs;
const Allocator = std.mem.Allocator;
const xdg = @import("../os/xdg.zig");
const args = @import("args.zig");
const Action = @import("ghostty.zig").Action;
pub const Entry = @import("ssh-cache/Entry.zig");

View File

@@ -3,7 +3,6 @@ const Allocator = std.mem.Allocator;
const args = @import("args.zig");
const Action = @import("ghostty.zig").Action;
const Config = @import("../config.zig").Config;
const cli = @import("../cli.zig");
pub const Options = struct {
/// The path of the config file to validate. If this isn't specified,

View File

@@ -1,5 +1,6 @@
const builtin = @import("builtin");
const file_load = @import("config/file_load.zig");
const formatter = @import("config/formatter.zig");
pub const Config = @import("config/Config.zig");
pub const conditional = @import("config/conditional.zig");
@@ -12,6 +13,7 @@ pub const ConditionalState = conditional.State;
pub const FileFormatter = formatter.FileFormatter;
pub const entryFormatter = formatter.entryFormatter;
pub const formatEntry = formatter.formatEntry;
pub const preferredDefaultFilePath = file_load.preferredDefaultFilePath;
// Field types
pub const BoldColor = Config.BoldColor;
@@ -29,7 +31,6 @@ pub const Keybinds = Config.Keybinds;
pub const MouseShiftCapture = Config.MouseShiftCapture;
pub const MouseScrollMultiplier = Config.MouseScrollMultiplier;
pub const NonNativeFullscreen = Config.NonNativeFullscreen;
pub const OptionAsAlt = Config.OptionAsAlt;
pub const RepeatableCodepointMap = Config.RepeatableCodepointMap;
pub const RepeatableFontVariation = Config.RepeatableFontVariation;
pub const RepeatableString = Config.RepeatableString;

View File

@@ -1,6 +1,4 @@
const std = @import("std");
const assert = std.debug.assert;
const cli = @import("../cli.zig");
const inputpkg = @import("../input.zig");
const state = &@import("../global.zig").state;
const c = @import("../main_c.zig");

View File

@@ -0,0 +1,44 @@
/// ClipboardCodepointMap is a map of codepoints to replacement values
/// for clipboard operations. When copying text to clipboard, matching
/// codepoints will be replaced with their mapped values.
const ClipboardCodepointMap = @This();
const std = @import("std");
const assert = @import("../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
// To ease our usage later, we map it directly to formatter entries.
pub const Entry = @import("../terminal/formatter.zig").CodepointMap;
pub const Replacement = Entry.Replacement;
/// The list of entries. We use a multiarraylist for cache-friendly lookups.
///
/// Note: we do a linear search because we expect to always have very
/// few entries, so the overhead of a binary search is not worth it.
list: std.MultiArrayList(Entry) = .{},
pub fn deinit(self: *ClipboardCodepointMap, alloc: Allocator) void {
self.list.deinit(alloc);
}
/// Deep copy of the struct. The given allocator is expected to
/// be an arena allocator of some sort since the struct itself
/// doesn't support fine-grained deallocation of fields.
pub fn clone(self: *const ClipboardCodepointMap, alloc: Allocator) !ClipboardCodepointMap {
var list = try self.list.clone(alloc);
for (list.items(.replacement)) |*r| switch (r.*) {
.string => |s| r.string = try alloc.dupe(u8, s),
.codepoint => {}, // no allocation needed
};
return .{ .list = list };
}
/// Add an entry to the map.
///
/// For conflicting codepoints, entries added later take priority over
/// entries added earlier.
pub fn add(self: *ClipboardCodepointMap, alloc: Allocator, entry: Entry) !void {
assert(entry.range[0] <= entry.range[1]);
try self.list.append(alloc, entry);
}

File diff suppressed because it is too large Load Diff

View File

@@ -193,20 +193,32 @@ test "c_get: background-blur" {
{
c.@"background-blur" = .false;
var cval: u8 = undefined;
var cval: i16 = undefined;
try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval)));
try testing.expectEqual(0, cval);
}
{
c.@"background-blur" = .true;
var cval: u8 = undefined;
var cval: i16 = undefined;
try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval)));
try testing.expectEqual(20, cval);
}
{
c.@"background-blur" = .{ .radius = 42 };
var cval: u8 = undefined;
var cval: i16 = undefined;
try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval)));
try testing.expectEqual(42, cval);
}
{
c.@"background-blur" = .@"macos-glass-regular";
var cval: i16 = undefined;
try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval)));
try testing.expectEqual(-1, cval);
}
{
c.@"background-blur" = .@"macos-glass-clear";
var cval: i16 = undefined;
try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval)));
try testing.expectEqual(-2, cval);
}
}

View File

@@ -1,5 +1,4 @@
const std = @import("std");
const builtin = @import("builtin");
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const formatterpkg = @import("formatter.zig");

View File

@@ -1,6 +1,5 @@
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
/// Conditionals in Ghostty configuration are based on a static, typed

View File

@@ -1,9 +1,9 @@
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const assert = @import("../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const internal_os = @import("../os/main.zig");
const file_load = @import("file_load.zig");
/// The path to the configuration that should be opened for editing.
///
@@ -89,20 +89,16 @@ fn configPath(alloc_arena: Allocator) ![]const u8 {
/// Returns a const list of possible paths the main config file could be
/// in for the current OS.
fn configPathCandidates(alloc_arena: Allocator) ![]const []const u8 {
var paths: std.ArrayList([]const u8) = try .initCapacity(alloc_arena, 2);
var paths: std.ArrayList([]const u8) = try .initCapacity(alloc_arena, 4);
errdefer paths.deinit(alloc_arena);
if (comptime builtin.os.tag == .macos) {
paths.appendAssumeCapacity(try internal_os.macos.appSupportDir(
alloc_arena,
"config",
));
paths.appendAssumeCapacity(try file_load.defaultAppSupportPath(alloc_arena));
paths.appendAssumeCapacity(try file_load.legacyDefaultAppSupportPath(alloc_arena));
}
paths.appendAssumeCapacity(try internal_os.xdg.config(
alloc_arena,
.{ .subdir = "ghostty/config" },
));
paths.appendAssumeCapacity(try file_load.defaultXdgPath(alloc_arena));
paths.appendAssumeCapacity(try file_load.legacyDefaultXdgPath(alloc_arena));
return paths.items;
}

166
src/config/file_load.zig Normal file
View File

@@ -0,0 +1,166 @@
const std = @import("std");
const builtin = @import("builtin");
const assert = @import("../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const internal_os = @import("../os/main.zig");
const log = std.log.scoped(.config);
/// Default path for the XDG home configuration file. Returned value
/// must be freed by the caller.
pub fn defaultXdgPath(alloc: Allocator) ![]const u8 {
return try internal_os.xdg.config(
alloc,
.{ .subdir = "ghostty/config.ghostty" },
);
}
/// Ghostty <1.3.0 default path for the XDG home configuration file.
/// Returned value must be freed by the caller.
pub fn legacyDefaultXdgPath(alloc: Allocator) ![]const u8 {
return try internal_os.xdg.config(
alloc,
.{ .subdir = "ghostty/config" },
);
}
/// Preferred default path for the XDG home configuration file.
/// Returned value must be freed by the caller.
pub fn preferredXdgPath(alloc: Allocator) ![]const u8 {
// If the XDG path exists, use that.
const xdg_path = try defaultXdgPath(alloc);
if (open(xdg_path)) |f| {
f.close();
return xdg_path;
} else |_| {}
// Try the legacy path
errdefer alloc.free(xdg_path);
const legacy_xdg_path = try legacyDefaultXdgPath(alloc);
if (open(legacy_xdg_path)) |f| {
f.close();
alloc.free(xdg_path);
return legacy_xdg_path;
} else |_| {}
// Legacy path and XDG path both don't exist. Return the
// new one.
alloc.free(legacy_xdg_path);
return xdg_path;
}
/// Default path for the macOS Application Support configuration file.
/// Returned value must be freed by the caller.
pub fn defaultAppSupportPath(alloc: Allocator) ![]const u8 {
return try internal_os.macos.appSupportDir(alloc, "config.ghostty");
}
/// Ghostty <1.3.0 default path for the macOS Application Support
/// configuration file. Returned value must be freed by the caller.
pub fn legacyDefaultAppSupportPath(alloc: Allocator) ![]const u8 {
return try internal_os.macos.appSupportDir(alloc, "config");
}
/// Preferred default path for the macOS Application Support configuration file.
/// Returned value must be freed by the caller.
pub fn preferredAppSupportPath(alloc: Allocator) ![]const u8 {
// If the app support path exists, use that.
const app_support_path = try defaultAppSupportPath(alloc);
if (open(app_support_path)) |f| {
f.close();
return app_support_path;
} else |_| {}
// Try the legacy path
errdefer alloc.free(app_support_path);
const legacy_app_support_path = try legacyDefaultAppSupportPath(alloc);
if (open(legacy_app_support_path)) |f| {
f.close();
alloc.free(app_support_path);
return legacy_app_support_path;
} else |_| {}
// Legacy path and app support path both don't exist. Return the
// new one.
alloc.free(legacy_app_support_path);
return app_support_path;
}
/// Returns the path to the preferred default configuration file.
/// This is the file where users should place their configuration.
///
/// This doesn't create or populate the file with any default
/// contents; downstream callers must handle this.
///
/// The returned value must be freed by the caller.
pub fn preferredDefaultFilePath(alloc: Allocator) ![]const u8 {
switch (builtin.os.tag) {
.macos => {
// macOS prefers the Application Support directory
// if it exists.
const app_support_path = try preferredAppSupportPath(alloc);
const app_support_file = open(app_support_path) catch {
// Try the XDG path if it exists
const xdg_path = try preferredXdgPath(alloc);
const xdg_file = open(xdg_path) catch {
// If neither file exists, use app support
alloc.free(xdg_path);
return app_support_path;
};
xdg_file.close();
alloc.free(app_support_path);
return xdg_path;
};
app_support_file.close();
return app_support_path;
},
// All other platforms use XDG only
else => return try preferredXdgPath(alloc),
}
}
const OpenFileError = error{
FileNotFound,
FileIsEmpty,
FileOpenFailed,
NotAFile,
};
/// Opens the file at the given path and returns the file handle
/// if it exists and is non-empty. This also constrains the possible
/// errors to a smaller set that we can explicitly handle.
pub fn open(path: []const u8) OpenFileError!std.fs.File {
assert(std.fs.path.isAbsolute(path));
var file = std.fs.openFileAbsolute(
path,
.{},
) catch |err| switch (err) {
error.FileNotFound => return OpenFileError.FileNotFound,
else => {
log.warn("unexpected file open error path={s} err={}", .{
path,
err,
});
return OpenFileError.FileOpenFailed;
},
};
errdefer file.close();
const stat = file.stat() catch |err| {
log.warn("error getting file stat path={s} err={}", .{
path,
err,
});
return OpenFileError.FileOpenFailed;
};
switch (stat.kind) {
.file => {},
else => return OpenFileError.NotAFile,
}
if (stat.size == 0) return OpenFileError.FileIsEmpty;
return file;
}

View File

@@ -1,5 +1,5 @@
const std = @import("std");
const assert = std.debug.assert;
const assert = @import("../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const string = @import("string.zig");

View File

@@ -1,6 +1,6 @@
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const assert = @import("../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;

View File

@@ -1,6 +1,5 @@
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const global_state = &@import("../global.zig").state;
const internal_os = @import("../os/main.zig");

View File

@@ -2,8 +2,6 @@
//! between threads.
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
/// Returns a blocking queue implementation for type T.
@@ -73,7 +71,7 @@ pub fn BlockingQueue(
not_full_waiters: usize = 0,
/// Allocate the blocking queue on the heap.
pub fn create(alloc: Allocator) !*Self {
pub fn create(alloc: Allocator) Allocator.Error!*Self {
const ptr = try alloc.create(Self);
errdefer alloc.destroy(ptr);

View File

@@ -1,7 +1,7 @@
const fastmem = @import("../fastmem.zig");
const std = @import("std");
const assert = std.debug.assert;
const assert = @import("../quirks.zig").inlineAssert;
/// An associative data structure used for efficiently storing and
/// retrieving values which are able to be recomputed if necessary.

View File

@@ -1,5 +1,5 @@
const std = @import("std");
const assert = std.debug.assert;
const assert = @import("../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const fastmem = @import("../fastmem.zig");
@@ -91,15 +91,24 @@ pub fn CircBuf(comptime T: type, comptime default: T) type {
self.full = self.head == self.tail;
}
/// Append a slice to the buffer. If the buffer cannot fit the
/// entire slice then an error will be returned. It is up to the
/// caller to rotate the circular buffer if they want to overwrite
/// the oldest data.
pub fn appendSlice(
/// Append a single value to the buffer, assuming there is capacity.
pub fn appendAssumeCapacity(self: *Self, v: T) void {
assert(!self.full);
self.storage[self.head] = v;
self.head += 1;
if (self.head >= self.storage.len) self.head = 0;
self.full = self.head == self.tail;
}
/// Append a slice to the buffer.
pub fn appendSliceAssumeCapacity(
self: *Self,
slice: []const T,
) Allocator.Error!void {
const storage = self.getPtrSlice(self.len(), slice.len);
) void {
const storage = self.getPtrSlice(
self.len(),
slice.len,
);
fastmem.copy(T, storage[0], slice[0..storage[0].len]);
fastmem.copy(T, storage[1], slice[storage[0].len..]);
}
@@ -456,7 +465,7 @@ test "CircBuf append slice" {
var buf = try Buf.init(alloc, 5);
defer buf.deinit(alloc);
try buf.appendSlice("hello");
buf.appendSliceAssumeCapacity("hello");
{
var it = buf.iterator(.forward);
try testing.expect(it.next().?.* == 'h');
@@ -486,7 +495,7 @@ test "CircBuf append slice with wrap" {
try testing.expect(!buf.full);
try testing.expectEqual(@as(usize, 2), buf.len());
try buf.appendSlice("AB");
buf.appendSliceAssumeCapacity("AB");
{
var it = buf.iterator(.forward);
try testing.expect(it.next().?.* == 0);

View File

@@ -1,5 +1,5 @@
const std = @import("std");
const assert = std.debug.assert;
const assert = @import("../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
/// Create a HashMap for a key type that can be automatically hashed.

View File

@@ -13,6 +13,7 @@ pub const BlockingQueue = blocking_queue.BlockingQueue;
pub const CacheTable = cache_table.CacheTable;
pub const CircBuf = circ_buf.CircBuf;
pub const IntrusiveDoublyLinkedList = intrusive_linked_list.DoublyLinkedList;
pub const MessageData = @import("message_data.zig").MessageData;
pub const SegmentedPool = segmented_pool.SegmentedPool;
pub const SplitTree = split_tree.SplitTree;

View File

@@ -0,0 +1,124 @@
const std = @import("std");
const assert = @import("../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
/// Creates a union that can be used to accommodate data that fit within an array,
/// are a stable pointer, or require deallocation. This is helpful for thread
/// messaging utilities.
pub fn MessageData(comptime Elem: type, comptime small_size: comptime_int) type {
return union(enum) {
pub const Self = @This();
pub const Small = struct {
pub const Max = small_size;
pub const Array = [Max]Elem;
pub const Len = std.math.IntFittingRange(0, small_size);
data: Array = undefined,
len: Len = 0,
};
pub const Alloc = struct {
alloc: Allocator,
data: []Elem,
};
pub const Stable = []const Elem;
/// A small write where the data fits into this union size.
small: Small,
/// A stable pointer so we can just pass the slice directly through.
/// This is useful i.e. for const data.
stable: Stable,
/// Allocated and must be freed with the provided allocator. This
/// should be rarely used.
alloc: Alloc,
/// Initializes the union for a given data type. This will
/// attempt to fit into a small value if possible, otherwise
/// will allocate and put into alloc.
///
/// This can't and will never detect stable pointers.
pub fn init(alloc: Allocator, data: anytype) !Self {
switch (@typeInfo(@TypeOf(data))) {
.pointer => |info| {
assert(info.size == .slice);
assert(info.child == Elem);
// If it fits in our small request, do that.
if (data.len <= Small.Max) {
var buf: Small.Array = undefined;
@memcpy(buf[0..data.len], data);
return Self{
.small = .{
.data = buf,
.len = @intCast(data.len),
},
};
}
// Otherwise, allocate
const buf = try alloc.dupe(Elem, data);
errdefer alloc.free(buf);
return Self{
.alloc = .{
.alloc = alloc,
.data = buf,
},
};
},
else => unreachable,
}
}
pub fn deinit(self: Self) void {
switch (self) {
.small, .stable => {},
.alloc => |v| v.alloc.free(v.data),
}
}
/// Returns a const slice of the data pointed to by this request.
pub fn slice(self: *const Self) []const Elem {
return switch (self.*) {
.small => |*v| v.data[0..v.len],
.stable => |v| v,
.alloc => |v| v.data,
};
}
};
}
test "MessageData init small" {
const testing = std.testing;
const alloc = testing.allocator;
const Data = MessageData(u8, 10);
const input = "hello!";
const io = try Data.init(alloc, @as([]const u8, input));
try testing.expect(io == .small);
}
test "MessageData init alloc" {
const testing = std.testing;
const alloc = testing.allocator;
const Data = MessageData(u8, 10);
const input = "hello! " ** 100;
const io = try Data.init(alloc, @as([]const u8, input));
try testing.expect(io == .alloc);
io.alloc.alloc.free(io.alloc.data);
}
test "MessageData small fits non-u8 sized data" {
const testing = std.testing;
const alloc = testing.allocator;
const len = 500;
const Data = MessageData(u8, len);
const input: []const u8 = "X" ** len;
const io = try Data.init(alloc, input);
try testing.expect(io == .small);
}

View File

@@ -1,5 +1,5 @@
const std = @import("std");
const assert = std.debug.assert;
const assert = @import("../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const testing = std.testing;

View File

@@ -1,5 +1,5 @@
const std = @import("std");
const assert = std.debug.assert;
const assert = @import("../quirks.zig").inlineAssert;
const build_config = @import("../build_config.zig");
const ArenaAllocator = std.heap.ArenaAllocator;
const Allocator = std.mem.Allocator;

Some files were not shown because too many files have changed in this diff Show More