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,
glslang,
gtk4,
gtk4-layer-shell,
gobject-introspection,
libadwaita,
blueprint-compiler,
@ -88,6 +89,7 @@
libadwaita
gtk4
gtk4-layer-shell
glib
gobject-introspection
wayland
@ -167,6 +169,7 @@ in
blueprint-compiler
libadwaita
gtk4
gtk4-layer-shell
glib
gobject-introspection
wayland

View File

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

View File

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

View File

@ -70,6 +70,10 @@ config_errors_window: ?*ConfigErrorsWindow = null,
/// The clipboard confirmation window, if it is currently open.
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.
running: bool = true,
@ -497,10 +501,10 @@ pub fn performAction(
.toggle_window_decorations => self.toggleWindowDecorations(target),
.quit_timer => self.quitTimer(value),
.prompt_title => try self.promptTitle(target),
.toggle_quick_terminal => return try self.toggleQuickTerminal(),
// Unimplemented
.close_all_windows,
.toggle_quick_terminal,
.toggle_visibility,
.cell_size,
.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 {
switch (mode) {
.start => self.startQuitTimer(),
@ -1372,6 +1403,9 @@ fn newWindow(self: *App, parent_: ?*CoreSurface) !void {
// Add our initial tab
try window.newTab(parent_);
// Show the new window
window.present();
}
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 {
// 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
// as true so that the close_page call below doesn't request
// confirmation.
@ -225,7 +221,7 @@ pub fn closeTab(self: *TabView, tab: *Tab) void {
box.as(gobject.Object).unref();
}
window.destroy();
self.window.close();
}
}
@ -234,7 +230,9 @@ pub fn createWindow(currentWindow: *Window) !*Window {
const app = currentWindow.app;
// 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 {

View File

@ -79,6 +79,8 @@ pub const DerivedConfig = struct {
gtk_wide_tabs: bool,
gtk_toolbar_style: configpkg.Config.GtkToolbarStyle,
quick_terminal_position: configpkg.Config.QuickTerminalPosition,
maximize: bool,
fullscreen: bool,
window_decoration: configpkg.Config.WindowDecoration,
@ -94,6 +96,8 @@ pub const DerivedConfig = struct {
.gtk_wide_tabs = config.@"gtk-wide-tabs",
.gtk_toolbar_style = config.@"gtk-toolbar-style",
.quick_terminal_position = config.@"quick-terminal-position",
.maximize = config.maximize,
.fullscreen = config.fullscreen,
.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 (self.config.fullscreen) c.gtk_window_fullscreen(self.window);
}
// Show the window
c.gtk_widget_show(gtk_widget);
pub fn present(self: *Window) void {
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(
@ -408,6 +419,9 @@ pub fn syncAppearance(self: *Window) !void {
// Never display the header bar when CSDs are disabled.
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.
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});
};
toggleCssClass(
@ptrCast(self.window),
"background",
self.config.background_opacity >= 1,
);
if (self.app.quick_terminal == self) {
self.winproto.syncQuickTerminal() catch |err| {
log.warn("failed to sync quick terminal appearance error={}", .{err});
};
}
}
fn toggleCssClass(
@ -780,11 +794,23 @@ fn adwTabOverviewFocusTimer(
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 {
_ = v;
log.debug("window close request", .{});
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.
for (self.app.core_app.surfaces.items) |surface| {
if (surface.container.window()) |window| {
@ -792,7 +818,7 @@ fn gtkCloseRequest(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool {
surface.core_surface.needsConfirmQuit()) break;
}
} else {
c.gtk_window_destroy(self.window);
self.close();
return true;
}
@ -836,7 +862,7 @@ fn gtkCloseConfirmation(
c.gtk_window_destroy(@ptrCast(alert));
if (response == c.GTK_RESPONSE_YES) {
const self = userdataSelf(ud.?);
c.gtk_window_destroy(self.window);
self.close();
}
}
@ -934,7 +960,7 @@ fn gtkActionClose(
_: ?*glib.Variant,
self: *Window,
) callconv(.C) void {
c.gtk_window_destroy(self.window);
self.close();
}
fn gtkActionNewWindow(

View File

@ -15,6 +15,7 @@ pub const c = @cImport({
@cInclude("X11/XKBlib.h");
}
if (build_options.wayland) {
if (build_options.layer_shell) @cInclude("gtk4-layer-shell/gtk4-layer-shell.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),
} 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.
@ -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 {
return switch (self) {
inline else => |v| v.clientSideDecorationEnabled(),

View File

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

View File

@ -1,7 +1,10 @@
//! Wayland protocol implementation for the Ghostty GTK apprt.
const std = @import("std");
const wayland = @import("wayland");
const Allocator = std.mem.Allocator;
const build_options = @import("build_options");
const wayland = @import("wayland");
const c = @import("../c.zig").c;
const Config = @import("../../../config.zig").Config;
const input = @import("../../../input.zig");
@ -84,6 +87,20 @@ pub const App = struct {
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(
registry: *wl.Registry,
event: wl.Registry.Event,
@ -156,7 +173,7 @@ pub const App = struct {
/// Per-window (wl_surface) state for the Wayland protocol.
pub const Window = struct {
config: *const ApprtWindow.DerivedConfig,
apprt_window: *ApprtWindow,
/// The Wayland surface for this window.
surface: *wl.Surface,
@ -210,7 +227,7 @@ pub const Window = struct {
};
return .{
.config = &apprt_window.config,
.apprt_window = apprt_window,
.surface = wl_surface,
.app_context = app.context,
.blur_token = null,
@ -255,7 +272,7 @@ pub const Window = struct {
/// Update the blur state of the window.
fn syncBlur(self: *Window) !void {
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| {
// Only release token when transitioning from blurred -> not blurred
@ -283,11 +300,51 @@ pub const Window = struct {
}
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,
.client => .Client,
.server => .Server,
.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;
}
pub fn supportsQuickTerminal(_: App) bool {
return false;
}
pub fn initQuickTerminal(_: *App, _: *ApprtWindow) !void {}
};
pub const Window = struct {
@ -222,6 +228,8 @@ pub const Window = struct {
};
}
pub fn syncQuickTerminal(_: *Window) !void {}
pub fn clientSideDecorationEnabled(self: Window) bool {
return switch (self.config.window_decoration) {
.auto, .client => true,

View File

@ -34,6 +34,7 @@ font_backend: font.Backend = .freetype,
/// Feature flags
x11: bool = false,
wayland: bool = false,
layer_shell: bool = false,
sentry: bool = true,
wasm_shared: bool = true,
@ -109,7 +110,6 @@ pub fn init(b: *std.Build) !Config {
//---------------------------------------------------------------
// Comptime Interfaces
config.font_backend = b.option(
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.",
) 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
@ -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, "x11", self.x11);
step.addOption(bool, "wayland", self.wayland);
step.addOption(bool, "layer_shell", self.layer_shell);
step.addOption(bool, "sentry", self.sentry);
step.addOption(apprt.Runtime, "app_runtime", self.app_runtime);
step.addOption(font.Backend, "font_backend", self.font_backend);

View File

@ -460,12 +460,8 @@ pub fn add(
if (self.config.wayland) {
const scanner = Scanner.create(b.dependency("zig_wayland", .{}), .{
// We shouldn't be using getPath but we need to for now
// https://codeberg.org/ifreund/zig-wayland/issues/66
.wayland_xml = b.dependency("wayland", .{})
.path("protocol/wayland.xml"),
.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 });
@ -485,6 +481,8 @@ pub fn add(
step.root_module.addImport("wayland", wayland);
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);
}

View File

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

View File

@ -3,6 +3,7 @@ const std = @import("std");
pub const Targets = packed struct {
x11: bool = false,
wayland: bool = false,
layer_shell: bool = false,
};
/// Returns the targets that GTK4 was compiled with.
@ -17,8 +18,24 @@ pub fn targets(b: *std.Build) Targets {
.Ignore,
) 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 .{
.x11 = std.mem.indexOf(u8, output, "x11") != null,
.wayland = std.mem.indexOf(u8, output, "wayland") != null,
.x11 = x11,
.wayland = wayland,
.layer_shell = layer_shell,
};
}