gtk: implement quick terminal

Using `gtk4-layer-shell` still seems like the path of least resistance,
and to my delight it pretty much Just Works. Hurrah!

This implementation could do with some further polish (e.g. animations,
which can be implemented via libadwaita's animations API, and global
shortcuts), but as a MVP it works well enough.

It even supports tabs!

Fixes #4624.
This commit is contained in:
Leah Amelia Chen
2025-02-28 11:33:08 +01:00
parent ef88d1cba9
commit a85651fe4f
15 changed files with 216 additions and 31 deletions

View File

@ -31,6 +31,7 @@
glib, glib,
glslang, glslang,
gtk4, gtk4,
gtk4-layer-shell,
gobject-introspection, gobject-introspection,
libadwaita, libadwaita,
blueprint-compiler, blueprint-compiler,
@ -88,6 +89,7 @@
libadwaita libadwaita
gtk4 gtk4
gtk4-layer-shell
glib glib
gobject-introspection gobject-introspection
wayland wayland
@ -167,6 +169,7 @@ in
blueprint-compiler blueprint-compiler
libadwaita libadwaita
gtk4 gtk4
gtk4-layer-shell
glib glib
gobject-introspection gobject-introspection
wayland wayland

View File

@ -13,6 +13,7 @@
libGL, libGL,
glib, glib,
gtk4, gtk4,
gtk4-layer-shell,
gobject-introspection, gobject-introspection,
libadwaita, libadwaita,
blueprint-compiler, blueprint-compiler,
@ -118,6 +119,7 @@ in
libXrandr libXrandr
] ]
++ lib.optionals enableWayland [ ++ lib.optionals enableWayland [
gtk4-layer-shell
wayland wayland
]; ];

View File

@ -73,6 +73,8 @@ parts:
- blueprint-compiler - blueprint-compiler
- libgtk-4-dev - libgtk-4-dev
- libadwaita-1-dev - libadwaita-1-dev
# TODO: Add when the Snap is updated to Ubuntu 24.10+
# - gtk4-layer-shell
- libxml2-utils - libxml2-utils
- git - git
- patchelf - patchelf

View File

