Merge branch 'main' into ko_kr

This commit is contained in:
RME
2025-06-28 23:23:11 +02:00
committed by GitHub
457 changed files with 38033 additions and 17852 deletions

View File

@@ -76,34 +76,38 @@ first: bool = true,
pub const CreateError = Allocator.Error || font.SharedGridSet.InitError;
/// Create a new app instance. This returns a stable pointer to the app
/// instance which is required for callbacks.
pub fn create(alloc: Allocator) CreateError!*App {
var app = try alloc.create(App);
errdefer alloc.destroy(app);
try app.init(alloc);
return app;
}
/// Initialize the main app instance. This creates the main window, sets
/// up the renderer state, compiles the shaders, etc. This is the primary
/// "startup" logic.
///
/// After calling this function, well behaved apprts should then call
/// `focusEvent` to set the initial focus state of the app.
pub fn create(
pub fn init(
self: *App,
alloc: Allocator,
) CreateError!*App {
var app = try alloc.create(App);
errdefer alloc.destroy(app);
) CreateError!void {
var font_grid_set = try font.SharedGridSet.init(alloc);
errdefer font_grid_set.deinit();
app.* = .{
self.* = .{
.alloc = alloc,
.surfaces = .{},
.mailbox = .{},
.font_grid_set = font_grid_set,
.config_conditional_state = .{},
};
errdefer app.surfaces.deinit(alloc);
return app;
}
pub fn destroy(self: *App) void {
pub fn deinit(self: *App) void {
// Clean up all our surfaces
for (self.surfaces.items) |surface| surface.deinit();
self.surfaces.deinit(self.alloc);
@@ -114,7 +118,13 @@ pub fn destroy(self: *App) void {
// should gracefully close all surfaces.
assert(self.font_grid_set.count() == 0);
self.font_grid_set.deinit();
}
pub fn destroy(self: *App) void {
// Deinitialize the app
self.deinit();
// Free the app memory
self.alloc.destroy(self);
}
@@ -444,6 +454,11 @@ pub fn performAction(
.close_all_windows => _ = try rt_app.performAction(.app, .close_all_windows, {}),
.toggle_quick_terminal => _ = try rt_app.performAction(.app, .toggle_quick_terminal, {}),
.toggle_visibility => _ = try rt_app.performAction(.app, .toggle_visibility, {}),
.check_for_updates => _ = try rt_app.performAction(.app, .check_for_updates, {}),
.show_gtk_inspector => _ = try rt_app.performAction(.app, .show_gtk_inspector, {}),
.undo => _ = try rt_app.performAction(.app, .undo, {}),
.redo => _ = try rt_app.performAction(.app, .redo, {}),
}
}

View File

@@ -33,14 +33,17 @@ const EnvMap = std.process.EnvMap;
const PreExecFn = fn (*Command) void;
/// Path to the command to run. This must be an absolute path. This
/// library does not do PATH lookup.
path: []const u8,
/// Path to the command to run. This doesn't have to be an absolute path,
/// because use exec functions that search the PATH, if necessary.
///
/// This field is null-terminated to avoid a copy for the sake of
/// adding a null terminator since POSIX systems are so common.
path: [:0]const u8,
/// Command-line arguments. It is the responsibility of the caller to set
/// args[0] to the command. If args is empty then args[0] will automatically
/// be set to equal path.
args: []const []const u8,
args: []const [:0]const u8,
/// Environment variables for the child process. If this is null, inherits
/// the environment variables from this process. These are the exact
@@ -129,9 +132,8 @@ pub fn start(self: *Command, alloc: Allocator) !void {
fn startPosix(self: *Command, arena: Allocator) !void {
// Null-terminate all our arguments
const pathZ = try arena.dupeZ(u8, self.path);
const argsZ = try arena.allocSentinel(?[*:0]u8, self.args.len, null);
for (self.args, 0..) |arg, i| argsZ[i] = (try arena.dupeZ(u8, arg)).ptr;
const argsZ = try arena.allocSentinel(?[*:0]const u8, self.args.len, null);
for (self.args, 0..) |arg, i| argsZ[i] = arg.ptr;
// Determine our env vars
const envp = if (self.env) |env_map|
@@ -184,7 +186,9 @@ fn startPosix(self: *Command, arena: Allocator) !void {
if (self.pre_exec) |f| f(self);
// Finally, replace our process.
_ = posix.execveZ(pathZ, argsZ, envp) catch null;
// Note: we must use the "p"-variant of exec here because we
// do not guarantee our command is looked up already in the path.
_ = posix.execvpeZ(self.path, argsZ, envp) catch null;
// If we are executing this code, the exec failed. In that scenario,
// we return a very specific error that can be detected to determine
@@ -319,7 +323,7 @@ fn setupFd(src: File.Handle, target: i32) !void {
}
}
},
.ios, .macos => {
.freebsd, .ios, .macos => {
// Mac doesn't support dup3 so we use dup2. We purposely clear
// CLO_ON_EXEC for this fd.
const flags = try posix.fcntl(src, posix.F.GETFD, 0);
@@ -366,7 +370,7 @@ pub fn wait(self: Command, block: bool) !Exit {
}
};
return Exit.init(res.status);
return .init(res.status);
}
/// Sets command->data to data.

File diff suppressed because it is too large Load Diff

View File

@@ -107,6 +107,9 @@ pub const Action = union(Key) {
/// Toggle the quick terminal in or out.
toggle_quick_terminal,
/// Toggle the command palette. This currently only works on macOS.
toggle_command_palette,
/// Toggle the visibility of all Ghostty terminal windows.
toggle_visibility,
@@ -162,6 +165,9 @@ pub const Action = union(Key) {
/// Control whether the inspector is shown or hidden.
inspector: Inspector,
/// Show the GTK inspector.
show_gtk_inspector,
/// The inspector for the given target has changes and should be
/// rendered at the next opportunity.
render_inspector,
@@ -202,6 +208,10 @@ pub const Action = union(Key) {
/// happen and can be ignored or cause a restart it isn't that important.
quit_timer: QuitTimer,
/// Set the window floating state. A floating window is one that is
/// always on top of other windows even when not focused.
float_window: FloatWindow,
/// Set the secure input functionality on or off. "Secure input" means
/// that the user is currently at some sort of prompt where they may be
/// entering a password or other sensitive information. This can be used
@@ -244,6 +254,19 @@ pub const Action = union(Key) {
/// Closes the currently focused window.
close_window,
/// Called when the bell character is seen. The apprt should do whatever
/// it needs to ring the bell. This is usually a sound or visual effect.
ring_bell,
/// Undo the last action. See the "undo" keybinding for more
/// details on what can and cannot be undone.
undo,
/// Redo the last undone action.
redo,
check_for_updates,
/// Sync with: ghostty_action_tag_e
pub const Key = enum(c_int) {
quit,
@@ -257,6 +280,7 @@ pub const Action = union(Key) {
toggle_tab_overview,
toggle_window_decorations,
toggle_quick_terminal,
toggle_command_palette,
toggle_visibility,
move_tab,
goto_tab,
@@ -270,6 +294,7 @@ pub const Action = union(Key) {
initial_size,
cell_size,
inspector,
show_gtk_inspector,
render_inspector,
desktop_notification,
set_title,
@@ -281,12 +306,17 @@ pub const Action = union(Key) {
renderer_health,
open_config,
quit_timer,
float_window,
secure_input,
key_sequence,
color_change,
reload_config,
config_change,
close_window,
ring_bell,
undo,
redo,
check_for_updates,
};
/// Sync with: ghostty_action_u
@@ -311,7 +341,7 @@ pub const Action = union(Key) {
break :cvalue @Type(.{ .@"union" = .{
.layout = .@"extern",
.tag_type = Key,
.tag_type = null,
.fields = &union_fields,
.decls = &.{},
} });
@@ -323,6 +353,13 @@ pub const Action = union(Key) {
value: CValue,
};
comptime {
// For ABI compatibility, we expect that this is our union size.
// At the time of writing, we don't promise ABI compatibility
// so we can change this but I want to be aware of it.
assert(@sizeOf(CValue) == 16);
}
/// Returns the value type for the given key.
pub fn Value(comptime key: Key) type {
inline for (@typeInfo(Action).@"union".fields) |field| {
@@ -409,6 +446,12 @@ pub const Fullscreen = enum(c_int) {
macos_non_native_padded_notch,
};
pub const FloatWindow = enum(c_int) {
on,
off,
toggle,
};
pub const SecureInput = enum(c_int) {
on,
off,

View File

@@ -1,2 +1,4 @@
const internal_os = @import("../os/main.zig");
pub const resourcesDir = internal_os.resourcesDir;
pub const App = struct {};
pub const Window = struct {};

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,8 @@ const darwin_enabled = builtin.target.os.tag.isDarwin() and
const log = std.log.scoped(.glfw);
pub const resourcesDir = internal_os.resourcesDir;
pub const App = struct {
app: *CoreApp,
config: Config,
@@ -48,7 +50,7 @@ pub const App = struct {
pub const Options = struct {};
pub fn init(core_app: *CoreApp, _: Options) !App {
pub fn init(self: *App, core_app: *CoreApp, _: Options) !void {
if (comptime builtin.target.os.tag.isDarwin()) {
log.warn("WARNING WARNING WARNING: GLFW ON MAC HAS BUGS.", .{});
log.warn("You should use the AppKit-based app instead. The official download", .{});
@@ -105,7 +107,7 @@ pub const App = struct {
// We want the event loop to wake up instantly so we can process our tick.
glfw.postEmptyEvent();
return .{
self.* = .{
.app = core_app,
.config = config,
.darwin = darwin,
@@ -228,12 +230,14 @@ pub const App = struct {
.toggle_tab_overview,
.toggle_window_decorations,
.toggle_quick_terminal,
.toggle_command_palette,
.toggle_visibility,
.goto_tab,
.move_tab,
.inspector,
.render_inspector,
.quit_timer,
.float_window,
.secure_input,
.key_sequence,
.desktop_notification,
@@ -246,6 +250,11 @@ pub const App = struct {
.toggle_maximize,
.prompt_title,
.reset_window_size,
.ring_bell,
.check_for_updates,
.undo,
.redo,
.show_gtk_inspector,
=> {
log.info("unimplemented action={}", .{action});
return false;
@@ -963,46 +972,46 @@ pub const Surface = struct {
.repeat => .repeat,
};
const key: input.Key = switch (glfw_key) {
.a => .a,
.b => .b,
.c => .c,
.d => .d,
.e => .e,
.f => .f,
.g => .g,
.h => .h,
.i => .i,
.j => .j,
.k => .k,
.l => .l,
.m => .m,
.n => .n,
.o => .o,
.p => .p,
.q => .q,
.r => .r,
.s => .s,
.t => .t,
.u => .u,
.v => .v,
.w => .w,
.x => .x,
.y => .y,
.z => .z,
.zero => .zero,
.one => .one,
.two => .two,
.three => .three,
.four => .four,
.five => .five,
.six => .six,
.seven => .seven,
.eight => .eight,
.nine => .nine,
.up => .up,
.down => .down,
.right => .right,
.left => .left,
.a => .key_a,
.b => .key_b,
.c => .key_c,
.d => .key_d,
.e => .key_e,
.f => .key_f,
.g => .key_g,
.h => .key_h,
.i => .key_i,
.j => .key_j,
.k => .key_k,
.l => .key_l,
.m => .key_m,
.n => .key_n,
.o => .key_o,
.p => .key_p,
.q => .key_q,
.r => .key_r,
.s => .key_s,
.t => .key_t,
.u => .key_u,
.v => .key_v,
.w => .key_w,
.x => .key_x,
.y => .key_y,
.z => .key_z,
.zero => .digit_0,
.one => .digit_1,
.two => .digit_2,
.three => .digit_3,
.four => .digit_4,
.five => .digit_5,
.six => .digit_6,
.seven => .digit_7,
.eight => .digit_8,
.nine => .digit_9,
.up => .arrow_up,
.down => .arrow_down,
.right => .arrow_right,
.left => .arrow_left,
.home => .home,
.end => .end,
.page_up => .page_up,
@@ -1033,34 +1042,34 @@ pub const Surface = struct {
.F23 => .f23,
.F24 => .f24,
.F25 => .f25,
.kp_0 => .kp_0,
.kp_1 => .kp_1,
.kp_2 => .kp_2,
.kp_3 => .kp_3,
.kp_4 => .kp_4,
.kp_5 => .kp_5,
.kp_6 => .kp_6,
.kp_7 => .kp_7,
.kp_8 => .kp_8,
.kp_9 => .kp_9,
.kp_decimal => .kp_decimal,
.kp_divide => .kp_divide,
.kp_multiply => .kp_multiply,
.kp_subtract => .kp_subtract,
.kp_add => .kp_add,
.kp_enter => .kp_enter,
.kp_equal => .kp_equal,
.grave_accent => .grave_accent,
.kp_0 => .numpad_0,
.kp_1 => .numpad_1,
.kp_2 => .numpad_2,
.kp_3 => .numpad_3,
.kp_4 => .numpad_4,
.kp_5 => .numpad_5,
.kp_6 => .numpad_6,
.kp_7 => .numpad_7,
.kp_8 => .numpad_8,
.kp_9 => .numpad_9,
.kp_decimal => .numpad_decimal,
.kp_divide => .numpad_divide,
.kp_multiply => .numpad_multiply,
.kp_subtract => .numpad_subtract,
.kp_add => .numpad_add,
.kp_enter => .numpad_enter,
.kp_equal => .numpad_equal,
.grave_accent => .backquote,
.minus => .minus,
.equal => .equal,
.space => .space,
.semicolon => .semicolon,
.apostrophe => .apostrophe,
.apostrophe => .quote,
.comma => .comma,
.period => .period,
.slash => .slash,
.left_bracket => .left_bracket,
.right_bracket => .right_bracket,
.left_bracket => .bracket_left,
.right_bracket => .bracket_right,
.backslash => .backslash,
.enter => .enter,
.tab => .tab,
@@ -1072,20 +1081,20 @@ pub const Surface = struct {
.num_lock => .num_lock,
.print_screen => .print_screen,
.pause => .pause,
.left_shift => .left_shift,
.left_control => .left_control,
.left_alt => .left_alt,
.left_super => .left_super,
.right_shift => .right_shift,
.right_control => .right_control,
.right_alt => .right_alt,
.right_super => .right_super,
.left_shift => .shift_left,
.left_control => .control_left,
.left_alt => .alt_left,
.left_super => .meta_left,
.right_shift => .shift_right,
.right_control => .control_right,
.right_alt => .alt_right,
.right_super => .meta_right,
.menu => .context_menu,
.menu,
.world_1,
.world_2,
.unknown,
=> .invalid,
=> .unidentified,
};
// This is a hack for GLFW. We require our apprts to send both
@@ -1105,7 +1114,6 @@ pub const Surface = struct {
const key_event: input.KeyEvent = .{
.action = action,
.key = key,
.physical_key = key,
.mods = mods,
.consumed_mods = .{},
.composing = false,

View File

@@ -2,6 +2,7 @@
pub const App = @import("gtk/App.zig");
pub const Surface = @import("gtk/Surface.zig");
pub const resourcesDir = @import("gtk/flatpak.zig").resourcesDir;
test {
@import("std").testing.refAllDecls(@This());

View File

@@ -40,6 +40,7 @@ const Window = @import("Window.zig");
const ConfigErrorsDialog = @import("ConfigErrorsDialog.zig");
const ClipboardConfirmationWindow = @import("ClipboardConfirmationWindow.zig");
const CloseDialog = @import("CloseDialog.zig");
const GlobalShortcuts = @import("GlobalShortcuts.zig");
const Split = @import("Split.zig");
const inspector = @import("inspector.zig");
const key = @import("key.zig");
@@ -54,6 +55,11 @@ pub const c = @cImport({
const log = std.log.scoped(.gtk);
/// This is detected by the Renderer, in which case it sends a `redraw_surface`
/// message so that we can call `drawFrame` ourselves from the app thread,
/// because GTK's `GLArea` does not support drawing from a different thread.
pub const must_draw_from_app_thread = true;
pub const Options = struct {};
core_app: *CoreApp,
@@ -74,6 +80,9 @@ cursor_none: ?*gdk.Cursor,
/// The clipboard confirmation window, if it is currently open.
clipboard_confirmation_window: ?*ClipboardConfirmationWindow = null,
/// The config errors dialog, if it is currently open.
config_errors_dialog: ?ConfigErrorsDialog = null,
/// The window containing the quick terminal.
/// Null when never initialized.
quick_terminal: ?*Window = null,
@@ -92,6 +101,8 @@ css_provider: *gtk.CssProvider,
/// Providers for loading custom stylesheets defined by user
custom_css_providers: std.ArrayListUnmanaged(*gtk.CssProvider) = .{},
global_shortcuts: ?GlobalShortcuts,
/// The timer used to quit the application after the last window is closed.
quit_timer: union(enum) {
off: void,
@@ -99,7 +110,7 @@ quit_timer: union(enum) {
expired: void,
} = .{ .off = {} },
pub fn init(core_app: *CoreApp, opts: Options) !App {
pub fn init(self: *App, core_app: *CoreApp, opts: Options) !void {
_ = opts;
// Log our GTK version
@@ -137,8 +148,8 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
if (config.@"async-backend" != .auto) {
const result: bool = switch (config.@"async-backend") {
.auto => unreachable,
.epoll => xev.prefer(.epoll),
.io_uring => xev.prefer(.io_uring),
.epoll => if (comptime xev.dynamic) xev.prefer(.epoll) else false,
.io_uring => if (comptime xev.dynamic) xev.prefer(.io_uring) else false,
};
if (result) {
@@ -159,6 +170,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
opengl: bool = false,
/// disable GLES, Ghostty can't use GLES
@"gl-disable-gles": bool = false,
// GTK's new renderer can cause blurry font when using fractional scaling.
@"gl-no-fractional": bool = false,
/// Disabling Vulkan can improve startup times by hundreds of
/// milliseconds on some systems. We don't use Vulkan so we can just
@@ -190,7 +202,6 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
// For the remainder of "why" see the 4.14 comment below.
gdk_disable.@"gles-api" = true;
gdk_disable.vulkan = true;
gdk_debug.@"gl-no-fractional" = true;
break :environment;
}
if (gtk_version.runtimeAtLeast(4, 14, 0)) {
@@ -201,8 +212,12 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
//
// Upstream issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/6589
gdk_debug.@"gl-disable-gles" = true;
gdk_debug.@"gl-no-fractional" = true;
gdk_debug.@"vulkan-disable" = true;
if (gtk_version.runtimeUntil(4, 17, 5)) {
// Removed at GTK v4.17.5
gdk_debug.@"gl-no-fractional" = true;
}
break :environment;
}
// Versions prior to 4.14 are a bit of an unknown for Ghostty. It
@@ -263,7 +278,10 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
const single_instance = switch (config.@"gtk-single-instance") {
.true => true,
.false => false,
.desktop => internal_os.launchedFromDesktop(),
.desktop => switch (config.@"launched-from".?) {
.desktop, .systemd, .dbus => true,
.cli => false,
},
};
// Setup the flags for our application.
@@ -278,7 +296,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
// can develop Ghostty in Ghostty.
const app_id: [:0]const u8 = app_id: {
if (config.class) |class| {
if (isValidAppId(class)) {
if (gio.Application.idIsValid(class) != 0) {
break :app_id class;
} else {
log.warn("invalid 'class' in config, ignoring", .{});
@@ -314,8 +332,8 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
.prefer_dark;
},
.system => .prefer_light,
.dark => .prefer_dark,
.light => .force_dark,
.dark => .force_dark,
.light => .force_light,
},
);
@@ -387,11 +405,15 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
// This just calls the `activate` signal but its part of the normal startup
// routine so we just call it, but only if the config allows it (this allows
// for launching Ghostty in the "background" without immediately opening
// a window)
// a window). An initial window will not be immediately created if we were
// launched by D-Bus activation or systemd. D-Bus activation will send it's
// own `activate` or `new-window` signal later.
//
// https://gitlab.gnome.org/GNOME/glib/-/blob/bd2ccc2f69ecfd78ca3f34ab59e42e2b462bad65/gio/gapplication.c#L2302
if (config.@"initial-window")
gio_app.activate();
if (config.@"initial-window") switch (config.@"launched-from".?) {
.desktop, .cli => gio_app.activate(),
.dbus, .systemd => {},
};
// Internally, GTK ensures that only one instance of this provider exists in the provider list
// for the display.
@@ -402,7 +424,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + 3,
);
return .{
self.* = .{
.core_app = core_app,
.app = adw_app,
.config = config,
@@ -415,6 +437,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
// our "activate" call above will open a window.
.running = gio_app.getIsRemote() == 0,
.css_provider = css_provider,
.global_shortcuts = .init(core_app.alloc, gio_app),
};
}
@@ -436,6 +459,8 @@ pub fn terminate(self: *App) void {
self.winproto.deinit(self.core_app.alloc);
if (self.global_shortcuts) |*shortcuts| shortcuts.deinit();
self.config.deinit();
}
@@ -468,6 +493,7 @@ pub fn performAction(
.config_change => self.configChange(target, value.config),
.reload_config => try self.reloadConfig(target, value),
.inspector => self.controlInspector(target, value),
.show_gtk_inspector => self.showGTKInspector(),
.desktop_notification => self.showDesktopNotification(target, value),
.set_title => try self.setTitle(target, value),
.pwd => try self.setPwd(target, value),
@@ -484,9 +510,12 @@ pub fn performAction(
.prompt_title => try self.promptTitle(target),
.toggle_quick_terminal => return try self.toggleQuickTerminal(),
.secure_input => self.setSecureInput(target, value),
.ring_bell => try self.ringBell(target),
.toggle_command_palette => try self.toggleCommandPalette(target),
// Unimplemented
.close_all_windows,
.float_window,
.toggle_visibility,
.cell_size,
.key_sequence,
@@ -494,6 +523,9 @@ pub fn performAction(
.renderer_health,
.color_change,
.reset_window_size,
.check_for_updates,
.undo,
.redo,
=> {
log.warn("unimplemented action={}", .{action});
return false;
@@ -670,6 +702,12 @@ fn controlInspector(
surface.controlInspector(mode);
}
fn showGTKInspector(
_: *const App,
) void {
gtk.Window.setInteractiveDebugging(@intFromBool(true));
}
fn toggleMaximize(_: *App, target: apprt.Target) void {
switch (target) {
.app => {},
@@ -740,7 +778,7 @@ fn toggleWindowDecorations(
.surface => |v| {
const window = v.rt_surface.container.window() orelse {
log.info(
"toggleFullscreen invalid for container={s}",
"toggleWindowDecorations invalid for container={s}",
.{@tagName(v.rt_surface.container)},
);
return;
@@ -775,6 +813,30 @@ fn toggleQuickTerminal(self: *App) !bool {
return true;
}
fn ringBell(_: *App, target: apprt.Target) !void {
switch (target) {
.app => {},
.surface => |surface| try surface.rt_surface.ringBell(),
}
}
fn toggleCommandPalette(_: *App, target: apprt.Target) !void {
switch (target) {
.app => {},
.surface => |surface| {
const window = surface.rt_surface.container.window() orelse {
log.info(
"toggleCommandPalette invalid for container={s}",
.{@tagName(surface.rt_surface.container)},
);
return;
};
window.toggleCommandPalette();
},
}
}
fn quitTimer(self: *App, mode: apprt.action.QuitTimer) void {
switch (mode) {
.start => self.startQuitTimer(),
@@ -995,6 +1057,12 @@ fn syncConfigChanges(self: *App, window: ?*Window) !void {
ConfigErrorsDialog.maybePresent(self, window);
try self.syncActionAccelerators();
if (self.global_shortcuts) |*shortcuts| {
shortcuts.refreshSession(self) catch |err| {
log.warn("failed to refresh global shortcuts={}", .{err});
};
}
// Load our runtime and custom CSS. If this fails then our window is just stuck
// with the old CSS but we don't want to fail the entire sync operation.
self.loadRuntimeCss() catch |err| switch (err) {
@@ -1013,6 +1081,8 @@ fn syncActionAccelerators(self: *App) !void {
try self.syncActionAccelerator("app.open-config", .{ .open_config = {} });
try self.syncActionAccelerator("app.reload-config", .{ .reload_config = {} });
try self.syncActionAccelerator("win.toggle-inspector", .{ .inspector = .toggle });
try self.syncActionAccelerator("app.show-gtk-inspector", .show_gtk_inspector);
try self.syncActionAccelerator("win.toggle-command-palette", .toggle_command_palette);
try self.syncActionAccelerator("win.close", .{ .close_window = {} });
try self.syncActionAccelerator("win.new-window", .{ .new_window = {} });
try self.syncActionAccelerator("win.new-tab", .{ .new_tab = {} });
@@ -1282,6 +1352,13 @@ pub fn run(self: *App) !void {
// Setup our actions
self.initActions();
// On startup, we want to check for configuration errors right away
// so we can show our error window. We also need to setup other initial
// state.
self.syncConfigChanges(null) catch |err| {
log.warn("error handling configuration changes err={}", .{err});
};
while (self.running) {
_ = glib.MainContext.iteration(self.ctx, 1);
@@ -1506,7 +1583,7 @@ fn adwNotifyDark(
style_manager: *adw.StyleManager,
_: *gobject.ParamSpec,
self: *App,
) callconv(.C) void {
) callconv(.c) void {
const color_scheme: apprt.ColorScheme = if (style_manager.getDark() == 0)
.light
else
@@ -1600,6 +1677,27 @@ fn gtkActionPresentSurface(
);
}
fn gtkActionShowGTKInspector(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *App,
) callconv(.c) void {
self.core_app.performAction(self, .show_gtk_inspector) catch |err| {
log.err("error showing GTK inspector err={}", .{err});
};
}
fn gtkActionNewWindow(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *App,
) callconv(.c) void {
log.info("received new window action", .{});
_ = self.core_app.mailbox.push(.{
.new_window = .{},
}, .{ .forever = {} });
}
/// This is called to setup the action map that this application supports.
/// This should be called only once on startup.
fn initActions(self: *App) void {
@@ -1618,7 +1716,10 @@ fn initActions(self: *App) void {
.{ "open-config", gtkActionOpenConfig, null },
.{ "reload-config", gtkActionReloadConfig, null },
.{ "present-surface", gtkActionPresentSurface, t },
.{ "show-gtk-inspector", gtkActionShowGTKInspector, null },
.{ "new-window", gtkActionNewWindow, null },
};
inline for (actions) |entry| {
const action = gio.SimpleAction.new(entry[0], entry[2]);
defer action.unref();
@@ -1633,32 +1734,3 @@ fn initActions(self: *App) void {
action_map.addAction(action.as(gio.Action));
}
}
fn isValidAppId(app_id: [:0]const u8) bool {
if (app_id.len > 255 or app_id.len == 0) return false;
if (app_id[0] == '.') return false;
if (app_id[app_id.len - 1] == '.') return false;
var hasDot = false;
for (app_id) |char| {
switch (char) {
'a'...'z', 'A'...'Z', '0'...'9', '_', '-' => {},
'.' => hasDot = true,
else => return false,
}
}
if (!hasDot) return false;
return true;
}
test "isValidAppId" {
try testing.expect(isValidAppId("foo.bar"));
try testing.expect(isValidAppId("foo.bar.baz"));
try testing.expect(!isValidAppId("foo"));
try testing.expect(!isValidAppId("foo.bar?"));
try testing.expect(!isValidAppId("foo."));
try testing.expect(!isValidAppId(".foo"));
try testing.expect(!isValidAppId(""));
try testing.expect(!isValidAppId("foo" ** 86));
}

View File

@@ -18,88 +18,37 @@ pub fn init(
/// The minor version of the minimum Adwaita version that is required to use
/// this resource.
comptime minor: u16,
/// `blp` signifies that the resource is a Blueprint that has been compiled
/// to GTK Builder XML at compile time. `ui` signifies that the resource is
/// a GTK Builder XML file that is included in the Ghostty source (perhaps
/// because the Blueprint compiler on some target platforms cannot compile a
/// Blueprint that generates the necessary resources).
comptime kind: enum { blp, ui },
) Builder {
const resource_path = comptime resource_path: {
const gresource = @import("gresource.zig");
switch (kind) {
.blp => {
// Check to make sure that our file is listed as a
// `blueprint_file` in `gresource.zig`. If it isn't Ghostty
// could crash at runtime when we try and load a nonexistent
// GResource.
for (gresource.blueprint_files) |file| {
if (major != file.major or minor != file.minor or !std.mem.eql(u8, file.name, name)) continue;
// Use @embedFile to make sure that the `.blp` file exists
// at compile time. Zig _should_ discard the data so that
// it doesn't end up in the final executable. At runtime we
// will load the data from a GResource.
const blp_filename = std.fmt.comptimePrint(
"ui/{d}.{d}/{s}.blp",
.{
file.major,
file.minor,
file.name,
},
);
_ = @embedFile(blp_filename);
break :resource_path std.fmt.comptimePrint(
"/com/mitchellh/ghostty/ui/{d}.{d}/{s}.ui",
.{
file.major,
file.minor,
file.name,
},
);
} else @compileError("missing blueprint file '" ++ name ++ "' in gresource.zig");
},
.ui => {
// Check to make sure that our file is listed as a `ui_file` in
// `gresource.zig`. If it isn't Ghostty could crash at runtime
// when we try and load a nonexistent GResource.
for (gresource.ui_files) |file| {
if (major != file.major or minor != file.minor or !std.mem.eql(u8, file.name, name)) continue;
// Use @embedFile to make sure that the `.ui` file exists
// at compile time. Zig _should_ discard the data so that
// it doesn't end up in the final executable. At runtime we
// will load the data from a GResource.
const ui_filename = std.fmt.comptimePrint(
"ui/{d}.{d}/{s}.ui",
.{
file.major,
file.minor,
file.name,
},
);
_ = @embedFile(ui_filename);
// Also use @embedFile to make sure that a matching `.blp`
// file exists at compile time. Zig _should_ discard the
// data so that it doesn't end up in the final executable.
const blp_filename = std.fmt.comptimePrint(
"ui/{d}.{d}/{s}.blp",
.{
file.major,
file.minor,
file.name,
},
);
_ = @embedFile(blp_filename);
break :resource_path std.fmt.comptimePrint(
"/com/mitchellh/ghostty/ui/{d}.{d}/{s}.ui",
.{
file.major,
file.minor,
file.name,
},
);
} else @compileError("missing ui file '" ++ name ++ "' in gresource.zig");
},
}
// Check to make sure that our file is listed as a
// `blueprint_file` in `gresource.zig`. If it isn't Ghostty
// could crash at runtime when we try and load a nonexistent
// GResource.
for (gresource.blueprint_files) |file| {
if (major != file.major or minor != file.minor or !std.mem.eql(u8, file.name, name)) continue;
// Use @embedFile to make sure that the `.blp` file exists
// at compile time. Zig _should_ discard the data so that
// it doesn't end up in the final executable. At runtime we
// will load the data from a GResource.
const blp_filename = std.fmt.comptimePrint(
"ui/{d}.{d}/{s}.blp",
.{
file.major,
file.minor,
file.name,
},
);
_ = @embedFile(blp_filename);
break :resource_path std.fmt.comptimePrint(
"/com/mitchellh/ghostty/ui/{d}.{d}/{s}.ui",
.{
file.major,
file.minor,
file.name,
},
);
} else @compileError("missing blueprint file '" ++ name ++ "' in gresource.zig");
};
return .{

View File

@@ -69,16 +69,16 @@ fn init(
request: apprt.ClipboardRequest,
is_secure_input: bool,
) !void {
var builder = switch (DialogType) {
var builder: Builder = switch (DialogType) {
adw.AlertDialog => switch (request) {
.osc_52_read => Builder.init("ccw-osc-52-read", 1, 5, .blp),
.osc_52_write => Builder.init("ccw-osc-52-write", 1, 5, .blp),
.paste => Builder.init("ccw-paste", 1, 5, .blp),
.osc_52_read => .init("ccw-osc-52-read", 1, 5),
.osc_52_write => .init("ccw-osc-52-write", 1, 5),
.paste => .init("ccw-paste", 1, 5),
},
adw.MessageDialog => switch (request) {
.osc_52_read => Builder.init("ccw-osc-52-read", 1, 2, .ui),
.osc_52_write => Builder.init("ccw-osc-52-write", 1, 2, .ui),
.paste => Builder.init("ccw-paste", 1, 2, .ui),
.osc_52_read => .init("ccw-osc-52-read", 1, 2),
.osc_52_write => .init("ccw-osc-52-write", 1, 2),
.paste => .init("ccw-paste", 1, 2),
},
else => unreachable,
};
@@ -152,7 +152,7 @@ fn init(
}
}
fn gtkResponse(_: *DialogType, response: [*:0]u8, self: *ClipboardConfirmation) callconv(.C) void {
fn gtkResponse(_: *DialogType, response: [*:0]u8, self: *ClipboardConfirmation) callconv(.c) void {
if (std.mem.orderZ(u8, response, "ok") == .eq) {
self.core_surface.completeClipboardRequest(
self.pending_req,
@@ -165,7 +165,7 @@ fn gtkResponse(_: *DialogType, response: [*:0]u8, self: *ClipboardConfirmation)
self.destroy();
}
fn gtkRevealButtonClicked(_: *gtk.Button, self: *ClipboardConfirmation) callconv(.C) void {
fn gtkRevealButtonClicked(_: *gtk.Button, self: *ClipboardConfirmation) callconv(.c) void {
self.text_view_scroll.as(gtk.Widget).setSensitive(@intFromBool(true));
self.text_view.as(gtk.Widget).removeCssClass("blurred");
@@ -173,7 +173,7 @@ fn gtkRevealButtonClicked(_: *gtk.Button, self: *ClipboardConfirmation) callconv
self.reveal_button.as(gtk.Widget).setVisible(@intFromBool(false));
}
fn gtkHideButtonClicked(_: *gtk.Button, self: *ClipboardConfirmation) callconv(.C) void {
fn gtkHideButtonClicked(_: *gtk.Button, self: *ClipboardConfirmation) callconv(.c) void {
self.text_view_scroll.as(gtk.Widget).setSensitive(@intFromBool(false));
self.text_view.as(gtk.Widget).addCssClass("blurred");

View File

@@ -64,7 +64,7 @@ fn responseCallback(
_: *DialogType,
response: [*:0]const u8,
target: *Target,
) callconv(.C) void {
) callconv(.c) void {
const alloc = target.allocator();
defer alloc.destroy(target);
@@ -141,7 +141,7 @@ pub const Target = union(enum) {
}
};
fn findActiveWindow(data: ?*const anyopaque, _: ?*const anyopaque) callconv(.C) c_int {
fn findActiveWindow(data: ?*const anyopaque, _: ?*const anyopaque) callconv(.c) c_int {
const window: *gtk.Window = @ptrCast(@alignCast(@constCast(data orelse return -1)));
// Confusingly, `isActive` returns 1 when active,

View File

@@ -0,0 +1,252 @@
const CommandPalette = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
const adw = @import("adw");
const gio = @import("gio");
const gobject = @import("gobject");
const gtk = @import("gtk");
const configpkg = @import("../../config.zig");
const inputpkg = @import("../../input.zig");
const key = @import("key.zig");
const Builder = @import("Builder.zig");
const Window = @import("Window.zig");
const log = std.log.scoped(.command_palette);
window: *Window,
arena: std.heap.ArenaAllocator,
/// The dialog object containing the palette UI.
dialog: *adw.Dialog,
/// The search input text field.
search: *gtk.SearchEntry,
/// The view containing each result row.
view: *gtk.ListView,
/// The model that provides filtered data for the view to display.
model: *gtk.SingleSelection,
/// The list that serves as the data source of the model.
/// This is where all command data is ultimately stored.
source: *gio.ListStore,
pub fn init(self: *CommandPalette, window: *Window) !void {
// Register the custom command type *before* initializing the builder
// If we don't do this now, the builder will complain that it doesn't know
// about this type and fail to initialize
_ = Command.getGObjectType();
var builder = Builder.init("command-palette", 1, 5);
defer builder.deinit();
self.* = .{
.window = window,
.arena = .init(window.app.core_app.alloc),
.dialog = builder.getObject(adw.Dialog, "command-palette").?,
.search = builder.getObject(gtk.SearchEntry, "search").?,
.view = builder.getObject(gtk.ListView, "view").?,
.model = builder.getObject(gtk.SingleSelection, "model").?,
.source = builder.getObject(gio.ListStore, "source").?,
};
// Manually take a reference here so that the dialog
// remains in memory after closing
self.dialog.ref();
errdefer self.dialog.unref();
_ = gtk.SearchEntry.signals.stop_search.connect(
self.search,
*CommandPalette,
searchStopped,
self,
.{},
);
_ = gtk.SearchEntry.signals.activate.connect(
self.search,
*CommandPalette,
searchActivated,
self,
.{},
);
_ = gtk.ListView.signals.activate.connect(
self.view,
*CommandPalette,
rowActivated,
self,
.{},
);
try self.updateConfig(&self.window.app.config);
}
pub fn deinit(self: *CommandPalette) void {
self.arena.deinit();
self.dialog.unref();
}
pub fn toggle(self: *CommandPalette) void {
self.dialog.present(self.window.window.as(gtk.Widget));
// Focus on the search bar when opening the dialog
_ = self.search.as(gtk.Widget).grabFocus();
}
pub fn updateConfig(self: *CommandPalette, config: *const configpkg.Config) !void {
// Clear existing binds and clear allocated data
self.source.removeAll();
_ = self.arena.reset(.retain_capacity);
for (config.@"command-palette-entry".value.items) |command| {
// Filter out actions that are not implemented
// or don't make sense for GTK
switch (command.action) {
.close_all_windows,
.toggle_secure_input,
.check_for_updates,
.redo,
.undo,
.reset_window_size,
.toggle_window_float_on_top,
=> continue,
else => {},
}
const cmd = try Command.new(
self.arena.allocator(),
command,
config.keybind.set,
);
const cmd_ref = cmd.as(gobject.Object);
self.source.append(cmd_ref);
cmd_ref.unref();
}
}
fn activated(self: *CommandPalette, pos: c_uint) void {
// Use self.model and not self.source here to use the list of *visible* results
const object = self.model.as(gio.ListModel).getObject(pos) orelse return;
const cmd = gobject.ext.cast(Command, object) orelse return;
// Close before running the action in order to avoid being replaced by another
// dialog (such as the change title dialog). If that occurs then the command
// palette dialog won't be counted as having closed properly and cannot
// receive focus when reopened.
_ = self.dialog.close();
const action = inputpkg.Binding.Action.parse(
std.mem.span(cmd.cmd_c.action_key),
) catch |err| {
log.err("got invalid action={s} ({})", .{ cmd.cmd_c.action_key, err });
return;
};
self.window.performBindingAction(action);
}
fn searchStopped(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void {
// ESC was pressed - close the palette
_ = self.dialog.close();
}
fn searchActivated(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void {
// If Enter is pressed, activate the selected entry
self.activated(self.model.getSelected());
}
fn rowActivated(_: *gtk.ListView, pos: c_uint, self: *CommandPalette) callconv(.c) void {
self.activated(pos);
}
/// Object that wraps around a command.
///
/// As GTK list models only accept objects that are within the GObject hierarchy,
/// we have to construct a wrapper to be easily consumed by the list model.
const Command = extern struct {
parent: Parent,
cmd_c: inputpkg.Command.C,
pub const getGObjectType = gobject.ext.defineClass(Command, .{
.name = "GhosttyCommand",
.classInit = Class.init,
});
pub fn new(alloc: Allocator, cmd: inputpkg.Command, keybinds: inputpkg.Binding.Set) !*Command {
const self = gobject.ext.newInstance(Command, .{});
var buf: [64]u8 = undefined;
const action = action: {
const trigger = keybinds.getTrigger(cmd.action) orelse break :action null;
const accel = try key.accelFromTrigger(&buf, trigger) orelse break :action null;
break :action try alloc.dupeZ(u8, accel);
};
self.cmd_c = .{
.title = cmd.title.ptr,
.description = cmd.description.ptr,
.action = if (action) |v| v.ptr else "",
.action_key = try std.fmt.allocPrintZ(alloc, "{}", .{cmd.action}),
};
return self;
}
fn as(self: *Command, comptime T: type) *T {
return gobject.ext.as(T, self);
}
pub const Parent = gobject.Object;
pub const Class = extern struct {
parent: Parent.Class,
pub const Instance = Command;
pub fn init(class: *Class) callconv(.c) void {
const info = @typeInfo(inputpkg.Command.C).@"struct";
// Expose all fields on the Command.C struct as properties
// that can be accessed by the GObject type system
// (and by extension, blueprints)
const properties = comptime props: {
var props: [info.fields.len]type = undefined;
for (info.fields, 0..) |field, i| {
const accessor = struct {
fn getter(cmd: *Command) ?[:0]const u8 {
return std.mem.span(@field(cmd.cmd_c, field.name));
}
};
// "Canonicalize" field names into the format GObject expects
const prop_name = prop_name: {
var buf: [field.name.len:0]u8 = undefined;
_ = std.mem.replace(u8, field.name, "_", "-", &buf);
break :prop_name buf;
};
props[i] = gobject.ext.defineProperty(
&prop_name,
Command,
?[:0]const u8,
.{
.default = null,
.accessor = .{ .getter = &accessor.getter },
},
);
}
break :props props;
};
gobject.ext.registerProperties(class, &properties);
}
};
};

View File

@@ -29,15 +29,38 @@ error_message: *gtk.TextBuffer,
pub fn maybePresent(app: *App, window: ?*Window) void {
if (app.config._diagnostics.empty()) return;
var builder = switch (DialogType) {
adw.AlertDialog => Builder.init("config-errors-dialog", 1, 5, .blp),
adw.MessageDialog => Builder.init("config-errors-dialog", 1, 2, .ui),
else => unreachable,
};
defer builder.deinit();
const config_errors_dialog = config_errors_dialog: {
if (app.config_errors_dialog) |config_errors_dialog| break :config_errors_dialog config_errors_dialog;
const dialog = builder.getObject(DialogType, "config_errors_dialog").?;
const error_message = builder.getObject(gtk.TextBuffer, "error_message").?;
var builder: Builder = switch (DialogType) {
adw.AlertDialog => .init("config-errors-dialog", 1, 5),
adw.MessageDialog => .init("config-errors-dialog", 1, 2),
else => unreachable,
};
const dialog = builder.getObject(DialogType, "config_errors_dialog").?;
const error_message = builder.getObject(gtk.TextBuffer, "error_message").?;
_ = DialogType.signals.response.connect(dialog, *App, onResponse, app, .{});
app.config_errors_dialog = .{
.builder = builder,
.dialog = dialog,
.error_message = error_message,
};
break :config_errors_dialog app.config_errors_dialog.?;
};
{
var start = std.mem.zeroes(gtk.TextIter);
config_errors_dialog.error_message.getStartIter(&start);
var end = std.mem.zeroes(gtk.TextIter);
config_errors_dialog.error_message.getEndIter(&end);
config_errors_dialog.error_message.delete(&start, &end);
}
var msg_buf: [4095:0]u8 = undefined;
var fbs = std.io.fixedBufferStream(&msg_buf);
@@ -52,22 +75,24 @@ pub fn maybePresent(app: *App, window: ?*Window) void {
continue;
};
error_message.insertAtCursor(&msg_buf, @intCast(fbs.pos));
error_message.insertAtCursor("\n", 1);
config_errors_dialog.error_message.insertAtCursor(&msg_buf, @intCast(fbs.pos));
config_errors_dialog.error_message.insertAtCursor("\n", 1);
}
_ = DialogType.signals.response.connect(dialog, *App, onResponse, app, .{});
const parent = if (window) |w| w.window.as(gtk.Widget) else null;
switch (DialogType) {
adw.AlertDialog => dialog.as(adw.Dialog).present(parent),
adw.MessageDialog => dialog.as(gtk.Window).present(),
adw.AlertDialog => {
const parent = if (window) |w| w.window.as(gtk.Widget) else null;
config_errors_dialog.dialog.as(adw.Dialog).present(parent);
},
adw.MessageDialog => config_errors_dialog.dialog.as(gtk.Window).present(),
else => unreachable,
}
}
fn onResponse(_: *DialogType, response: [*:0]const u8, app: *App) callconv(.C) void {
fn onResponse(_: *DialogType, response: [*:0]const u8, app: *App) callconv(.c) void {
if (app.config_errors_dialog) |config_errors_dialog| config_errors_dialog.builder.deinit();
app.config_errors_dialog = null;
if (std.mem.orderZ(u8, response, "reload") == .eq) {
app.reloadConfig(.app, .{}) catch |err| {
log.warn("error reloading config error={}", .{err});

View File

@@ -0,0 +1,421 @@
const GlobalShortcuts = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
const gio = @import("gio");
const glib = @import("glib");
const gobject = @import("gobject");
const App = @import("App.zig");
const configpkg = @import("../../config.zig");
const Binding = @import("../../input.zig").Binding;
const key = @import("key.zig");
const log = std.log.scoped(.global_shortcuts);
const Token = [16]u8;
app: *App,
arena: std.heap.ArenaAllocator,
dbus: *gio.DBusConnection,
/// A mapping from a unique ID to an action.
/// Currently the unique ID is simply the serialized representation of the
/// trigger that was used for the action as triggers are unique in the keymap,
/// but this may change in the future.
map: std.StringArrayHashMapUnmanaged(Binding.Action) = .{},
/// The handle of the current global shortcuts portal session,
/// as a D-Bus object path.
handle: ?[:0]const u8 = null,
/// The D-Bus signal subscription for the response signal on requests.
/// The ID is guaranteed to be non-zero, so we can use 0 to indicate null.
response_subscription: c_uint = 0,
/// The D-Bus signal subscription for the keybind activate signal.
/// The ID is guaranteed to be non-zero, so we can use 0 to indicate null.
activate_subscription: c_uint = 0,
pub fn init(alloc: Allocator, gio_app: *gio.Application) ?GlobalShortcuts {
const dbus = gio_app.getDbusConnection() orelse return null;
return .{
// To be initialized later
.app = undefined,
.arena = .init(alloc),
.dbus = dbus,
};
}
pub fn deinit(self: *GlobalShortcuts) void {
self.close();
self.arena.deinit();
}
fn close(self: *GlobalShortcuts) void {
if (self.response_subscription != 0) {
self.dbus.signalUnsubscribe(self.response_subscription);
self.response_subscription = 0;
}
if (self.activate_subscription != 0) {
self.dbus.signalUnsubscribe(self.activate_subscription);
self.activate_subscription = 0;
}
if (self.handle) |handle| {
// Close existing session
self.dbus.call(
"org.freedesktop.portal.Desktop",
handle,
"org.freedesktop.portal.Session",
"Close",
null,
null,
.{},
-1,
null,
null,
null,
);
self.handle = null;
}
}
pub fn refreshSession(self: *GlobalShortcuts, app: *App) !void {
// Ensure we have a valid reference to the app
// (it was left uninitialized in `init`)
self.app = app;
// Close any existing sessions
self.close();
// Update map
var trigger_buf: [256]u8 = undefined;
self.map.clearRetainingCapacity();
var it = self.app.config.keybind.set.bindings.iterator();
while (it.next()) |entry| {
const leaf = switch (entry.value_ptr.*) {
// Global shortcuts can't have leaders
.leader => continue,
.leaf => |leaf| leaf,
};
if (!leaf.flags.global) continue;
const trigger = try key.xdgShortcutFromTrigger(
&trigger_buf,
entry.key_ptr.*,
) orelse continue;
try self.map.put(
self.arena.allocator(),
try self.arena.allocator().dupeZ(u8, trigger),
leaf.action,
);
}
if (self.map.count() > 0) {
try self.request(.create_session);
}
}
fn shortcutActivated(
_: *gio.DBusConnection,
_: ?[*:0]const u8,
_: [*:0]const u8,
_: [*:0]const u8,
_: [*:0]const u8,
params: *glib.Variant,
ud: ?*anyopaque,
) callconv(.c) void {
const self: *GlobalShortcuts = @ptrCast(@alignCast(ud));
// 2nd value in the tuple is the activated shortcut ID
// See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-activated
var shortcut_id: [*:0]const u8 = undefined;
params.getChild(1, "&s", &shortcut_id);
log.debug("activated={s}", .{shortcut_id});
const action = self.map.get(std.mem.span(shortcut_id)) orelse return;
self.app.core_app.performAllAction(self.app, action) catch |err| {
log.err("failed to perform action={}", .{err});
};
}
const Method = enum {
create_session,
bind_shortcuts,
fn name(self: Method) [:0]const u8 {
return switch (self) {
.create_session => "CreateSession",
.bind_shortcuts => "BindShortcuts",
};
}
/// Construct the payload expected by the XDG portal call.
fn makePayload(
self: Method,
shortcuts: *GlobalShortcuts,
request_token: [:0]const u8,
) ?*glib.Variant {
switch (self) {
// See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-createsession
.create_session => {
var session_token: Token = undefined;
return glib.Variant.newParsed(
"({'handle_token': <%s>, 'session_handle_token': <%s>},)",
request_token.ptr,
generateToken(&session_token).ptr,
);
},
// See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-bindshortcuts
.bind_shortcuts => {
const handle = shortcuts.handle orelse return null;
const bind_type = glib.VariantType.new("a(sa{sv})");
defer glib.free(bind_type);
var binds: glib.VariantBuilder = undefined;
glib.VariantBuilder.init(&binds, bind_type);
var action_buf: [256]u8 = undefined;
var it = shortcuts.map.iterator();
while (it.next()) |entry| {
const trigger = entry.key_ptr.*.ptr;
const action = std.fmt.bufPrintZ(
&action_buf,
"{}",
.{entry.value_ptr.*},
) catch continue;
binds.addParsed(
"(%s, {'description': <%s>, 'preferred_trigger': <%s>})",
trigger,
action.ptr,
trigger,
);
}
return glib.Variant.newParsed(
"(%o, %*, '', {'handle_token': <%s>})",
handle.ptr,
binds.end(),
request_token.ptr,
);
},
}
}
fn onResponse(self: Method, shortcuts: *GlobalShortcuts, vardict: *glib.Variant) void {
switch (self) {
.create_session => {
var handle: ?[*:0]u8 = null;
if (vardict.lookup("session_handle", "&s", &handle) == 0) {
log.err(
"session handle not found in response={s}",
.{vardict.print(@intFromBool(true))},
);
return;
}
shortcuts.handle = shortcuts.arena.allocator().dupeZ(u8, std.mem.span(handle.?)) catch {
log.err("out of memory: failed to clone session handle", .{});
return;
};
log.debug("session_handle={?s}", .{handle});
// Subscribe to keybind activations
shortcuts.activate_subscription = shortcuts.dbus.signalSubscribe(
null,
"org.freedesktop.portal.GlobalShortcuts",
"Activated",
"/org/freedesktop/portal/desktop",
handle,
.{ .match_arg0_path = true },
shortcutActivated,
shortcuts,
null,
);
shortcuts.request(.bind_shortcuts) catch |err| {
log.err("failed to bind shortcuts={}", .{err});
return;
};
},
.bind_shortcuts => {},
}
}
};
/// Submit a request to the global shortcuts portal.
fn request(
self: *GlobalShortcuts,
comptime method: Method,
) !void {
// NOTE(pluiedev):
// XDG Portals are really, really poorly-designed pieces of hot garbage.
// How the protocol is _initially_ designed to work is as follows:
//
// 1. The client calls a method which returns the path of a Request object;
// 2. The client waits for the Response signal under said object path;
// 3. When the signal arrives, the actual return value and status code
// become available for the client for further processing.
//
// THIS DOES NOT WORK. Once the first two steps are complete, the client
// needs to immediately start listening for the third step, but an overeager
// server implementation could easily send the Response signal before the
// client is even ready, causing communications to break down over a simple
// race condition/two generals' problem that even _TCP_ had figured out
// decades ago. Worse yet, you get exactly _one_ chance to listen for the
// signal, or else your communication attempt so far has all been in vain.
//
// And they know this. Instead of fixing their freaking protocol, they just
// ask clients to manually construct the expected object path and subscribe
// to the request signal beforehand, making the whole response value of
// the original call COMPLETELY MEANINGLESS.
//
// Furthermore, this is _entirely undocumented_ aside from one tiny
// paragraph under the documentation for the Request interface, and
// anyone would be forgiven for missing it without reading the libportal
// source code.
//
// When in Rome, do as the Romans do, I guess...?
const callbacks = struct {
fn gotResponseHandle(
source: ?*gobject.Object,
res: *gio.AsyncResult,
_: ?*anyopaque,
) callconv(.c) void {
const dbus_ = gobject.ext.cast(gio.DBusConnection, source.?).?;
var err: ?*glib.Error = null;
defer if (err) |err_| err_.free();
const params_ = dbus_.callFinish(res, &err) orelse {
if (err) |err_| log.err("request failed={s} ({})", .{
err_.f_message orelse "(unknown)",
err_.f_code,
});
return;
};
defer params_.unref();
// TODO: XDG recommends updating the signal subscription if the actual
// returned request path is not the same as the expected request
// path, to retain compatibility with older versions of XDG portals.
// Although it suffers from the race condition outlined above,
// we should still implement this at some point.
}
// See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html#org-freedesktop-portal-request-response
fn responded(
dbus: *gio.DBusConnection,
_: ?[*:0]const u8,
_: [*:0]const u8,
_: [*:0]const u8,
_: [*:0]const u8,
params_: *glib.Variant,
ud: ?*anyopaque,
) callconv(.c) void {
const self_: *GlobalShortcuts = @ptrCast(@alignCast(ud));
// Unsubscribe from the response signal
if (self_.response_subscription != 0) {
dbus.signalUnsubscribe(self_.response_subscription);
self_.response_subscription = 0;
}
var response: u32 = 0;
var vardict: ?*glib.Variant = null;
params_.get("(u@a{sv})", &response, &vardict);
switch (response) {
0 => {
log.debug("request successful", .{});
method.onResponse(self_, vardict.?);
},
1 => log.debug("request was cancelled by user", .{}),
2 => log.warn("request ended unexpectedly", .{}),
else => log.err("unrecognized response code={}", .{response}),
}
}
};
var request_token_buf: Token = undefined;
const request_token = generateToken(&request_token_buf);
const payload = method.makePayload(self, request_token) orelse return;
const request_path = try self.getRequestPath(request_token);
self.response_subscription = self.dbus.signalSubscribe(
null,
"org.freedesktop.portal.Request",
"Response",
request_path,
null,
.{},
callbacks.responded,
self,
null,
);
self.dbus.call(
"org.freedesktop.portal.Desktop",
"/org/freedesktop/portal/desktop",
"org.freedesktop.portal.GlobalShortcuts",
method.name(),
payload,
null,
.{},
-1,
null,
callbacks.gotResponseHandle,
null,
);
}
/// Generate a random token suitable for use in requests.
fn generateToken(buf: *Token) [:0]const u8 {
// u28 takes up 7 bytes in hex, 8 bytes for "ghostty_" and 1 byte for NUL
// 7 + 8 + 1 = 16
return std.fmt.bufPrintZ(
buf,
"ghostty_{x:0<7}",
.{std.crypto.random.int(u28)},
) catch unreachable;
}
/// Get the XDG portal request path for the current Ghostty instance.
///
/// If this sounds like nonsense, see `request` for an explanation as to
/// why we need to do this.
fn getRequestPath(self: *GlobalShortcuts, token: [:0]const u8) ![:0]const u8 {
// See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html
// for the syntax XDG portals expect.
// `getUniqueName` should never return null here as we're using an ordinary
// message bus connection. If it doesn't, something is very wrong
const unique_name = std.mem.span(self.dbus.getUniqueName().?);
const object_path = try std.mem.joinZ(self.arena.allocator(), "/", &.{
"/org/freedesktop/portal/desktop/request",
unique_name[1..], // Remove leading `:`
token,
});
// Sanitize the unique name by replacing every `.` with `_`.
// In effect, this will turn a unique name like `:1.192` into `1_192`.
// Valid D-Bus object path components never contain `.`s anyway, so we're
// free to replace all instances of `.` here and avoid extra allocation.
std.mem.replaceScalar(u8, object_path, '.', '_');
return object_path;
}

View File

@@ -221,12 +221,12 @@ fn translateMouseButton(button: c_uint) ?c_int {
};
}
fn gtkDestroy(_: *gtk.GLArea, self: *ImguiWidget) callconv(.C) void {
fn gtkDestroy(_: *gtk.GLArea, self: *ImguiWidget) callconv(.c) void {
log.debug("imgui widget destroy", .{});
self.deinit();
}
fn gtkRealize(area: *gtk.GLArea, self: *ImguiWidget) callconv(.C) void {
fn gtkRealize(area: *gtk.GLArea, self: *ImguiWidget) callconv(.c) void {
log.debug("gl surface realized", .{});
// We need to make the context current so we can call GL functions.
@@ -242,7 +242,7 @@ fn gtkRealize(area: *gtk.GLArea, self: *ImguiWidget) callconv(.C) void {
_ = cimgui.ImGui_ImplOpenGL3_Init(null);
}
fn gtkUnrealize(area: *gtk.GLArea, self: *ImguiWidget) callconv(.C) void {
fn gtkUnrealize(area: *gtk.GLArea, self: *ImguiWidget) callconv(.c) void {
_ = area;
log.debug("gl surface unrealized", .{});
@@ -250,7 +250,7 @@ fn gtkUnrealize(area: *gtk.GLArea, self: *ImguiWidget) callconv(.C) void {
cimgui.ImGui_ImplOpenGL3_Shutdown();
}
fn gtkResize(area: *gtk.GLArea, width: c_int, height: c_int, self: *ImguiWidget) callconv(.C) void {
fn gtkResize(area: *gtk.GLArea, width: c_int, height: c_int, self: *ImguiWidget) callconv(.c) void {
cimgui.c.igSetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
const scale_factor = area.as(gtk.Widget).getScaleFactor();
@@ -273,7 +273,7 @@ fn gtkResize(area: *gtk.GLArea, width: c_int, height: c_int, self: *ImguiWidget)
active_style.* = style.*;
}
fn gtkRender(_: *gtk.GLArea, _: *gdk.GLContext, self: *ImguiWidget) callconv(.C) c_int {
fn gtkRender(_: *gtk.GLArea, _: *gdk.GLContext, self: *ImguiWidget) callconv(.c) c_int {
cimgui.c.igSetCurrentContext(self.ig_ctx);
// Setup our frame. We render twice because some ImGui behaviors
@@ -307,7 +307,7 @@ fn gtkMouseMotion(
x: f64,
y: f64,
self: *ImguiWidget,
) callconv(.C) void {
) callconv(.c) void {
cimgui.c.igSetCurrentContext(self.ig_ctx);
const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO();
const scale_factor: f64 = @floatFromInt(self.gl_area.as(gtk.Widget).getScaleFactor());
@@ -325,7 +325,7 @@ fn gtkMouseDown(
_: f64,
_: f64,
self: *ImguiWidget,
) callconv(.C) void {
) callconv(.c) void {
self.queueRender();
cimgui.c.igSetCurrentContext(self.ig_ctx);
@@ -343,7 +343,7 @@ fn gtkMouseUp(
_: f64,
_: f64,
self: *ImguiWidget,
) callconv(.C) void {
) callconv(.c) void {
self.queueRender();
cimgui.c.igSetCurrentContext(self.ig_ctx);
@@ -359,7 +359,7 @@ fn gtkMouseScroll(
x: f64,
y: f64,
self: *ImguiWidget,
) callconv(.C) c_int {
) callconv(.c) c_int {
self.queueRender();
cimgui.c.igSetCurrentContext(self.ig_ctx);
@@ -373,7 +373,7 @@ fn gtkMouseScroll(
return @intFromBool(true);
}
fn gtkFocusEnter(_: *gtk.EventControllerFocus, self: *ImguiWidget) callconv(.C) void {
fn gtkFocusEnter(_: *gtk.EventControllerFocus, self: *ImguiWidget) callconv(.c) void {
self.queueRender();
cimgui.c.igSetCurrentContext(self.ig_ctx);
@@ -381,7 +381,7 @@ fn gtkFocusEnter(_: *gtk.EventControllerFocus, self: *ImguiWidget) callconv(.C)
cimgui.c.ImGuiIO_AddFocusEvent(io, true);
}
fn gtkFocusLeave(_: *gtk.EventControllerFocus, self: *ImguiWidget) callconv(.C) void {
fn gtkFocusLeave(_: *gtk.EventControllerFocus, self: *ImguiWidget) callconv(.c) void {
self.queueRender();
cimgui.c.igSetCurrentContext(self.ig_ctx);
@@ -393,7 +393,7 @@ fn gtkInputCommit(
_: *gtk.IMMulticontext,
bytes: [*:0]u8,
self: *ImguiWidget,
) callconv(.C) void {
) callconv(.c) void {
self.queueRender();
cimgui.c.igSetCurrentContext(self.ig_ctx);
@@ -407,7 +407,7 @@ fn gtkKeyPressed(
keycode: c_uint,
gtk_mods: gdk.ModifierType,
self: *ImguiWidget,
) callconv(.C) c_int {
) callconv(.c) c_int {
return @intFromBool(self.keyEvent(
.press,
ec_key,
@@ -423,7 +423,7 @@ fn gtkKeyReleased(
keycode: c_uint,
gtk_mods: gdk.ModifierType,
self: *ImguiWidget,
) callconv(.C) void {
) callconv(.c) void {
_ = self.keyEvent(
.release,
ec_key,

View File

@@ -50,12 +50,12 @@ first: bool = true,
pub fn init(self: *ResizeOverlay, surface: *Surface, config: *const configpkg.Config) void {
self.* = .{
.surface = surface,
.config = DerivedConfig.init(config),
.config = .init(config),
};
}
pub fn updateConfig(self: *ResizeOverlay, config: *const configpkg.Config) void {
self.config = DerivedConfig.init(config);
self.config = .init(config);
}
/// De-initialize the ResizeOverlay. This removes any pending idlers/timers that
@@ -104,7 +104,7 @@ pub fn maybeShow(self: *ResizeOverlay) void {
/// Actually update the overlay widget. This should only be called from a GTK
/// idle handler.
fn gtkUpdate(ud: ?*anyopaque) callconv(.C) c_int {
fn gtkUpdate(ud: ?*anyopaque) callconv(.c) c_int {
const self: *ResizeOverlay = @ptrCast(@alignCast(ud orelse return 0));
// No matter what our idler is complete with this callback
@@ -198,7 +198,7 @@ fn setPosition(label: *gtk.Label, config: *DerivedConfig) void {
/// If this fires, it means that the delay period has expired and the resize
/// overlay widget should be hidden.
fn gtkTimerExpired(ud: ?*anyopaque) callconv(.C) c_int {
fn gtkTimerExpired(ud: ?*anyopaque) callconv(.c) c_int {
const self: *ResizeOverlay = @ptrCast(@alignCast(ud orelse return 0));
self.timer = null;
if (self.label) |label| hide(label);

View File

@@ -138,7 +138,7 @@ pub fn init(
.container = container,
.top_left = .{ .surface = tl },
.bottom_right = .{ .surface = br },
.orientation = Orientation.fromDirection(direction),
.orientation = .fromDirection(direction),
};
// Replace the previous containers element with our split. This allows a

View File

@@ -41,10 +41,6 @@ const adw_version = @import("adw_version.zig");
const log = std.log.scoped(.gtk_surface);
/// This is detected by the OpenGL renderer to move to a single-threaded
/// draw operation. This basically puts locks around our draw path.
pub const opengl_single_threaded_draw = true;
pub const Options = struct {
/// The parent surface to inherit settings such as font size, working
/// directory, etc. from.
@@ -394,7 +390,10 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void {
// Various other GL properties
gl_area_widget.setCursorFromName("text");
gl_area.setRequiredVersion(3, 3);
gl_area.setRequiredVersion(
renderer.OpenGL.MIN_VERSION_MAJOR,
renderer.OpenGL.MIN_VERSION_MINOR,
);
gl_area.setHasStencilBuffer(0);
gl_area.setHasDepthBuffer(0);
gl_area.setUseEs(0);
@@ -683,12 +682,13 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void {
fn realize(self: *Surface) !void {
// If this surface has already been realized, then we don't need to
// reinitialize. This can happen if a surface is moved from one GDK surface
// to another (i.e. a tab is pulled out into a window).
// reinitialize. This can happen if a surface is moved from one GDK
// surface to another (i.e. a tab is pulled out into a window).
if (self.realized) {
// If we have no OpenGL state though, we do need to reinitialize.
// We allow the renderer to figure that out
try self.core_surface.renderer.displayRealize();
// We allow the renderer to figure that out, and then queue a draw.
try self.core_surface.renderer.displayRealized();
self.redraw();
return;
}
@@ -746,7 +746,21 @@ pub fn deinit(self: *Surface) void {
self.core_surface.deinit();
self.core_surface = undefined;
if (self.cgroup_path) |path| self.app.core_app.alloc.free(path);
// Remove the cgroup if we have one. We do this after deiniting the core
// surface to ensure all processes have exited.
if (self.cgroup_path) |path| {
internal_os.cgroup.remove(path) catch |err| {
// We don't want this to be fatal in any way so we just log
// and continue. A dangling empty cgroup is not a big deal
// and this should be rare.
log.warn(
"failed to remove cgroup for surface path={s} err={}",
.{ path, err },
);
};
self.app.core_app.alloc.free(path);
}
// Free all our GTK stuff
//
@@ -780,7 +794,7 @@ pub fn primaryWidget(self: *Surface) *gtk.Widget {
}
fn render(self: *Surface) !void {
try self.core_surface.renderer.drawFrame(self);
try self.core_surface.renderer.drawFrame(true);
}
/// Called by core surface to get the cgroup.
@@ -1025,7 +1039,7 @@ pub fn setTitle(self: *Surface, slice: [:0]const u8, source: SetTitleSource) !vo
self.update_title_timer = glib.timeoutAdd(75, updateTitleTimerExpired, self);
}
fn updateTitleTimerExpired(ud: ?*anyopaque) callconv(.C) c_int {
fn updateTitleTimerExpired(ud: ?*anyopaque) callconv(.c) c_int {
const self: *Surface = @ptrCast(@alignCast(ud.?));
self.updateTitleLabels();
@@ -1061,7 +1075,7 @@ pub fn promptTitle(self: *Surface) !void {
if (!adw_version.atLeast(1, 5, 0)) return;
const window = self.container.window() orelse return;
var builder = Builder.init("prompt-title-dialog", 1, 5, .blp);
var builder = Builder.init("prompt-title-dialog", 1, 5);
defer builder.deinit();
const entry = builder.getObject(gtk.Entry, "title_entry").?;
@@ -1191,7 +1205,7 @@ pub fn mouseOverLink(self: *Surface, uri_: ?[]const u8) void {
return;
}
self.url_widget = URLWidget.init(self.overlay, uriZ);
self.url_widget = .init(self.overlay, uriZ);
}
pub fn supportsClipboard(
@@ -1265,7 +1279,7 @@ fn gtkClipboardRead(
source: ?*gobject.Object,
res: *gio.AsyncResult,
ud: ?*anyopaque,
) callconv(.C) void {
) callconv(.c) void {
const clipboard = gobject.ext.cast(gdk.Clipboard, source orelse return) orelse return;
const req: *ClipboardRequest = @ptrCast(@alignCast(ud orelse return));
const self = req.self;
@@ -1349,7 +1363,7 @@ pub fn showDesktopNotification(
app.sendNotification(body.ptr, notification);
}
fn gtkRealize(gl_area: *gtk.GLArea, self: *Surface) callconv(.C) void {
fn gtkRealize(gl_area: *gtk.GLArea, self: *Surface) callconv(.c) void {
log.debug("gl surface realized", .{});
// We need to make the context current so we can call GL functions.
@@ -1377,7 +1391,7 @@ fn gtkRealize(gl_area: *gtk.GLArea, self: *Surface) callconv(.C) void {
/// This is called when the underlying OpenGL resources must be released.
/// This is usually due to the OpenGL area changing GDK surfaces.
fn gtkUnrealize(gl_area: *gtk.GLArea, self: *Surface) callconv(.C) void {
fn gtkUnrealize(gl_area: *gtk.GLArea, self: *Surface) callconv(.c) void {
log.debug("gl surface unrealized", .{});
// See gtkRealize for why we do this here.
@@ -1405,7 +1419,7 @@ fn gtkUnrealize(gl_area: *gtk.GLArea, self: *Surface) callconv(.C) void {
}
/// render signal
fn gtkRender(_: *gtk.GLArea, _: *gdk.GLContext, self: *Surface) callconv(.C) c_int {
fn gtkRender(_: *gtk.GLArea, _: *gdk.GLContext, self: *Surface) callconv(.c) c_int {
self.render() catch |err| {
log.err("surface failed to render: {}", .{err});
return 0;
@@ -1415,7 +1429,7 @@ fn gtkRender(_: *gtk.GLArea, _: *gdk.GLContext, self: *Surface) callconv(.C) c_i
}
/// resize signal
fn gtkResize(gl_area: *gtk.GLArea, width: c_int, height: c_int, self: *Surface) callconv(.C) void {
fn gtkResize(gl_area: *gtk.GLArea, width: c_int, height: c_int, self: *Surface) callconv(.c) void {
// Some debug output to help understand what GTK is telling us.
{
const scale_factor = scale: {
@@ -1471,7 +1485,7 @@ fn gtkResize(gl_area: *gtk.GLArea, width: c_int, height: c_int, self: *Surface)
}
/// "destroy" signal for surface
fn gtkDestroy(_: *gtk.GLArea, self: *Surface) callconv(.C) void {
fn gtkDestroy(_: *gtk.GLArea, self: *Surface) callconv(.c) void {
log.debug("gl destroy", .{});
const alloc = self.app.core_app.alloc;
@@ -1505,7 +1519,7 @@ fn gtkMouseDown(
x: f64,
y: f64,
self: *Surface,
) callconv(.C) void {
) callconv(.c) void {
const event = gesture.as(gtk.EventController).getCurrentEvent() orelse return;
const gtk_mods = event.getModifierState();
@@ -1538,7 +1552,7 @@ fn gtkMouseUp(
_: f64,
_: f64,
self: *Surface,
) callconv(.C) void {
) callconv(.c) void {
const event = gesture.as(gtk.EventController).getCurrentEvent() orelse return;
const gtk_mods = event.getModifierState();
@@ -1557,13 +1571,13 @@ fn gtkMouseMotion(
x: f64,
y: f64,
self: *Surface,
) callconv(.C) void {
) callconv(.c) void {
const event = ec.as(gtk.EventController).getCurrentEvent() orelse return;
const scaled = self.scaledCoordinates(x, y);
const pos: apprt.CursorPos = .{
.x = @floatCast(@max(0, scaled.x)),
.x = @floatCast(scaled.x),
.y = @floatCast(scaled.y),
};
@@ -1603,7 +1617,7 @@ fn gtkMouseMotion(
fn gtkMouseLeave(
ec_motion: *gtk.EventControllerMotion,
self: *Surface,
) callconv(.C) void {
) callconv(.c) void {
const event = ec_motion.as(gtk.EventController).getCurrentEvent() orelse return;
// Get our modifiers
@@ -1618,14 +1632,14 @@ fn gtkMouseLeave(
fn gtkMouseScrollPrecisionBegin(
_: *gtk.EventControllerScroll,
self: *Surface,
) callconv(.C) void {
) callconv(.c) void {
self.precision_scroll = true;
}
fn gtkMouseScrollPrecisionEnd(
_: *gtk.EventControllerScroll,
self: *Surface,
) callconv(.C) void {
) callconv(.c) void {
self.precision_scroll = false;
}
@@ -1634,7 +1648,7 @@ fn gtkMouseScroll(
x: f64,
y: f64,
self: *Surface,
) callconv(.C) c_int {
) callconv(.c) c_int {
const scaled = self.scaledCoordinates(x, y);
// GTK doesn't support any of the scroll mods.
@@ -1664,7 +1678,7 @@ fn gtkKeyPressed(
keycode: c_uint,
gtk_mods: gdk.ModifierType,
self: *Surface,
) callconv(.C) c_int {
) callconv(.c) c_int {
return @intFromBool(self.keyEvent(
.press,
ec_key,
@@ -1680,7 +1694,7 @@ fn gtkKeyReleased(
keycode: c_uint,
state: gdk.ModifierType,
self: *Surface,
) callconv(.C) void {
) callconv(.c) void {
_ = self.keyEvent(
.release,
ec_key,
@@ -1840,7 +1854,7 @@ pub fn keyEvent(
// (These are keybinds explicitly marked as requesting physical mapping).
const physical_key = keycode: for (input.keycodes.entries) |entry| {
if (entry.native == keycode) break :keycode entry.key;
} else .invalid;
} else .unidentified;
// Get our modifier for the event
const mods: input.Mods = gtk_key.eventMods(
@@ -1861,52 +1875,6 @@ pub fn keyEvent(
break :consumed gtk_key.translateMods(@bitCast(masked));
};
// If we're not in a dead key state, we want to translate our text
// to some input.Key.
const key = if (!self.im_composing) key: {
// First, try to convert the keyval directly to a key. This allows the
// use of key remapping and identification of keypad numerics (as
// opposed to their ASCII counterparts)
if (gtk_key.keyFromKeyval(keyval)) |key| {
break :key key;
}
// A completed key. If the length of the key is one then we can
// attempt to translate it to a key enum and call the key
// callback. First try plain ASCII.
if (self.im_len > 0) {
if (input.Key.fromASCII(self.im_buf[0])) |key| {
break :key key;
}
}
// If that doesn't work then we try to translate the kevval..
if (keyval_unicode != 0) {
if (std.math.cast(u8, keyval_unicode)) |byte| {
if (input.Key.fromASCII(byte)) |key| {
break :key key;
}
}
}
// If that doesn't work we use the unshifted value...
if (std.math.cast(u8, keyval_unicode_unshifted)) |ascii| {
if (input.Key.fromASCII(ascii)) |key| {
break :key key;
}
}
// If we have im text then this is invalid. This means that
// the keypress generated some character that we don't know about
// in our key enum. We don't want to use the physical key because
// it can be simply wrong. For example on "Turkish Q" the "i" key
// on a US layout results in "ı" which is not the same as "i" so
// we shouldn't use the physical key.
if (self.im_len > 0 or keyval_unicode_unshifted != 0) break :key .invalid;
break :key physical_key;
} else .invalid;
// log.debug("key pressed key={} keyval={x} physical_key={} composing={} text_len={} mods={}", .{
// key,
// keyval,
@@ -1936,8 +1904,7 @@ pub fn keyEvent(
// Invoke the core Ghostty logic to handle this input.
const effect = self.core_surface.keyCallback(.{
.action = action,
.key = key,
.physical_key = physical_key,
.key = physical_key,
.mods = mods,
.consumed_mods = consumed_mods,
.composing = self.im_composing,
@@ -1971,7 +1938,7 @@ pub fn keyEvent(
fn gtkInputPreeditStart(
_: *gtk.IMMulticontext,
self: *Surface,
) callconv(.C) void {
) callconv(.c) void {
// log.warn("GTKIM: preedit start", .{});
// Start our composing state for the input method and reset our
@@ -1983,7 +1950,7 @@ fn gtkInputPreeditStart(
fn gtkInputPreeditChanged(
ctx: *gtk.IMMulticontext,
self: *Surface,
) callconv(.C) void {
) callconv(.c) void {
// Any preedit change should mark that we're composing. Its possible this
// is false using fcitx5-hangul and typing "dkssud<space>" ("안녕"). The
// second "s" results in a "commit" for "안" which sets composing to false,
@@ -2009,7 +1976,7 @@ fn gtkInputPreeditChanged(
fn gtkInputPreeditEnd(
_: *gtk.IMMulticontext,
self: *Surface,
) callconv(.C) void {
) callconv(.c) void {
// log.warn("GTKIM: preedit end", .{});
// End our composing state for GTK, allowing us to commit the text.
@@ -2025,7 +1992,7 @@ fn gtkInputCommit(
_: *gtk.IMMulticontext,
bytes: [*:0]u8,
self: *Surface,
) callconv(.C) void {
) callconv(.c) void {
const str = std.mem.sliceTo(bytes, 0);
// log.debug("GTKIM: input commit composing={} keyevent={} str={s}", .{
@@ -2088,8 +2055,7 @@ fn gtkInputCommit(
// invalid key, which should produce no PTY encoding).
_ = self.core_surface.keyCallback(.{
.action = .press,
.key = .invalid,
.physical_key = .invalid,
.key = .unidentified,
.mods = .{},
.consumed_mods = .{},
.composing = false,
@@ -2100,7 +2066,7 @@ fn gtkInputCommit(
};
}
fn gtkFocusEnter(_: *gtk.EventControllerFocus, self: *Surface) callconv(.C) void {
fn gtkFocusEnter(_: *gtk.EventControllerFocus, self: *Surface) callconv(.c) void {
if (!self.realized) return;
// Notify our IM context
@@ -2125,7 +2091,7 @@ fn gtkFocusEnter(_: *gtk.EventControllerFocus, self: *Surface) callconv(.C) void
};
}
fn gtkFocusLeave(_: *gtk.EventControllerFocus, self: *Surface) callconv(.C) void {
fn gtkFocusLeave(_: *gtk.EventControllerFocus, self: *Surface) callconv(.c) void {
if (!self.realized) return;
// Notify our IM context
@@ -2243,7 +2209,7 @@ fn gtkDrop(
_: f64,
_: f64,
self: *Surface,
) callconv(.C) c_int {
) callconv(.c) c_int {
const alloc = self.app.core_app.alloc;
if (g_value_holds(value, gdk.FileList.getGObjectType())) {
@@ -2359,6 +2325,15 @@ pub fn defaultTermioEnv(self: *Surface) !std.process.EnvMap {
env.remove("GDK_DISABLE");
env.remove("GSK_RENDERER");
// Remove some environment variables that are set when Ghostty is launched
// from a `.desktop` file, by D-Bus activation, or systemd.
env.remove("GIO_LAUNCHED_DESKTOP_FILE");
env.remove("GIO_LAUNCHED_DESKTOP_FILE_PID");
env.remove("DBUS_STARTER_ADDRESS");
env.remove("DBUS_STARTER_BUS_TYPE");
env.remove("INVOCATION_ID");
env.remove("JOURNAL_STREAM");
// Unset environment varies set by snaps if we're running in a snap.
// This allows Ghostty to further launch additional snaps.
if (env.get("SNAP")) |_| {
@@ -2395,7 +2370,7 @@ fn g_value_holds(value_: ?*gobject.Value, g_type: gobject.Type) bool {
return false;
}
fn gtkPromptTitleResponse(source_object: ?*gobject.Object, result: *gio.AsyncResult, ud: ?*anyopaque) callconv(.C) void {
fn gtkPromptTitleResponse(source_object: ?*gobject.Object, result: *gio.AsyncResult, ud: ?*anyopaque) callconv(.c) void {
if (!adw_version.supportsDialogs()) return;
const dialog = gobject.ext.cast(adw.AlertDialog, source_object.?).?;
const self: *Surface = @ptrCast(@alignCast(ud));
@@ -2439,3 +2414,91 @@ pub fn setSecureInput(self: *Surface, value: apprt.action.SecureInput) void {
.toggle => self.is_secure_input = !self.is_secure_input,
}
}
pub fn ringBell(self: *Surface) !void {
const features = self.app.config.@"bell-features";
const window = self.container.window() orelse {
log.warn("failed to ring bell: surface is not attached to any window", .{});
return;
};
// System beep
if (features.system) system: {
const surface = window.window.as(gtk.Native).getSurface() orelse break :system;
surface.beep();
}
if (features.audio) audio: {
// Play a user-specified audio file.
const pathname, const required = switch (self.app.config.@"bell-audio-path" orelse break :audio) {
.optional => |path| .{ path, false },
.required => |path| .{ path, true },
};
const volume = std.math.clamp(self.app.config.@"bell-audio-volume", 0.0, 1.0);
std.debug.assert(std.fs.path.isAbsolute(pathname));
const media_file = gtk.MediaFile.newForFilename(pathname);
if (required) {
_ = gobject.Object.signals.notify.connect(
media_file,
?*anyopaque,
gtkStreamError,
null,
.{ .detail = "error" },
);
}
_ = gobject.Object.signals.notify.connect(
media_file,
?*anyopaque,
gtkStreamEnded,
null,
.{ .detail = "ended" },
);
const media_stream = media_file.as(gtk.MediaStream);
media_stream.setVolume(volume);
media_stream.play();
}
if (features.attention) {
// Request user attention
window.winproto.setUrgent(true) catch |err| {
log.err("failed to request user attention={}", .{err});
};
}
// Mark tab as needing attention
if (self.container.tab()) |tab| tab: {
const page = window.notebook.getTabPage(tab) orelse break :tab;
// Need attention if we're not the currently selected tab
if (page.getSelected() == 0) page.setNeedsAttention(@intFromBool(true));
}
}
/// Handle a stream that is in an error state.
fn gtkStreamError(media_file: *gtk.MediaFile, _: *gobject.ParamSpec, _: ?*anyopaque) callconv(.c) void {
const path = path: {
const file = media_file.getFile() orelse break :path null;
break :path file.getPath();
};
defer if (path) |p| glib.free(p);
const media_stream = media_file.as(gtk.MediaStream);
const err = media_stream.getError() orelse return;
log.warn("error playing bell from {s}: {s} {d} {s}", .{
path orelse "<<unknown>>",
glib.quarkToString(err.f_domain),
err.f_code,
err.f_message orelse "",
});
}
/// Stream is finished, release the memory.
fn gtkStreamEnded(media_file: *gtk.MediaFile, _: *gobject.ParamSpec, _: ?*anyopaque) callconv(.c) void {
media_file.unref();
}

View File

@@ -161,7 +161,7 @@ pub fn closeWithConfirmation(tab: *Tab) void {
}
}
fn gtkDestroy(_: *gtk.Box, self: *Tab) callconv(.C) void {
fn gtkDestroy(_: *gtk.Box, self: *Tab) callconv(.c) void {
log.debug("tab box destroy", .{});
const alloc = self.window.app.core_app.alloc;

View File

@@ -7,6 +7,7 @@ const std = @import("std");
const gtk = @import("gtk");
const adw = @import("adw");
const gobject = @import("gobject");
const glib = @import("glib");
const Window = @import("Window.zig");
const Tab = @import("Tab.zig");
@@ -114,9 +115,12 @@ pub fn gotoNthTab(self: *TabView, position: c_int) bool {
return true;
}
pub fn getTabPage(self: *TabView, tab: *Tab) ?*adw.TabPage {
return self.tab_view.getPage(tab.box.as(gtk.Widget));
}
pub fn getTabPosition(self: *TabView, tab: *Tab) ?c_int {
const page = self.tab_view.getPage(tab.box.as(gtk.Widget));
return self.tab_view.getPagePosition(page);
return self.tab_view.getPagePosition(self.getTabPage(tab) orelse return null);
}
pub fn gotoPreviousTab(self: *TabView, tab: *Tab) bool {
@@ -161,17 +165,16 @@ pub fn moveTab(self: *TabView, tab: *Tab, position: c_int) void {
}
pub fn reorderPage(self: *TabView, tab: *Tab, position: c_int) void {
const page = self.tab_view.getPage(tab.box.as(gtk.Widget));
_ = self.tab_view.reorderPage(page, position);
_ = self.tab_view.reorderPage(self.getTabPage(tab) orelse return, position);
}
pub fn setTabTitle(self: *TabView, tab: *Tab, title: [:0]const u8) void {
const page = self.tab_view.getPage(tab.box.as(gtk.Widget));
const page = self.getTabPage(tab) orelse return;
page.setTitle(title.ptr);
}
pub fn setTabTooltip(self: *TabView, tab: *Tab, tooltip: [:0]const u8) void {
const page = self.tab_view.getPage(tab.box.as(gtk.Widget));
const page = self.getTabPage(tab) orelse return;
page.setTooltip(tooltip.ptr);
}
@@ -203,8 +206,7 @@ pub fn closeTab(self: *TabView, tab: *Tab) void {
if (n > 1) self.forcing_close = false;
}
const page = self.tab_view.getPage(tab.box.as(gtk.Widget));
self.tab_view.closePage(page);
if (self.getTabPage(tab)) |page| self.tab_view.closePage(page);
// If we have no more tabs we close the window
if (self.nPages() == 0) {
@@ -226,7 +228,7 @@ pub fn createWindow(window: *Window) !*Window {
return new_window;
}
fn adwPageAttached(_: *adw.TabView, page: *adw.TabPage, _: c_int, self: *TabView) callconv(.C) void {
fn adwPageAttached(_: *adw.TabView, page: *adw.TabPage, _: c_int, self: *TabView) callconv(.c) void {
const child = page.getChild().as(gobject.Object);
const tab: *Tab = @ptrCast(@alignCast(child.getData(Tab.GHOSTTY_TAB) orelse return));
tab.window = self.window;
@@ -238,11 +240,18 @@ fn adwClosePage(
_: *adw.TabView,
page: *adw.TabPage,
self: *TabView,
) callconv(.C) c_int {
) callconv(.c) c_int {
const child = page.getChild().as(gobject.Object);
const tab: *Tab = @ptrCast(@alignCast(child.getData(Tab.GHOSTTY_TAB) orelse return 0));
self.tab_view.closePageFinish(page, @intFromBool(self.forcing_close));
if (!self.forcing_close) tab.closeWithConfirmation();
if (!self.forcing_close) {
// We cannot trigger a close directly in here as the page will stay
// alive until this handler returns, breaking the assumption where
// no pages means they are all destroyed.
//
// Schedule the close request to happen in the next event cycle.
_ = glib.idleAddOnce(glibIdleOnceCloseTab, tab);
}
return 1;
}
@@ -250,7 +259,7 @@ fn adwClosePage(
fn adwTabViewCreateWindow(
_: *adw.TabView,
self: *TabView,
) callconv(.C) ?*adw.TabView {
) callconv(.c) ?*adw.TabView {
const window = createWindow(self.window) catch |err| {
log.warn("error creating new window error={}", .{err});
return null;
@@ -258,8 +267,18 @@ fn adwTabViewCreateWindow(
return window.notebook.tab_view;
}
fn adwSelectPage(_: *adw.TabView, _: *gobject.ParamSpec, self: *TabView) callconv(.C) void {
fn adwSelectPage(_: *adw.TabView, _: *gobject.ParamSpec, self: *TabView) callconv(.c) void {
const page = self.tab_view.getSelectedPage() orelse return;
// If the tab was previously marked as needing attention
// (e.g. due to a bell character), we now unmark that
page.setNeedsAttention(@intFromBool(false));
const title = page.getTitle();
self.window.setTitle(std.mem.span(title));
}
fn glibIdleOnceCloseTab(data: ?*anyopaque) callconv(.c) void {
const tab: *Tab = @ptrCast(@alignCast(data orelse return));
tab.closeWithConfirmation();
}

View File

@@ -101,7 +101,7 @@ fn gtkLeftEnter(
_: f64,
_: f64,
right: *gtk.Label,
) callconv(.C) void {
) callconv(.c) void {
right.as(gtk.Widget).removeCssClass("hidden");
}
@@ -110,6 +110,6 @@ fn gtkLeftEnter(
fn gtkLeftLeave(
_: *gtk.EventControllerMotion,
right: *gtk.Label,
) callconv(.C) void {
) callconv(.c) void {
right.as(gtk.Widget).addCssClass("hidden");
}

View File

@@ -25,6 +25,7 @@ const input = @import("../../input.zig");
const CoreSurface = @import("../../Surface.zig");
const App = @import("App.zig");
const Builder = @import("Builder.zig");
const Color = configpkg.Config.Color;
const Surface = @import("Surface.zig");
const Menu = @import("menu.zig").Menu;
@@ -33,6 +34,7 @@ const gtk_key = @import("key.zig");
const TabView = @import("TabView.zig");
const HeaderBar = @import("headerbar.zig");
const CloseDialog = @import("CloseDialog.zig");
const CommandPalette = @import("CommandPalette.zig");
const winprotopkg = @import("winproto.zig");
const gtk_version = @import("gtk_version.zig");
const adw_version = @import("adw_version.zig");
@@ -53,6 +55,9 @@ window: *adw.ApplicationWindow,
/// The header bar for the window.
headerbar: HeaderBar,
/// The tab bar for the window.
tab_bar: *adw.TabBar,
/// The tab overview for the window. This is possibly null since there is no
/// taboverview without a AdwApplicationWindow (libadwaita >= 1.4.0).
tab_overview: ?*adw.TabOverview,
@@ -66,6 +71,9 @@ titlebar_menu: Menu(Window, "titlebar_menu", true),
/// The libadwaita widget for receiving toast send requests.
toast_overlay: *adw.ToastOverlay,
/// The command palette.
command_palette: CommandPalette,
/// See adwTabOverviewOpen for why we have this.
adw_tab_overview_focus_timer: ?c_uint = null,
@@ -81,10 +89,12 @@ pub const DerivedConfig = struct {
gtk_tabs_location: configpkg.Config.GtkTabsLocation,
gtk_wide_tabs: bool,
gtk_toolbar_style: configpkg.Config.GtkToolbarStyle,
window_show_tab_bar: configpkg.Config.WindowShowTabBar,
quick_terminal_position: configpkg.Config.QuickTerminalPosition,
quick_terminal_size: configpkg.Config.QuickTerminalSize,
quick_terminal_autohide: bool,
quick_terminal_keyboard_interactivity: configpkg.Config.QuickTerminalKeyboardInteractivity,
maximize: bool,
fullscreen: bool,
@@ -100,10 +110,12 @@ pub const DerivedConfig = struct {
.gtk_tabs_location = config.@"gtk-tabs-location",
.gtk_wide_tabs = config.@"gtk-wide-tabs",
.gtk_toolbar_style = config.@"gtk-toolbar-style",
.window_show_tab_bar = config.@"window-show-tab-bar",
.quick_terminal_position = config.@"quick-terminal-position",
.quick_terminal_size = config.@"quick-terminal-size",
.quick_terminal_autohide = config.@"quick-terminal-autohide",
.quick_terminal_keyboard_interactivity = config.@"quick-terminal-keyboard-interactivity",
.maximize = config.maximize,
.fullscreen = config.fullscreen,
@@ -131,18 +143,20 @@ pub fn init(self: *Window, app: *App) !void {
self.* = .{
.app = app,
.last_config = @intFromPtr(&app.config),
.config = DerivedConfig.init(&app.config),
.config = .init(&app.config),
.window = undefined,
.headerbar = undefined,
.tab_bar = undefined,
.tab_overview = null,
.notebook = undefined,
.titlebar_menu = undefined,
.toast_overlay = undefined,
.command_palette = undefined,
.winproto = .none,
};
// Create the window
self.window = adw.ApplicationWindow.new(app.app.as(gtk.Application));
self.window = .new(app.app.as(gtk.Application));
const gtk_window = self.window.as(gtk.Window);
const gtk_widget = self.window.as(gtk.Widget);
errdefer gtk_window.destroy();
@@ -166,6 +180,8 @@ pub fn init(self: *Window, app: *App) !void {
// Setup our notebook
self.notebook.init(self);
if (adw_version.supportsDialogs()) try self.command_palette.init(self);
// If we are using Adwaita, then we can support the tab overview.
self.tab_overview = if (adw_version.supportsTabOverview()) overview: {
const tab_overview = adw.TabOverview.new();
@@ -215,8 +231,9 @@ pub fn init(self: *Window, app: *App) !void {
// If we're using an AdwWindow then we can support the tab overview.
if (self.tab_overview) |tab_overview| {
if (!adw_version.supportsTabOverview()) unreachable;
const btn = switch (self.config.gtk_tabs_location) {
.top, .bottom => btn: {
const btn = switch (self.config.window_show_tab_bar) {
.always, .auto => btn: {
const btn = gtk.ToggleButton.new();
btn.as(gtk.Widget).setTooltipText(i18n._("View Open Tabs"));
btn.as(gtk.Button).setIconName("view-grid-symbolic");
@@ -228,8 +245,7 @@ pub fn init(self: *Window, app: *App) !void {
);
break :btn btn.as(gtk.Widget);
},
.hidden => btn: {
.never => btn: {
const btn = adw.TabButton.new();
btn.setView(self.notebook.tab_view);
btn.as(gtk.Actionable).setActionName("overview.open");
@@ -242,12 +258,19 @@ pub fn init(self: *Window, app: *App) !void {
}
{
const btn = gtk.Button.newFromIconName("tab-new-symbolic");
const btn = adw.SplitButton.new();
btn.setIconName("tab-new-symbolic");
btn.as(gtk.Widget).setTooltipText(i18n._("New Tab"));
_ = gtk.Button.signals.clicked.connect(
btn.setDropdownTooltip(i18n._("New Split"));
var builder = Builder.init("menu-headerbar-split_menu", 1, 0);
defer builder.deinit();
btn.setMenuModel(builder.getObject(gio.MenuModel, "menu"));
_ = adw.SplitButton.signals.clicked.connect(
btn,
*Window,
gtkTabNewClick,
adwNewTabClick,
self,
.{},
);
@@ -281,6 +304,15 @@ pub fn init(self: *Window, app: *App) !void {
.detail = "is-active",
},
);
_ = gobject.Object.signals.notify.connect(
self.window,
*Window,
gtkWindowUpdateScaleFactor,
self,
.{
.detail = "scale-factor",
},
);
// If Adwaita is enabled and is older than 1.4.0 we don't have the tab overview and so we
// need to stick the headerbar into the content box.
@@ -309,7 +341,7 @@ pub fn init(self: *Window, app: *App) !void {
}
// Setup our toast overlay if we have one
self.toast_overlay = adw.ToastOverlay.new();
self.toast_overlay = .new();
self.toast_overlay.setChild(self.notebook.asWidget());
box.append(self.toast_overlay.as(gtk.Widget));
@@ -359,21 +391,16 @@ pub fn init(self: *Window, app: *App) !void {
// Our actions for the menu
initActions(self);
self.tab_bar = adw.TabBar.new();
self.tab_bar.setView(self.notebook.tab_view);
if (adw_version.supportsToolbarView()) {
const toolbar_view = adw.ToolbarView.new();
toolbar_view.addTopBar(self.headerbar.asWidget());
if (self.config.gtk_tabs_location != .hidden) {
const tab_bar = adw.TabBar.new();
tab_bar.setView(self.notebook.tab_view);
if (!self.config.gtk_wide_tabs) tab_bar.setExpandTabs(0);
switch (self.config.gtk_tabs_location) {
.top => toolbar_view.addTopBar(tab_bar.as(gtk.Widget)),
.bottom => toolbar_view.addBottomBar(tab_bar.as(gtk.Widget)),
.hidden => unreachable,
}
switch (self.config.gtk_tabs_location) {
.top => toolbar_view.addTopBar(self.tab_bar.as(gtk.Widget)),
.bottom => toolbar_view.addBottomBar(self.tab_bar.as(gtk.Widget)),
}
toolbar_view.setContent(box.as(gtk.Widget));
@@ -388,23 +415,18 @@ pub fn init(self: *Window, app: *App) !void {
// Set our application window content.
self.tab_overview.?.setChild(toolbar_view.as(gtk.Widget));
self.window.setContent(self.tab_overview.?.as(gtk.Widget));
} else tab_bar: {
if (self.config.gtk_tabs_location == .hidden) break :tab_bar;
} else {
// In earlier adwaita versions, we need to add the tabbar manually since we do not use
// an AdwToolbarView.
const tab_bar = adw.TabBar.new();
tab_bar.as(gtk.Widget).addCssClass("inline");
self.tab_bar.as(gtk.Widget).addCssClass("inline");
switch (self.config.gtk_tabs_location) {
.top => box.insertChildAfter(
tab_bar.as(gtk.Widget),
self.tab_bar.as(gtk.Widget),
self.headerbar.asWidget(),
),
.bottom => box.append(tab_bar.as(gtk.Widget)),
.hidden => unreachable,
.bottom => box.append(self.tab_bar.as(gtk.Widget)),
}
tab_bar.setView(self.notebook.tab_view);
if (!self.config.gtk_wide_tabs) tab_bar.setExpandTabs(0);
}
// If we want the window to be maximized, we do that here.
@@ -439,10 +461,13 @@ pub fn updateConfig(
if (self.last_config == this_config) return;
self.last_config = this_config;
self.config = DerivedConfig.init(config);
self.config = .init(config);
// We always resync our appearance whenever the config changes.
try self.syncAppearance();
// Update binds inside the command palette
try self.command_palette.updateConfig(config);
}
/// Updates appearance based on config settings. Will be called once upon window
@@ -526,6 +551,16 @@ pub fn syncAppearance(self: *Window) !void {
}
}
self.tab_bar.setExpandTabs(@intFromBool(self.config.gtk_wide_tabs));
self.tab_bar.setAutohide(switch (self.config.window_show_tab_bar) {
.auto, .never => @intFromBool(true),
.always => @intFromBool(false),
});
self.tab_bar.as(gtk.Widget).setVisible(switch (self.config.window_show_tab_bar) {
.always, .auto => @intFromBool(true),
.never => @intFromBool(false),
});
self.winproto.syncAppearance() catch |err| {
log.warn("failed to sync winproto appearance error={}", .{err});
};
@@ -560,6 +595,7 @@ fn initActions(self: *Window) void {
.{ "split-left", gtkActionSplitLeft },
.{ "split-up", gtkActionSplitUp },
.{ "toggle-inspector", gtkActionToggleInspector },
.{ "toggle-command-palette", gtkActionToggleCommandPalette },
.{ "copy", gtkActionCopy },
.{ "paste", gtkActionPaste },
.{ "reset", gtkActionReset },
@@ -583,6 +619,7 @@ fn initActions(self: *Window) void {
pub fn deinit(self: *Window) void {
self.winproto.deinit(self.app.core_app.alloc);
if (adw_version.supportsDialogs()) self.command_palette.deinit();
if (self.adw_tab_overview_focus_timer) |timer| {
_ = glib.Source.remove(timer);
@@ -712,6 +749,15 @@ pub fn toggleWindowDecorations(self: *Window) void {
};
}
/// Toggle the window decorations for this window.
pub fn toggleCommandPalette(self: *Window) void {
if (adw_version.supportsDialogs()) {
self.command_palette.toggle();
} else {
log.warn("libadwaita 1.5+ is required for the command palette", .{});
}
}
/// Grabs focus on the currently selected tab.
pub fn focusCurrentTab(self: *Window) void {
const tab = self.notebook.currentTab() orelse return;
@@ -775,25 +821,55 @@ fn gtkWindowNotifyIsActive(
_: *adw.ApplicationWindow,
_: *gobject.ParamSpec,
self: *Window,
) callconv(.C) void {
if (!self.isQuickTerminal()) return;
) callconv(.c) void {
self.winproto.setUrgent(false) catch |err| {
log.err("failed to unrequest user attention={}", .{err});
};
// Hide when we're unfocused
if (self.config.quick_terminal_autohide and self.window.as(gtk.Window).isActive() == 0) {
self.toggleVisibility();
if (self.isQuickTerminal()) {
// Hide when we're unfocused
if (self.config.quick_terminal_autohide and self.window.as(gtk.Window).isActive() == 0) {
self.toggleVisibility();
}
}
}
// Note: we MUST NOT use the GtkButton parameter because gtkActionNewTab
// sends an undefined value.
fn gtkTabNewClick(_: *gtk.Button, self: *Window) callconv(.c) void {
fn gtkWindowUpdateScaleFactor(
_: *adw.ApplicationWindow,
_: *gobject.ParamSpec,
self: *Window,
) callconv(.c) void {
// On some platforms (namely X11) we need to refresh our appearance when
// the scale factor changes. In theory this could be more fine-grained as
// a full refresh could be expensive, but a) this *should* be rare, and
// b) quite noticeable visual bugs would occur if this is not present.
self.winproto.syncAppearance() catch |err| {
log.err(
"failed to sync appearance after scale factor has been updated={}",
.{err},
);
return;
};
}
/// Perform a binding action on the window's action surface.
pub fn performBindingAction(self: *Window, action: input.Binding.Action) void {
const surface = self.actionSurface() orelse return;
_ = surface.performBindingAction(.{ .new_tab = {} }) catch |err| {
_ = surface.performBindingAction(action) catch |err| {
log.warn("error performing binding action error={}", .{err});
return;
};
}
fn gtkTabNewClick(_: *gtk.Button, self: *Window) callconv(.c) void {
self.performBindingAction(.{ .new_tab = {} });
}
/// Create a new surface (tab or split).
fn adwNewTabClick(_: *adw.SplitButton, self: *Window) callconv(.c) void {
self.performBindingAction(.{ .new_tab = {} });
}
/// Create a new tab from the AdwTabOverview. We can't copy gtkTabNewClick
/// because we need to return an AdwTabPage from this function.
fn gtkNewTabFromOverview(_: *adw.TabOverview, self: *Window) callconv(.c) *adw.TabPage {
@@ -840,7 +916,7 @@ fn adwTabOverviewOpen(
fn adwTabOverviewFocusTimer(
ud: ?*anyopaque,
) callconv(.C) c_int {
) callconv(.c) c_int {
if (!adw_version.supportsTabOverview()) unreachable;
const self: *Window = @ptrCast(@alignCast(ud orelse return 0));
self.adw_tab_overview_focus_timer = null;
@@ -927,7 +1003,7 @@ fn gtkActionAbout(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Window,
) callconv(.C) void {
) callconv(.c) void {
const name = "Ghostty";
const icon = "com.mitchellh.ghostty";
const website = "https://ghostty.org";
@@ -971,7 +1047,7 @@ fn gtkActionClose(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Window,
) callconv(.C) void {
) callconv(.c) void {
self.closeWithConfirmation();
}
@@ -979,153 +1055,112 @@ fn gtkActionNewWindow(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Window,
) callconv(.C) void {
const surface = self.actionSurface() orelse return;
_ = surface.performBindingAction(.{ .new_window = {} }) catch |err| {
log.warn("error performing binding action error={}", .{err});
return;
};
) callconv(.c) void {
self.performBindingAction(.{ .new_window = {} });
}
fn gtkActionNewTab(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Window,
) callconv(.C) void {
// We can use undefined because the button is not used.
gtkTabNewClick(undefined, self);
) callconv(.c) void {
self.performBindingAction(.{ .new_tab = {} });
}
fn gtkActionCloseTab(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Window,
) callconv(.C) void {
const surface = self.actionSurface() orelse return;
_ = surface.performBindingAction(.{ .close_tab = {} }) catch |err| {
log.warn("error performing binding action error={}", .{err});
return;
};
) callconv(.c) void {
self.performBindingAction(.{ .close_tab = {} });
}
fn gtkActionSplitRight(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Window,
) callconv(.C) void {
const surface = self.actionSurface() orelse return;
_ = surface.performBindingAction(.{ .new_split = .right }) catch |err| {
log.warn("error performing binding action error={}", .{err});
return;
};
) callconv(.c) void {
self.performBindingAction(.{ .new_split = .right });
}
fn gtkActionSplitDown(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Window,
) callconv(.C) void {
const surface = self.actionSurface() orelse return;
_ = surface.performBindingAction(.{ .new_split = .down }) catch |err| {
log.warn("error performing binding action error={}", .{err});
return;
};
) callconv(.c) void {
self.performBindingAction(.{ .new_split = .down });
}
fn gtkActionSplitLeft(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Window,
) callconv(.C) void {
const surface = self.actionSurface() orelse return;
_ = surface.performBindingAction(.{ .new_split = .left }) catch |err| {
log.warn("error performing binding action error={}", .{err});
return;
};
) callconv(.c) void {
self.performBindingAction(.{ .new_split = .left });
}
fn gtkActionSplitUp(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Window,
) callconv(.C) void {
const surface = self.actionSurface() orelse return;
_ = surface.performBindingAction(.{ .new_split = .up }) catch |err| {
log.warn("error performing binding action error={}", .{err});
return;
};
) callconv(.c) void {
self.performBindingAction(.{ .new_split = .up });
}
fn gtkActionToggleInspector(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Window,
) callconv(.c) void {
self.performBindingAction(.{ .inspector = .toggle });
}
fn gtkActionToggleCommandPalette(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Window,
) callconv(.C) void {
const surface = self.actionSurface() orelse return;
_ = surface.performBindingAction(.{ .inspector = .toggle }) catch |err| {
log.warn("error performing binding action error={}", .{err});
return;
};
self.performBindingAction(.toggle_command_palette);
}
fn gtkActionCopy(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Window,
) callconv(.C) void {
const surface = self.actionSurface() orelse return;
_ = surface.performBindingAction(.{ .copy_to_clipboard = {} }) catch |err| {
log.warn("error performing binding action error={}", .{err});
return;
};
) callconv(.c) void {
self.performBindingAction(.{ .copy_to_clipboard = {} });
}
fn gtkActionPaste(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Window,
) callconv(.C) void {
const surface = self.actionSurface() orelse return;
_ = surface.performBindingAction(.{ .paste_from_clipboard = {} }) catch |err| {
log.warn("error performing binding action error={}", .{err});
return;
};
) callconv(.c) void {
self.performBindingAction(.{ .paste_from_clipboard = {} });
}
fn gtkActionReset(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Window,
) callconv(.C) void {
const surface = self.actionSurface() orelse return;
_ = surface.performBindingAction(.{ .reset = {} }) catch |err| {
log.warn("error performing binding action error={}", .{err});
return;
};
) callconv(.c) void {
self.performBindingAction(.{ .reset = {} });
}
fn gtkActionClear(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Window,
) callconv(.C) void {
const surface = self.actionSurface() orelse return;
_ = surface.performBindingAction(.{ .clear_screen = {} }) catch |err| {
log.warn("error performing binding action error={}", .{err});
return;
};
) callconv(.c) void {
self.performBindingAction(.{ .clear_screen = {} });
}
fn gtkActionPromptTitle(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Window,
) callconv(.C) void {
const surface = self.actionSurface() orelse return;
_ = surface.performBindingAction(.{ .prompt_surface_title = {} }) catch |err| {
log.warn("error performing binding action error={}", .{err});
return;
};
) callconv(.c) void {
self.performBindingAction(.{ .prompt_surface_title = {} });
}
/// Returns the surface to use for an action.
@@ -1139,7 +1174,7 @@ fn gtkTitlebarMenuActivate(
btn: *gtk.MenuButton,
_: *gobject.ParamSpec,
self: *Window,
) callconv(.C) void {
) callconv(.c) void {
// debian 12 is stuck on GTK 4.8
if (!gtk_version.atLeast(4, 10, 0)) return;
const active = btn.getActive() != 0;

View File

@@ -4,62 +4,157 @@ pub const c = @cImport({
@cInclude("adwaita.h");
});
const adwaita_version = std.SemanticVersion{
.major = c.ADW_MAJOR_VERSION,
.minor = c.ADW_MINOR_VERSION,
.patch = c.ADW_MICRO_VERSION,
};
const required_blueprint_version = std.SemanticVersion{
.major = 0,
.minor = 16,
.patch = 0,
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const alloc = gpa.allocator();
var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
defer _ = debug_allocator.deinit();
const alloc = debug_allocator.allocator();
var it = try std.process.argsWithAllocator(alloc);
defer it.deinit();
_ = it.next();
const major = try std.fmt.parseUnsigned(u8, it.next() orelse return error.NoMajorVersion, 10);
const minor = try std.fmt.parseUnsigned(u8, it.next() orelse return error.NoMinorVersion, 10);
const required_adwaita_version = std.SemanticVersion{
.major = try std.fmt.parseUnsigned(u8, it.next() orelse return error.NoMajorVersion, 10),
.minor = try std.fmt.parseUnsigned(u8, it.next() orelse return error.NoMinorVersion, 10),
.patch = 0,
};
const output = it.next() orelse return error.NoOutput;
const input = it.next() orelse return error.NoInput;
if (c.ADW_MAJOR_VERSION < major or (c.ADW_MAJOR_VERSION == major and c.ADW_MINOR_VERSION < minor)) {
// If the Adwaita version is too old, generate an "empty" file.
const file = try std.fs.createFileAbsolute(output, .{
.truncate = true,
});
try file.writeAll(
\\<?xml version="1.0" encoding="UTF-8"?>
\\<interface domain="com.mitchellh.ghostty"/>
);
defer file.close();
return;
if (adwaita_version.order(required_adwaita_version) == .lt) {
std.debug.print(
\\`libadwaita` is too old.
\\
\\Ghostty requires a version {} or newer of `libadwaita` to
\\compile this blueprint. Please install it, ensure that it is
\\available on your PATH, and then retry building Ghostty.
, .{required_adwaita_version});
std.posix.exit(1);
}
var compiler = std.process.Child.init(
&.{
"blueprint-compiler",
"compile",
"--output",
output,
input,
},
alloc,
);
{
var stdout: std.ArrayListUnmanaged(u8) = .empty;
defer stdout.deinit(alloc);
var stderr: std.ArrayListUnmanaged(u8) = .empty;
defer stderr.deinit(alloc);
const term = compiler.spawnAndWait() catch |err| switch (err) {
error.FileNotFound => {
std.log.err(
\\`blueprint-compiler` not found.
var blueprint_compiler = std.process.Child.init(
&.{
"blueprint-compiler",
"--version",
},
alloc,
);
blueprint_compiler.stdout_behavior = .Pipe;
blueprint_compiler.stderr_behavior = .Pipe;
try blueprint_compiler.spawn();
try blueprint_compiler.collectOutput(
alloc,
&stdout,
&stderr,
std.math.maxInt(u16),
);
const term = blueprint_compiler.wait() catch |err| switch (err) {
error.FileNotFound => {
std.debug.print(
\\`blueprint-compiler` not found.
\\
\\Ghostty requires version {} 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});
std.posix.exit(1);
},
else => return err,
};
switch (term) {
.Exited => |rc| {
if (rc != 0) std.process.exit(1);
},
else => std.process.exit(1),
}
const version = try std.SemanticVersion.parse(std.mem.trim(u8, stdout.items, &std.ascii.whitespace));
if (version.order(required_blueprint_version) == .lt) {
std.debug.print(
\\`blueprint-compiler` is the wrong version.
\\
\\Ghostty requires `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.
, .{});
\\Ghostty requires version {} 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});
std.posix.exit(1);
},
else => return err,
};
}
}
switch (term) {
.Exited => |rc| {
if (rc != 0) std.process.exit(1);
},
else => std.process.exit(1),
{
var stdout: std.ArrayListUnmanaged(u8) = .empty;
defer stdout.deinit(alloc);
var stderr: std.ArrayListUnmanaged(u8) = .empty;
defer stderr.deinit(alloc);
var blueprint_compiler = std.process.Child.init(
&.{
"blueprint-compiler",
"compile",
"--output",
output,
input,
},
alloc,
);
blueprint_compiler.stdout_behavior = .Pipe;
blueprint_compiler.stderr_behavior = .Pipe;
try blueprint_compiler.spawn();
try blueprint_compiler.collectOutput(
alloc,
&stdout,
&stderr,
std.math.maxInt(u16),
);
const term = blueprint_compiler.wait() catch |err| switch (err) {
error.FileNotFound => {
std.debug.print(
\\`blueprint-compiler` not found.
\\
\\Ghostty requires version {} 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});
std.posix.exit(1);
},
else => return err,
};
switch (term) {
.Exited => |rc| {
if (rc != 0) {
std.debug.print("{s}", .{stderr.items});
std.process.exit(1);
}
},
else => {
std.debug.print("{s}", .{stderr.items});
std.process.exit(1);
},
}
}
}

View File

@@ -1,32 +0,0 @@
const std = @import("std");
const build_options = @import("build_options");
const gtk = @import("gtk");
const adw = @import("adw");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const alloc = gpa.allocator();
const filename = filename: {
var it = try std.process.argsWithAllocator(alloc);
defer it.deinit();
_ = it.next() orelse return error.NoFilename;
break :filename try alloc.dupeZ(u8, it.next() orelse return error.NoFilename);
};
defer alloc.free(filename);
const data = try std.fs.cwd().readFileAllocOptions(alloc, filename, std.math.maxInt(u16), null, 1, 0);
defer alloc.free(data);
if (gtk.initCheck() == 0) {
std.debug.print("{s}: skipping builder check because we can't connect to display!\n", .{filename});
return;
}
adw.init();
const builder = gtk.Builder.newFromString(data.ptr, @intCast(data.len));
defer builder.unref();
}

29
src/apprt/gtk/flatpak.zig Normal file
View File

@@ -0,0 +1,29 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const build_config = @import("../../build_config.zig");
const internal_os = @import("../../os/main.zig");
const glib = @import("glib");
pub fn resourcesDir(alloc: Allocator) !internal_os.ResourcesDir {
if (comptime build_config.flatpak) {
// Only consult Flatpak runtime data for host case.
if (internal_os.isFlatpak()) {
var result: internal_os.ResourcesDir = .{
.app_path = try alloc.dupe(u8, "/app/share/ghostty"),
};
errdefer alloc.free(result.app_path.?);
const keyfile = glib.KeyFile.new();
defer keyfile.unref();
if (keyfile.loadFromFile("/.flatpak-info", .{}, null) == 0) return result;
const app_dir = std.mem.span(keyfile.getString("Instance", "app-path", null)) orelse return result;
defer glib.free(app_dir.ptr);
result.host_path = try std.fs.path.join(alloc, &[_][]const u8{ app_dir, "share", "ghostty" });
return result;
}
}
return try internal_os.resourcesDir(alloc);
}

View File

@@ -53,19 +53,6 @@ const icons = [_]struct {
},
};
pub const VersionedBuilderXML = struct {
major: u16,
minor: u16,
name: []const u8,
};
pub const ui_files = [_]VersionedBuilderXML{
.{ .major = 1, .minor = 2, .name = "config-errors-dialog" },
.{ .major = 1, .minor = 2, .name = "ccw-osc-52-read" },
.{ .major = 1, .minor = 2, .name = "ccw-osc-52-write" },
.{ .major = 1, .minor = 2, .name = "ccw-paste" },
};
pub const VersionedBlueprint = struct {
major: u16,
minor: u16,
@@ -75,21 +62,28 @@ pub const VersionedBlueprint = struct {
pub const blueprint_files = [_]VersionedBlueprint{
.{ .major = 1, .minor = 5, .name = "prompt-title-dialog" },
.{ .major = 1, .minor = 5, .name = "config-errors-dialog" },
.{ .major = 1, .minor = 0, .name = "menu-headerbar-split_menu" },
.{ .major = 1, .minor = 5, .name = "command-palette" },
.{ .major = 1, .minor = 0, .name = "menu-surface-context_menu" },
.{ .major = 1, .minor = 0, .name = "menu-window-titlebar_menu" },
.{ .major = 1, .minor = 5, .name = "ccw-osc-52-read" },
.{ .major = 1, .minor = 5, .name = "ccw-osc-52-write" },
.{ .major = 1, .minor = 5, .name = "ccw-paste" },
.{ .major = 1, .minor = 2, .name = "config-errors-dialog" },
.{ .major = 1, .minor = 2, .name = "ccw-osc-52-read" },
.{ .major = 1, .minor = 2, .name = "ccw-osc-52-write" },
.{ .major = 1, .minor = 2, .name = "ccw-paste" },
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const alloc = gpa.allocator();
var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
defer _ = debug_allocator.deinit();
const alloc = debug_allocator.allocator();
var extra_ui_files = std.ArrayList([]const u8).init(alloc);
var extra_ui_files: std.ArrayListUnmanaged([]const u8) = .empty;
defer {
for (extra_ui_files.items) |item| alloc.free(item);
extra_ui_files.deinit();
extra_ui_files.deinit(alloc);
}
var it = try std.process.argsWithAllocator(alloc);
@@ -97,7 +91,7 @@ pub fn main() !void {
while (it.next()) |argument| {
if (std.mem.eql(u8, std.fs.path.extension(argument), ".ui")) {
try extra_ui_files.append(try alloc.dupe(u8, argument));
try extra_ui_files.append(alloc, try alloc.dupe(u8, argument));
}
}
@@ -131,16 +125,11 @@ pub fn main() !void {
\\ <gresource prefix="/com/mitchellh/ghostty/ui">
\\
);
for (ui_files) |ui_file| {
try writer.print(
" <file compressed=\"true\" preprocess=\"xml-stripblanks\" alias=\"{0d}.{1d}/{2s}.ui\">src/apprt/gtk/ui/{0d}.{1d}/{2s}.ui</file>\n",
.{ ui_file.major, ui_file.minor, ui_file.name },
);
}
for (extra_ui_files.items) |ui_file| {
const stem = std.fs.path.stem(ui_file);
for (blueprint_files) |file| {
if (!std.mem.eql(u8, file.name, stem)) continue;
const expected = try std.fmt.allocPrint(alloc, "/{d}.{d}/{s}.ui", .{ file.major, file.minor, file.name });
defer alloc.free(expected);
if (!std.mem.endsWith(u8, ui_file, expected)) continue;
try writer.print(
" <file compressed=\"true\" preprocess=\"xml-stripblanks\" alias=\"{d}.{d}/{s}.ui\">{s}</file>\n",
.{ file.major, file.minor, file.name, ui_file },
@@ -156,7 +145,7 @@ pub fn main() !void {
}
pub const dependencies = deps: {
const total = css_files.len + icons.len + ui_files.len + blueprint_files.len;
const total = css_files.len + icons.len + blueprint_files.len;
var deps: [total][]const u8 = undefined;
var index: usize = 0;
for (css_files) |css_file| {
@@ -167,14 +156,6 @@ pub const dependencies = deps: {
deps[index] = std.fmt.comptimePrint("images/icons/icon_{s}.png", .{icon.source});
index += 1;
}
for (ui_files) |ui_file| {
deps[index] = std.fmt.comptimePrint("src/apprt/gtk/ui/{d}.{d}/{s}.ui", .{
ui_file.major,
ui_file.minor,
ui_file.name,
});
index += 1;
}
for (blueprint_files) |blueprint_file| {
deps[index] = std.fmt.comptimePrint("src/apprt/gtk/ui/{d}.{d}/{s}.blp", .{
blueprint_file.major,

View File

@@ -87,10 +87,23 @@ pub inline fn runtimeAtLeast(
}) != .lt;
}
pub inline fn runtimeUntil(
comptime major: u16,
comptime minor: u16,
comptime micro: u16,
) bool {
const runtime_version = getRuntimeVersion();
return runtime_version.order(.{
.major = major,
.minor = minor,
.patch = micro,
}) == .lt;
}
test "atLeast" {
const testing = std.testing;
const funs = &.{ atLeast, runtimeAtLeast };
const funs = &.{ atLeast, runtimeAtLeast, runtimeUntil };
inline for (funs) |fun| {
try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION));

View File

@@ -138,7 +138,7 @@ const Window = struct {
};
// Create the window
self.window = gtk.ApplicationWindow.new(inspector.surface.app.app.as(gtk.Application));
self.window = .new(inspector.surface.app.app.as(gtk.Application));
errdefer self.window.as(gtk.Window).destroy();
self.window.as(gtk.Window).setTitle(i18n._("Ghostty: Terminal Inspector"));
@@ -177,7 +177,7 @@ const Window = struct {
}
/// "destroy" signal for the window
fn gtkDestroy(_: *gtk.ApplicationWindow, self: *Window) callconv(.C) void {
fn gtkDestroy(_: *gtk.ApplicationWindow, self: *Window) callconv(.c) void {
log.debug("window destroy", .{});
self.deinit();
}

View File

@@ -20,10 +20,45 @@ pub fn accelFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u
if (trigger.mods.super) try writer.writeAll("<Super>");
// Write our key
if (!try writeTriggerKey(writer, trigger)) return null;
// We need to make the string null terminated.
try writer.writeByte(0);
const slice = buf_stream.getWritten();
return slice[0 .. slice.len - 1 :0];
}
/// Returns a XDG-compliant shortcuts string from a trigger.
/// Spec: https://specifications.freedesktop.org/shortcuts-spec/latest/
pub fn xdgShortcutFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u8 {
var buf_stream = std.io.fixedBufferStream(buf);
const writer = buf_stream.writer();
// Modifiers
if (trigger.mods.shift) try writer.writeAll("SHIFT+");
if (trigger.mods.ctrl) try writer.writeAll("CTRL+");
if (trigger.mods.alt) try writer.writeAll("ALT+");
if (trigger.mods.super) try writer.writeAll("LOGO+");
// Write our key
// NOTE: While the spec specifies that only libxkbcommon keysyms are
// expected, using GTK's keysyms should still work as they are identical
// to *X11's* keysyms (which I assume is a subset of libxkbcommon's).
// I haven't been able to any evidence to back up that assumption but
// this works for now
if (!try writeTriggerKey(writer, trigger)) return null;
// We need to make the string null terminated.
try writer.writeByte(0);
const slice = buf_stream.getWritten();
return slice[0 .. slice.len - 1 :0];
}
fn writeTriggerKey(writer: anytype, trigger: input.Binding.Trigger) !bool {
switch (trigger.key) {
.physical, .translated => |k| {
const keyval = keyvalFromKey(k) orelse return null;
try writer.writeAll(std.mem.span(gdk.keyvalName(keyval) orelse return null));
.physical => |k| {
const keyval = keyvalFromKey(k) orelse return false;
try writer.writeAll(std.mem.span(gdk.keyvalName(keyval) orelse return false));
},
.unicode => |cp| {
@@ -35,10 +70,7 @@ pub fn accelFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u
},
}
// We need to make the string null terminated.
try writer.writeByte(0);
const slice = buf_stream.getWritten();
return slice[0 .. slice.len - 1 :0];
return true;
}
pub fn translateMods(state: gdk.ModifierType) input.Mods {
@@ -122,42 +154,42 @@ pub fn eventMods(
// if only the modifier key is pressed, but our core logic
// relies on it.
switch (physical_key) {
.left_shift => {
.shift_left => {
mods.shift = action != .release;
mods.sides.shift = .left;
},
.right_shift => {
.shift_right => {
mods.shift = action != .release;
mods.sides.shift = .right;
},
.left_control => {
.control_left => {
mods.ctrl = action != .release;
mods.sides.ctrl = .left;
},
.right_control => {
.control_right => {
mods.ctrl = action != .release;
mods.sides.ctrl = .right;
},
.left_alt => {
.alt_left => {
mods.alt = action != .release;
mods.sides.alt = .left;
},
.right_alt => {
.alt_right => {
mods.alt = action != .release;
mods.sides.alt = .right;
},
.left_super => {
.meta_left => {
mods.super = action != .release;
mods.sides.super = .left;
},
.right_super => {
.meta_right => {
mods.super = action != .release;
mods.sides.super = .right;
},
@@ -182,7 +214,7 @@ pub fn keyvalFromKey(key: input.Key) ?c_uint {
switch (key) {
inline else => |key_comptime| {
return comptime value: {
@setEvalBranchQuota(10_000);
@setEvalBranchQuota(50_000);
for (keymap) |entry| {
if (entry[1] == key_comptime) break :value entry[0];
}
@@ -199,7 +231,7 @@ test "accelFromTrigger" {
try testing.expectEqualStrings("<Super>q", (try accelFromTrigger(&buf, .{
.mods = .{ .super = true },
.key = .{ .translated = .q },
.key = .{ .unicode = 'q' },
})).?);
try testing.expectEqualStrings("<Shift><Ctrl><Alt><Super>backslash", (try accelFromTrigger(&buf, .{
@@ -208,66 +240,81 @@ test "accelFromTrigger" {
})).?);
}
test "xdgShortcutFromTrigger" {
const testing = std.testing;
var buf: [256]u8 = undefined;
try testing.expectEqualStrings("LOGO+q", (try xdgShortcutFromTrigger(&buf, .{
.mods = .{ .super = true },
.key = .{ .unicode = 'q' },
})).?);
try testing.expectEqualStrings("SHIFT+CTRL+ALT+LOGO+backslash", (try xdgShortcutFromTrigger(&buf, .{
.mods = .{ .ctrl = true, .alt = true, .super = true, .shift = true },
.key = .{ .unicode = 92 },
})).?);
}
/// A raw entry in the keymap. Our keymap contains mappings between
/// GDK keys and our own key enum.
const RawEntry = struct { c_uint, input.Key };
const keymap: []const RawEntry = &.{
.{ gdk.KEY_a, .a },
.{ gdk.KEY_b, .b },
.{ gdk.KEY_c, .c },
.{ gdk.KEY_d, .d },
.{ gdk.KEY_e, .e },
.{ gdk.KEY_f, .f },
.{ gdk.KEY_g, .g },
.{ gdk.KEY_h, .h },
.{ gdk.KEY_i, .i },
.{ gdk.KEY_j, .j },
.{ gdk.KEY_k, .k },
.{ gdk.KEY_l, .l },
.{ gdk.KEY_m, .m },
.{ gdk.KEY_n, .n },
.{ gdk.KEY_o, .o },
.{ gdk.KEY_p, .p },
.{ gdk.KEY_q, .q },
.{ gdk.KEY_r, .r },
.{ gdk.KEY_s, .s },
.{ gdk.KEY_t, .t },
.{ gdk.KEY_u, .u },
.{ gdk.KEY_v, .v },
.{ gdk.KEY_w, .w },
.{ gdk.KEY_x, .x },
.{ gdk.KEY_y, .y },
.{ gdk.KEY_z, .z },
.{ gdk.KEY_a, .key_a },
.{ gdk.KEY_b, .key_b },
.{ gdk.KEY_c, .key_c },
.{ gdk.KEY_d, .key_d },
.{ gdk.KEY_e, .key_e },
.{ gdk.KEY_f, .key_f },
.{ gdk.KEY_g, .key_g },
.{ gdk.KEY_h, .key_h },
.{ gdk.KEY_i, .key_i },
.{ gdk.KEY_j, .key_j },
.{ gdk.KEY_k, .key_k },
.{ gdk.KEY_l, .key_l },
.{ gdk.KEY_m, .key_m },
.{ gdk.KEY_n, .key_n },
.{ gdk.KEY_o, .key_o },
.{ gdk.KEY_p, .key_p },
.{ gdk.KEY_q, .key_q },
.{ gdk.KEY_r, .key_r },
.{ gdk.KEY_s, .key_s },
.{ gdk.KEY_t, .key_t },
.{ gdk.KEY_u, .key_u },
.{ gdk.KEY_v, .key_v },
.{ gdk.KEY_w, .key_w },
.{ gdk.KEY_x, .key_x },
.{ gdk.KEY_y, .key_y },
.{ gdk.KEY_z, .key_z },
.{ gdk.KEY_0, .zero },
.{ gdk.KEY_1, .one },
.{ gdk.KEY_2, .two },
.{ gdk.KEY_3, .three },
.{ gdk.KEY_4, .four },
.{ gdk.KEY_5, .five },
.{ gdk.KEY_6, .six },
.{ gdk.KEY_7, .seven },
.{ gdk.KEY_8, .eight },
.{ gdk.KEY_9, .nine },
.{ gdk.KEY_0, .digit_0 },
.{ gdk.KEY_1, .digit_1 },
.{ gdk.KEY_2, .digit_2 },
.{ gdk.KEY_3, .digit_3 },
.{ gdk.KEY_4, .digit_4 },
.{ gdk.KEY_5, .digit_5 },
.{ gdk.KEY_6, .digit_6 },
.{ gdk.KEY_7, .digit_7 },
.{ gdk.KEY_8, .digit_8 },
.{ gdk.KEY_9, .digit_9 },
.{ gdk.KEY_semicolon, .semicolon },
.{ gdk.KEY_space, .space },
.{ gdk.KEY_apostrophe, .apostrophe },
.{ gdk.KEY_apostrophe, .quote },
.{ gdk.KEY_comma, .comma },
.{ gdk.KEY_grave, .grave_accent },
.{ gdk.KEY_grave, .backquote },
.{ gdk.KEY_period, .period },
.{ gdk.KEY_slash, .slash },
.{ gdk.KEY_minus, .minus },
.{ gdk.KEY_equal, .equal },
.{ gdk.KEY_bracketleft, .left_bracket },
.{ gdk.KEY_bracketright, .right_bracket },
.{ gdk.KEY_bracketleft, .bracket_left },
.{ gdk.KEY_bracketright, .bracket_right },
.{ gdk.KEY_backslash, .backslash },
.{ gdk.KEY_Up, .up },
.{ gdk.KEY_Down, .down },
.{ gdk.KEY_Right, .right },
.{ gdk.KEY_Left, .left },
.{ gdk.KEY_Up, .arrow_up },
.{ gdk.KEY_Down, .arrow_down },
.{ gdk.KEY_Right, .arrow_right },
.{ gdk.KEY_Left, .arrow_left },
.{ gdk.KEY_Home, .home },
.{ gdk.KEY_End, .end },
.{ gdk.KEY_Insert, .insert },
@@ -310,45 +357,49 @@ const keymap: []const RawEntry = &.{
.{ gdk.KEY_F24, .f24 },
.{ gdk.KEY_F25, .f25 },
.{ gdk.KEY_KP_0, .kp_0 },
.{ gdk.KEY_KP_1, .kp_1 },
.{ gdk.KEY_KP_2, .kp_2 },
.{ gdk.KEY_KP_3, .kp_3 },
.{ gdk.KEY_KP_4, .kp_4 },
.{ gdk.KEY_KP_5, .kp_5 },
.{ gdk.KEY_KP_6, .kp_6 },
.{ gdk.KEY_KP_7, .kp_7 },
.{ gdk.KEY_KP_8, .kp_8 },
.{ gdk.KEY_KP_9, .kp_9 },
.{ gdk.KEY_KP_Decimal, .kp_decimal },
.{ gdk.KEY_KP_Divide, .kp_divide },
.{ gdk.KEY_KP_Multiply, .kp_multiply },
.{ gdk.KEY_KP_Subtract, .kp_subtract },
.{ gdk.KEY_KP_Add, .kp_add },
.{ gdk.KEY_KP_Enter, .kp_enter },
.{ gdk.KEY_KP_Equal, .kp_equal },
.{ gdk.KEY_KP_0, .numpad_0 },
.{ gdk.KEY_KP_1, .numpad_1 },
.{ gdk.KEY_KP_2, .numpad_2 },
.{ gdk.KEY_KP_3, .numpad_3 },
.{ gdk.KEY_KP_4, .numpad_4 },
.{ gdk.KEY_KP_5, .numpad_5 },
.{ gdk.KEY_KP_6, .numpad_6 },
.{ gdk.KEY_KP_7, .numpad_7 },
.{ gdk.KEY_KP_8, .numpad_8 },
.{ gdk.KEY_KP_9, .numpad_9 },
.{ gdk.KEY_KP_Decimal, .numpad_decimal },
.{ gdk.KEY_KP_Divide, .numpad_divide },
.{ gdk.KEY_KP_Multiply, .numpad_multiply },
.{ gdk.KEY_KP_Subtract, .numpad_subtract },
.{ gdk.KEY_KP_Add, .numpad_add },
.{ gdk.KEY_KP_Enter, .numpad_enter },
.{ gdk.KEY_KP_Equal, .numpad_equal },
.{ gdk.KEY_KP_Separator, .kp_separator },
.{ gdk.KEY_KP_Left, .kp_left },
.{ gdk.KEY_KP_Right, .kp_right },
.{ gdk.KEY_KP_Up, .kp_up },
.{ gdk.KEY_KP_Down, .kp_down },
.{ gdk.KEY_KP_Page_Up, .kp_page_up },
.{ gdk.KEY_KP_Page_Down, .kp_page_down },
.{ gdk.KEY_KP_Home, .kp_home },
.{ gdk.KEY_KP_End, .kp_end },
.{ gdk.KEY_KP_Insert, .kp_insert },
.{ gdk.KEY_KP_Delete, .kp_delete },
.{ gdk.KEY_KP_Begin, .kp_begin },
.{ gdk.KEY_KP_Separator, .numpad_separator },
.{ gdk.KEY_KP_Left, .numpad_left },
.{ gdk.KEY_KP_Right, .numpad_right },
.{ gdk.KEY_KP_Up, .numpad_up },
.{ gdk.KEY_KP_Down, .numpad_down },
.{ gdk.KEY_KP_Page_Up, .numpad_page_up },
.{ gdk.KEY_KP_Page_Down, .numpad_page_down },
.{ gdk.KEY_KP_Home, .numpad_home },
.{ gdk.KEY_KP_End, .numpad_end },
.{ gdk.KEY_KP_Insert, .numpad_insert },
.{ gdk.KEY_KP_Delete, .numpad_delete },
.{ gdk.KEY_KP_Begin, .numpad_begin },
.{ gdk.KEY_Shift_L, .left_shift },
.{ gdk.KEY_Control_L, .left_control },
.{ gdk.KEY_Alt_L, .left_alt },
.{ gdk.KEY_Super_L, .left_super },
.{ gdk.KEY_Shift_R, .right_shift },
.{ gdk.KEY_Control_R, .right_control },
.{ gdk.KEY_Alt_R, .right_alt },
.{ gdk.KEY_Super_R, .right_super },
.{ gdk.KEY_Copy, .copy },
.{ gdk.KEY_Cut, .cut },
.{ gdk.KEY_Paste, .paste },
.{ gdk.KEY_Shift_L, .shift_left },
.{ gdk.KEY_Control_L, .control_left },
.{ gdk.KEY_Alt_L, .alt_left },
.{ gdk.KEY_Super_L, .meta_left },
.{ gdk.KEY_Shift_R, .shift_right },
.{ gdk.KEY_Control_R, .control_right },
.{ gdk.KEY_Alt_R, .alt_right },
.{ gdk.KEY_Super_R, .meta_right },
// TODO: media keys
};

View File

@@ -41,7 +41,7 @@ pub fn Menu(
else => unreachable,
};
var builder = Builder.init("menu-" ++ object_type ++ "-" ++ menu_name, 1, 0, .blp);
var builder = Builder.init("menu-" ++ object_type ++ "-" ++ menu_name, 1, 0);
defer builder.deinit();
const menu_model = builder.getObject(gio.MenuModel, "menu").?;
@@ -130,7 +130,7 @@ pub fn Menu(
}
/// Refocus tab that lost focus because of the popover menu
fn gtkRefocusTerm(_: *gtk.PopoverMenu, self: *Self) callconv(.C) void {
fn gtkRefocusTerm(_: *gtk.PopoverMenu, self: *Self) callconv(.c) void {
const window: *Window = switch (T) {
Window => self.parent,
Surface => self.parent.container.window() orelse return,

View File

@@ -73,3 +73,19 @@ window.ssd.no-border-radius {
filter: blur(5px);
transition: filter 0.3s ease;
}
.command-palette-search {
font-size: 1.25rem;
padding: 4px;
-gtk-icon-size: 20px;
}
.command-palette-search > image:first-child {
margin-left: 8px;
margin-right: 4px;
}
.command-palette-search > image:last-child {
margin-left: 4px;
margin-right: 8px;
}

View File

@@ -0,0 +1,25 @@
using Gtk 4.0;
menu menu {
section {
item {
label: _("Split Up");
action: "win.split-up";
}
item {
label: _("Split Down");
action: "win.split-down";
}
item {
label: _("Split Left");
action: "win.split-left";
}
item {
label: _("Split Right");
action: "win.split-right";
}
}
}

View File

@@ -81,6 +81,11 @@ menu menu {
}
section {
item {
label: _("Command Palette");
action: "win.toggle-command-palette";
}
item {
label: _("Terminal Inspector");
action: "win.toggle-inspector";

View File

@@ -1,77 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
DO NOT EDIT!
This file was @generated by blueprint-compiler. Instead, edit the
corresponding .blp file and regenerate this file with blueprint-compiler.
-->
<interface domain="com.mitchellh.ghostty">
<requires lib="gtk" version="4.0"/>
<object class="AdwMessageDialog" id="clipboard_confirmation_window">
<property name="heading" translatable="true">Authorize Clipboard Access</property>
<property name="body" translatable="true">An application is attempting to read from the clipboard. The current clipboard contents are shown below.</property>
<responses>
<response id="cancel" translatable="true" appearance="suggested">Deny</response>
<response id="ok" translatable="true" appearance="destructive">Allow</response>
</responses>
<property name="default-response">cancel</property>
<property name="close-response">cancel</property>
<property name="extra-child">
<object class="GtkOverlay">
<style>
<class name="osd"/>
</style>
<child>
<object class="GtkScrolledWindow" id="text_view_scroll">
<property name="width-request">500</property>
<property name="height-request">250</property>
<child>
<object class="GtkTextView" id="text_view">
<property name="cursor-visible">false</property>
<property name="editable">false</property>
<property name="monospace">true</property>
<property name="top-margin">8</property>
<property name="left-margin">8</property>
<property name="bottom-margin">8</property>
<property name="right-margin">8</property>
<style>
<class name="clipboard-content-view"/>
</style>
</object>
</child>
</object>
</child>
<child type="overlay">
<object class="GtkButton" id="reveal_button">
<property name="visible">false</property>
<property name="halign">2</property>
<property name="valign">1</property>
<property name="margin-end">12</property>
<property name="margin-top">12</property>
<child>
<object class="GtkImage">
<property name="icon-name">view-reveal-symbolic</property>
</object>
</child>
</object>
</child>
<child type="overlay">
<object class="GtkButton" id="hide_button">
<property name="visible">false</property>
<property name="halign">2</property>
<property name="valign">1</property>
<property name="margin-end">12</property>
<property name="margin-top">12</property>
<style>
<class name="opaque"/>
</style>
<child>
<object class="GtkImage">
<property name="icon-name">view-conceal-symbolic</property>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</interface>

View File

@@ -1,77 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
DO NOT EDIT!
This file was @generated by blueprint-compiler. Instead, edit the
corresponding .blp file and regenerate this file with blueprint-compiler.
-->
<interface domain="com.mitchellh.ghostty">
<requires lib="gtk" version="4.0"/>
<object class="AdwMessageDialog" id="clipboard_confirmation_window">
<property name="heading" translatable="true">Authorize Clipboard Access</property>
<property name="body" translatable="true">An application is attempting to write to the clipboard. The current clipboard contents are shown below.</property>
<responses>
<response id="cancel" translatable="true" appearance="suggested">Deny</response>
<response id="ok" translatable="true" appearance="destructive">Allow</response>
</responses>
<property name="default-response">cancel</property>
<property name="close-response">cancel</property>
<property name="extra-child">
<object class="GtkOverlay">
<style>
<class name="osd"/>
</style>
<child>
<object class="GtkScrolledWindow" id="text_view_scroll">
<property name="width-request">500</property>
<property name="height-request">250</property>
<child>
<object class="GtkTextView" id="text_view">
<property name="cursor-visible">false</property>
<property name="editable">false</property>
<property name="monospace">true</property>
<property name="top-margin">8</property>
<property name="left-margin">8</property>
<property name="bottom-margin">8</property>
<property name="right-margin">8</property>
<style>
<class name="clipboard-content-view"/>
</style>
</object>
</child>
</object>
</child>
<child type="overlay">
<object class="GtkButton" id="reveal_button">
<property name="visible">false</property>
<property name="halign">2</property>
<property name="valign">1</property>
<property name="margin-end">12</property>
<property name="margin-top">12</property>
<child>
<object class="GtkImage">
<property name="icon-name">view-reveal-symbolic</property>
</object>
</child>
</object>
</child>
<child type="overlay">
<object class="GtkButton" id="hide_button">
<property name="visible">false</property>
<property name="halign">2</property>
<property name="valign">1</property>
<property name="margin-end">12</property>
<property name="margin-top">12</property>
<style>
<class name="opaque"/>
</style>
<child>
<object class="GtkImage">
<property name="icon-name">view-conceal-symbolic</property>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</interface>

View File

@@ -1,77 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
DO NOT EDIT!
This file was @generated by blueprint-compiler. Instead, edit the
corresponding .blp file and regenerate this file with blueprint-compiler.
-->
<interface domain="com.mitchellh.ghostty">
<requires lib="gtk" version="4.0"/>
<object class="AdwMessageDialog" id="clipboard_confirmation_window">
<property name="heading" translatable="true">Warning: Potentially Unsafe Paste</property>
<property name="body" translatable="true">Pasting this text into the terminal may be dangerous as it looks like some commands may be executed.</property>
<responses>
<response id="cancel" translatable="true" appearance="suggested">Cancel</response>
<response id="ok" translatable="true" appearance="destructive">Paste</response>
</responses>
<property name="default-response">cancel</property>
<property name="close-response">cancel</property>
<property name="extra-child">
<object class="GtkOverlay">
<style>
<class name="osd"/>
</style>
<child>
<object class="GtkScrolledWindow" id="text_view_scroll">
<property name="width-request">500</property>
<property name="height-request">250</property>
<child>
<object class="GtkTextView" id="text_view">
<property name="cursor-visible">false</property>
<property name="editable">false</property>
<property name="monospace">true</property>
<property name="top-margin">8</property>
<property name="left-margin">8</property>
<property name="bottom-margin">8</property>
<property name="right-margin">8</property>
<style>
<class name="clipboard-content-view"/>
</style>
</object>
</child>
</object>
</child>
<child type="overlay">
<object class="GtkButton" id="reveal_button">
<property name="visible">false</property>
<property name="halign">2</property>
<property name="valign">1</property>
<property name="margin-end">12</property>
<property name="margin-top">12</property>
<child>
<object class="GtkImage">
<property name="icon-name">view-reveal-symbolic</property>
</object>
</child>
</object>
</child>
<child type="overlay">
<object class="GtkButton" id="hide_button">
<property name="visible">false</property>
<property name="halign">2</property>
<property name="valign">1</property>
<property name="margin-end">12</property>
<property name="margin-top">12</property>
<style>
<class name="opaque"/>
</style>
<child>
<object class="GtkImage">
<property name="icon-name">view-conceal-symbolic</property>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</interface>

View File

@@ -1,36 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
DO NOT EDIT!
This file was @generated by blueprint-compiler. Instead, edit the
corresponding .blp file and regenerate this file with blueprint-compiler.
-->
<interface>
<requires lib="gtk" version="4.0"/>
<object class="AdwMessageDialog" id="config_errors_dialog">
<property name="heading" translatable="yes">Configuration Errors</property>
<property name="body" translatable="yes">One or more configuration errors were found. Please review the errors below, and either reload your configuration or ignore these errors.</property>
<responses>
<response id="ignore" translatable="yes">Ignore</response>
<response id="reload" translatable="yes" appearance="suggested">Reload Configuration</response>
</responses>
<property name="extra-child">
<object class="GtkScrolledWindow">
<property name="min-content-width">500</property>
<property name="min-content-height">100</property>
<child>
<object class="GtkTextView">
<property name="editable">false</property>
<property name="cursor-visible">false</property>
<property name="top-margin">8</property>
<property name="bottom-margin">8</property>
<property name="left-margin">8</property>
<property name="right-margin">8</property>
<property name="buffer">
<object class="GtkTextBuffer" id="error_message"></object>
</property>
</object>
</child>
</object>
</property>
</object>
</interface>

View File

@@ -0,0 +1,106 @@
using Gtk 4.0;
using Gio 2.0;
using Adw 1;
Adw.Dialog command-palette {
content-width: 700;
Adw.ToolbarView {
top-bar-style: flat;
[top]
Adw.HeaderBar {
[title]
SearchEntry search {
hexpand: true;
placeholder-text: _("Execute a command…");
styles [
"command-palette-search",
]
}
}
ScrolledWindow {
min-content-height: 300;
ListView view {
show-separators: true;
single-click-activate: true;
model: SingleSelection model {
model: FilterListModel {
incremental: true;
filter: AnyFilter {
StringFilter {
expression: expr item as <$GhosttyCommand>.title;
search: bind search.text;
}
StringFilter {
expression: expr item as <$GhosttyCommand>.action-key;
search: bind search.text;
}
};
model: Gio.ListStore source {
item-type: typeof<$GhosttyCommand>;
};
};
};
styles [
"rich-list",
]
factory: BuilderListItemFactory {
template ListItem {
child: Box {
orientation: horizontal;
spacing: 10;
tooltip-text: bind template.item as <$GhosttyCommand>.description;
Box {
orientation: vertical;
hexpand: true;
Label {
ellipsize: end;
halign: start;
wrap: false;
single-line-mode: true;
styles [
"title",
]
label: bind template.item as <$GhosttyCommand>.title;
}
Label {
ellipsize: end;
halign: start;
wrap: false;
single-line-mode: true;
styles [
"subtitle",
"monospace",
]
label: bind template.item as <$GhosttyCommand>.action-key;
}
}
ShortcutLabel {
accelerator: bind template.item as <$GhosttyCommand>.action;
valign: center;
}
};
}
};
}
}
}
}

View File

@@ -1,21 +1,15 @@
# GTK UI files
This directory is for storing GTK resource definitions. With one exception, the
files should be be in the Blueprint markup language.
This directory is for storing GTK blueprints. GTK blueprints are compiled into
GTK resource builder `.ui` files by `blueprint-compiler` at build time and then
converted into an embeddable resource by `glib-compile-resources`.
Resource files should be stored in directories that represent the minimum
Adwaita version needed to use that resource. Resource files should also be
formatted using `blueprint-compiler format` as well to ensure consistency.
Blueprint files should be stored in directories that represent the minimum
Adwaita version needed to use that resource. Blueprint files should also be
formatted using `blueprint-compiler format` as well to ensure consistency
(formatting will be checked in CI).
The one exception to files being in Blueprint markup language is when Adwaita
features are used that the `blueprint-compiler` on a supported platform does not
compile. For example, Debian 12 includes Adwaita 1.2 and `blueprint-compiler`
0.6.0. Adwaita 1.2 includes support for `MessageDialog` but `blueprint-compiler`
0.6.0 does not. In cases like that the Blueprint markup should be compiled on a
platform that provides a new enough `blueprint-compiler` and the resulting `.ui`
file should be committed to the Ghostty source code. Care should be taken that
the `.blp` file and the `.ui` file remain in sync.
In all other cases only the `.blp` should be committed to the Ghostty source
code. The build process will use `blueprint-compiler` to generate the `.ui`
files necessary at runtime.
`blueprint-compiler` version 0.16.0 or newer is required to compile Blueprint
files. If your system does not have `blueprint-compiler` or does not have a
new enough version you can use the generated source tarballs, which contain
precompiled versions of the blueprints.

View File

@@ -146,4 +146,10 @@ pub const Window = union(Protocol) {
inline else => |*v| try v.addSubprocessEnv(env),
}
}
pub fn setUrgent(self: *Window, urgent: bool) !void {
switch (self.*) {
inline else => |*v| try v.setUrgent(urgent),
}
}
};

View File

@@ -70,4 +70,6 @@ pub const Window = struct {
}
pub fn addSubprocessEnv(_: *Window, _: *std.process.EnvMap) !void {}
pub fn setUrgent(_: *Window, _: bool) !void {}
};

View File

@@ -6,8 +6,8 @@ const build_options = @import("build_options");
const gdk = @import("gdk");
const gdk_wayland = @import("gdk_wayland");
const gobject = @import("gobject");
const gtk4_layer_shell = @import("gtk4-layer-shell");
const gtk = @import("gtk");
const layer_shell = @import("gtk4-layer-shell");
const wayland = @import("wayland");
const Config = @import("../../../config.zig").Config;
@@ -16,6 +16,7 @@ const ApprtWindow = @import("../Window.zig");
const wl = wayland.client.wl;
const org = wayland.client.org;
const xdg = wayland.client.xdg;
const log = std.log.scoped(.winproto_wayland);
@@ -34,6 +35,21 @@ pub const App = struct {
kde_slide_manager: ?*org.KdeKwinSlideManager = null,
default_deco_mode: ?org.KdeKwinServerDecorationManager.Mode = null,
xdg_activation: ?*xdg.ActivationV1 = null,
/// Whether the xdg_wm_dialog_v1 protocol is present.
///
/// If it is present, gtk4-layer-shell < 1.0.4 may crash when the user
/// creates a quick terminal, and we need to ensure this fails
/// gracefully if this situation occurs.
///
/// FIXME: This is a temporary workaround - we should remove this when
/// all of our supported distros drop support for affected old
/// gtk4-layer-shell versions.
///
/// See https://github.com/wmww/gtk4-layer-shell/issues/50
xdg_wm_dialog_present: bool = false,
};
pub fn init(
@@ -45,16 +61,11 @@ pub const App = struct {
_ = config;
_ = app_id;
// Check if we're actually on Wayland
if (gobject.typeCheckInstanceIsA(
gdk_display.as(gobject.TypeInstance),
gdk_wayland.WaylandDisplay.getGObjectType(),
) == 0) return null;
const gdk_wayland_display = gobject.ext.cast(
gdk_wayland.WaylandDisplay,
gdk_display,
) orelse return error.NoWaylandDisplay;
) orelse return null;
const display: *wl.Display = @ptrCast(@alignCast(
gdk_wayland_display.getWlDisplay() orelse return error.NoWaylandDisplay,
));
@@ -73,9 +84,9 @@ pub const App = struct {
registry.setListener(*Context, registryListener, context);
if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;
if (context.kde_decoration_manager != null) {
// FIXME: Roundtrip again because we have to wait for the decoration
// manager to respond with the preferred default mode. Ew.
// Do another round-trip to get the default decoration mode
if (context.kde_decoration_manager) |deco_manager| {
deco_manager.setListener(*Context, decoManagerListener, context);
if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;
}
@@ -97,20 +108,45 @@ pub const App = struct {
return null;
}
pub fn supportsQuickTerminal(_: App) bool {
if (!gtk4_layer_shell.isSupported()) {
pub fn supportsQuickTerminal(self: App) bool {
if (!layer_shell.isSupported()) {
log.warn("your compositor does not support the wlr-layer-shell protocol; disabling quick terminal", .{});
return false;
}
if (self.context.xdg_wm_dialog_present and layer_shell.getLibraryVersion().order(.{
.major = 1,
.minor = 0,
.patch = 4,
}) == .lt) {
log.warn("the version of gtk4-layer-shell installed on your system is too old (must be 1.0.4 or newer); disabling quick terminal", .{});
return false;
}
return true;
}
pub fn initQuickTerminal(_: *App, apprt_window: *ApprtWindow) !void {
const window = apprt_window.window.as(gtk.Window);
gtk4_layer_shell.initForWindow(window);
gtk4_layer_shell.setLayer(window, .top);
gtk4_layer_shell.setKeyboardMode(window, .on_demand);
layer_shell.initForWindow(window);
layer_shell.setLayer(window, .top);
layer_shell.setNamespace(window, "ghostty-quick-terminal");
}
fn getInterfaceType(comptime field: std.builtin.Type.StructField) ?type {
// Globals should be optional pointers
const T = switch (@typeInfo(field.type)) {
.optional => |o| switch (@typeInfo(o.child)) {
.pointer => |v| v.child,
else => return null,
},
else => return null,
};
// Only process Wayland interfaces
if (!@hasDecl(T, "interface")) return null;
return T;
}
fn registryListener(
@@ -118,71 +154,54 @@ pub const App = struct {
event: wl.Registry.Event,
context: *Context,
) void {
const ctx_fields = @typeInfo(Context).@"struct".fields;
switch (event) {
// https://wayland.app/protocols/wayland#wl_registry:event:global
.global => |global| {
log.debug("wl_registry.global: interface={s}", .{global.interface});
.global => |v| global: {
// We don't actually do anything with this other than checking
// for its existence, so we process this separately.
if (std.mem.orderZ(u8, v.interface, "xdg_wm_dialog_v1") == .eq)
context.xdg_wm_dialog_present = true;
if (registryBind(
org.KdeKwinBlurManager,
registry,
global,
)) |blur_manager| {
context.kde_blur_manager = blur_manager;
return;
}
inline for (ctx_fields) |field| {
const T = getInterfaceType(field) orelse continue;
if (registryBind(
org.KdeKwinServerDecorationManager,
registry,
global,
)) |deco_manager| {
context.kde_decoration_manager = deco_manager;
deco_manager.setListener(*Context, decoManagerListener, context);
return;
}
if (std.mem.orderZ(
u8,
v.interface,
T.interface.name,
) != .eq) break :global;
if (registryBind(
org.KdeKwinSlideManager,
registry,
global,
)) |slide_manager| {
context.kde_slide_manager = slide_manager;
return;
@field(context, field.name) = registry.bind(
v.name,
T,
T.generated_version,
) catch |err| {
log.warn(
"error binding interface {s} error={}",
.{ v.interface, err },
);
return;
};
}
},
// We don't handle removal events
.global_remove => {},
// This should be a rare occurrence, but in case a global
// is suddenly no longer available, we destroy and unset it
// as the protocol mandates.
.global_remove => |v| remove: {
inline for (ctx_fields) |field| {
if (getInterfaceType(field) == null) continue;
const global = @field(context, field.name) orelse break :remove;
if (global.getId() == v.name) {
global.destroy();
@field(context, field.name) = null;
}
}
},
}
}
/// Bind a Wayland interface to a global object. Returns non-null
/// if the binding was successful, otherwise null.
///
/// The type T is the Wayland interface type that we're requesting.
/// This function will verify that the global object is the correct
/// interface and version before binding.
fn registryBind(
comptime T: type,
registry: *wl.Registry,
global: anytype,
) ?*T {
if (std.mem.orderZ(
u8,
global.interface,
T.interface.name,
) != .eq) return null;
return registry.bind(global.name, T, T.generated_version) catch |err| {
log.warn("error binding interface {s} error={}", .{
global.interface,
err,
});
return null;
};
}
fn decoManagerListener(
_: *org.KdeKwinServerDecorationManager,
event: org.KdeKwinServerDecorationManager.Event,
@@ -207,15 +226,19 @@ pub const Window = struct {
app_context: *App.Context,
/// A token that, when present, indicates that the window is blurred.
blur_token: ?*org.KdeKwinBlur,
blur_token: ?*org.KdeKwinBlur = null,
/// Object that controls the decoration mode (client/server/auto)
/// of the window.
decoration: ?*org.KdeKwinServerDecoration,
decoration: ?*org.KdeKwinServerDecoration = null,
/// Object that controls the slide-in/slide-out animations of the
/// quick terminal. Always null for windows other than the quick terminal.
slide: ?*org.KdeKwinSlide,
slide: ?*org.KdeKwinSlide = null,
/// Object that, when present, denotes that the window is currently
/// requesting attention from the user.
activation_token: ?*xdg.ActivationTokenV1 = null,
pub fn init(
alloc: Allocator,
@@ -268,9 +291,7 @@ pub const Window = struct {
.apprt_window = apprt_window,
.surface = wl_surface,
.app_context = app.context,
.blur_token = null,
.decoration = deco,
.slide = null,
};
}
@@ -315,6 +336,21 @@ pub const Window = struct {
_ = env;
}
pub fn setUrgent(self: *Window, urgent: bool) !void {
const activation = self.app_context.xdg_activation orelse return;
// If there already is a token, destroy and unset it
if (self.activation_token) |token| token.destroy();
self.activation_token = if (urgent) token: {
const token = try activation.getActivationToken();
token.setSurface(self.surface);
token.setListener(*Window, onActivationTokenEvent, self);
token.commit();
break :token token;
} else null;
}
/// Update the blur state of the window.
fn syncBlur(self: *Window) !void {
const manager = self.app_context.kde_blur_manager orelse return;
@@ -356,9 +392,24 @@ pub const Window = struct {
fn syncQuickTerminal(self: *Window) !void {
const window = self.apprt_window.window.as(gtk.Window);
const position = self.apprt_window.config.quick_terminal_position;
const config = &self.apprt_window.config;
const anchored_edge: ?gtk4_layer_shell.ShellEdge = switch (position) {
layer_shell.setKeyboardMode(
window,
switch (config.quick_terminal_keyboard_interactivity) {
.none => .none,
.@"on-demand" => on_demand: {
if (layer_shell.getProtocolVersion() < 4) {
log.warn("your compositor does not support on-demand keyboard access; falling back to exclusive access", .{});
break :on_demand .exclusive;
}
break :on_demand .on_demand;
},
.exclusive => .exclusive,
},
);
const anchored_edge: ?layer_shell.ShellEdge = switch (config.quick_terminal_position) {
.left => .left,
.right => .right,
.top => .top,
@@ -366,43 +417,41 @@ pub const Window = struct {
.center => null,
};
for (std.meta.tags(gtk4_layer_shell.ShellEdge)) |edge| {
for (std.meta.tags(layer_shell.ShellEdge)) |edge| {
if (anchored_edge) |anchored| {
if (edge == anchored) {
gtk4_layer_shell.setMargin(window, edge, 0);
gtk4_layer_shell.setAnchor(window, edge, true);
layer_shell.setMargin(window, edge, 0);
layer_shell.setAnchor(window, edge, true);
continue;
}
}
// Arbitrary margin - could be made customizable?
gtk4_layer_shell.setMargin(window, edge, 20);
gtk4_layer_shell.setAnchor(window, edge, false);
layer_shell.setMargin(window, edge, 20);
layer_shell.setAnchor(window, edge, false);
}
if (self.apprt_window.isQuickTerminal()) {
if (self.slide) |slide| slide.release();
if (self.slide) |slide| slide.release();
self.slide = if (anchored_edge) |anchored| slide: {
const mgr = self.app_context.kde_slide_manager orelse break :slide null;
self.slide = if (anchored_edge) |anchored| slide: {
const mgr = self.app_context.kde_slide_manager orelse break :slide null;
const slide = mgr.create(self.surface) catch |err| {
log.warn("could not create slide object={}", .{err});
break :slide null;
};
const slide = mgr.create(self.surface) catch |err| {
log.warn("could not create slide object={}", .{err});
break :slide null;
};
const slide_location: org.KdeKwinSlide.Location = switch (anchored) {
.top => .top,
.bottom => .bottom,
.left => .left,
.right => .right,
};
const slide_location: org.KdeKwinSlide.Location = switch (anchored) {
.top => .top,
.bottom => .bottom,
.left => .left,
.right => .right,
};
slide.setLocation(@intCast(@intFromEnum(slide_location)));
slide.commit();
break :slide slide;
} else null;
}
slide.setLocation(@intCast(@intFromEnum(slide_location)));
slide.commit();
break :slide slide;
} else null;
}
/// Update the size of the quick terminal based on monitor dimensions.
@@ -410,19 +459,43 @@ pub const Window = struct {
_: *gdk.Surface,
monitor: *gdk.Monitor,
apprt_window: *ApprtWindow,
) callconv(.C) void {
) callconv(.c) void {
const window = apprt_window.window.as(gtk.Window);
const size = apprt_window.config.quick_terminal_size;
const position = apprt_window.config.quick_terminal_position;
const config = &apprt_window.config;
var monitor_size: gdk.Rectangle = undefined;
monitor.getGeometry(&monitor_size);
const dims = size.calculate(position, .{
.width = @intCast(monitor_size.f_width),
.height = @intCast(monitor_size.f_height),
});
const dims = config.quick_terminal_size.calculate(
config.quick_terminal_position,
.{
.width = @intCast(monitor_size.f_width),
.height = @intCast(monitor_size.f_height),
},
);
window.setDefaultSize(@intCast(dims.width), @intCast(dims.height));
}
fn onActivationTokenEvent(
token: *xdg.ActivationTokenV1,
event: xdg.ActivationTokenV1.Event,
self: *Window,
) void {
const activation = self.app_context.xdg_activation orelse return;
const current_token = self.activation_token orelse return;
if (token.getId() != current_token.getId()) {
log.warn("received event for unknown activation token; ignoring", .{});
return;
}
switch (event) {
.done => |done| {
activation.activate(done.token, self.surface);
token.destroy();
self.activation_token = null;
},
}
}
};

View File

@@ -36,16 +36,11 @@ pub const App = struct {
config: *const Config,
) !?App {
// If the display isn't X11, then we don't need to do anything.
if (gobject.typeCheckInstanceIsA(
gdk_display.as(gobject.TypeInstance),
gdk_x11.X11Display.getGObjectType(),
) == 0) return null;
// Get our X11 display
const gdk_x11_display = gobject.ext.cast(
gdk_x11.X11Display,
gdk_display,
) orelse return null;
const xlib_display = gdk_x11_display.getXdisplay();
const x11_program_name: [:0]const u8 = if (config.@"x11-instance-name") |pn|
@@ -109,7 +104,7 @@ pub const App = struct {
return .{
.display = xlib_display,
.base_event_code = base_event_code,
.atoms = Atoms.init(gdk_x11_display),
.atoms = .init(gdk_x11_display),
};
}
@@ -176,8 +171,8 @@ pub const App = struct {
pub const Window = struct {
app: *App,
config: *const ApprtWindow.DerivedConfig,
window: xlib.Window,
gtk_window: *adw.ApplicationWindow,
x11_surface: *gdk_x11.X11Surface,
blur_region: Region = .{},
@@ -192,13 +187,6 @@ pub const Window = struct {
gtk.Native,
).getSurface() orelse return error.NotX11Surface;
// Check if we're actually on X11
if (gobject.typeCheckInstanceIsA(
surface.as(gobject.TypeInstance),
gdk_x11.X11Surface.getGObjectType(),
) == 0)
return error.NotX11Surface;
const x11_surface = gobject.ext.cast(
gdk_x11.X11Surface,
surface,
@@ -207,8 +195,8 @@ pub const Window = struct {
return .{
.app = app,
.config = &apprt_window.config,
.window = x11_surface.getXid(),
.gtk_window = apprt_window.window,
.x11_surface = x11_surface,
};
}
@@ -219,13 +207,12 @@ pub const Window = struct {
pub fn resizeEvent(self: *Window) !void {
// The blur region must update with window resizes
const gtk_widget = self.gtk_window.as(gtk.Widget);
self.blur_region.width = gtk_widget.getWidth();
self.blur_region.height = gtk_widget.getHeight();
try self.syncBlur();
}
pub fn syncAppearance(self: *Window) !void {
// The user could have toggled between CSDs and SSDs,
// therefore we need to recalculate the blur region offset.
self.blur_region = blur: {
// NOTE(pluiedev): CSDs are a f--king mistake.
// Please, GNOME, stop this nonsense of making a window ~30% bigger
@@ -236,6 +223,11 @@ pub const Window = struct {
self.gtk_window.as(gtk.Native).getSurfaceTransform(&x, &y);
// Transform surface coordinates to device coordinates.
const scale: f64 = @floatFromInt(self.gtk_window.as(gtk.Widget).getScaleFactor());
x *= scale;
y *= scale;
break :blur .{
.x = @intFromFloat(x),
.y = @intFromFloat(y),
@@ -265,10 +257,17 @@ pub const Window = struct {
// and I think it's not really noticeable enough to justify the effort.
// (Wayland also has this visual artifact anyway...)
const gtk_widget = self.gtk_window.as(gtk.Widget);
// Transform surface coordinates to device coordinates.
const scale = self.gtk_window.as(gtk.Widget).getScaleFactor();
self.blur_region.width = gtk_widget.getWidth() * scale;
self.blur_region.height = gtk_widget.getHeight() * scale;
const blur = self.config.background_blur;
log.debug("set blur={}, window xid={}, region={}", .{
blur,
self.window,
self.x11_surface.getXid(),
self.blur_region,
});
@@ -324,11 +323,19 @@ pub const Window = struct {
pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void {
var buf: [64]u8 = undefined;
const window_id = try std.fmt.bufPrint(&buf, "{}", .{self.window});
const window_id = try std.fmt.bufPrint(
&buf,
"{}",
.{self.x11_surface.getXid()},
);
try env.put("WINDOWID", window_id);
}
pub fn setUrgent(self: *Window, urgent: bool) !void {
self.x11_surface.setUrgencyHint(@intFromBool(urgent));
}
fn getWindowProperty(
self: *Window,
comptime T: type,
@@ -352,7 +359,7 @@ pub const Window = struct {
const code = c.XGetWindowProperty(
@ptrCast(@alignCast(self.app.display)),
self.window,
self.x11_surface.getXid(),
name,
options.offset,
options.length,
@@ -390,7 +397,7 @@ pub const Window = struct {
const status = c.XChangeProperty(
@ptrCast(@alignCast(self.app.display)),
self.window,
self.x11_surface.getXid(),
name,
typ,
@intFromEnum(format),
@@ -408,7 +415,7 @@ pub const Window = struct {
fn deleteProperty(self: *Window, name: c.Atom) X11Error!void {
const status = c.XDeleteProperty(
@ptrCast(@alignCast(self.app.display)),
self.window,
self.x11_surface.getXid(),
name,
);
if (status == 0) return error.RequestFailed;

View File

@@ -1,2 +1,4 @@
const internal_os = @import("../os/main.zig");
pub const resourcesDir = internal_os.resourcesDir;
pub const App = struct {};
pub const Surface = struct {};

View File

@@ -43,8 +43,9 @@ pub const Message = union(enum) {
close: void,
/// The child process running in the surface has exited. This may trigger
/// a surface close, it may not.
child_exited: void,
/// a surface close, it may not. Additional details about the child
/// command are given in the `ChildExited` struct.
child_exited: ChildExited,
/// Show a desktop notification.
desktop_notification: struct {
@@ -74,18 +75,26 @@ pub const Message = union(enum) {
/// A terminal color was changed using OSC sequences.
color_change: struct {
kind: terminal.osc.Command.ColorKind,
kind: terminal.osc.Command.ColorOperation.Kind,
color: terminal.color.RGB,
},
/// The terminal has reported a change in the working directory.
pwd_change: WriteReq,
/// The terminal encountered a bell character.
ring_bell,
pub const ReportTitleStyle = enum {
csi_21_t,
// This enum is a placeholder for future title styles.
};
pub const ChildExited = struct {
exit_code: u32,
runtime_ms: u64,
};
};
/// A surface mailbox.

View File

@@ -68,7 +68,7 @@ pub fn main() !void {
var args: Args = .{};
defer args.deinit();
{
var iter = try std.process.argsWithAllocator(alloc);
var iter = try cli.args.argsIterator(alloc);
defer iter.deinit();
try cli.args.parse(Args, alloc, &args, &iter);
}

View File

@@ -60,7 +60,7 @@ pub fn main() !void {
var args: Args = .{};
defer args.deinit();
{
var iter = try std.process.argsWithAllocator(alloc);
var iter = try cli.args.argsIterator(alloc);
defer iter.deinit();
try cli.args.parse(Args, alloc, &args, &iter);
}

View File

@@ -45,7 +45,7 @@ pub fn main() !void {
var args: Args = .{};
defer args.deinit();
{
var iter = try std.process.argsWithAllocator(alloc);
var iter = try cli.args.argsIterator(alloc);
defer iter.deinit();
try cli.args.parse(Args, alloc, &args, &iter);
}

View File

@@ -27,7 +27,7 @@ pub fn main() !void {
var args: Args = args: {
var args: Args = .{};
errdefer args.deinit();
var iter = try std.process.argsWithAllocator(alloc);
var iter = try cli.args.argsIterator(alloc);
defer iter.deinit();
try cli.args.parse(Args, alloc, &args, &iter);
break :args args;

View File

@@ -12,9 +12,9 @@ const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const ziglyph = @import("ziglyph");
const cli = @import("../cli.zig");
const terminal = @import("../terminal/main.zig");
const synthetic = @import("../synthetic/main.zig");
const Args = struct {
mode: Mode = .noop,
@@ -70,6 +70,14 @@ const Mode = enum {
// Generate an infinite stream of arbitrary random bytes.
@"gen-rand",
// Generate an infinite stream of OSC requests. These will be mixed
// with valid and invalid OSC requests by default, but the
// `-valid` and `-invalid`-suffixed variants can be used to get only
// a specific type of OSC request.
@"gen-osc",
@"gen-osc-valid",
@"gen-osc-invalid",
};
pub const std_options: std.Options = .{
@@ -84,7 +92,7 @@ pub fn main() !void {
var args: Args = .{};
defer args.deinit();
{
var iter = try std.process.argsWithAllocator(alloc);
var iter = try cli.args.argsIterator(alloc);
defer iter.deinit();
try cli.args.parse(Args, alloc, &args, &iter);
}
@@ -93,13 +101,57 @@ pub fn main() !void {
const writer = std.io.getStdOut().writer();
const buf = try alloc.alloc(u8, args.@"buffer-size");
// Build our RNG
const seed: u64 = if (args.seed >= 0) @bitCast(args.seed) else @truncate(@as(u128, @bitCast(std.time.nanoTimestamp())));
var prng = std.Random.DefaultPrng.init(seed);
const rand = prng.random();
// Handle the modes that do not depend on terminal state first.
switch (args.mode) {
.@"gen-ascii" => try genAscii(writer, seed),
.@"gen-utf8" => try genUtf8(writer, seed),
.@"gen-rand" => try genRand(writer, seed),
.@"gen-ascii" => {
var gen: synthetic.Bytes = .{
.rand = rand,
.alphabet = synthetic.Bytes.Alphabet.ascii,
};
try generate(writer, gen.generator());
},
.@"gen-utf8" => {
var gen: synthetic.Utf8 = .{
.rand = rand,
};
try generate(writer, gen.generator());
},
.@"gen-rand" => {
var gen: synthetic.Bytes = .{ .rand = rand };
try generate(writer, gen.generator());
},
.@"gen-osc" => {
var gen: synthetic.Osc = .{
.rand = rand,
.p_valid = 0.5,
};
try generate(writer, gen.generator());
},
.@"gen-osc-valid" => {
var gen: synthetic.Osc = .{
.rand = rand,
.p_valid = 1.0,
};
try generate(writer, gen.generator());
},
.@"gen-osc-invalid" => {
var gen: synthetic.Osc = .{
.rand = rand,
.p_valid = 0.0,
};
try generate(writer, gen.generator());
},
.noop => try benchNoop(reader, buf),
// Handle the ones that depend on terminal state next
@@ -133,61 +185,14 @@ pub fn main() !void {
}
}
/// Generates an infinite stream of random printable ASCII characters.
/// This has no control characters in it at all.
fn genAscii(writer: anytype, seed: u64) !void {
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=[]{}|;':\\\",./<>?`~";
try genData(writer, alphabet, seed);
}
/// Generates an infinite stream of bytes from the given alphabet.
fn genData(writer: anytype, alphabet: []const u8, seed: u64) !void {
var prng = std.rand.DefaultPrng.init(seed);
const rnd = prng.random();
fn generate(
writer: anytype,
gen: synthetic.Generator,
) !void {
var buf: [1024]u8 = undefined;
while (true) {
for (&buf) |*c| {
const idx = rnd.uintLessThanBiased(usize, alphabet.len);
c.* = alphabet[idx];
}
writer.writeAll(&buf) catch |err| switch (err) {
error.BrokenPipe => return, // stdout closed
else => return err,
};
}
}
fn genUtf8(writer: anytype, seed: u64) !void {
var prng = std.rand.DefaultPrng.init(seed);
const rnd = prng.random();
var buf: [1024]u8 = undefined;
while (true) {
var i: usize = 0;
while (i <= buf.len - 4) {
const cp: u18 = while (true) {
const cp = rnd.int(u18);
if (ziglyph.isPrint(cp)) break cp;
};
i += try std.unicode.utf8Encode(cp, buf[i..]);
}
writer.writeAll(buf[0..i]) catch |err| switch (err) {
error.BrokenPipe => return, // stdout closed
else => return err,
};
}
}
fn genRand(writer: anytype, seed: u64) !void {
var prng = std.rand.DefaultPrng.init(seed);
const rnd = prng.random();
var buf: [1024]u8 = undefined;
while (true) {
rnd.bytes(&buf);
writer.writeAll(&buf) catch |err| switch (err) {
const data = try gen.next(&buf);
writer.writeAll(data) catch |err| switch (err) {
error.BrokenPipe => return, // stdout closed
else => return err,
};

View File

@@ -87,7 +87,7 @@ pub fn init(b: *std.Build) !Config {
// This is set to true when we're building a system package. For now
// this is trivially detected using the "system_package_mode" bool
// but we may want to make this more sophisticated in the future.
const system_package: bool = b.graph.system_package_mode;
const system_package = b.graph.system_package_mode;
// This specifies our target wasm runtime. For now only one semi-usable
// one exists so this is hardcoded.
@@ -361,7 +361,6 @@ pub fn init(b: *std.Build) !Config {
"libpng",
"zlib",
"oniguruma",
"gtk4-layer-shell",
}) |dep| {
_ = b.systemIntegrationOption(
dep,
@@ -387,6 +386,15 @@ pub fn init(b: *std.Build) !Config {
}) |dep| {
_ = b.systemIntegrationOption(dep, .{ .default = false });
}
// These are dynamic libraries we default to true, preferring
// to use system packages over building and installing libs
// as they require additional ldconfig of library paths or
// patching the rpath of the program to discover the dynamic library
// at runtime
for (&[_][]const u8{"gtk4-layer-shell"}) |dep| {
_ = b.systemIntegrationOption(dep, .{ .default = true });
}
}
return config;

View File

@@ -36,11 +36,13 @@ pub fn init(
const bin_name = try std.fmt.allocPrint(b.allocator, "bench-{s}", .{name});
const c_exe = b.addExecutable(.{
.name = bin_name,
.root_source_file = b.path("src/main.zig"),
.target = deps.config.target,
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = deps.config.target,
// We always want our benchmarks to be in release mode.
.optimize = .ReleaseFast,
// We always want our benchmarks to be in release mode.
.optimize = .ReleaseFast,
}),
});
c_exe.linkLibC();

View File

@@ -36,6 +36,17 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyDist {
"--format=tgz",
});
// embed the Ghostty version in the tarball
{
const version = b.addWriteFiles().add("VERSION", b.fmt("{}", .{cfg.version}));
// --add-file uses the most recent --prefix to determine the path
// in the archive to copy the file (the directory only).
git_archive.addArg(b.fmt("--prefix=ghostty-{}/", .{
cfg.version,
}));
git_archive.addPrefixedFileArg("--add-file=", version);
}
// Add all of our resources into the tarball.
for (resources.items) |resource| {
// Our dist path basename may not match our generated file basename,

View File

@@ -26,8 +26,13 @@ pub fn init(
inline for (manpages) |manpage| {
const generate_markdown = b.addExecutable(.{
.name = "mdgen_" ++ manpage.name ++ "_" ++ manpage.section,
.root_source_file = b.path("src/main.zig"),
.target = b.graph.host,
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = b.graph.host,
.strip = false,
.omit_frame_pointer = false,
.unwind_tables = .sync,
}),
});
deps.help_strings.addImport(generate_markdown);

View File

@@ -13,10 +13,14 @@ install_step: *std.Build.Step.InstallArtifact,
pub fn init(b: *std.Build, cfg: *const Config, deps: *const SharedDeps) !Ghostty {
const exe: *std.Build.Step.Compile = b.addExecutable(.{
.name = "ghostty",
.root_source_file = b.path("src/main.zig"),
.target = cfg.target,
.optimize = cfg.optimize,
.strip = cfg.strip,
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = cfg.target,
.optimize = cfg.optimize,
.strip = cfg.strip,
.omit_frame_pointer = cfg.strip,
.unwind_tables = if (cfg.strip) .none else .sync,
}),
});
const install_step = b.addInstallArtifact(exe, .{});

View File

@@ -15,8 +15,13 @@ output: std.Build.LazyPath,
pub fn init(b: *std.Build) !GhosttyFrameData {
const exe = b.addExecutable(.{
.name = "framegen",
.root_source_file = b.path("src/build/framegen/main.zig"),
.target = b.graph.host,
.root_module = b.createModule(.{
.root_source_file = b.path("src/build/framegen/main.zig"),
.target = b.graph.host,
.strip = false,
.omit_frame_pointer = false,
.unwind_tables = .sync,
}),
});
const run = b.addRunArtifact(exe);

View File

@@ -1,6 +1,7 @@
const GhosttyI18n = @This();
const std = @import("std");
const builtin = @import("builtin");
const Config = @import("Config.zig");
const gresource = @import("../apprt/gtk/gresource.zig");
const internal_os = @import("../os/main.zig");
@@ -21,6 +22,14 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyI18n {
defer steps.deinit();
inline for (internal_os.i18n.locales) |locale| {
// There is no encoding suffix in the LC_MESSAGES path on FreeBSD,
// so we need to remove it from `locale` to have a correct destination string.
// (/usr/local/share/locale/en_AU/LC_MESSAGES)
const target_locale = comptime if (builtin.target.os.tag == .freebsd)
std.mem.trimRight(u8, locale, ".UTF-8")
else
locale;
const msgfmt = b.addSystemCommand(&.{ "msgfmt", "-o", "-" });
msgfmt.addFileArg(b.path("po/" ++ locale ++ ".po"));
@@ -28,7 +37,7 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyI18n {
msgfmt.captureStdOut(),
std.fmt.comptimePrint(
"share/locale/{s}/LC_MESSAGES/{s}.mo",
.{ locale, domain },
.{ target_locale, domain },
),
).step);
}
@@ -54,7 +63,7 @@ fn createUpdateStep(b: *std.Build) !*std.Build.Step {
"--keyword=C_:1c,2",
"--package-name=" ++ domain,
"--msgid-bugs-address=m@mitchellh.com",
"--copyright-holder=Mitchell Hashimoto",
"--copyright-holder=\"Mitchell Hashimoto, Ghostty contributors\"",
"-o",
"-",
});

View File

@@ -1,6 +1,8 @@
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 config_vim = @import("../config/vim.zig");
@@ -16,6 +18,12 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources {
// Terminfo
terminfo: {
const os_tag = cfg.target.result.os.tag;
const terminfo_share_dir = if (os_tag == .freebsd)
"site-terminfo"
else
"terminfo";
// Encode our terminfo
var str = std.ArrayList(u8).init(b.allocator);
defer str.deinit();
@@ -26,12 +34,19 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources {
const source = wf.add("ghostty.terminfo", str.items);
if (cfg.emit_terminfo) {
const source_install = b.addInstallFile(source, "share/terminfo/ghostty.terminfo");
const source_install = b.addInstallFile(
source,
if (os_tag == .freebsd)
"share/site-terminfo/ghostty.terminfo"
else
"share/terminfo/ghostty.terminfo",
);
try steps.append(&source_install.step);
}
// Windows doesn't have the binaries below.
if (cfg.target.result.os.tag == .windows) break :terminfo;
if (os_tag == .windows) break :terminfo;
// Convert to termcap source format if thats helpful to people and
// install it. The resulting value here is the termcap source in case
@@ -43,7 +58,14 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources {
const out_source = run_step.captureStdOut();
_ = run_step.captureStdErr(); // so we don't see stderr
const cap_install = b.addInstallFile(out_source, "share/terminfo/ghostty.termcap");
const cap_install = b.addInstallFile(
out_source,
if (os_tag == .freebsd)
"share/site-terminfo/ghostty.termcap"
else
"share/terminfo/ghostty.termcap",
);
try steps.append(&cap_install.step);
}
@@ -51,7 +73,8 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources {
{
const run_step = RunStep.create(b, "tic");
run_step.addArgs(&.{ "tic", "-x", "-o" });
const path = run_step.addOutputFileArg("terminfo");
const path = run_step.addOutputFileArg(terminfo_share_dir);
run_step.addFileArg(source);
_ = run_step.captureStdErr(); // so we don't see stderr
@@ -63,7 +86,12 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources {
.windows => mkdir_step.addArgs(&.{"mkdir"}),
else => mkdir_step.addArgs(&.{ "mkdir", "-p" }),
}
mkdir_step.addArg(b.fmt("{s}/share/terminfo", .{b.install_path}));
mkdir_step.addArg(b.fmt(
"{s}/share/{s}",
.{ b.install_path, terminfo_share_dir },
));
try steps.append(&mkdir_step.step);
// Use cp -R instead of Step.InstallDir because we need to preserve
@@ -193,77 +221,178 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources {
}
// App (Linux)
if (cfg.target.result.os.tag == .linux) {
// https://developer.gnome.org/documentation/guidelines/maintainer/integrating.html
if (cfg.target.result.os.tag == .linux) try addLinuxAppResources(
b,
cfg,
&steps,
);
return .{ .steps = steps.items };
}
/// Add the resource files needed to make Ghostty a proper
/// Linux desktop application (for various desktop environments).
fn addLinuxAppResources(
b: *std.Build,
cfg: *const Config,
steps: *std.ArrayList(*std.Build.Step),
) !void {
assert(cfg.target.result.os.tag == .linux);
// Background:
// https://developer.gnome.org/documentation/guidelines/maintainer/integrating.html
const name = b.fmt("Ghostty{s}", .{
switch (cfg.optimize) {
.Debug, .ReleaseSafe => " (Debug)",
.ReleaseFast, .ReleaseSmall => "",
},
});
const app_id = b.fmt("com.mitchellh.ghostty{s}", .{
switch (cfg.optimize) {
.Debug, .ReleaseSafe => "-debug",
.ReleaseFast, .ReleaseSmall => "",
},
});
const exe_abs_path = b.fmt(
"{s}/bin/ghostty",
.{b.install_prefix},
);
// The templates that we will process. The templates are in
// cmake format and will be processed and saved to the
// second element of the tuple.
const Template = struct { std.Build.LazyPath, []const u8 };
const templates: []const Template = templates: {
var ts: std.ArrayList(Template) = .init(b.allocator);
// Desktop file so that we have an icon and other metadata
try steps.append(&b.addInstallFile(
b.path("dist/linux/app.desktop"),
"share/applications/com.mitchellh.ghostty.desktop",
).step);
try ts.append(.{
b.path("dist/linux/app.desktop.in"),
b.fmt("share/applications/{s}.desktop", .{app_id}),
});
// Right click menu action for Plasma desktop
try steps.append(&b.addInstallFile(
b.path("dist/linux/ghostty_dolphin.desktop"),
"share/kio/servicemenus/com.mitchellh.ghostty.desktop",
).step);
// Service for DBus activation.
try ts.append(.{
if (cfg.flatpak)
b.path("dist/linux/dbus.service.flatpak.in")
else
b.path("dist/linux/dbus.service.in"),
b.fmt("share/dbus-1/services/{s}.service", .{app_id}),
});
// Right click menu action for Nautilus. Note that this _must_ be named
// `ghostty.py`. Using the full app id causes problems (see #5468).
try steps.append(&b.addInstallFile(
b.path("dist/linux/ghostty_nautilus.py"),
"share/nautilus-python/extensions/ghostty.py",
).step);
// systemd user service. This is kind of nasty but systemd
// looks for user services in different paths depending on
// if we are installed as a system package or not (lib vs.
// share) so we have to handle that here. We might be able
// to get away with always installing to both because it
// only ever searches in one... but I don't want to do that hack
// until we have to.
if (!cfg.flatpak) try ts.append(.{
b.path("dist/linux/systemd.service.in"),
b.fmt(
"{s}/systemd/user/{s}.service",
.{
if (b.graph.system_package_mode) "lib" else "share",
app_id,
},
),
});
// Various icons that our application can use, including the icon
// that will be used for the desktop.
try steps.append(&b.addInstallFile(
b.path("images/icons/icon_16.png"),
"share/icons/hicolor/16x16/apps/com.mitchellh.ghostty.png",
).step);
try steps.append(&b.addInstallFile(
b.path("images/icons/icon_32.png"),
"share/icons/hicolor/32x32/apps/com.mitchellh.ghostty.png",
).step);
try steps.append(&b.addInstallFile(
b.path("images/icons/icon_128.png"),
"share/icons/hicolor/128x128/apps/com.mitchellh.ghostty.png",
).step);
try steps.append(&b.addInstallFile(
b.path("images/icons/icon_256.png"),
"share/icons/hicolor/256x256/apps/com.mitchellh.ghostty.png",
).step);
try steps.append(&b.addInstallFile(
b.path("images/icons/icon_512.png"),
"share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png",
).step);
// Flatpaks only support icons up to 512x512.
if (!cfg.flatpak) {
try steps.append(&b.addInstallFile(
b.path("images/icons/icon_1024.png"),
"share/icons/hicolor/1024x1024/apps/com.mitchellh.ghostty.png",
).step);
}
// AppStream metainfo so that application has rich metadata
// within app stores
try ts.append(.{
b.path("dist/linux/com.mitchellh.ghostty.metainfo.xml.in"),
b.fmt("share/metainfo/{s}.metainfo.xml", .{app_id}),
});
break :templates ts.items;
};
// Process all our templates
for (templates) |template| {
const tpl = b.addConfigHeader(.{
.style = .{ .cmake = template[0] },
}, .{
.NAME = name,
.APPID = app_id,
.GHOSTTY = exe_abs_path,
});
// Template output has a single header line we want to remove.
// We use `tail` to do it since its part of the POSIX standard.
const tail = b.addSystemCommand(&.{ "tail", "-n", "+2" });
tail.setStdIn(.{ .lazy_path = tpl.getOutput() });
const copy = b.addInstallFile(
tail.captureStdOut(),
template[1],
);
try steps.append(&copy.step);
}
// Right click menu action for Plasma desktop
try steps.append(&b.addInstallFile(
b.path("dist/linux/ghostty_dolphin.desktop"),
"share/kio/servicemenus/com.mitchellh.ghostty.desktop",
).step);
// Right click menu action for Nautilus. Note that this _must_ be named
// `ghostty.py`. Using the full app id causes problems (see #5468).
try steps.append(&b.addInstallFile(
b.path("dist/linux/ghostty_nautilus.py"),
"share/nautilus-python/extensions/ghostty.py",
).step);
// Various icons that our application can use, including the icon
// that will be used for the desktop.
try steps.append(&b.addInstallFile(
b.path("images/icons/icon_16.png"),
"share/icons/hicolor/16x16/apps/com.mitchellh.ghostty.png",
).step);
try steps.append(&b.addInstallFile(
b.path("images/icons/icon_32.png"),
"share/icons/hicolor/32x32/apps/com.mitchellh.ghostty.png",
).step);
try steps.append(&b.addInstallFile(
b.path("images/icons/icon_128.png"),
"share/icons/hicolor/128x128/apps/com.mitchellh.ghostty.png",
).step);
try steps.append(&b.addInstallFile(
b.path("images/icons/icon_256.png"),
"share/icons/hicolor/256x256/apps/com.mitchellh.ghostty.png",
).step);
try steps.append(&b.addInstallFile(
b.path("images/icons/icon_512.png"),
"share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png",
).step);
// Flatpaks only support icons up to 512x512.
if (!cfg.flatpak) {
try steps.append(&b.addInstallFile(
b.path("images/icons/icon_16@2x.png"),
"share/icons/hicolor/16x16@2/apps/com.mitchellh.ghostty.png",
).step);
try steps.append(&b.addInstallFile(
b.path("images/icons/icon_32@2x.png"),
"share/icons/hicolor/32x32@2/apps/com.mitchellh.ghostty.png",
).step);
try steps.append(&b.addInstallFile(
b.path("images/icons/icon_128@2x.png"),
"share/icons/hicolor/128x128@2/apps/com.mitchellh.ghostty.png",
).step);
try steps.append(&b.addInstallFile(
b.path("images/icons/icon_256@2x.png"),
"share/icons/hicolor/256x256@2/apps/com.mitchellh.ghostty.png",
b.path("images/icons/icon_1024.png"),
"share/icons/hicolor/1024x1024/apps/com.mitchellh.ghostty.png",
).step);
}
return .{ .steps = steps.items };
try steps.append(&b.addInstallFile(
b.path("images/icons/icon_16@2x.png"),
"share/icons/hicolor/16x16@2/apps/com.mitchellh.ghostty.png",
).step);
try steps.append(&b.addInstallFile(
b.path("images/icons/icon_32@2x.png"),
"share/icons/hicolor/32x32@2/apps/com.mitchellh.ghostty.png",
).step);
try steps.append(&b.addInstallFile(
b.path("images/icons/icon_128@2x.png"),
"share/icons/hicolor/128x128@2/apps/com.mitchellh.ghostty.png",
).step);
try steps.append(&b.addInstallFile(
b.path("images/icons/icon_256@2x.png"),
"share/icons/hicolor/256x256@2/apps/com.mitchellh.ghostty.png",
).step);
}
pub fn install(self: *const GhosttyResources) void {

View File

@@ -18,8 +18,13 @@ pub fn init(
{
const webgen_config = b.addExecutable(.{
.name = "webgen_config",
.root_source_file = b.path("src/main.zig"),
.target = b.graph.host,
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = b.graph.host,
.strip = false,
.omit_frame_pointer = false,
.unwind_tables = .sync,
}),
});
deps.help_strings.addImport(webgen_config);

View File

@@ -12,8 +12,13 @@ output: std.Build.LazyPath,
pub fn init(b: *std.Build, cfg: *const Config) !HelpStrings {
const exe = b.addExecutable(.{
.name = "helpgen",
.root_source_file = b.path("src/helpgen.zig"),
.target = b.graph.host,
.root_module = b.createModule(.{
.root_source_file = b.path("src/helpgen.zig"),
.target = b.graph.host,
.strip = false,
.omit_frame_pointer = false,
.unwind_tables = .sync,
}),
});
const help_config = config: {

View File

@@ -22,13 +22,26 @@ step: *Step,
output: LazyPath,
pub fn create(b: *std.Build, opts: Options) ?*MetallibStep {
const self = b.allocator.create(MetallibStep) catch @panic("OOM");
const sdk = switch (opts.target.result.os.tag) {
.macos => "macosx",
.ios => "iphoneos",
.ios => switch (opts.target.result.abi) {
// The iOS simulator uses the same SDK for Metal as the device,
// but the minimum version tag causes different behaviors.
.simulator => "iphoneos",
else => "iphoneos",
},
else => return null,
};
const platform_version_arg = switch (opts.target.result.os.tag) {
.macos => "-mmacos-version-min",
.ios => switch (opts.target.result.abi) {
.simulator => "-mios-simulator-version-min",
else => "-mios-version-min",
},
else => null,
};
const self = b.allocator.create(MetallibStep) catch @panic("OOM");
const min_version = if (opts.target.query.os_version_min) |v|
b.fmt("{}", .{v.semver})
@@ -46,16 +59,11 @@ pub fn create(b: *std.Build, opts: Options) ?*MetallibStep {
const output_ir = run_ir.addOutputFileArg(b.fmt("{s}.ir", .{opts.name}));
run_ir.addArgs(&.{"-c"});
for (opts.sources) |source| run_ir.addFileArg(source);
switch (opts.target.result.os.tag) {
.ios => run_ir.addArgs(&.{b.fmt(
"-mios-version-min={s}",
.{min_version},
)}),
.macos => run_ir.addArgs(&.{b.fmt(
"-mmacos-version-min={s}",
.{min_version},
)}),
else => {},
if (platform_version_arg) |arg| {
run_ir.addArgs(&.{b.fmt(
"{s}={s}",
.{ arg, min_version },
)});
}
const run_lib = RunStep.create(

View File

@@ -24,9 +24,9 @@ pub const LazyPathList = std.ArrayList(std.Build.LazyPath);
pub fn init(b: *std.Build, cfg: *const Config) !SharedDeps {
var result: SharedDeps = .{
.config = cfg,
.help_strings = try HelpStrings.init(b, cfg),
.unicode_tables = try UnicodeTables.init(b),
.framedata = try GhosttyFrameData.init(b),
.help_strings = try .init(b, cfg),
.unicode_tables = try .init(b),
.framedata = try .init(b),
// Setup by retarget
.options = undefined,
@@ -60,6 +60,9 @@ pub fn changeEntrypoint(
var result = self.*;
result.config = config;
result.options = b.addOptions();
try config.addOptions(result.options);
return result;
}
@@ -69,10 +72,10 @@ fn initTarget(
target: std.Build.ResolvedTarget,
) !void {
// Update our metallib
self.metallib = MetallibStep.create(b, .{
self.metallib = .create(b, .{
.name = "Ghostty",
.target = target,
.sources = &.{b.path("src/renderer/shaders/cell.metal")},
.sources = &.{b.path("src/renderer/shaders/shaders.metal")},
});
// Change our config
@@ -374,7 +377,7 @@ pub fn add(
// We always require the system SDK so that our system headers are available.
// This makes things like `os/log.h` available for cross-compiling.
if (step.rootModuleTarget().os.tag.isDarwin()) {
try @import("apple_sdk").addPaths(b, step.root_module);
try @import("apple_sdk").addPaths(b, step);
const metallib = self.metallib.?;
metallib.output.addStepDependencies(&step.step);
@@ -606,21 +609,23 @@ fn addGTK(
.wayland_protocols = wayland_protocols_dep.path(""),
});
// FIXME: replace with `zxdg_decoration_v1` once GTK merges https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398
scanner.addCustomProtocol(
plasma_wayland_protocols_dep.path("src/protocols/blur.xml"),
);
// FIXME: replace with `zxdg_decoration_v1` once GTK merges https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398
scanner.addCustomProtocol(
plasma_wayland_protocols_dep.path("src/protocols/server-decoration.xml"),
);
scanner.addCustomProtocol(
plasma_wayland_protocols_dep.path("src/protocols/slide.xml"),
);
scanner.addSystemProtocol("staging/xdg-activation/xdg-activation-v1.xml");
scanner.generate("wl_compositor", 1);
scanner.generate("org_kde_kwin_blur_manager", 1);
scanner.generate("org_kde_kwin_server_decoration_manager", 1);
scanner.generate("org_kde_kwin_slide_manager", 1);
scanner.generate("xdg_activation_v1", 1);
step.root_module.addImport("wayland", b.createModule(.{
.root_source_file = scanner.result,
@@ -647,14 +652,13 @@ fn addGTK(
// IMPORTANT: gtk4-layer-shell must be linked BEFORE
// wayland-client, as it relies on shimming libwayland's APIs.
if (b.systemIntegrationOption("gtk4-layer-shell", .{})) {
step.linkSystemLibrary2(
"gtk4-layer-shell-0",
dynamic_link_opts,
);
step.linkSystemLibrary2("gtk4-layer-shell-0", dynamic_link_opts);
} else {
// gtk4-layer-shell *must* be dynamically linked,
// so we don't add it as a static library
step.linkLibrary(gtk4_layer_shell.artifact("gtk4-layer-shell"));
const shared_lib = gtk4_layer_shell.artifact("gtk4-layer-shell");
b.installArtifact(shared_lib);
step.linkLibrary(shared_lib);
}
}
@@ -662,34 +666,6 @@ fn addGTK(
}
{
// For our actual build, we validate our GTK builder files if we can.
{
const gtk_builder_check = b.addExecutable(.{
.name = "gtk_builder_check",
.root_source_file = b.path("src/apprt/gtk/builder_check.zig"),
.target = b.graph.host,
});
gtk_builder_check.root_module.addOptions("build_options", self.options);
if (gobject_) |gobject| {
gtk_builder_check.root_module.addImport(
"gtk",
gobject.module("gtk4"),
);
gtk_builder_check.root_module.addImport(
"adw",
gobject.module("adw1"),
);
}
for (gresource.dependencies) |pathname| {
const extension = std.fs.path.extension(pathname);
if (!std.mem.eql(u8, extension, ".ui")) continue;
const check = b.addRunArtifact(gtk_builder_check);
check.addFileArg(b.path(pathname));
step.step.dependOn(&check.step);
}
}
// Get our gresource c/h files and add them to our build.
const dist = gtkDistResources(b);
step.addCSourceFile(.{ .file = dist.resources_c.path(b), .flags = &.{} });

View File

@@ -12,8 +12,13 @@ output: std.Build.LazyPath,
pub fn init(b: *std.Build) !UnicodeTables {
const exe = b.addExecutable(.{
.name = "unigen",
.root_source_file = b.path("src/unicode/props.zig"),
.target = b.graph.host,
.root_module = b.createModule(.{
.root_source_file = b.path("src/unicode/props.zig"),
.target = b.graph.host,
.strip = false,
.omit_frame_pointer = false,
.unwind_tables = .sync,
}),
});
if (b.lazyDependency("ziglyph", .{

View File

@@ -44,6 +44,7 @@ See GitHub issues: <https://github.com/ghostty-org/ghostty/issues>
# AUTHOR
Mitchell Hashimoto <m@mitchellh.com>
Ghostty contributors <https://github.com/ghostty-org/ghostty/graphs/contributors>
# SEE ALSO

View File

@@ -36,6 +36,7 @@ See GitHub issues: <https://github.com/ghostty-org/ghostty/issues>
# AUTHOR
Mitchell Hashimoto <m@mitchellh.com>
Ghostty contributors <https://github.com/ghostty-org/ghostty/graphs/contributors>
# SEE ALSO

View File

@@ -26,7 +26,7 @@ pub fn genConfig(writer: anytype, cli: bool) !void {
\\
);
@setEvalBranchQuota(3000);
@setEvalBranchQuota(5000);
inline for (@typeInfo(Config).@"struct".fields) |field| {
if (field.name[0] == '_') continue;
@@ -94,6 +94,7 @@ pub fn genKeybindActions(writer: anytype) !void {
const info = @typeInfo(KeybindAction);
std.debug.assert(info == .@"union");
@setEvalBranchQuota(5000);
inline for (info.@"union".fields) |field| {
if (field.name[0] == '_') continue;

View File

@@ -9,6 +9,7 @@ const list_keybinds = @import("list_keybinds.zig");
const list_themes = @import("list_themes.zig");
const list_colors = @import("list_colors.zig");
const list_actions = @import("list_actions.zig");
const edit_config = @import("edit_config.zig");
const show_config = @import("show_config.zig");
const validate_config = @import("validate_config.zig");
const crash_report = @import("crash_report.zig");
@@ -40,6 +41,9 @@ pub const Action = enum {
/// List keybind actions
@"list-actions",
/// Edit the config file in the configured terminal editor.
@"edit-config",
/// Dump the config to stdout
@"show-config",
@@ -151,6 +155,7 @@ pub const Action = enum {
.@"list-themes" => try list_themes.run(alloc),
.@"list-colors" => try list_colors.run(alloc),
.@"list-actions" => try list_actions.run(alloc),
.@"edit-config" => try edit_config.run(alloc),
.@"show-config" => try show_config.run(alloc),
.@"validate-config" => try validate_config.run(alloc),
.@"crash-report" => try crash_report.run(alloc),
@@ -187,6 +192,7 @@ pub const Action = enum {
.@"list-themes" => list_themes.Options,
.@"list-colors" => list_colors.Options,
.@"list-actions" => list_actions.Options,
.@"edit-config" => edit_config.Options,
.@"show-config" => show_config.Options,
.@"validate-config" => validate_config.Options,
.@"crash-report" => crash_report.Options,

View File

@@ -84,7 +84,7 @@ pub fn parse(
// If the arena is unset, we create it. We mark that we own it
// only so that we can clean it up on error.
if (dst._arena == null) {
dst._arena = ArenaAllocator.init(alloc);
dst._arena = .init(alloc);
arena_owned = true;
}
@@ -414,7 +414,7 @@ pub fn parseIntoField(
return error.InvalidField;
}
fn parseTaggedUnion(comptime T: type, alloc: Allocator, v: []const u8) !T {
pub fn parseTaggedUnion(comptime T: type, alloc: Allocator, v: []const u8) !T {
const info = @typeInfo(T).@"union";
assert(@typeInfo(info.tag_type.?) == .@"enum");
@@ -481,7 +481,7 @@ pub fn parseAutoStruct(comptime T: type, alloc: Allocator, v: []const u8) !T {
// Keep track of which fields were set so we can error if a required
// field was not set.
const FieldSet = std.StaticBitSet(info.fields.len);
var fields_set: FieldSet = FieldSet.initEmpty();
var fields_set: FieldSet = .initEmpty();
// We split each value by ","
var iter = std.mem.splitSequence(u8, v, ",");
@@ -1090,6 +1090,7 @@ test "parseIntoField: tagged union" {
b: u8,
c: void,
d: []const u8,
e: [:0]const u8,
} = undefined,
} = .{};
@@ -1108,6 +1109,10 @@ test "parseIntoField: tagged union" {
// Set string field
try parseIntoField(@TypeOf(data), alloc, &data, "value", "d:hello");
try testing.expectEqualStrings("hello", data.value.d);
// Set sentinel string field
try parseIntoField(@TypeOf(data), alloc, &data, "value", "e:hello");
try testing.expectEqualStrings("hello", data.value.e);
}
test "parseIntoField: tagged union unknown filed" {

View File

@@ -176,7 +176,7 @@ const Boo = struct {
pub fn run(gpa: Allocator) !u8 {
// Disable on non-desktop systems.
switch (builtin.os.tag) {
.windows, .macos, .linux => {},
.windows, .macos, .linux, .freebsd => {},
else => return 1,
}

159
src/cli/edit_config.zig Normal file
View File

@@ -0,0 +1,159 @@
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const args = @import("args.zig");
const Allocator = std.mem.Allocator;
const Action = @import("action.zig").Action;
const configpkg = @import("../config.zig");
const internal_os = @import("../os/main.zig");
const Config = configpkg.Config;
pub const Options = struct {
pub fn deinit(self: Options) void {
_ = self;
}
/// Enables `-h` and `--help` to work.
pub fn help(self: Options) !void {
_ = self;
return Action.help_error;
}
};
/// The `edit-config` command opens the Ghostty configuration file in the
/// editor specified by the `$VISUAL` or `$EDITOR` environment variables.
///
/// IMPORTANT: This command will not reload the configuration after
/// editing. You will need to manually reload the configuration using the
/// application menu, configured keybind, or by restarting Ghostty. We
/// plan to auto-reload in the future, but Ghostty isn't capable of
/// this yet.
///
/// The filepath opened is the default user-specific configuration
/// file, which is typically located at `$XDG_CONFIG_HOME/ghostty/config`.
/// On macOS, this may also be located at
/// `~/Library/Application Support/com.mitchellh.ghostty/config`.
/// On macOS, whichever path exists and is non-empty will be prioritized,
/// prioritizing the Application Support directory if neither are
/// non-empty.
///
/// This command prefers the `$VISUAL` environment variable over `$EDITOR`,
/// if both are set. If neither are set, it will print an error
/// and exit.
pub fn run(alloc: Allocator) !u8 {
// Implementation note (by @mitchellh): I do proper memory cleanup
// throughout this command, even though we plan on doing `exec`.
// I do this out of good hygiene in case we ever change this to
// not using `exec` anymore and because this command isn't performance
// critical where setting up the defer cleanup is a problem.
const stderr = std.io.getStdErr().writer();
var opts: Options = .{};
defer opts.deinit();
{
var iter = try args.argsIterator(alloc);
defer iter.deinit();
try args.parse(Options, alloc, &opts, &iter);
}
// We load the configuration once because that will write our
// default configuration files to disk. We don't use the config.
var config = try Config.load(alloc);
defer config.deinit();
// Find the preferred path.
const path = try Config.preferredDefaultFilePath(alloc);
defer alloc.free(path);
// We don't currently support Windows because we use the exec syscall.
if (comptime builtin.os.tag == .windows) {
try stderr.print(
\\The `ghostty +edit-config` command is not supported on Windows.
\\Please edit the configuration file manually at the following path:
\\
\\{s}
\\
,
.{path},
);
return 1;
}
// Get our editor
const get_env_: ?internal_os.GetEnvResult = env: {
// VISUAL vs. EDITOR: https://unix.stackexchange.com/questions/4859/visual-vs-editor-what-s-the-difference
if (try internal_os.getenv(alloc, "VISUAL")) |v| {
if (v.value.len > 0) break :env v;
v.deinit(alloc);
}
if (try internal_os.getenv(alloc, "EDITOR")) |v| {
if (v.value.len > 0) break :env v;
v.deinit(alloc);
}
break :env null;
};
defer if (get_env_) |v| v.deinit(alloc);
const editor: []const u8 = if (get_env_) |v| v.value else "";
// If we don't have `$EDITOR` set then we can't do anything
// but we can still print a helpful message.
if (editor.len == 0) {
try stderr.print(
\\The $EDITOR or $VISUAL environment variable is not set or is empty.
\\This environment variable is required to edit the Ghostty configuration
\\via this CLI command.
\\
\\Please set the environment variable to your preferred terminal
\\text editor and try again.
\\
\\If you prefer to edit the configuration file another way,
\\you can find the configuration file at the following path:
\\
\\
,
.{},
);
// Output the path using the OSC8 sequence so that it is linked.
try stderr.print(
"\x1b]8;;file://{s}\x1b\\{s}\x1b]8;;\x1b\\\n",
.{ path, path },
);
return 1;
}
// We require libc because we want to use std.c.environ for envp
// and not have to build that ourselves. We can remove this
// limitation later but Ghostty already heavily requires libc
// so this is not a big deal.
comptime assert(builtin.link_libc);
const editorZ = try alloc.dupeZ(u8, editor);
defer alloc.free(editorZ);
const pathZ = try alloc.dupeZ(u8, path);
defer alloc.free(pathZ);
const err = std.posix.execvpeZ(
editorZ,
&.{ editorZ, pathZ },
std.c.environ,
);
// If we reached this point then exec failed.
try stderr.print(
\\Failed to execute the editor. Error code={}.
\\
\\This is usually due to the executable path not existing, invalid
\\permissions, or the shell environment not being set up
\\correctly.
\\
\\Editor: {s}
\\Path: {s}
\\
, .{ err, editor, path });
return 1;
}

View File

@@ -155,14 +155,12 @@ const ChordBinding = struct {
while (l_trigger != null and r_trigger != null) {
const lhs_key: c_int = blk: {
switch (l_trigger.?.data.key) {
.translated => |key| break :blk @intFromEnum(key),
.physical => |key| break :blk @intFromEnum(key),
.unicode => |key| break :blk @intCast(key),
}
};
const rhs_key: c_int = blk: {
switch (r_trigger.?.data.key) {
.translated => |key| break :blk @intFromEnum(key),
.physical => |key| break :blk @intFromEnum(key),
.unicode => |key| break :blk @intCast(key),
}
@@ -254,8 +252,7 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 {
result = win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col });
}
const key = switch (trigger.data.key) {
.translated => |k| try std.fmt.allocPrint(alloc, "{s}", .{@tagName(k)}),
.physical => |k| try std.fmt.allocPrint(alloc, "physical:{s}", .{@tagName(k)}),
.physical => |k| try std.fmt.allocPrint(alloc, "{s}", .{@tagName(k)}),
.unicode => |c| try std.fmt.allocPrint(alloc, "{u}", .{c}),
};
result = win.printSegment(.{ .text = key }, .{ .col_offset = result.col });
@@ -297,8 +294,7 @@ fn iterateBindings(alloc: Allocator, iter: anytype, win: *const vaxis.Window) !s
if (t.mods.shift) try std.fmt.format(buf.writer(), "shift + ", .{});
switch (t.key) {
.translated => |k| try std.fmt.format(buf.writer(), "{s}", .{@tagName(k)}),
.physical => |k| try std.fmt.format(buf.writer(), "physical:{s}", .{@tagName(k)}),
.physical => |k| try std.fmt.format(buf.writer(), "{s}", .{@tagName(k)}),
.unicode => |c| try std.fmt.format(buf.writer(), "{u}", .{c}),
}

View File

@@ -24,6 +24,9 @@ pub const Options = struct {
/// If true, force a plain list of themes.
plain: bool = false,
/// Specifies the color scheme of the themes to include in the list.
color: enum { all, dark, light } = .all,
pub fn deinit(self: Options) void {
_ = self;
}
@@ -74,7 +77,7 @@ const ThemeListElement = struct {
/// Two different directories will be searched for themes.
///
/// The first directory is the `themes` subdirectory of your Ghostty
/// configuration directory. This is `$XDG_CONFIG_DIR/ghostty/themes` or
/// configuration directory. This is `$XDG_CONFIG_HOME/ghostty/themes` or
/// `~/.config/ghostty/themes`.
///
/// The second directory is the `themes` subdirectory of the Ghostty resources
@@ -93,6 +96,9 @@ const ThemeListElement = struct {
/// * `--path`: Show the full path to the theme.
///
/// * `--plain`: Force a plain listing of themes.
///
/// * `--color`: Specify the color scheme of the themes included in the list.
/// This can be `dark`, `light`, or `all`. The default is `all`.
pub fn run(gpa_alloc: std.mem.Allocator) !u8 {
var opts: Options = .{};
defer opts.deinit();
@@ -109,7 +115,8 @@ pub fn run(gpa_alloc: std.mem.Allocator) !u8 {
const stderr = std.io.getStdErr().writer();
const stdout = std.io.getStdOut().writer();
if (global_state.resources_dir == null)
const resources_dir = global_state.resources_dir.app();
if (resources_dir == null)
try stderr.print("Could not find the Ghostty resources directory. Please ensure " ++
"that Ghostty is installed correctly.\n", .{});
@@ -137,11 +144,30 @@ pub fn run(gpa_alloc: std.mem.Allocator) !u8 {
if (std.mem.eql(u8, entry.name, ".DS_Store"))
continue;
count += 1;
try themes.append(.{
.location = loc.location,
.path = try std.fs.path.join(alloc, &.{ loc.dir, entry.name }),
.theme = try alloc.dupe(u8, entry.name),
});
const path = try std.fs.path.join(alloc, &.{ loc.dir, entry.name });
// if there is no need to filter just append the theme to the list
if (opts.color == .all) {
try themes.append(.{
.path = path,
.location = loc.location,
.theme = try alloc.dupe(u8, entry.name),
});
continue;
}
// otherwise check if the theme should be included based on the provided options
var config = try Config.default(alloc);
defer config.deinit();
try config.loadFile(config._arena.?.allocator(), path);
if (shouldIncludeTheme(opts, config)) {
try themes.append(.{
.path = path,
.location = loc.location,
.theme = try alloc.dupe(u8, entry.name),
});
}
},
else => {},
}
@@ -1594,3 +1620,13 @@ fn preview(allocator: std.mem.Allocator, themes: []ThemeListElement) !void {
defer app.deinit();
try app.run();
}
fn shouldIncludeTheme(opts: Options, theme_config: Config) bool {
const rf = @as(f32, @floatFromInt(theme_config.background.r)) / 255.0;
const gf = @as(f32, @floatFromInt(theme_config.background.g)) / 255.0;
const bf = @as(f32, @floatFromInt(theme_config.background.b)) / 255.0;
const luminance = 0.2126 * rf + 0.7152 * gf + 0.0722 * bf;
const is_dark = luminance < 0.5;
return (opts.color == .dark and is_dark) or (opts.color == .light and !is_dark);
}

View File

@@ -3,6 +3,7 @@ const builtin = @import("builtin");
const formatter = @import("config/formatter.zig");
pub const Config = @import("config/Config.zig");
pub const conditional = @import("config/conditional.zig");
pub const io = @import("config/io.zig");
pub const string = @import("config/string.zig");
pub const edit = @import("config/edit.zig");
pub const url = @import("config/url.zig");
@@ -14,6 +15,7 @@ pub const formatEntry = formatter.formatEntry;
// Field types
pub const ClipboardAccess = Config.ClipboardAccess;
pub const Command = Config.Command;
pub const ConfirmCloseSurface = Config.ConfirmCloseSurface;
pub const CopyOnSelect = Config.CopyOnSelect;
pub const CustomShaderAnimation = Config.CustomShaderAnimation;
@@ -29,8 +31,11 @@ pub const RepeatableFontVariation = Config.RepeatableFontVariation;
pub const RepeatableString = Config.RepeatableString;
pub const RepeatableStringMap = @import("config/RepeatableStringMap.zig");
pub const RepeatablePath = Config.RepeatablePath;
pub const Path = Config.Path;
pub const ShellIntegrationFeatures = Config.ShellIntegrationFeatures;
pub const WindowPaddingColor = Config.WindowPaddingColor;
pub const BackgroundImagePosition = Config.BackgroundImagePosition;
pub const BackgroundImageFit = Config.BackgroundImageFit;
// Alternate APIs
pub const CAPI = @import("config/CAPI.zig");

File diff suppressed because it is too large Load Diff

322
src/config/command.zig Normal file
View File

@@ -0,0 +1,322 @@
const std = @import("std");
const builtin = @import("builtin");
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const formatterpkg = @import("formatter.zig");
/// A command to execute (argv0 and args).
///
/// A command is specified as a simple string such as "nvim a b c".
/// By default, we expect the downstream to do some sort of shell expansion
/// on this string.
///
/// If a command is already expanded and the user does NOT want to do
/// shell expansion (because this usually requires a round trip into
/// /bin/sh or equivalent), specify a `direct:`-prefix. e.g.
/// `direct:nvim a b c`.
///
/// The whitespace before or around the prefix is ignored. For example,
/// ` direct:nvim a b c` and `direct: nvim a b c` are equivalent.
///
/// If the command is not absolute, it'll be looked up via the PATH.
/// For the shell-expansion case, we let the shell do this. For the
/// direct case, we do this directly.
pub const Command = union(enum) {
const Self = @This();
/// Execute a command directly, e.g. via `exec`. The format here
/// is already structured to be ready to passed directly to `exec`
/// with index zero being the command to execute.
///
/// Index zero is not guaranteed to be an absolute path, and may require
/// PATH lookup. It is up to the downstream to do this, usually via
/// delegation to something like `execvp`.
direct: []const [:0]const u8,
/// Execute a command via shell expansion. This provides the command
/// as a single string that is expected to be expanded in some way
/// (up to the downstream). Usually `/bin/sh -c`.
shell: [:0]const u8,
pub fn parseCLI(
self: *Self,
alloc: Allocator,
input_: ?[]const u8,
) !void {
// Input is required. Whitespace on the edges isn't needed.
// Commands must be non-empty.
const input = input_ orelse return error.ValueRequired;
const trimmed = std.mem.trim(u8, input, " ");
if (trimmed.len == 0) return error.ValueRequired;
// If we have a `:` then we MIGHT have a prefix to specify what
// tag we should use.
const tag: std.meta.Tag(Self), const str: []const u8 = tag: {
if (std.mem.indexOfScalar(u8, trimmed, ':')) |idx| {
const prefix = trimmed[0..idx];
if (std.mem.eql(u8, prefix, "direct")) {
break :tag .{ .direct, trimmed[idx + 1 ..] };
} else if (std.mem.eql(u8, prefix, "shell")) {
break :tag .{ .shell, trimmed[idx + 1 ..] };
}
}
break :tag .{ .shell, trimmed };
};
switch (tag) {
.shell => {
// We have a shell command, so we can just dupe it.
const copy = try alloc.dupeZ(u8, std.mem.trim(u8, str, " "));
self.* = .{ .shell = copy };
},
.direct => {
// We're not shell expanding, so the arguments are naively
// split on spaces.
var builder: std.ArrayListUnmanaged([:0]const u8) = .empty;
var args = std.mem.splitScalar(
u8,
std.mem.trim(u8, str, " "),
' ',
);
while (args.next()) |arg| {
const copy = try alloc.dupeZ(u8, arg);
try builder.append(alloc, copy);
}
self.* = .{ .direct = try builder.toOwnedSlice(alloc) };
},
}
}
/// Creates a command as a single string, joining arguments as
/// necessary with spaces. Its not guaranteed that this is a valid
/// command; it is only meant to be human readable.
pub fn string(
self: *const Self,
alloc: Allocator,
) Allocator.Error![:0]const u8 {
return switch (self.*) {
.shell => |v| try alloc.dupeZ(u8, v),
.direct => |v| try std.mem.joinZ(alloc, " ", v),
};
}
/// Get an iterator over the arguments array. This may allocate
/// depending on the active tag of the command.
///
/// For direct commands, this is very cheap and just iterates over
/// the array. There is no allocation.
///
/// For shell commands, this will use Zig's ArgIteratorGeneral as
/// a best effort shell string parser. This is not guaranteed to be
/// 100% accurate, but it works for common cases. This requires allocation.
pub fn argIterator(
self: *const Self,
alloc: Allocator,
) Allocator.Error!ArgIterator {
return switch (self.*) {
.direct => |v| .{ .direct = .{ .args = v } },
.shell => |v| .{ .shell = try .init(alloc, v) },
};
}
/// Iterates over each argument in the command.
pub const ArgIterator = union(enum) {
shell: std.process.ArgIteratorGeneral(.{}),
direct: struct {
i: usize = 0,
args: []const [:0]const u8,
},
/// Return the next argument. This may or may not be a copy
/// depending on the active tag. If you want to ensure that every
/// argument is a copy, use the `clone` method first.
pub fn next(self: *ArgIterator) ?[:0]const u8 {
return switch (self.*) {
.shell => |*v| v.next(),
.direct => |*v| {
if (v.i >= v.args.len) return null;
defer v.i += 1;
return v.args[v.i];
},
};
}
pub fn deinit(self: *ArgIterator) void {
switch (self.*) {
.shell => |*v| v.deinit(),
.direct => {},
}
}
};
pub fn clone(
self: *const Self,
alloc: Allocator,
) Allocator.Error!Self {
return switch (self.*) {
.shell => |v| .{ .shell = try alloc.dupeZ(u8, v) },
.direct => |v| direct: {
const copy = try alloc.alloc([:0]const u8, v.len);
for (v, 0..) |arg, i| copy[i] = try alloc.dupeZ(u8, arg);
break :direct .{ .direct = copy };
},
};
}
pub fn formatEntry(self: Self, formatter: anytype) !void {
switch (self) {
.shell => |v| try formatter.formatEntry([]const u8, v),
.direct => |v| {
var buf: [4096]u8 = undefined;
var fbs = std.io.fixedBufferStream(&buf);
const writer = fbs.writer();
writer.writeAll("direct:") catch return error.OutOfMemory;
for (v) |arg| {
writer.writeAll(arg) catch return error.OutOfMemory;
writer.writeByte(' ') catch return error.OutOfMemory;
}
const written = fbs.getWritten();
try formatter.formatEntry(
[]const u8,
written[0..@intCast(written.len - 1)],
);
},
}
}
test "Command: parseCLI errors" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var v: Self = undefined;
try testing.expectError(error.ValueRequired, v.parseCLI(alloc, null));
try testing.expectError(error.ValueRequired, v.parseCLI(alloc, ""));
try testing.expectError(error.ValueRequired, v.parseCLI(alloc, " "));
}
test "Command: parseCLI shell expanded" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var v: Self = undefined;
try v.parseCLI(alloc, "echo hello");
try testing.expect(v == .shell);
try testing.expectEqualStrings(v.shell, "echo hello");
// Spaces are stripped
try v.parseCLI(alloc, " echo hello ");
try testing.expect(v == .shell);
try testing.expectEqualStrings(v.shell, "echo hello");
}
test "Command: parseCLI direct" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var v: Self = undefined;
try v.parseCLI(alloc, "direct:echo hello");
try testing.expect(v == .direct);
try testing.expectEqual(v.direct.len, 2);
try testing.expectEqualStrings(v.direct[0], "echo");
try testing.expectEqualStrings(v.direct[1], "hello");
// Spaces around the prefix
try v.parseCLI(alloc, " direct: echo hello");
try testing.expect(v == .direct);
try testing.expectEqual(v.direct.len, 2);
try testing.expectEqualStrings(v.direct[0], "echo");
try testing.expectEqualStrings(v.direct[1], "hello");
}
test "Command: argIterator shell" {
const testing = std.testing;
const alloc = testing.allocator;
var v: Self = .{ .shell = "echo hello world" };
var it = try v.argIterator(alloc);
defer it.deinit();
try testing.expectEqualStrings(it.next().?, "echo");
try testing.expectEqualStrings(it.next().?, "hello");
try testing.expectEqualStrings(it.next().?, "world");
try testing.expect(it.next() == null);
}
test "Command: argIterator direct" {
const testing = std.testing;
const alloc = testing.allocator;
var v: Self = .{ .direct = &.{ "echo", "hello world" } };
var it = try v.argIterator(alloc);
defer it.deinit();
try testing.expectEqualStrings(it.next().?, "echo");
try testing.expectEqualStrings(it.next().?, "hello world");
try testing.expect(it.next() == null);
}
test "Command: string shell" {
const testing = std.testing;
const alloc = testing.allocator;
var v: Self = .{ .shell = "echo hello world" };
const str = try v.string(alloc);
defer alloc.free(str);
try testing.expectEqualStrings(str, "echo hello world");
}
test "Command: string direct" {
const testing = std.testing;
const alloc = testing.allocator;
var v: Self = .{ .direct = &.{ "echo", "hello world" } };
const str = try v.string(alloc);
defer alloc.free(str);
try testing.expectEqualStrings(str, "echo hello world");
}
test "Command: formatConfig shell" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var buf = std.ArrayList(u8).init(alloc);
defer buf.deinit();
var v: Self = undefined;
try v.parseCLI(alloc, "echo hello");
try v.formatEntry(formatterpkg.entryFormatter("a", buf.writer()));
try std.testing.expectEqualSlices(u8, "a = echo hello\n", buf.items);
}
test "Command: formatConfig direct" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var buf = std.ArrayList(u8).init(alloc);
defer buf.deinit();
var v: Self = undefined;
try v.parseCLI(alloc, "direct: echo hello");
try v.formatEntry(formatterpkg.entryFormatter("a", buf.writer()));
try std.testing.expectEqualSlices(u8, "a = direct:echo hello\n", buf.items);
}
};
test {
_ = Command;
}

View File

@@ -20,10 +20,10 @@ pub fn open(alloc_gpa: Allocator) !void {
// Use an arena to make memory management easier in here.
var arena = ArenaAllocator.init(alloc_gpa);
defer arena.deinit();
const alloc = arena.allocator();
const alloc_arena = arena.allocator();
// Get the path we should open
const config_path = try configPath(alloc);
const config_path = try configPath(alloc_arena);
// Create config directory recursively.
if (std.fs.path.dirname(config_path)) |config_dir| {
@@ -41,7 +41,7 @@ pub fn open(alloc_gpa: Allocator) !void {
}
};
try internal_os.open(alloc, .text, config_path);
try internal_os.open(alloc_gpa, .text, config_path);
}
/// Returns the config path to use for open for the current OS.

View File

@@ -153,7 +153,7 @@ pub const FileFormatter = struct {
// If we're change-tracking then we need the default config to
// compare against.
var default: ?Config = if (self.changed)
try Config.default(self.alloc)
try .default(self.alloc)
else
null;
defer if (default) |*v| v.deinit();

256
src/config/io.zig Normal file
View File

@@ -0,0 +1,256 @@
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const string = @import("string.zig");
const formatterpkg = @import("formatter.zig");
const cli = @import("../cli.zig");
/// ReadableIO is some kind of IO source that is readable.
///
/// It can be either a direct string or a filepath. The filepath will
/// be deferred and read later, so it won't be checked for existence
/// or readability at configuration time. This allows using a path that
/// might be produced in an intermediate state.
pub const ReadableIO = union(enum) {
const Self = @This();
raw: [:0]const u8,
path: [:0]const u8,
pub fn parseCLI(
self: *Self,
alloc: Allocator,
input_: ?[]const u8,
) !void {
const input = input_ orelse return error.ValueRequired;
if (input.len == 0) return error.ValueRequired;
// We create a buffer only to do string parsing and validate
// it works. We store the value as raw so that our formatting
// can recreate it.
{
const buf = try alloc.alloc(u8, input.len);
defer alloc.free(buf);
_ = try string.parse(buf, input);
}
// Next, parse the tagged union using normal rules.
self.* = cli.args.parseTaggedUnion(
Self,
alloc,
input,
) catch |err| switch (err) {
// Invalid values in the tagged union are interpreted as
// raw values. This lets users pass in simple string values
// without needing to tag them.
error.InvalidValue => .{ .raw = try alloc.dupeZ(u8, input) },
else => return err,
};
}
pub fn clone(self: Self, alloc: Allocator) Allocator.Error!Self {
return switch (self) {
.raw => |v| .{ .raw = try alloc.dupeZ(u8, v) },
.path => |v| .{ .path = try alloc.dupeZ(u8, v) },
};
}
/// Same as clone but also parses the values as Zig strings in
/// the final resulting value all at once so we can avoid extra
/// allocations.
pub fn cloneParsed(
self: Self,
alloc: Allocator,
) Allocator.Error!Self {
switch (self) {
inline else => |v, tag| {
// Parsing can't fail because we validate it in parseCLI
const copied = try alloc.dupeZ(u8, v);
const parsed = string.parse(copied, v) catch unreachable;
assert(copied.ptr == parsed.ptr);
// If we parsed less than our original length we need
// to keep it null-terminated.
if (parsed.len < copied.len) copied[parsed.len] = 0;
return @unionInit(
Self,
@tagName(tag),
copied[0..parsed.len :0],
);
},
}
}
pub fn equal(self: Self, other: Self) bool {
if (std.meta.activeTag(self) != std.meta.activeTag(other)) {
return false;
}
return switch (self) {
.raw => |v| std.mem.eql(u8, v, other.raw),
.path => |v| std.mem.eql(u8, v, other.path),
};
}
pub fn formatEntry(self: Self, formatter: anytype) !void {
var buf: [4096]u8 = undefined;
var fbs = std.io.fixedBufferStream(&buf);
const writer = fbs.writer();
switch (self) {
inline else => |v, tag| {
writer.writeAll(@tagName(tag)) catch return error.OutOfMemory;
writer.writeByte(':') catch return error.OutOfMemory;
writer.writeAll(v) catch return error.OutOfMemory;
},
}
const written = fbs.getWritten();
try formatter.formatEntry(
[]const u8,
written,
);
}
test "parseCLI" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
{
var io: Self = undefined;
try Self.parseCLI(&io, alloc, "foo");
try testing.expect(io == .raw);
try testing.expectEqualStrings("foo", io.raw);
}
{
var io: Self = undefined;
try Self.parseCLI(&io, alloc, "raw:foo");
try testing.expect(io == .raw);
try testing.expectEqualStrings("foo", io.raw);
}
{
var io: Self = undefined;
try Self.parseCLI(&io, alloc, "path:foo");
try testing.expect(io == .path);
try testing.expectEqualStrings("foo", io.path);
}
}
test "formatEntry" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var buf = std.ArrayList(u8).init(alloc);
defer buf.deinit();
var v: Self = undefined;
try v.parseCLI(alloc, "raw:foo");
try v.formatEntry(formatterpkg.entryFormatter("a", buf.writer()));
try std.testing.expectEqualSlices(u8, "a = raw:foo\n", buf.items);
}
};
pub const RepeatableReadableIO = struct {
const Self = @This();
// Allocator for the list is the arena for the parent config.
list: std.ArrayListUnmanaged(ReadableIO) = .{},
pub fn parseCLI(
self: *Self,
alloc: Allocator,
input: ?[]const u8,
) !void {
const value = input orelse return error.ValueRequired;
// Empty value resets the list
if (value.len == 0) {
self.list.clearRetainingCapacity();
return;
}
var io: ReadableIO = undefined;
try ReadableIO.parseCLI(&io, alloc, value);
try self.list.append(alloc, io);
}
/// Deep copy of the struct. Required by Config.
pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self {
var list = try std.ArrayListUnmanaged(ReadableIO).initCapacity(
alloc,
self.list.items.len,
);
for (self.list.items) |item| {
const copy = try item.clone(alloc);
list.appendAssumeCapacity(copy);
}
return .{ .list = list };
}
/// See ReadableIO.cloneParsed
pub fn cloneParsed(
self: *const Self,
alloc: Allocator,
) Allocator.Error!Self {
var list = try std.ArrayListUnmanaged(ReadableIO).initCapacity(
alloc,
self.list.items.len,
);
for (self.list.items) |item| {
const copy = try item.cloneParsed(alloc);
list.appendAssumeCapacity(copy);
}
return .{ .list = list };
}
/// Compare if two of our value are requal. Required by Config.
pub fn equal(self: Self, other: Self) bool {
const itemsA = self.list.items;
const itemsB = other.list.items;
if (itemsA.len != itemsB.len) return false;
for (itemsA, itemsB) |a, b| {
if (!a.equal(b)) return false;
} else return true;
}
/// Used by Formatter
pub fn formatEntry(
self: Self,
formatter: anytype,
) !void {
if (self.list.items.len == 0) {
try formatter.formatEntry(void, {});
return;
}
for (self.list.items) |value| {
try formatter.formatEntry(ReadableIO, value);
}
}
test "parseCLI" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var list: Self = .{};
try list.parseCLI(alloc, "raw:A");
try list.parseCLI(alloc, "path:B");
try testing.expectEqual(@as(usize, 2), list.list.items.len);
try list.parseCLI(alloc, "");
try testing.expectEqual(@as(usize, 0), list.list.items.len);
}
};
test {
_ = ReadableIO;
_ = RepeatableReadableIO;
}

View File

@@ -3,7 +3,7 @@ const std = @import("std");
/// Parse a string literal into a byte array. The string can contain
/// any valid Zig string literal escape sequences.
///
/// The output buffer never needs sto be larger than the input buffer.
/// The output buffer never needs to be larger than the input buffer.
/// The buffers may alias.
pub fn parse(out: []u8, bytes: []const u8) ![]u8 {
var dst_i: usize = 0;

View File

@@ -56,7 +56,7 @@ pub const Location = enum {
},
.resources => try std.fs.path.join(arena_alloc, &.{
global_state.resources_dir orelse return null,
global_state.resources_dir.app() orelse return null,
"themes",
}),
};

View File

@@ -26,7 +26,7 @@ pub const regex =
"(?:" ++ url_schemes ++
\\)(?:
++ ipv6_url_pattern ++
\\|[\w\-.~:/?#@!$&*+,;=%]+(?:[\(\[]\w*[\)\]])?)+(?<![,.])|(?:\.\.\/|\.\/*|\/)[\w\-.~:\/?#@!$&*+,;=%]+(?:\/[\w\-.~:\/?#@!$&*+,;=%]*)*
\\|[\w\-.~:/?#@!$&*+,;=%]+(?:[\(\[]\w*[\)\]])?)+(?<![,.])|(?:\.\.\/|\.\/|\/)[\w\-.~:\/?#@!$&*+,;=%]+(?:\/[\w\-.~:\/?#@!$&*+,;=%]*)*
;
const url_schemes =
\\https?://|mailto:|ftp://|file:|ssh:|git://|ssh://|tel:|magnet:|ipfs://|ipns://|gemini://|gopher://|news:
@@ -112,6 +112,10 @@ test "url regex" {
.input = "url with dashes [mode 2027](https://github.com/contour-terminal/terminal-unicode-core) for better unicode support",
.expect = "https://github.com/contour-terminal/terminal-unicode-core",
},
.{
.input = "dot.http://example.com",
.expect = "http://example.com",
},
// weird characters in URL
.{
.input = "weird characters https://example.com/~user/?query=1&other=2#hash and more",

View File

@@ -88,6 +88,7 @@ fn writeSyntax(writer: anytype) !void {
\\let s:cpo_save = &cpo
\\set cpo&vim
\\
\\syn iskeyword @,48-57,-
\\syn keyword ghosttyConfigKeyword
);

View File

@@ -81,6 +81,13 @@ pub fn init(gpa: Allocator) !void {
fn initThread(gpa: Allocator) !void {
if (comptime !build_options.sentry) return;
// Right now, on Darwin, `std.Thread.setName` can only name the current
// thread, and we have no way to get the current thread from within it,
// so instead we use this code to name the thread instead.
if (builtin.os.tag.isDarwin()) {
internal_os.macos.pthread_setname_np(&"sentry-init".*);
}
var arena = std.heap.ArenaAllocator.init(gpa);
defer arena.deinit();
const alloc = arena.allocator();
@@ -166,7 +173,7 @@ fn beforeSend(
event_val: sentry.c.sentry_value_t,
_: ?*anyopaque,
_: ?*anyopaque,
) callconv(.C) sentry.c.sentry_value_t {
) callconv(.c) sentry.c.sentry_value_t {
// The native SDK at the time of writing doesn't support thread-local
// scopes. The full SDK has one global scope. So we use the beforeSend
// handler to set thread-specific data such as window size, grid size,
@@ -237,7 +244,7 @@ fn beforeSend(
}
pub const Transport = struct {
pub fn send(envelope: *sentry.Envelope, ud: ?*anyopaque) callconv(.C) void {
pub fn send(envelope: *sentry.Envelope, ud: ?*anyopaque) callconv(.c) void {
_ = ud;
defer envelope.deinit();

View File

@@ -331,7 +331,7 @@ pub const Item = union(enum) {
// Decode the item.
self.* = switch (encoded.type) {
.attachment => .{ .attachment = try Attachment.decode(
.attachment => .{ .attachment = try .decode(
alloc,
encoded,
) },

View File

@@ -0,0 +1,44 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
/// A collection of ArrayLists with methods for bulk operations.
pub fn ArrayListCollection(comptime T: type) type {
return struct {
const Self = ArrayListCollection(T);
const ArrayListT = std.ArrayListUnmanaged(T);
// An array containing the lists that belong to this collection.
lists: []ArrayListT,
// The collection will be initialized with empty ArrayLists.
pub fn init(
alloc: Allocator,
list_count: usize,
initial_capacity: usize,
) Allocator.Error!Self {
const self: Self = .{
.lists = try alloc.alloc(ArrayListT, list_count),
};
for (self.lists) |*list| {
list.* = try .initCapacity(alloc, initial_capacity);
}
return self;
}
pub fn deinit(self: *Self, alloc: Allocator) void {
for (self.lists) |*list| {
list.deinit(alloc);
}
alloc.free(self.lists);
}
/// Clear all lists in the collection, retaining capacity.
pub fn reset(self: *Self) void {
for (self.lists) |*list| {
list.clearRetainingCapacity();
}
}
};
}

View File

@@ -70,7 +70,7 @@ pub fn CacheTable(
/// become a pointless check, but hopefully branch prediction picks
/// up on it at that point. The memory cost isn't too bad since it's
/// just bytes, so should be a fraction the size of the main table.
lengths: [bucket_count]u8 = [_]u8{0} ** bucket_count,
lengths: [bucket_count]u8 = @splat(0),
/// An instance of the context structure.
/// Must be initialized before calling any operations.

View File

@@ -152,7 +152,7 @@ pub fn CircBuf(comptime T: type, comptime default: T) type {
/// If larger, new values will be set to the default value.
pub fn resize(self: *Self, alloc: Allocator, size: usize) Allocator.Error!void {
// Rotate to zero so it is aligned.
try self.rotateToZero(alloc);
try self.rotateToZero();
// Reallocate, this adds to the end so we're ready to go.
const prev_len = self.len();
@@ -173,29 +173,16 @@ pub fn CircBuf(comptime T: type, comptime default: T) type {
}
/// Rotate the data so that it is zero-aligned.
fn rotateToZero(self: *Self, alloc: Allocator) Allocator.Error!void {
// TODO: this does this in the worst possible way by allocating.
// rewrite to not allocate, its possible, I'm just lazy right now.
fn rotateToZero(self: *Self) Allocator.Error!void {
// If we're already at zero then do nothing.
if (self.tail == 0) return;
var buf = try alloc.alloc(T, self.storage.len);
defer {
self.head = if (self.full) 0 else self.len();
self.tail = 0;
alloc.free(self.storage);
self.storage = buf;
}
// We use std.mem.rotate to rotate our storage in-place.
std.mem.rotate(T, self.storage, self.tail);
if (!self.full and self.head >= self.tail) {
fastmem.copy(T, buf, self.storage[self.tail..self.head]);
return;
}
const middle = self.storage.len - self.tail;
fastmem.copy(T, buf, self.storage[self.tail..]);
fastmem.copy(T, buf[middle..], self.storage[0..self.head]);
// Then fix up our head and tail.
self.head = self.len() % self.storage.len;
self.tail = 0;
}
/// Returns if the buffer is currently empty. To check if its
@@ -589,7 +576,7 @@ test "CircBuf rotateToZero" {
defer buf.deinit(alloc);
_ = buf.getPtrSlice(0, 11);
try buf.rotateToZero(alloc);
try buf.rotateToZero();
}
test "CircBuf rotateToZero offset" {
@@ -611,7 +598,7 @@ test "CircBuf rotateToZero offset" {
try testing.expect(buf.tail > 0 and buf.head >= buf.tail);
// Rotate to zero
try buf.rotateToZero(alloc);
try buf.rotateToZero();
try testing.expectEqual(@as(usize, 0), buf.tail);
try testing.expectEqual(@as(usize, 1), buf.head);
}
@@ -645,7 +632,7 @@ test "CircBuf rotateToZero wraps" {
}
// Rotate to zero
try buf.rotateToZero(alloc);
try buf.rotateToZero();
try testing.expectEqual(@as(usize, 0), buf.tail);
try testing.expectEqual(@as(usize, 3), buf.head);
{
@@ -681,7 +668,7 @@ test "CircBuf rotateToZero full no wrap" {
}
// Rotate to zero
try buf.rotateToZero(alloc);
try buf.rotateToZero();
try testing.expect(buf.full);
try testing.expectEqual(@as(usize, 0), buf.tail);
try testing.expectEqual(@as(usize, 0), buf.head);

102
src/file_type.zig Normal file
View File

@@ -0,0 +1,102 @@
const std = @import("std");
const type_details: []const struct {
typ: FileType,
sigs: []const []const ?u8,
exts: []const []const u8,
} = &.{
.{
.typ = .jpeg,
.sigs = &.{
&.{ 0xFF, 0xD8, 0xFF, 0xDB },
&.{ 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01 },
&.{ 0xFF, 0xD8, 0xFF, 0xEE },
&.{ 0xFF, 0xD8, 0xFF, 0xE1, null, null, 0x45, 0x78, 0x69, 0x66, 0x00, 0x00 },
&.{ 0xFF, 0xD8, 0xFF, 0xE0 },
},
.exts = &.{ ".jpg", ".jpeg", ".jfif" },
},
.{
.typ = .png,
.sigs = &.{&.{ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }},
.exts = &.{".png"},
},
.{
.typ = .gif,
.sigs = &.{
&.{ 'G', 'I', 'F', '8', '7', 'a' },
&.{ 'G', 'I', 'F', '8', '9', 'a' },
},
.exts = &.{".gif"},
},
.{
.typ = .bmp,
.sigs = &.{&.{ 'B', 'M' }},
.exts = &.{".bmp"},
},
.{
.typ = .qoi,
.sigs = &.{&.{ 'q', 'o', 'i', 'f' }},
.exts = &.{".qoi"},
},
.{
.typ = .webp,
.sigs = &.{
&.{ 0x52, 0x49, 0x46, 0x46, null, null, null, null, 0x57, 0x45, 0x42, 0x50 },
},
.exts = &.{".webp"},
},
};
/// This is a helper for detecting file types based on magic bytes.
///
/// Ref: https://en.wikipedia.org/wiki/List_of_file_signatures
pub const FileType = enum {
/// JPEG image file.
jpeg,
/// PNG image file.
png,
/// GIF image file.
gif,
/// BMP image file.
bmp,
/// QOI image file.
qoi,
/// WebP image file.
webp,
/// Unknown file format.
unknown,
/// Detect file type based on the magic bytes
/// at the start of the provided file contents.
pub fn detect(contents: []const u8) FileType {
inline for (type_details) |typ| {
inline for (typ.sigs) |signature| {
if (contents.len >= signature.len) {
for (contents[0..signature.len], signature) |f, sig| {
if (sig) |s| if (f != s) break;
} else {
return typ.typ;
}
}
}
}
return .unknown;
}
/// Guess file type from its extension.
pub fn guessFromExtension(extension: []const u8) FileType {
inline for (type_details) |typ| {
inline for (typ.exts) |ext| {
if (std.ascii.eqlIgnoreCase(extension, ext)) return typ.typ;
}
}
return .unknown;
}
};

View File

@@ -50,15 +50,18 @@ modified: std.atomic.Value(usize) = .{ .raw = 0 },
resized: std.atomic.Value(usize) = .{ .raw = 0 },
pub const Format = enum(u8) {
/// 1 byte per pixel grayscale.
grayscale = 0,
rgb = 1,
rgba = 2,
/// 3 bytes per pixel BGR.
bgr = 1,
/// 4 bytes per pixel BGRA.
bgra = 2,
pub fn depth(self: Format) u8 {
return switch (self) {
.grayscale => 1,
.rgb => 3,
.rgba => 4,
.bgr => 3,
.bgra => 4,
};
}
};
@@ -303,7 +306,12 @@ pub fn clear(self: *Atlas) void {
}
/// Dump the atlas as a PPM to a writer, for debug purposes.
/// Only supports grayscale and rgb atlases.
/// Only supports grayscale and bgr atlases.
///
/// NOTE: BGR atlases will have the red and blue channels
/// swapped because PPM expects RGB. This would be
/// easy enough to fix so next time someone needs
/// to debug a color atlas they should fix it.
pub fn dump(self: Atlas, writer: anytype) !void {
try writer.print(
\\P{c}
@@ -313,7 +321,7 @@ pub fn dump(self: Atlas, writer: anytype) !void {
, .{
@as(u8, switch (self.format) {
.grayscale => '5',
.rgb => '6',
.bgr => '6',
else => {
log.err("Unsupported format for dump: {}", .{self.format});
@panic("Cannot dump this atlas format.");
@@ -418,8 +426,16 @@ pub const Wasm = struct {
// We need to draw pixels so this is format dependent.
const buf: []u8 = switch (self.format) {
// RGBA is the native ImageData format
.rgba => self.data,
.bgra => buf: {
// Convert from BGRA to RGBA by swapping every R and B.
var buf: []u8 = try alloc.dupe(u8, self.data);
errdefer alloc.free(buf);
var i: usize = 0;
while (i < self.data.len) : (i += 4) {
std.mem.swap(u8, &buf[i], &buf[i + 2]);
}
break :buf buf;
},
.grayscale => buf: {
// Convert from A8 to RGBA so every 4th byte is set to a value.
@@ -572,12 +588,12 @@ test "grow" {
try testing.expectEqual(@as(u8, 4), atlas.data[atlas.size * 2 + 2]);
}
test "writing RGB data" {
test "writing BGR data" {
const alloc = testing.allocator;
var atlas = try init(alloc, 32, .rgb);
var atlas = try init(alloc, 32, .bgr);
defer atlas.deinit(alloc);
// This is RGB so its 3 bpp
// This is BGR so its 3 bpp
const reg = try atlas.reserve(alloc, 1, 2);
atlas.set(reg, &[_]u8{
1, 2, 3,
@@ -594,18 +610,18 @@ test "writing RGB data" {
try testing.expectEqual(@as(u8, 6), atlas.data[65 * depth + 2]);
}
test "grow RGB" {
test "grow BGR" {
const alloc = testing.allocator;
// Atlas is 4x4 so its a 1px border meaning we only have 2x2 available
var atlas = try init(alloc, 4, .rgb);
var atlas = try init(alloc, 4, .bgr);
defer atlas.deinit(alloc);
// Get our 2x2, which should be ALL our usable space
const reg = try atlas.reserve(alloc, 2, 2);
try testing.expectError(Error.AtlasFull, atlas.reserve(alloc, 1, 1));
// This is RGB so its 3 bpp
// This is BGR so its 3 bpp
atlas.set(reg, &[_]u8{
10, 11, 12, // (0, 0) (x, y) from top-left
13, 14, 15, // (1, 0)

View File

@@ -37,7 +37,7 @@ collection: Collection,
/// The set of statuses and whether they're enabled or not. This defaults
/// to true. This can be changed at runtime with no ill effect.
styles: StyleStatus = StyleStatus.initFill(true),
styles: StyleStatus = .initFill(true),
/// If discovery is available, we'll look up fonts where we can't find
/// the codepoint. This can be set after initialization.
@@ -140,7 +140,7 @@ pub fn getIndex(
// handle this.
if (self.sprite) |sprite| {
if (sprite.hasCodepoint(cp, p)) {
return Collection.Index.initSpecial(.sprite);
return .initSpecial(.sprite);
}
}
@@ -380,7 +380,7 @@ test getIndex {
const testEmoji = font.embedded.emoji;
const testEmojiText = font.embedded.emoji_text;
var lib = try Library.init();
var lib = try Library.init(alloc);
defer lib.deinit();
var c = Collection.init();
@@ -388,7 +388,7 @@ test getIndex {
{
errdefer c.deinit(alloc);
_ = try c.add(alloc, .regular, .{ .loaded = try Face.init(
_ = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testFont,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
@@ -398,7 +398,7 @@ test getIndex {
_ = try c.add(
alloc,
.regular,
.{ .loaded = try Face.init(
.{ .loaded = try .init(
lib,
testEmoji,
.{ .size = .{ .points = 12 } },
@@ -408,7 +408,7 @@ test getIndex {
_ = try c.add(
alloc,
.regular,
.{ .loaded = try Face.init(
.{ .loaded = try .init(
lib,
testEmojiText,
.{ .size = .{ .points = 12 } },
@@ -461,23 +461,23 @@ test "getIndex disabled font style" {
var atlas_grayscale = try font.Atlas.init(alloc, 512, .grayscale);
defer atlas_grayscale.deinit(alloc);
var lib = try Library.init();
var lib = try Library.init(alloc);
defer lib.deinit();
var c = Collection.init();
c.load_options = .{ .library = lib };
_ = try c.add(alloc, .regular, .{ .loaded = try Face.init(
_ = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testFont,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
) });
_ = try c.add(alloc, .bold, .{ .loaded = try Face.init(
_ = try c.add(alloc, .bold, .{ .loaded = try .init(
lib,
testFont,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
) });
_ = try c.add(alloc, .italic, .{ .loaded = try Face.init(
_ = try c.add(alloc, .italic, .{ .loaded = try .init(
lib,
testFont,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
@@ -513,7 +513,7 @@ test "getIndex box glyph" {
const testing = std.testing;
const alloc = testing.allocator;
var lib = try Library.init();
var lib = try Library.init(alloc);
defer lib.deinit();
const c = Collection.init();

View File

@@ -55,7 +55,7 @@ load_options: ?LoadOptions = null,
pub fn init() Collection {
// Initialize our styles array, preallocating some space that is
// likely to be used.
return .{ .faces = StyleArray.initFill(.{}) };
return .{ .faces = .initFill(.{}) };
}
pub fn deinit(self: *Collection, alloc: Allocator) void {
@@ -78,8 +78,8 @@ pub const AddError = Allocator.Error || error{
/// next in priority if others exist already, i.e. it'll be the _last_ to be
/// searched for a glyph in that list.
///
/// The collection takes ownership of the face. The face will be deallocated
/// when the collection is deallocated.
/// If no error is encountered then the collection takes ownership of the face,
/// in which case face will be deallocated when the collection is deallocated.
///
/// If a loaded face is added to the collection, it should be the same
/// size as all the other faces in the collection. This function will not
@@ -700,29 +700,32 @@ test "add full" {
const alloc = testing.allocator;
const testFont = font.embedded.regular;
var lib = try Library.init();
var lib = try Library.init(alloc);
defer lib.deinit();
var c = init();
defer c.deinit(alloc);
for (0..Index.Special.start - 1) |_| {
_ = try c.add(alloc, .regular, .{ .loaded = try Face.init(
_ = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testFont,
.{ .size = .{ .points = 12 } },
) });
}
try testing.expectError(error.CollectionFull, c.add(
alloc,
.regular,
.{ .loaded = try Face.init(
lib,
testFont,
.{ .size = .{ .points = 12 } },
) },
));
var face = try Face.init(
lib,
testFont,
.{ .size = .{ .points = 12 } },
);
// We have to deinit it manually since the
// collection doesn't do it if adding fails.
defer face.deinit();
try testing.expectError(
error.CollectionFull,
c.add(alloc, .regular, .{ .loaded = face }),
);
}
test "add deferred without loading options" {
@@ -746,13 +749,13 @@ test getFace {
const alloc = testing.allocator;
const testFont = font.embedded.regular;
var lib = try Library.init();
var lib = try Library.init(alloc);
defer lib.deinit();
var c = init();
defer c.deinit(alloc);
const idx = try c.add(alloc, .regular, .{ .loaded = try Face.init(
const idx = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testFont,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
@@ -770,13 +773,13 @@ test getIndex {
const alloc = testing.allocator;
const testFont = font.embedded.regular;
var lib = try Library.init();
var lib = try Library.init(alloc);
defer lib.deinit();
var c = init();
defer c.deinit(alloc);
_ = try c.add(alloc, .regular, .{ .loaded = try Face.init(
_ = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testFont,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
@@ -801,14 +804,14 @@ test completeStyles {
const alloc = testing.allocator;
const testFont = font.embedded.regular;
var lib = try Library.init();
var lib = try Library.init(alloc);
defer lib.deinit();
var c = init();
defer c.deinit(alloc);
c.load_options = .{ .library = lib };
_ = try c.add(alloc, .regular, .{ .loaded = try Face.init(
_ = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testFont,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
@@ -828,14 +831,14 @@ test setSize {
const alloc = testing.allocator;
const testFont = font.embedded.regular;
var lib = try Library.init();
var lib = try Library.init(alloc);
defer lib.deinit();
var c = init();
defer c.deinit(alloc);
c.load_options = .{ .library = lib };
_ = try c.add(alloc, .regular, .{ .loaded = try Face.init(
_ = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testFont,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
@@ -851,14 +854,14 @@ test hasCodepoint {
const alloc = testing.allocator;
const testFont = font.embedded.regular;
var lib = try Library.init();
var lib = try Library.init(alloc);
defer lib.deinit();
var c = init();
defer c.deinit(alloc);
c.load_options = .{ .library = lib };
const idx = try c.add(alloc, .regular, .{ .loaded = try Face.init(
const idx = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testFont,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
@@ -875,14 +878,14 @@ test "hasCodepoint emoji default graphical" {
const alloc = testing.allocator;
const testEmoji = font.embedded.emoji;
var lib = try Library.init();
var lib = try Library.init(alloc);
defer lib.deinit();
var c = init();
defer c.deinit(alloc);
c.load_options = .{ .library = lib };
const idx = try c.add(alloc, .regular, .{ .loaded = try Face.init(
const idx = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testEmoji,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
@@ -898,14 +901,14 @@ test "metrics" {
const alloc = testing.allocator;
const testFont = font.embedded.inconsolata;
var lib = try Library.init();
var lib = try Library.init(alloc);
defer lib.deinit();
var c = init();
defer c.deinit(alloc);
c.load_options = .{ .library = lib };
_ = try c.add(alloc, .regular, .{ .loaded = try Face.init(
_ = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testFont,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },

View File

@@ -254,7 +254,7 @@ fn loadWebCanvas(
opts: font.face.Options,
) !Face {
const wc = self.wc.?;
return try Face.initNamed(wc.alloc, wc.font_str, opts, wc.presentation);
return try .initNamed(wc.alloc, wc.font_str, opts, wc.presentation);
}
/// Returns true if this face can satisfy the given codepoint and
@@ -407,7 +407,7 @@ test "fontconfig" {
const alloc = testing.allocator;
// Load freetype
var lib = try Library.init();
var lib = try Library.init(alloc);
defer lib.deinit();
// Get a deferred face from fontconfig
@@ -425,7 +425,8 @@ test "fontconfig" {
try testing.expect(n.len > 0);
// Load it and verify it works
const face = try def.load(lib, .{ .size = .{ .points = 12 } });
var face = try def.load(lib, .{ .size = .{ .points = 12 } });
defer face.deinit();
try testing.expect(face.glyphIndex(' ') != null);
}
@@ -437,7 +438,7 @@ test "coretext" {
const alloc = testing.allocator;
// Load freetype
var lib = try Library.init();
var lib = try Library.init(alloc);
defer lib.deinit();
// Get a deferred face from fontconfig
@@ -456,6 +457,7 @@ test "coretext" {
try testing.expect(n.len > 0);
// Load it and verify it works
const face = try def.load(lib, .{ .size = .{ .points = 12 } });
var face = try def.load(lib, .{ .size = .{ .points = 12 } });
defer face.deinit();
try testing.expect(face.glyphIndex(' ') != null);
}

View File

@@ -40,7 +40,7 @@ const log = std.log.scoped(.font_shared_grid);
codepoints: std.AutoHashMapUnmanaged(CodepointKey, ?Collection.Index) = .{},
/// Cache for glyph renders into the atlas.
glyphs: std.AutoHashMapUnmanaged(GlyphKey, Render) = .{},
glyphs: std.HashMapUnmanaged(GlyphKey, Render, GlyphKey.Context, 80) = .{},
/// The texture atlas to store renders in. The Glyph data in the glyphs
/// cache is dependent on the atlas matching.
@@ -79,7 +79,7 @@ pub fn init(
var atlas_grayscale = try Atlas.init(alloc, 512, .grayscale);
errdefer atlas_grayscale.deinit(alloc);
var atlas_color = try Atlas.init(alloc, 512, .rgba);
var atlas_color = try Atlas.init(alloc, 512, .bgra);
errdefer atlas_color.deinit(alloc);
var result: SharedGrid = .{
@@ -307,6 +307,39 @@ const GlyphKey = struct {
index: Collection.Index,
glyph: u32,
opts: RenderOptions,
const Context = struct {
pub fn hash(_: Context, key: GlyphKey) u64 {
return @bitCast(Packed.from(key));
}
pub fn eql(_: Context, a: GlyphKey, b: GlyphKey) bool {
return Packed.from(a) == Packed.from(b);
}
};
const Packed = packed struct(u64) {
index: Collection.Index,
glyph: u32,
opts: packed struct(u16) {
cell_width: u2,
thicken: bool,
thicken_strength: u8,
_padding: u5 = 0,
},
inline fn from(key: GlyphKey) Packed {
return .{
.index = key.index,
.glyph = key.glyph,
.opts = .{
.cell_width = key.opts.cell_width orelse 0,
.thicken = key.opts.thicken,
.thicken_strength = key.opts.thicken_strength,
},
};
}
};
};
const TestMode = enum { normal };
@@ -319,7 +352,7 @@ fn testGrid(mode: TestMode, alloc: Allocator, lib: Library) !SharedGrid {
switch (mode) {
.normal => {
_ = try c.add(alloc, .regular, .{ .loaded = try Face.init(
_ = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testFont,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
@@ -338,7 +371,7 @@ test getIndex {
const alloc = testing.allocator;
// const testEmoji = @import("test.zig").fontEmoji;
var lib = try Library.init();
var lib = try Library.init(alloc);
defer lib.deinit();
var grid = try testGrid(.normal, alloc, lib);

View File

@@ -50,7 +50,7 @@ pub const InitError = Library.InitError;
/// Initialize a new SharedGridSet.
pub fn init(alloc: Allocator) InitError!SharedGridSet {
var font_lib = try Library.init();
var font_lib = try Library.init(alloc);
errdefer font_lib.deinit();
return .{
@@ -126,7 +126,7 @@ pub fn ref(
.ref = 1,
};
grid.* = try SharedGrid.init(self.alloc, resolver: {
grid.* = try .init(self.alloc, resolver: {
// Build our collection. This is the expensive operation that
// involves finding fonts, loading them (maybe, some are deferred),
// etc.
@@ -258,7 +258,7 @@ fn collection(
_ = try c.add(
self.alloc,
.regular,
.{ .fallback_loaded = try Face.init(
.{ .fallback_loaded = try .init(
self.font_lib,
font.embedded.regular,
load_options.faceOptions(),
@@ -267,7 +267,7 @@ fn collection(
_ = try c.add(
self.alloc,
.bold,
.{ .fallback_loaded = try Face.init(
.{ .fallback_loaded = try .init(
self.font_lib,
font.embedded.bold,
load_options.faceOptions(),
@@ -276,7 +276,7 @@ fn collection(
_ = try c.add(
self.alloc,
.italic,
.{ .fallback_loaded = try Face.init(
.{ .fallback_loaded = try .init(
self.font_lib,
font.embedded.italic,
load_options.faceOptions(),
@@ -285,7 +285,7 @@ fn collection(
_ = try c.add(
self.alloc,
.bold_italic,
.{ .fallback_loaded = try Face.init(
.{ .fallback_loaded = try .init(
self.font_lib,
font.embedded.bold_italic,
load_options.faceOptions(),
@@ -318,7 +318,7 @@ fn collection(
_ = try c.add(
self.alloc,
.regular,
.{ .fallback_loaded = try Face.init(
.{ .fallback_loaded = try .init(
self.font_lib,
font.embedded.emoji,
load_options.faceOptions(),
@@ -327,7 +327,7 @@ fn collection(
_ = try c.add(
self.alloc,
.regular,
.{ .fallback_loaded = try Face.init(
.{ .fallback_loaded = try .init(
self.font_lib,
font.embedded.emoji_text,
load_options.faceOptions(),
@@ -391,7 +391,7 @@ fn discover(self: *SharedGridSet) !?*Discover {
// If we initialized, use it
if (self.font_discover) |*v| return v;
self.font_discover = Discover.init();
self.font_discover = .init();
return &self.font_discover.?;
}
@@ -498,7 +498,7 @@ pub const Key = struct {
/// each style. For example, bold is from
/// offsets[@intFromEnum(.bold) - 1] to
/// offsets[@intFromEnum(.bold)].
style_offsets: StyleOffsets = .{0} ** style_offsets_len,
style_offsets: StyleOffsets = @splat(0),
/// The codepoint map configuration.
codepoint_map: CodepointMap = .{},

View File

@@ -4,6 +4,7 @@ const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const fontconfig = @import("fontconfig");
const macos = @import("macos");
const opentype = @import("opentype.zig");
const options = @import("main.zig").options;
const Collection = @import("main.zig").Collection;
const DeferredFace = @import("main.zig").DeferredFace;
@@ -562,149 +563,266 @@ pub const CoreText = struct {
desc: *const Descriptor,
list: []*macos.text.FontDescriptor,
) void {
var desc_mut = desc.*;
if (desc_mut.style == null) {
// If there is no explicit style set, we set a preferred
// based on the style bool attributes.
//
// TODO: doesn't handle i18n font names well, we should have
// another mechanism that uses the weight attribute if it exists.
// Wait for this to be a real problem.
desc_mut.style = if (desc_mut.bold and desc_mut.italic)
"Bold Italic"
else if (desc_mut.bold)
"Bold"
else if (desc_mut.italic)
"Italic"
else
null;
}
std.mem.sortUnstable(*macos.text.FontDescriptor, list, &desc_mut, struct {
std.mem.sortUnstable(*macos.text.FontDescriptor, list, desc, struct {
fn lessThan(
desc_inner: *const Descriptor,
lhs: *macos.text.FontDescriptor,
rhs: *macos.text.FontDescriptor,
) bool {
const lhs_score = score(desc_inner, lhs);
const rhs_score = score(desc_inner, rhs);
const lhs_score: Score = .score(desc_inner, lhs);
const rhs_score: Score = .score(desc_inner, rhs);
// Higher score is "less" (earlier)
return lhs_score.int() > rhs_score.int();
}
}.lessThan);
}
/// We represent our sorting score as a packed struct so that we can
/// compare scores numerically but build scores symbolically.
/// We represent our sorting score as a packed struct so that we
/// can compare scores numerically but build scores symbolically.
///
/// Note that packed structs store their fields from least to most
/// significant, so the fields here are defined in increasing order
/// of precedence.
const Score = packed struct {
const Backing = @typeInfo(@This()).@"struct".backing_integer.?;
glyph_count: u16 = 0, // clamped if > intmax
traits: Traits = .unmatched,
style: Style = .unmatched,
/// Number of glyphs in the font, if two fonts have identical
/// scores otherwise then we prefer the one with more glyphs.
///
/// (Number of glyphs clamped at u16 intmax)
glyph_count: u16 = 0,
/// A fuzzy match on the style string, less important than
/// an exact match, and less important than trait matches.
fuzzy_style: u8 = 0,
/// Whether the bold-ness of the font matches the descriptor.
/// This is less important than italic because a font that's italic
/// when it shouldn't be or not italic when it should be is a bigger
/// problem (subjectively) than being the wrong weight.
bold: bool = false,
/// Whether the italic-ness of the font matches the descriptor.
/// This is less important than an exact match on the style string
/// because we want users to be allowed to override trait matching
/// for the bold/italic/bold italic styles if they want.
italic: bool = false,
/// An exact (case-insensitive) match on the style string.
exact_style: bool = false,
/// Whether the font is monospace, this is more important than any of
/// the other fields unless we're looking for a specific codepoint,
/// in which case that is the most important thing.
monospace: bool = false,
/// If we're looking for a codepoint, whether this font has it.
codepoint: bool = false,
const Traits = enum(u8) { unmatched = 0, _ };
const Style = enum(u8) { unmatched = 0, match = 0xFF, _ };
pub fn int(self: Score) Backing {
return @bitCast(self);
}
};
fn score(desc: *const Descriptor, ct_desc: *const macos.text.FontDescriptor) Score {
var score_acc: Score = .{};
fn score(desc: *const Descriptor, ct_desc: *const macos.text.FontDescriptor) Score {
var self: Score = .{};
// We always load the font if we can since some things can only be
// inspected on the font itself.
const font_: ?*macos.text.Font = macos.text.Font.createWithFontDescriptor(
ct_desc,
12,
) catch null;
defer if (font_) |font| font.release();
// We always load the font if we can since some things can only be
// inspected on the font itself. Fonts that can't be loaded score
// 0 automatically because we don't want a font we can't load.
const font: *macos.text.Font = macos.text.Font.createWithFontDescriptor(
ct_desc,
12,
) catch return self;
defer font.release();
// If we have a font, prefer the font with more glyphs.
if (font_) |font| {
const Type = @TypeOf(score_acc.glyph_count);
score_acc.glyph_count = std.math.cast(
Type,
font.getGlyphCount(),
) orelse std.math.maxInt(Type);
}
// If we're searching for a codepoint, prioritize fonts that
// have that codepoint.
if (desc.codepoint > 0) codepoint: {
const font = font_ orelse break :codepoint;
// Turn UTF-32 into UTF-16 for CT API
var unichars: [2]u16 = undefined;
const pair = macos.foundation.stringGetSurrogatePairForLongCharacter(
desc.codepoint,
&unichars,
);
const len: usize = if (pair) 2 else 1;
// Get our glyphs
var glyphs = [2]macos.graphics.Glyph{ 0, 0 };
score_acc.codepoint = font.getGlyphsForCharacters(unichars[0..len], glyphs[0..len]);
}
// Get our symbolic traits for the descriptor so we can compare
// boolean attributes like bold, monospace, etc.
const symbolic_traits: macos.text.FontSymbolicTraits = traits: {
const traits = ct_desc.copyAttribute(.traits) orelse break :traits .{};
defer traits.release();
const key = macos.text.FontTraitKey.symbolic.key();
const symbolic = traits.getValue(macos.foundation.Number, key) orelse
break :traits .{};
break :traits macos.text.FontSymbolicTraits.init(symbolic);
};
score_acc.monospace = symbolic_traits.monospace;
score_acc.style = style: {
const style = ct_desc.copyAttribute(.style_name) orelse
break :style .unmatched;
defer style.release();
// Get our style string
var buf: [128]u8 = undefined;
const style_str = style.cstring(&buf, .utf8) orelse break :style .unmatched;
// If we have a specific desired style, attempt to search for that.
if (desc.style) |desired_style| {
// Matching style string gets highest score
if (std.mem.eql(u8, desired_style, style_str)) break :style .match;
} else if (!desc.bold and !desc.italic) {
// If we do not, and we have no symbolic traits, then we try
// to find "regular" (or no style). If we have symbolic traits
// we do nothing but we can improve scoring by taking that into
// account, too.
if (std.mem.eql(u8, "Regular", style_str)) {
break :style .match;
}
// We prefer fonts with more glyphs, all else being equal.
{
const Type = @TypeOf(self.glyph_count);
self.glyph_count = std.math.cast(
Type,
font.getGlyphCount(),
) orelse std.math.maxInt(Type);
}
// Otherwise the score is based on the length of the style string.
// Shorter styles are scored higher. This is a heuristic that
// if we don't have a desired style then shorter tends to be
// more often the "regular" style.
break :style @enumFromInt(100 -| style_str.len);
};
// If we're searching for a codepoint, then we
// prioritize fonts that have that codepoint.
if (desc.codepoint > 0) {
// Turn UTF-32 into UTF-16 for CT API
var unichars: [2]u16 = undefined;
const pair = macos.foundation.stringGetSurrogatePairForLongCharacter(
desc.codepoint,
&unichars,
);
const len: usize = if (pair) 2 else 1;
score_acc.traits = traits: {
var count: u8 = 0;
if (desc.bold == symbolic_traits.bold) count += 1;
if (desc.italic == symbolic_traits.italic) count += 1;
break :traits @enumFromInt(count);
};
// Get our glyphs
var glyphs = [2]macos.graphics.Glyph{ 0, 0 };
self.codepoint = font.getGlyphsForCharacters(
unichars[0..len],
glyphs[0..len],
);
}
return score_acc;
}
// Get our symbolic traits for the descriptor so we can
// compare boolean attributes like bold, monospace, etc.
const symbolic_traits: macos.text.FontSymbolicTraits = traits: {
const traits = ct_desc.copyAttribute(.traits) orelse break :traits .{};
defer traits.release();
const key = macos.text.FontTraitKey.symbolic.key();
const symbolic = traits.getValue(macos.foundation.Number, key) orelse
break :traits .{};
break :traits macos.text.FontSymbolicTraits.init(symbolic);
};
self.monospace = symbolic_traits.monospace;
// We try to derived data from the font itself, which is generally
// more reliable than only using the symbolic traits for this.
const is_bold: bool, const is_italic: bool = derived: {
// We start with initial guesses based on the symbolic traits,
// but refine these with more information if we can get it.
var is_italic = symbolic_traits.italic;
var is_bold = symbolic_traits.bold;
// Read the 'head' table out of the font data if it's available.
if (head: {
const tag = macos.text.FontTableTag.init("head");
const data = font.copyTable(tag) orelse break :head null;
defer data.release();
const ptr = data.getPointer();
const len = data.getLength();
break :head opentype.Head.init(ptr[0..len]) catch |err| {
log.warn("error parsing head table: {}", .{err});
break :head null;
};
}) |head_| {
const head: opentype.Head = head_;
is_bold = is_bold or (head.macStyle & 1 == 1);
is_italic = is_italic or (head.macStyle & 2 == 2);
}
// Read the 'OS/2' table out of the font data if it's available.
if (os2: {
const tag = macos.text.FontTableTag.init("OS/2");
const data = font.copyTable(tag) orelse break :os2 null;
defer data.release();
const ptr = data.getPointer();
const len = data.getLength();
break :os2 opentype.OS2.init(ptr[0..len]) catch |err| {
log.warn("error parsing OS/2 table: {}", .{err});
break :os2 null;
};
}) |os2| {
is_bold = is_bold or os2.fsSelection.bold;
is_italic = is_italic or os2.fsSelection.italic;
}
// Check if we have variation axes in our descriptor, if we
// do then we can derive weight italic-ness or both from them.
if (font.copyAttribute(.variation_axes)) |axes| variations: {
defer axes.release();
// Copy the variation values for this instance of the font.
// if there are none then we just break out immediately.
const values: *macos.foundation.Dictionary =
font.copyAttribute(.variation) orelse break :variations;
defer values.release();
var buf: [1024]u8 = undefined;
// If we see the 'ital' value then we ignore 'slnt'.
var ital_seen = false;
const len = axes.getCount();
for (0..len) |i| {
const dict = axes.getValueAtIndex(macos.foundation.Dictionary, i);
const Key = macos.text.FontVariationAxisKey;
const cf_id = dict.getValue(Key.identifier.Value(), Key.identifier.key()).?;
const cf_name = dict.getValue(Key.name.Value(), Key.name.key()).?;
const cf_def = dict.getValue(Key.default_value.Value(), Key.default_value.key()).?;
const name_str = cf_name.cstring(&buf, .utf8) orelse "";
// Default value
var def: f64 = 0;
_ = cf_def.getValue(.double, &def);
// Value in this font
var val: f64 = def;
if (values.getValue(
macos.foundation.Number,
cf_id,
)) |cf_val| _ = cf_val.getValue(.double, &val);
if (std.mem.eql(u8, "wght", name_str)) {
// Somewhat subjective threshold, we consider fonts
// bold if they have a 'wght' set greater than 600.
is_bold = val > 600;
continue;
}
if (std.mem.eql(u8, "ital", name_str)) {
is_italic = val > 0.5;
ital_seen = true;
continue;
}
if (!ital_seen and std.mem.eql(u8, "slnt", name_str)) {
// Arbitrary threshold of anything more than a 5
// degree clockwise slant is considered italic.
is_italic = val <= -5.0;
continue;
}
}
}
break :derived .{ is_bold, is_italic };
};
self.bold = desc.bold == is_bold;
self.italic = desc.italic == is_italic;
// Get the style string from the font.
var style_str_buf: [128]u8 = undefined;
const style_str: []const u8 = style_str: {
const style = ct_desc.copyAttribute(.style_name) orelse
break :style_str "";
defer style.release();
break :style_str style.cstring(&style_str_buf, .utf8) orelse "";
};
// The first string in this slice will be used for the exact match,
// and for the fuzzy match, all matching substrings will increase
// the rank.
const desired_styles: []const [:0]const u8 = desired: {
if (desc.style) |s| break :desired &.{s};
// If we don't have an explicitly desired style name, we base
// it on the bold and italic properties, this isn't ideal since
// fonts may use style names other than these, but it helps in
// some edge cases.
if (desc.bold) {
if (desc.italic) break :desired &.{ "bold italic", "bold", "italic", "oblique" };
break :desired &.{ "bold", "upright" };
} else if (desc.italic) {
break :desired &.{ "italic", "regular", "oblique" };
}
break :desired &.{ "regular", "upright" };
};
self.exact_style = std.ascii.eqlIgnoreCase(
style_str,
desired_styles[0],
);
// Our "fuzzy match" score is 0 if the desired style isn't present
// in the string, otherwise we give higher priority for styles that
// have fewer characters not in the desired_styles list.
const fuzzy_type = @TypeOf(self.fuzzy_style);
self.fuzzy_style = @intCast(style_str.len);
for (desired_styles) |s| {
if (std.ascii.indexOfIgnoreCase(style_str, s) != null) {
self.fuzzy_style -|= @intCast(s.len);
}
}
self.fuzzy_style = std.math.maxInt(fuzzy_type) -| self.fuzzy_style;
return self;
}
};
pub const DiscoverIterator = struct {
alloc: Allocator,
@@ -837,3 +955,85 @@ test "coretext codepoint" {
// Should have other codepoints too
try testing.expect(face.hasCodepoint('B', null));
}
test "coretext sorting" {
if (options.backend != .coretext and options.backend != .coretext_freetype)
return error.SkipZigTest;
// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!//
// FIXME: Disabled for now because SF Pro is not available in CI
// The solution likely involves directly testing that the
// `sortMatchingDescriptors` function sorts a bundled test
// font correctly, instead of relying on the system fonts.
if (true) return error.SkipZigTest;
// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!//
const testing = std.testing;
const alloc = testing.allocator;
var ct = CoreText.init();
defer ct.deinit();
// We try to get a Regular, Italic, Bold, & Bold Italic version of SF Pro,
// which should be installed on all Macs, and has many styles which makes
// it a good test, since there will be many results for each discovery.
// Regular
{
var it = try ct.discover(alloc, .{
.family = "SF Pro",
.size = 12,
});
defer it.deinit();
const res = (try it.next()).?;
var buf: [1024]u8 = undefined;
const name = try res.name(&buf);
try testing.expectEqualStrings("SF Pro Regular", name);
}
// Regular Italic
//
// NOTE: This makes sure that we don't accidentally prefer "Thin Italic",
// which we previously did, because it has a shorter name.
{
var it = try ct.discover(alloc, .{
.family = "SF Pro",
.size = 12,
.italic = true,
});
defer it.deinit();
const res = (try it.next()).?;
var buf: [1024]u8 = undefined;
const name = try res.name(&buf);
try testing.expectEqualStrings("SF Pro Regular Italic", name);
}
// Bold
{
var it = try ct.discover(alloc, .{
.family = "SF Pro",
.size = 12,
.bold = true,
});
defer it.deinit();
const res = (try it.next()).?;
var buf: [1024]u8 = undefined;
const name = try res.name(&buf);
try testing.expectEqualStrings("SF Pro Bold", name);
}
// Bold Italic
{
var it = try ct.discover(alloc, .{
.family = "SF Pro",
.size = 12,
.bold = true,
.italic = true,
});
defer it.deinit();
const res = (try it.next()).?;
var buf: [1024]u8 = undefined;
const name = try res.name(&buf);
try testing.expectEqualStrings("SF Pro Bold Italic", name);
}
}

View File

@@ -46,7 +46,11 @@ pub const Face = struct {
};
/// Initialize a CoreText-based font from a TTF/TTC in memory.
pub fn init(lib: font.Library, source: [:0]const u8, opts: font.face.Options) !Face {
pub fn init(
lib: font.Library,
source: [:0]const u8,
opts: font.face.Options,
) !Face {
_ = lib;
const data = try macos.foundation.Data.createWithBytesNoCopy(source);
@@ -93,7 +97,7 @@ pub const Face = struct {
errdefer if (comptime harfbuzz_shaper) hb_font.destroy();
const color: ?ColorState = if (traits.color_glyphs)
try ColorState.init(ct_font)
try .init(ct_font)
else
null;
errdefer if (color) |v| v.deinit();
@@ -914,7 +918,7 @@ test "in-memory" {
var atlas = try font.Atlas.init(alloc, 512, .grayscale);
defer atlas.deinit(alloc);
var lib = try font.Library.init();
var lib = try font.Library.init(alloc);
defer lib.deinit();
var face = try Face.init(lib, testFont, .{ .size = .{ .points = 12 } });
@@ -941,7 +945,7 @@ test "variable" {
var atlas = try font.Atlas.init(alloc, 512, .grayscale);
defer atlas.deinit(alloc);
var lib = try font.Library.init();
var lib = try font.Library.init(alloc);
defer lib.deinit();
var face = try Face.init(lib, testFont, .{ .size = .{ .points = 12 } });
@@ -968,7 +972,7 @@ test "variable set variation" {
var atlas = try font.Atlas.init(alloc, 512, .grayscale);
defer atlas.deinit(alloc);
var lib = try font.Library.init();
var lib = try font.Library.init(alloc);
defer lib.deinit();
var face = try Face.init(lib, testFont, .{ .size = .{ .points = 12 } });
@@ -996,7 +1000,7 @@ test "svg font table" {
const alloc = testing.allocator;
const testFont = font.embedded.julia_mono;
var lib = try font.Library.init();
var lib = try font.Library.init(alloc);
defer lib.deinit();
var face = try Face.init(lib, testFont, .{ .size = .{ .points = 12 } });
@@ -1010,9 +1014,10 @@ test "svg font table" {
test "glyphIndex colored vs text" {
const testing = std.testing;
const alloc = testing.allocator;
const testFont = font.embedded.julia_mono;
var lib = try font.Library.init();
var lib = try font.Library.init(alloc);
defer lib.deinit();
var face = try Face.init(lib, testFont, .{ .size = .{ .points = 12 } });

View File

@@ -29,12 +29,20 @@ pub const Face = struct {
assert(font.face.FreetypeLoadFlags != void);
}
/// Our freetype library
lib: freetype.Library,
/// Our Library
lib: Library,
/// Our font face.
face: freetype.Face,
/// This mutex MUST be held while doing anything with the
/// glyph slot on the freetype face, because this struct
/// may be shared across multiple surfaces.
///
/// This means that anywhere where `self.face.loadGlyph`
/// is called, this mutex must be held.
ft_mutex: *std.Thread.Mutex,
/// Harfbuzz font corresponding to this face.
hb_font: harfbuzz.Font,
@@ -59,30 +67,52 @@ pub const Face = struct {
};
/// Initialize a new font face with the given source in-memory.
pub fn initFile(lib: Library, path: [:0]const u8, index: i32, opts: font.face.Options) !Face {
pub fn initFile(
lib: Library,
path: [:0]const u8,
index: i32,
opts: font.face.Options,
) !Face {
lib.mutex.lock();
defer lib.mutex.unlock();
const face = try lib.lib.initFace(path, index);
errdefer face.deinit();
return try initFace(lib, face, opts);
}
/// Initialize a new font face with the given source in-memory.
pub fn init(lib: Library, source: [:0]const u8, opts: font.face.Options) !Face {
pub fn init(
lib: Library,
source: [:0]const u8,
opts: font.face.Options,
) !Face {
lib.mutex.lock();
defer lib.mutex.unlock();
const face = try lib.lib.initMemoryFace(source, 0);
errdefer face.deinit();
return try initFace(lib, face, opts);
}
fn initFace(lib: Library, face: freetype.Face, opts: font.face.Options) !Face {
fn initFace(
lib: Library,
face: freetype.Face,
opts: font.face.Options,
) !Face {
try face.selectCharmap(.unicode);
try setSize_(face, opts.size);
var hb_font = try harfbuzz.freetype.createFont(face.handle);
errdefer hb_font.destroy();
const ft_mutex = try lib.alloc.create(std.Thread.Mutex);
errdefer lib.alloc.destroy(ft_mutex);
ft_mutex.* = .{};
var result: Face = .{
.lib = lib.lib,
.lib = lib,
.face = face,
.hb_font = hb_font,
.ft_mutex = ft_mutex,
.load_flags = opts.freetype_load_flags,
};
result.quirks_disable_default_font_features = quirks.disableDefaultFontFeatures(&result);
@@ -114,7 +144,13 @@ pub const Face = struct {
}
pub fn deinit(self: *Face) void {
self.face.deinit();
self.lib.alloc.destroy(self.ft_mutex);
{
self.lib.mutex.lock();
defer self.lib.mutex.unlock();
self.face.deinit();
}
self.hb_font.destroy();
self.* = undefined;
}
@@ -147,11 +183,7 @@ pub const Face = struct {
self.face.ref();
errdefer self.face.deinit();
var f = try initFace(
.{ .lib = self.lib },
self.face,
opts,
);
var f = try initFace(self.lib, self.face, opts);
errdefer f.deinit();
f.synthetic = self.synthetic;
f.synthetic.bold = true;
@@ -166,11 +198,7 @@ pub const Face = struct {
self.face.ref();
errdefer self.face.deinit();
var f = try initFace(
.{ .lib = self.lib },
self.face,
opts,
);
var f = try initFace(self.lib, self.face, opts);
errdefer f.deinit();
f.synthetic = self.synthetic;
f.synthetic.italic = true;
@@ -228,7 +256,7 @@ pub const Face = struct {
// first thing we have to do is get all the vars and put them into
// an array.
const mm = try self.face.getMMVar();
defer self.lib.doneMMVar(mm);
defer self.lib.lib.doneMMVar(mm);
// To avoid allocations, we cap the number of variation axes we can
// support. This is arbitrary but Firefox caps this at 16 so I
@@ -270,6 +298,9 @@ pub const Face = struct {
/// Returns true if the given glyph ID is colorized.
pub fn isColorGlyph(self: *const Face, glyph_id: u32) bool {
self.ft_mutex.lock();
defer self.ft_mutex.unlock();
// Load the glyph and see what pixel mode it renders with.
// All modes other than BGRA are non-color.
// If the glyph fails to load, just return false.
@@ -296,6 +327,9 @@ pub const Face = struct {
glyph_index: u32,
opts: font.face.RenderOptions,
) !Glyph {
self.ft_mutex.lock();
defer self.ft_mutex.unlock();
const metrics = opts.grid_metrics;
// If we have synthetic italic, then we apply a transformation matrix.
@@ -357,7 +391,7 @@ pub const Face = struct {
const format: ?font.Atlas.Format = switch (bitmap_ft.pixel_mode) {
freetype.c.FT_PIXEL_MODE_MONO => null,
freetype.c.FT_PIXEL_MODE_GRAY => .grayscale,
freetype.c.FT_PIXEL_MODE_BGRA => .rgba,
freetype.c.FT_PIXEL_MODE_BGRA => .bgra,
else => {
log.warn("glyph={} pixel mode={}", .{ glyph_index, bitmap_ft.pixel_mode });
@panic("unsupported pixel mode");
@@ -741,6 +775,9 @@ pub const Face = struct {
// If we fail to load any visible ASCII we just use max_advance from
// the metrics provided by FreeType.
const cell_width: f64 = cell_width: {
self.ft_mutex.lock();
defer self.ft_mutex.unlock();
var max: f64 = 0.0;
var c: u8 = ' ';
while (c < 127) : (c += 1) {
@@ -780,6 +817,8 @@ pub const Face = struct {
break :heights .{
cap: {
self.ft_mutex.lock();
defer self.ft_mutex.unlock();
if (face.getCharIndex('H')) |glyph_index| {
if (face.loadGlyph(glyph_index, .{
.render = true,
@@ -791,6 +830,8 @@ pub const Face = struct {
break :cap null;
},
ex: {
self.ft_mutex.lock();
defer self.ft_mutex.unlock();
if (face.getCharIndex('x')) |glyph_index| {
if (face.loadGlyph(glyph_index, .{
.render = true,
@@ -832,7 +873,7 @@ test {
const testFont = font.embedded.inconsolata;
const alloc = testing.allocator;
var lib = try Library.init();
var lib = try Library.init(alloc);
defer lib.deinit();
var atlas = try font.Atlas.init(alloc, 512, .grayscale);
@@ -881,10 +922,10 @@ test "color emoji" {
const alloc = testing.allocator;
const testFont = font.embedded.emoji;
var lib = try Library.init();
var lib = try Library.init(alloc);
defer lib.deinit();
var atlas = try font.Atlas.init(alloc, 512, .rgba);
var atlas = try font.Atlas.init(alloc, 512, .bgra);
defer atlas.deinit(alloc);
var ft_font = try Face.init(
@@ -932,14 +973,14 @@ test "color emoji" {
}
}
test "mono to rgba" {
test "mono to bgra" {
const alloc = testing.allocator;
const testFont = font.embedded.emoji;
var lib = try Library.init();
var lib = try Library.init(alloc);
defer lib.deinit();
var atlas = try font.Atlas.init(alloc, 512, .rgba);
var atlas = try font.Atlas.init(alloc, 512, .bgra);
defer atlas.deinit(alloc);
var ft_font = try Face.init(lib, testFont, .{ .size = .{ .points = 12, .xdpi = 72, .ydpi = 72 } });
@@ -958,7 +999,7 @@ test "svg font table" {
const alloc = testing.allocator;
const testFont = font.embedded.julia_mono;
var lib = try font.Library.init();
var lib = try font.Library.init(alloc);
defer lib.deinit();
var face = try Face.init(lib, testFont, .{ .size = .{ .points = 12, .xdpi = 72, .ydpi = 72 } });
@@ -995,7 +1036,7 @@ test "bitmap glyph" {
const alloc = testing.allocator;
const testFont = font.embedded.terminus_ttf;
var lib = try Library.init();
var lib = try Library.init(alloc);
defer lib.deinit();
var atlas = try font.Atlas.init(alloc, 512, .grayscale);

View File

@@ -30,7 +30,7 @@ fn genMap() Map {
// Initialize to no converter
var i: usize = 0;
while (i < freetype.c.FT_PIXEL_MODE_MAX) : (i += 1) {
result[i] = AtlasArray.initFill(null);
result[i] = .initFill(null);
}
// Map our converters

View File

@@ -1,5 +1,7 @@
//! A library represents the shared state that the underlying font
//! library implementation(s) require per-process.
const std = @import("std");
const Allocator = std.mem.Allocator;
const builtin = @import("builtin");
const options = @import("main.zig").options;
const freetype = @import("freetype");
@@ -24,13 +26,26 @@ pub const Library = switch (options.backend) {
pub const FreetypeLibrary = struct {
lib: freetype.Library,
pub const InitError = freetype.Error;
alloc: Allocator,
pub fn init() InitError!Library {
return Library{ .lib = try freetype.Library.init() };
/// Mutex to be held any time the library is
/// being used to create or destroy a face.
mutex: *std.Thread.Mutex,
pub const InitError = freetype.Error || Allocator.Error;
pub fn init(alloc: Allocator) InitError!Library {
const lib = try freetype.Library.init();
errdefer lib.deinit();
const mutex = try alloc.create(std.Thread.Mutex);
mutex.* = .{};
return Library{ .lib = lib, .alloc = alloc, .mutex = mutex };
}
pub fn deinit(self: *Library) void {
self.alloc.destroy(self.mutex);
self.lib.deinit();
}
};
@@ -38,7 +53,8 @@ pub const FreetypeLibrary = struct {
pub const NoopLibrary = struct {
pub const InitError = error{};
pub fn init() InitError!Library {
pub fn init(alloc: Allocator) InitError!Library {
_ = alloc;
return Library{};
}

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