@ -70,6 +70,10 @@ config_errors_window: ?*ConfigErrorsWindow = null,
/// The clipboard confirmation window, if it is currently open. /// The clipboard confirmation window, if it is currently open.
clipboard_confirmation_window: ?*ClipboardConfirmationWindow = null, clipboard_confirmation_window: ?*ClipboardConfirmationWindow = null,
/// The window containing the quick terminal.
/// Null when never initialized.
quick_terminal: ?*Window = null,
/// This is set to false when the main loop should exit. /// This is set to false when the main loop should exit.
running: bool = true, running: bool = true,
@ -497,10 +501,10 @@ pub fn performAction(
.toggle_window_decorations => self.toggleWindowDecorations(target), .toggle_window_decorations => self.toggleWindowDecorations(target),
.quit_timer => self.quitTimer(value), .quit_timer => self.quitTimer(value),
.prompt_title => try self.promptTitle(target), .prompt_title => try self.promptTitle(target),
.toggle_quick_terminal => return try self.toggleQuickTerminal(),
// Unimplemented // Unimplemented
.close_all_windows, .close_all_windows,
.toggle_quick_terminal,
.toggle_visibility, .toggle_visibility,
.cell_size, .cell_size,
.secure_input, .secure_input,
@ -764,6 +768,33 @@ fn toggleWindowDecorations(
} }
} }
fn toggleQuickTerminal(self: *App) !bool {
if (self.quick_terminal) |qt| {
qt.toggleVisibility();
return true;
}
if (!self.winproto.supportsQuickTerminal()) {
log.err("quick terminal not supported on current platform", .{});
return false;
}
const qt = Window.create(self.core_app.alloc, self) catch |err| {
log.err("failed to initialize quick terminal={}", .{err});
return true;
};
self.quick_terminal = qt;
// The setup has to happen *before* the window-specific winproto is
// initialized, so we need to initialize it through the app winproto
try self.winproto.initQuickTerminal(qt);
// Finalize creating the quick terminal
try qt.newTab(null);
qt.present();
return true;
}
fn quitTimer(self: *App, mode: apprt.action.QuitTimer) void { fn quitTimer(self: *App, mode: apprt.action.QuitTimer) void {
switch (mode) { switch (mode) {
.start => self.startQuitTimer(), .start => self.startQuitTimer(),
@ -1372,6 +1403,9 @@ fn newWindow(self: *App, parent_: ?*CoreSurface) !void {
// Add our initial tab // Add our initial tab
try window.newTab(parent_); try window.newTab(parent_);
// Show the new window
window.present();
} }
fn quit(self: *App) void { fn quit(self: *App) void {

View File

@ -193,10 +193,6 @@ pub fn addTab(self: *TabView, tab: *Tab, title: [:0]const u8) void {
} }
pub fn closeTab(self: *TabView, tab: *Tab) void { pub fn closeTab(self: *TabView, tab: *Tab) void {
// Save a pointer to the GTK window in case we need it later. It may be
// impossible to access later due to how resources are cleaned up.
const window: *gtk.Window = @ptrCast(@alignCast(self.window.window));
// closeTab always expects to close unconditionally so we mark this // closeTab always expects to close unconditionally so we mark this
// as true so that the close_page call below doesn't request // as true so that the close_page call below doesn't request
// confirmation. // confirmation.
@ -225,7 +221,7 @@ pub fn closeTab(self: *TabView, tab: *Tab) void {
box.as(gobject.Object).unref(); box.as(gobject.Object).unref();
} }
window.destroy(); self.window.close();
} }
} }
@ -234,7 +230,9 @@ pub fn createWindow(currentWindow: *Window) !*Window {
const app = currentWindow.app; const app = currentWindow.app;
// Create a new window // Create a new window
return Window.create(alloc, app); const window = try Window.create(alloc, app);
window.present();
return 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 {

View File

@ -79,6 +79,8 @@ pub const DerivedConfig = struct {
gtk_wide_tabs: bool, gtk_wide_tabs: bool,
gtk_toolbar_style: configpkg.Config.GtkToolbarStyle, gtk_toolbar_style: configpkg.Config.GtkToolbarStyle,
quick_terminal_position: configpkg.Config.QuickTerminalPosition,
maximize: bool, maximize: bool,
fullscreen: bool, fullscreen: bool,
window_decoration: configpkg.Config.WindowDecoration, window_decoration: configpkg.Config.WindowDecoration,
@ -94,6 +96,8 @@ pub const DerivedConfig = struct {
.gtk_wide_tabs = config.@"gtk-wide-tabs", .gtk_wide_tabs = config.@"gtk-wide-tabs",
.gtk_toolbar_style = config.@"gtk-toolbar-style", .gtk_toolbar_style = config.@"gtk-toolbar-style",
.quick_terminal_position = config.@"quick-terminal-position",
.maximize = config.maximize, .maximize = config.maximize,
.fullscreen = config.fullscreen, .fullscreen = config.fullscreen,
.window_decoration = config.@"window-decoration", .window_decoration = config.@"window-decoration",
@ -364,9 +368,16 @@ pub fn init(self: *Window, app: *App) !void {
// If we are in fullscreen mode, new windows start fullscreen. // If we are in fullscreen mode, new windows start fullscreen.
if (self.config.fullscreen) c.gtk_window_fullscreen(self.window); if (self.config.fullscreen) c.gtk_window_fullscreen(self.window);
}
// Show the window pub fn present(self: *Window) void {
c.gtk_widget_show(gtk_widget); const window: *gtk.Window = @ptrCast(self.window);
window.present();
}
pub fn toggleVisibility(self: *Window) void {
const window: *gtk.Widget = @ptrCast(self.window);
window.setVisible(@intFromBool(window.isVisible() == 0));
} }
pub fn updateConfig( pub fn updateConfig(
@ -408,6 +419,9 @@ pub fn syncAppearance(self: *Window) !void {
// Never display the header bar when CSDs are disabled. // Never display the header bar when CSDs are disabled.
if (!csd_enabled) break :visible false; if (!csd_enabled) break :visible false;
// Never display the header bar as a quick terminal.
if (self.app.quick_terminal == self) break :visible false;
// Unconditionally disable the header bar when fullscreened. // Unconditionally disable the header bar when fullscreened.
if (self.config.fullscreen) break :visible false; if (self.config.fullscreen) break :visible false;
@ -458,11 +472,11 @@ pub fn syncAppearance(self: *Window) !void {
log.warn("failed to sync winproto appearance error={}", .{err}); log.warn("failed to sync winproto appearance error={}", .{err});
}; };
toggleCssClass( if (self.app.quick_terminal == self) {
@ptrCast(self.window), self.winproto.syncQuickTerminal() catch |err| {
"background", log.warn("failed to sync quick terminal appearance error={}", .{err});
self.config.background_opacity >= 1, };
); }
} }
fn toggleCssClass( fn toggleCssClass(
@ -780,11 +794,23 @@ fn adwTabOverviewFocusTimer(
return 0; return 0;
} }
pub fn close(self: *Window) void {
const window: *gtk.Window = @ptrCast(self.window);
// Unset the quick terminal on the app level
if (self.app.quick_terminal == self) self.app.quick_terminal = null;
window.destroy();
}
fn gtkCloseRequest(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool { fn gtkCloseRequest(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool {
_ = v; _ = v;
log.debug("window close request", .{}); log.debug("window close request", .{});
const self = userdataSelf(ud.?); const self = userdataSelf(ud.?);
// This path should never occur, but this is here as a safety measure.
if (self.app.quick_terminal == self) return true;
// If none of our surfaces need confirmation, we can just exit. // If none of our surfaces need confirmation, we can just exit.
for (self.app.core_app.surfaces.items) |surface| { for (self.app.core_app.surfaces.items) |surface| {
if (surface.container.window()) |window| { if (surface.container.window()) |window| {
@ -792,7 +818,7 @@ fn gtkCloseRequest(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool {
surface.core_surface.needsConfirmQuit()) break; surface.core_surface.needsConfirmQuit()) break;
} }
} else { } else {
c.gtk_window_destroy(self.window); self.close();
return true; return true;
} }
@ -836,7 +862,7 @@ fn gtkCloseConfirmation(
c.gtk_window_destroy(@ptrCast(alert)); c.gtk_window_destroy(@ptrCast(alert));
if (response == c.GTK_RESPONSE_YES) { if (response == c.GTK_RESPONSE_YES) {
const self = userdataSelf(ud.?); const self = userdataSelf(ud.?);
c.gtk_window_destroy(self.window); self.close();
} }
} }
@ -934,7 +960,7 @@ fn gtkActionClose(
_: ?*glib.Variant, _: ?*glib.Variant,
self: *Window, self: *Window,
) callconv(.C) void { ) callconv(.C) void {
c.gtk_window_destroy(self.window); self.close();
} }
fn gtkActionNewWindow( fn gtkActionNewWindow(

View File

@ -15,6 +15,7 @@ pub const c = @cImport({
@cInclude("X11/XKBlib.h"); @cInclude("X11/XKBlib.h");
} }
if (build_options.wayland) { if (build_options.wayland) {
if (build_options.layer_shell) @cInclude("gtk4-layer-shell/gtk4-layer-shell.h");
@cInclude("gdk/wayland/gdkwayland.h"); @cInclude("gdk/wayland/gdkwayland.h");
} }

View File

@ -59,6 +59,23 @@ pub const App = union(Protocol) {
inline else => |*v| v.eventMods(device, gtk_mods), inline else => |*v| v.eventMods(device, gtk_mods),
} orelse key.translateMods(gtk_mods); } orelse key.translateMods(gtk_mods);
} }
pub fn supportsQuickTerminal(self: App) bool {
return switch (self) {
inline else => |v| v.supportsQuickTerminal(),
};
}
/// Set up necessary support for the quick terminal that must occur
/// *before* the window-level winproto object is created.
///
/// Only has an effect on the Wayland backend, where the gtk4-layer-shell
/// library is initialized.
pub fn initQuickTerminal(self: *App, apprt_window: *ApprtWindow) !void {
switch (self.*) {
inline else => |*v| try v.initQuickTerminal(apprt_window),
}
}
}; };
/// Per-Window state for the underlying windowing protocol. /// Per-Window state for the underlying windowing protocol.
@ -116,6 +133,12 @@ pub const Window = union(Protocol) {
} }
} }
pub fn syncQuickTerminal(self: *Window) !void {
switch (self.*) {
inline else => |*v| try v.syncQuickTerminal(),
}
}
pub fn clientSideDecorationEnabled(self: Window) bool { pub fn clientSideDecorationEnabled(self: Window) bool {
return switch (self) { return switch (self) {
inline else => |v| v.clientSideDecorationEnabled(), inline else => |v| v.clientSideDecorationEnabled(),

View File

@ -29,6 +29,11 @@ pub const App = struct {
) ?input.Mods { ) ?input.Mods {
return null; return null;
} }
pub fn supportsQuickTerminal(_: App) bool {
return false;
}
pub fn initQuickTerminal(_: *App, _: *ApprtWindow) !void {}
}; };
pub const Window = struct { pub const Window = struct {
@ -54,6 +59,8 @@ pub const Window = struct {
pub fn syncAppearance(_: *Window) !void {} pub fn syncAppearance(_: *Window) !void {}
pub fn syncQuickTerminal(_: *Window) !void {}
/// This returns true if CSD is enabled for this window. This /// This returns true if CSD is enabled for this window. This
/// should be the actual present state of the window, not the /// should be the actual present state of the window, not the
/// desired state. /// desired state.

View File

@ -1,7 +1,10 @@
//! Wayland protocol implementation for the Ghostty GTK apprt. //! Wayland protocol implementation for the Ghostty GTK apprt.
const std = @import("std"); const std = @import("std");
const wayland = @import("wayland");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const build_options = @import("build_options");
const wayland = @import("wayland");
const c = @import("../c.zig").c; const c = @import("../c.zig").c;
const Config = @import("../../../config.zig").Config; const Config = @import("../../../config.zig").Config;
const input = @import("../../../input.zig"); const input = @import("../../../input.zig");
@ -84,6 +87,20 @@ pub const App = struct {
return null; return null;
} }
pub fn supportsQuickTerminal(_: App) bool {
if (comptime !build_options.layer_shell) return false;
return c.gtk_layer_is_supported() != 0;
}
pub fn initQuickTerminal(_: *App, apprt_window: *ApprtWindow) !void {
if (comptime !build_options.layer_shell) unreachable;
c.gtk_layer_init_for_window(apprt_window.window);
c.gtk_layer_set_layer(apprt_window.window, c.GTK_LAYER_SHELL_LAYER_TOP);
c.gtk_layer_set_keyboard_mode(apprt_window.window, c.GTK_LAYER_SHELL_KEYBOARD_MODE_ON_DEMAND);
}
fn registryListener( fn registryListener(
registry: *wl.Registry, registry: *wl.Registry,
event: wl.Registry.Event, event: wl.Registry.Event,
@ -156,7 +173,7 @@ pub const App = struct {
/// Per-window (wl_surface) state for the Wayland protocol. /// Per-window (wl_surface) state for the Wayland protocol.
pub const Window = struct { pub const Window = struct {
config: *const ApprtWindow.DerivedConfig, apprt_window: *ApprtWindow,
/// The Wayland surface for this window. /// The Wayland surface for this window.
surface: *wl.Surface, surface: *wl.Surface,
@ -210,7 +227,7 @@ pub const Window = struct {
}; };
return .{ return .{
.config = &apprt_window.config, .apprt_window = apprt_window,
.surface = wl_surface, .surface = wl_surface,
.app_context = app.context, .app_context = app.context,
.blur_token = null, .blur_token = null,
@ -255,7 +272,7 @@ pub const Window = struct {
/// Update the blur state of the window. /// Update the blur state of the window.
fn syncBlur(self: *Window) !void { fn syncBlur(self: *Window) !void {
const manager = self.app_context.kde_blur_manager orelse return; const manager = self.app_context.kde_blur_manager orelse return;
const blur = self.config.background_blur; const blur = self.apprt_window.config.background_blur;
if (self.blur_token) |tok| { if (self.blur_token) |tok| {
// Only release token when transitioning from blurred -> not blurred // Only release token when transitioning from blurred -> not blurred
@ -283,11 +300,51 @@ pub const Window = struct {
} }
fn getDecorationMode(self: Window) org.KdeKwinServerDecorationManager.Mode { fn getDecorationMode(self: Window) org.KdeKwinServerDecorationManager.Mode {
return switch (self.config.window_decoration) { return switch (self.apprt_window.config.window_decoration) {
.auto => self.app_context.default_deco_mode orelse .Client, .auto => self.app_context.default_deco_mode orelse .Client,
.client => .Client, .client => .Client,
.server => .Server, .server => .Server,
.none => .None, .none => .None,
}; };
} }
pub fn syncQuickTerminal(self: *Window) !void {
if (comptime !build_options.layer_shell) return;
const window = self.apprt_window.window;
const anchored_edge: ?LayerShellEdge = switch (self.apprt_window.config.quick_terminal_position) {
.left => .left,
.right => .right,
.top => .top,
.bottom => .bottom,
.center => null,
};
for (std.meta.tags(LayerShellEdge)) |edge| {
if (anchored_edge) |anchored| {
if (edge == anchored) {
c.gtk_layer_set_margin(window, @intFromEnum(edge), 0);
c.gtk_layer_set_anchor(window, @intFromEnum(edge), @intFromBool(true));
continue;
}
}
// Arbitrary margin - could be made customizable?
c.gtk_layer_set_margin(window, @intFromEnum(edge), 20);
c.gtk_layer_set_anchor(window, @intFromEnum(edge), @intFromBool(false));
}
switch (self.apprt_window.config.quick_terminal_position) {
.top, .bottom, .center => c.gtk_window_set_default_size(window, 800, 400),
.left, .right => c.gtk_window_set_default_size(window, 400, 800),
}
}
};
const LayerShellEdge = enum(c_uint) {
left = c.GTK_LAYER_SHELL_EDGE_LEFT,
right = c.GTK_LAYER_SHELL_EDGE_RIGHT,
top = c.GTK_LAYER_SHELL_EDGE_TOP,
bottom = c.GTK_LAYER_SHELL_EDGE_BOTTOM,
}; };

View File

@ -148,6 +148,12 @@ pub const App = struct {
return mods; return mods;
} }
pub fn supportsQuickTerminal(_: App) bool {
return false;
}
pub fn initQuickTerminal(_: *App, _: *ApprtWindow) !void {}
}; };
pub const Window = struct { pub const Window = struct {
@ -222,6 +228,8 @@ pub const Window = struct {
}; };
} }
pub fn syncQuickTerminal(_: *Window) !void {}
pub fn clientSideDecorationEnabled(self: Window) bool { pub fn clientSideDecorationEnabled(self: Window) bool {
return switch (self.config.window_decoration) { return switch (self.config.window_decoration) {
.auto, .client => true, .auto, .client => true,

View File

@ -34,6 +34,7 @@ font_backend: font.Backend = .freetype,
/// Feature flags /// Feature flags
x11: bool = false, x11: bool = false,
wayland: bool = false, wayland: bool = false,
layer_shell: bool = false,
sentry: bool = true, sentry: bool = true,
wasm_shared: bool = true, wasm_shared: bool = true,
@ -109,7 +110,6 @@ pub fn init(b: *std.Build) !Config {
//--------------------------------------------------------------- //---------------------------------------------------------------
// Comptime Interfaces // Comptime Interfaces
config.font_backend = b.option( config.font_backend = b.option(
font.Backend, font.Backend,
"font-backend", "font-backend",
@ -163,6 +163,12 @@ pub fn init(b: *std.Build) !Config {
"Enables linking against X11 libraries when using the GTK rendering backend.", "Enables linking against X11 libraries when using the GTK rendering backend.",
) orelse gtk_targets.x11; ) orelse gtk_targets.x11;
config.layer_shell = b.option(
bool,
"gtk-layer-shell",
"Enables linking against the gtk4-layer-shell library for quick terminal support. Requires Wayland.",
) orelse gtk_targets.layer_shell;
//--------------------------------------------------------------- //---------------------------------------------------------------
// Ghostty Exe Properties // Ghostty Exe Properties
@ -392,6 +398,7 @@ pub fn addOptions(self: *const Config, step: *std.Build.Step.Options) !void {
step.addOption(bool, "flatpak", self.flatpak); step.addOption(bool, "flatpak", self.flatpak);
step.addOption(bool, "x11", self.x11); step.addOption(bool, "x11", self.x11);
step.addOption(bool, "wayland", self.wayland); step.addOption(bool, "wayland", self.wayland);
step.addOption(bool, "layer_shell", self.layer_shell);
step.addOption(bool, "sentry", self.sentry); step.addOption(bool, "sentry", self.sentry);
step.addOption(apprt.Runtime, "app_runtime", self.app_runtime); step.addOption(apprt.Runtime, "app_runtime", self.app_runtime);
step.addOption(font.Backend, "font_backend", self.font_backend); step.addOption(font.Backend, "font_backend", self.font_backend);

View File

@ -460,12 +460,8 @@ pub fn add(
if (self.config.wayland) { if (self.config.wayland) {
const scanner = Scanner.create(b.dependency("zig_wayland", .{}), .{ const scanner = Scanner.create(b.dependency("zig_wayland", .{}), .{
// We shouldn't be using getPath but we need to for now .wayland_xml = b.dependency("wayland", .{}).path("protocol/wayland.xml"),
// https://codeberg.org/ifreund/zig-wayland/issues/66 .wayland_protocols = b.dependency("wayland_protocols", .{}).path(""),
.wayland_xml = b.dependency("wayland", .{})
.path("protocol/wayland.xml"),
.wayland_protocols = b.dependency("wayland_protocols", .{})
.path(""),
}); });
const wayland = b.createModule(.{ .root_source_file = scanner.result }); const wayland = b.createModule(.{ .root_source_file = scanner.result });
@ -485,6 +481,8 @@ pub fn add(
step.root_module.addImport("wayland", wayland); step.root_module.addImport("wayland", wayland);
step.root_module.addImport("gdk_wayland", gobject.module("gdkwayland4")); step.root_module.addImport("gdk_wayland", gobject.module("gdkwayland4"));
if (self.config.layer_shell) step.linkSystemLibrary2("gtk4-layer-shell", dynamic_link_opts);
step.linkSystemLibrary2("wayland-client", dynamic_link_opts); step.linkSystemLibrary2("wayland-client", dynamic_link_opts);
} }

View File

@ -18,6 +18,8 @@ RUN DEBIAN_FRONTEND="noninteractive" apt-get -qq update && \
# Ghostty Dependencies # Ghostty Dependencies
libadwaita-1-dev \ libadwaita-1-dev \
libgtk-4-dev && \ libgtk-4-dev && \
# TODO: Add when this is updated to Debian 13++
# gtk4-layer-shell
# Clean up for better caching # Clean up for better caching
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*

View File

@ -3,6 +3,7 @@ const std = @import("std");
pub const Targets = packed struct { pub const Targets = packed struct {
x11: bool = false, x11: bool = false,
wayland: bool = false, wayland: bool = false,
layer_shell: bool = false,
}; };
/// Returns the targets that GTK4 was compiled with. /// Returns the targets that GTK4 was compiled with.
@ -17,8 +18,24 @@ pub fn targets(b: *std.Build) Targets {
.Ignore, .Ignore,
) catch return .{}; ) catch return .{};
const x11 = std.mem.indexOf(u8, output, "x11") != null;
const wayland = std.mem.indexOf(u8, output, "wayland") != null;
const layer_shell = layer_shell: {
if (!wayland) break :layer_shell false;
_ = b.runAllowFail(
&.{ "pkg-config", "--exists", "gtk4-layer-shell-0" },
&code,
.Ignore,
) catch break :layer_shell false;
break :layer_shell true;
};
return .{ return .{
.x11 = std.mem.indexOf(u8, output, "x11") != null, .x11 = x11,
.wayland = std.mem.indexOf(u8, output, "wayland") != null, .wayland = wayland,
.layer_shell = layer_shell,
}; };
} }