feat(apprt-gtk): port the GTK4/libadwaita app runtime

Ports ZIG/src/apprt/gtk into ghostty-apprt-gtk: every GObject class
(application, window, tab, split tree, surface + scrolled window,
child-exited banner, search/key-state/resize overlays, command palette,
global shortcuts, all dialogs, config wrapper) as glib subclasses with
the same GType names, properties, signals and template children; GDK
key translation; X11 and Wayland window-protocol helpers (blur, CSD,
quick terminal via gtk4-layer-shell); single-instance D-Bus IPC;
gsettings; cgroup scope creation.

build.rs compiles Ghostty's blueprints with blueprint-compiler and
bundles UI, CSS and icons into a gresource, as Ghostty's build does.

The core is not ported yet, so the crate defines the boundary it needs
in src/core.rs (CoreApp, CoreSurface, RtApp, RtSurface, Waker); the
spec is in docs/rewrite/notes/ghostty-apprt-gtk.md. ghostty-core will
implement these traits (the crate no longer depends on core to avoid a
cycle). examples/demo.rs runs the app with a dummy core that clears the
GLArea; it launches on Wayland without GTK criticals.

Stubs/open items: toggle_visibility, float_window, secure_input and
close_all_windows return false; inspector is a no-op; X11/Wayland
protocol code is untested against real compositors; gtk4-rs doesn't
expose the GLArea framebuffer id, so the renderer must query it.

Also registers ghostty-apprt and ghostty-cli in workspace.dependencies.

Test Plan:
- cargo test -p ghostty-apprt-gtk (36 tests)
- DEMO_QUIT_AFTER_MS=3000 cargo run -p ghostty-apprt-gtk --example demo
This commit is contained in:
ddidderr committed 2026-10-11 22:57:15 +02:00
1 parent 72434a1255
commit 88cc13871c
80 files changed
+17271 -4

No files matched your search

@@ -0,0 +1,208 @@
//! Port of apprt/gtk/winproto.zig: abstraction over the windowing protocol
//! (X11 / Wayland / none) in use by the GDK display.
//!
//! * [`App`] is application-wide protocol state (one per application).
//! * [`Window`] is per-toplevel protocol state.
//!
//! Zig's tagged unions over `noop`/`x11`/`wayland` become Rust enums. The Zig
//! code held a `*ApprtWindow` pointer; here the window GObject implements
//! [`WindowHost`] (a small, weak-referencing adapter), see below.
use std::rc::Rc;
use std::sync::Arc;
use gtk::gdk;
use gtk::prelude::*;
use ghostty_config::config::Config;
use ghostty_config::types_b::WindowDecoration;
use ghostty_input::Mods;
pub mod noop;
pub mod wayland;
pub mod x11;
/// Errors from the winproto backends.
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("window surface is not an X11 surface")]
NotX11Surface,
#[error("window surface is not a Wayland surface")]
NotWaylandSurface,
#[error("no Wayland display/surface available")]
NoWaylandDisplay,
#[error("error initializing the XKB extension")]
XkbInitialization,
#[error("X11 request failed")]
RequestFailed,
#[error("X11 property not found")]
PropertyNotFound,
#[error("X11 property type mismatch")]
PropertyTypeMismatch,
#[error("X11 property format mismatch")]
PropertyFormatMismatch,
#[error("wayland: {0}")]
Wayland(String),
}
pub type Result<T> = std::result::Result<T, Error>;
/// What the winproto backends need from the Ghostty window GObject
/// (Zig: `ApprtWindow`). Implemented by `class::window` using a weak
/// reference to the window (so winproto state, which the window owns, does not
/// create a reference cycle).
pub trait WindowHost {
/// `apprt_window.as(gtk.Window)` (also a `gtk::Native`).
fn gtk_window(&self) -> Option<gtk::Window>;
/// `apprt_window.isQuickTerminal()`.
fn is_quick_terminal(&self) -> bool;
/// `apprt_window.getWindowDecoration()`.
fn window_decoration(&self) -> WindowDecoration;
/// `apprt_window.getConfig()?.get()`.
fn config(&self) -> Option<Arc<Config>>;
}
/// Which protocol is active (Zig: `winproto.Protocol`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Protocol {
None,
Wayland,
X11,
}
/// App-state for the underlying windowing protocol.
pub enum App {
None(noop::App),
Wayland(wayland::App),
X11(x11::App),
}
impl App {
/// Probe wayland, then x11, falling back to `None` (noop).
pub fn init(gdk_display: &gdk::Display, app_id: &str, config: &Config) -> Result<App> {
if let Some(v) = wayland::App::init(gdk_display, app_id, config)? {
return Ok(App::Wayland(v));
}
if let Some(v) = x11::App::init(gdk_display, app_id, config)? {
return Ok(App::X11(v));
}
Ok(App::None(noop::App))
}
pub fn protocol(&self) -> Protocol {
match self {
App::None(_) => Protocol::None,
App::Wayland(_) => Protocol::Wayland,
App::X11(_) => Protocol::X11,
}
}
/// Modifier state for a key event, falling back to the translation of
/// the GDK modifiers (`key.translateMods`).
pub fn event_mods(&self, device: Option<&gdk::Device>, gtk_mods: gdk::ModifierType) -> Mods {
let v = match self {
App::None(v) => v.event_mods(device, gtk_mods),
App::Wayland(v) => v.event_mods(device, gtk_mods),
App::X11(v) => v.event_mods(device, gtk_mods),
};
v.unwrap_or_else(|| crate::key::translate_mods(gtk_mods))
}
pub fn supports_quick_terminal(&self) -> bool {
match self {
App::None(v) => v.supports_quick_terminal(),
App::Wayland(v) => v.supports_quick_terminal(),
App::X11(v) => v.supports_quick_terminal(),
}
}
/// Set up necessary support for the quick terminal that must occur
/// *before* the window-level winproto object is created (and before the
/// window is realized). Only has an effect on Wayland, where
/// gtk4-layer-shell is initialized for the window.
pub fn init_quick_terminal(&mut self, window: &gtk::Window) -> Result<()> {
match self {
App::None(v) => v.init_quick_terminal(window),
App::Wayland(v) => v.init_quick_terminal(window),
App::X11(v) => v.init_quick_terminal(window),
}
}
}
/// Per-Window state for the underlying windowing protocol.
pub enum Window {
None(noop::Window),
Wayland(wayland::Window),
X11(x11::Window),
}
impl Window {
/// Create the per-window state. The window must be realized (it needs its
/// `gdk::Surface`).
pub fn init(app: &App, host: Rc<dyn WindowHost>) -> Result<Window> {
Ok(match app {
App::None(a) => Window::None(noop::Window::init(a, host)?),
App::Wayland(a) => Window::Wayland(wayland::Window::init(a, host)?),
App::X11(a) => Window::X11(x11::Window::init(a, host)?),
})
}
pub fn resize_event(&mut self) -> Result<()> {
match self {
Window::None(v) => v.resize_event(),
Window::Wayland(v) => v.resize_event(),
Window::X11(v) => v.resize_event(),
}
}
pub fn sync_appearance(&mut self) -> Result<()> {
match self {
Window::None(v) => v.sync_appearance(),
Window::Wayland(v) => v.sync_appearance(),
Window::X11(v) => v.sync_appearance(),
}
}
/// True if CSD is currently enabled for this window (actual state, not
/// desired state).
pub fn client_side_decoration_enabled(&self) -> bool {
match self {
Window::None(v) => v.client_side_decoration_enabled(),
Window::Wayland(v) => v.client_side_decoration_enabled(),
Window::X11(v) => v.client_side_decoration_enabled(),
}
}
pub fn add_subprocess_env(&mut self, env: &mut Vec<(String, String)>) -> Result<()> {
match self {
Window::None(v) => v.add_subprocess_env(env),
Window::Wayland(v) => v.add_subprocess_env(env),
Window::X11(v) => v.add_subprocess_env(env),
}
}
pub fn set_urgent(&mut self, urgent: bool) -> Result<()> {
match self {
Window::None(v) => v.set_urgent(urgent),
Window::Wayland(v) => v.set_urgent(urgent),
Window::X11(v) => v.set_urgent(urgent),
}
}
}
/// Helper: widget scale factor etc. used by backends.
pub(crate) fn native_surface(w: &gtk::Window) -> Option<gdk::Surface> {
w.upcast_ref::<gtk::Native>().surface()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn noop_defaults() {
let a = noop::App;
assert!(!a.supports_quick_terminal());
assert!(a.event_mods(None, gdk::ModifierType::empty()).is_none());
}
}
@@ -0,0 +1,63 @@
//! Port of apprt/gtk/winproto/noop.zig: used when neither X11 nor Wayland is
//! detected.
use std::rc::Rc;
use gtk::gdk;
use super::{Result, WindowHost};
use ghostty_input::Mods;
#[derive(Debug, Default, Clone, Copy)]
pub struct App;
impl App {
pub fn event_mods(&self, _: Option<&gdk::Device>, _: gdk::ModifierType) -> Option<Mods> {
None
}
pub fn supports_quick_terminal(&self) -> bool {
false
}
#[allow(clippy::unused_self, clippy::unnecessary_wraps)]
pub fn init_quick_terminal(&mut self, _: &gtk::Window) -> Result<()> {
Ok(())
}
}
#[derive(Debug, Default)]
pub struct Window;
impl Window {
#[allow(clippy::unnecessary_wraps)]
pub fn init(_: &App, _: Rc<dyn WindowHost>) -> Result<Window> {
Ok(Window)
}
#[allow(clippy::unused_self, clippy::unnecessary_wraps)]
pub fn resize_event(&mut self) -> Result<()> {
Ok(())
}
#[allow(clippy::unused_self, clippy::unnecessary_wraps)]
pub fn sync_appearance(&mut self) -> Result<()> {
Ok(())
}
/// Always true: this should be the actual present state of the window.
#[allow(clippy::unused_self)]
pub fn client_side_decoration_enabled(&self) -> bool {
true
}
#[allow(clippy::unused_self, clippy::unnecessary_wraps)]
pub fn add_subprocess_env(&mut self, _: &mut Vec<(String, String)>) -> Result<()> {
Ok(())
}
#[allow(clippy::unused_self, clippy::unnecessary_wraps)]
pub fn set_urgent(&mut self, _: bool) -> Result<()> {
Ok(())
}
}
@@ -0,0 +1,615 @@
//! Port of apprt/gtk/winproto/wayland.zig: Wayland protocol implementation.
//!
//! The GDK display's `wl_display` is shared through `gdk4-wayland`'s
//! `wayland_crate` feature (a `wayland_client::Connection` over GDK's foreign
//! display), so no `unsafe` is required here. We use a private event queue on
//! that connection for the protocol objects we own (KDE blur / server
//! decoration / slide, xdg-activation). GTK's own queue dispatching reads the
//! socket; events destined for our queue are picked up by
//! [`Shared::dispatch_pending`] (blocking roundtrips at init, a short poll
//! while an activation token request is in flight).
//!
//! # gtk4-layer-shell
//! The quick terminal uses the `gtk4-layer-shell` library. Upstream documents
//! that libgtk4-layer-shell must be linked/loaded *before* libwayland-client
//! (otherwise it cannot interpose the protocol). With Rust the link order is
//! not under our control: the final binary should link `gtk4-layer-shell`
//! first (or run with `LD_PRELOAD=libgtk4-layer-shell.so`). Without it,
//! `layer_shell::is_supported()` is false and the quick terminal is disabled.
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use gdk4_wayland::WaylandDisplay;
use gdk4_wayland::prelude::*;
use gtk::gdk;
use gtk::glib;
use gtk::prelude::*;
use gtk4_layer_shell::{self as layer_shell, LayerShell};
use wayland_client::globals::{GlobalListContents, registry_queue_init};
use wayland_client::protocol::wl_registry::WlRegistry;
use wayland_client::protocol::wl_surface::WlSurface;
use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle};
use wayland_protocols::xdg::activation::v1::client::xdg_activation_token_v1::{
self, XdgActivationTokenV1,
};
use wayland_protocols::xdg::activation::v1::client::xdg_activation_v1::XdgActivationV1;
use wayland_protocols_plasma::blur::client::org_kde_kwin_blur::OrgKdeKwinBlur;
use wayland_protocols_plasma::blur::client::org_kde_kwin_blur_manager::OrgKdeKwinBlurManager;
use wayland_protocols_plasma::server_decoration::client::org_kde_kwin_server_decoration::OrgKdeKwinServerDecoration;
use wayland_protocols_plasma::server_decoration::client::org_kde_kwin_server_decoration_manager::{
self, OrgKdeKwinServerDecorationManager,
};
use wayland_protocols_plasma::slide::client::org_kde_kwin_slide::OrgKdeKwinSlide;
use wayland_protocols_plasma::slide::client::org_kde_kwin_slide_manager::OrgKdeKwinSlideManager;
use super::{Error, Result, WindowHost};
use ghostty_config::config::Config;
use ghostty_config::types_b::{
QuickTerminalDimensions, QuickTerminalKeyboardInteractivity, QuickTerminalLayer,
QuickTerminalPosition, WindowDecoration,
};
use ghostty_input::Mods;
/// `org_kde_kwin_server_decoration_manager.mode` values.
const DECO_NONE: u32 = 0;
const DECO_CLIENT: u32 = 1;
const DECO_SERVER: u32 = 2;
/// `org_kde_kwin_slide.location` values.
const SLIDE_TOP: u32 = 1;
const SLIDE_LEFT: u32 = 2;
const SLIDE_RIGHT: u32 = 3;
const SLIDE_BOTTOM: u32 = 4;
/// Application-wide Wayland globals (Zig: `App.Context`).
#[derive(Default)]
struct State {
kde_blur_manager: Option<OrgKdeKwinBlurManager>,
// FIXME (upstream): replace with `zxdg_decoration_v1` once GTK merges
// https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398
kde_decoration_manager: Option<OrgKdeKwinServerDecorationManager>,
kde_slide_manager: Option<OrgKdeKwinSlideManager>,
default_deco_mode: Option<u32>,
xdg_activation: Option<XdgActivationV1>,
/// Whether the xdg_wm_dialog_v1 protocol is present. If so,
/// gtk4-layer-shell < 1.0.4 may crash creating a quick terminal.
/// See https://github.com/wmww/gtk4-layer-shell/issues/50
xdg_wm_dialog_present: bool,
/// Activation tokens requested but not yet `done`.
pending_tokens: usize,
}
struct Shared {
conn: Connection,
qh: QueueHandle<State>,
queue: RefCell<EventQueue<State>>,
state: RefCell<State>,
}
impl Shared {
fn dispatch_pending(&self) {
let mut queue = self.queue.borrow_mut();
let mut state = self.state.borrow_mut();
if let Err(e) = queue.dispatch_pending(&mut *state) {
log::warn!("wayland dispatch error: {e}");
}
}
fn flush(&self) {
if let Err(e) = self.conn.flush() {
log::warn!("wayland flush error: {e}");
}
}
}
fn bind<I: Proxy + 'static>(
globals: &wayland_client::globals::GlobalList,
qh: &QueueHandle<State>,
) -> Option<I>
where
State: Dispatch<I, ()>,
{
match globals.bind::<I, _, _>(qh, 1..=1, ()) {
Ok(v) => Some(v),
Err(wayland_client::globals::BindError::NotPresent) => None,
Err(e) => {
log::warn!("error binding interface {} error={e}", I::interface().name);
None
}
}
}
/// Wayland application-wide state.
pub struct App {
shared: Rc<Shared>,
}
impl App {
/// Returns `Ok(None)` if the display isn't Wayland.
pub fn init(
gdk_display: &gdk::Display,
_app_id: &str,
_config: &Config,
) -> Result<Option<App>> {
let Some(wl_display) = gdk_display.downcast_ref::<WaylandDisplay>() else {
return Ok(None);
};
let display = wl_display.wl_display().ok_or(Error::NoWaylandDisplay)?;
let backend = display.backend().upgrade().ok_or(Error::NoWaylandDisplay)?;
let conn = Connection::from_backend(backend);
// Get our display registry so we can get all the available interfaces
// and bind to what we need.
let (globals, mut queue) = registry_queue_init::<State>(&conn)
.map_err(|e| Error::Wayland(format!("registry: {e}")))?;
let qh = queue.handle();
let mut state = State::default();
globals.contents().with_list(|list| {
for g in list {
log::debug!("found global {}", g.interface);
if g.interface == "xdg_wm_dialog_v1" {
state.xdg_wm_dialog_present = true;
}
}
});
state.kde_blur_manager = bind(&globals, &qh);
state.kde_decoration_manager = bind(&globals, &qh);
state.kde_slide_manager = bind(&globals, &qh);
state.xdg_activation = bind(&globals, &qh);
// Do another round-trip to get the default decoration mode
queue
.roundtrip(&mut state)
.map_err(|e| Error::Wayland(format!("roundtrip: {e}")))?;
Ok(Some(App {
shared: Rc::new(Shared {
conn,
qh,
queue: RefCell::new(queue),
state: RefCell::new(state),
}),
}))
}
#[allow(clippy::unused_self)]
pub fn event_mods(&self, _: Option<&gdk::Device>, _: gdk::ModifierType) -> Option<Mods> {
None
}
pub fn supports_quick_terminal(&self) -> bool {
if !layer_shell::is_supported() {
log::warn!(
"your compositor does not support the wlr-layer-shell protocol; disabling quick terminal"
);
return false;
}
let version = (
layer_shell::major_version(),
layer_shell::minor_version(),
layer_shell::micro_version(),
);
if self.shared.state.borrow().xdg_wm_dialog_present && version < (1, 0, 4) {
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;
}
true
}
/// Initialize gtk4-layer-shell for the window (must happen before the
/// window is realized).
#[allow(clippy::unused_self, clippy::unnecessary_wraps)]
pub fn init_quick_terminal(&mut self, window: &gtk::Window) -> Result<()> {
window.init_layer_shell();
Ok(())
}
}
/// Data attached to an xdg activation token.
struct ActivationData {
surface: WlSurface,
/// The live token; cleared when `done` fires.
token: Mutex<Option<XdgActivationTokenV1>>,
done: AtomicBool,
}
/// Per-window (wl_surface) state for the Wayland protocol.
pub struct Window {
host: Rc<dyn WindowHost>,
shared: Rc<Shared>,
surface: WlSurface,
/// Present when the window is blurred.
blur_token: Option<OrgKdeKwinBlur>,
/// Controls the decoration mode (client/server/auto) of the window.
decoration: Option<OrgKdeKwinServerDecoration>,
/// Slide-in/out animation of the quick terminal (otherwise None).
slide: Option<OrgKdeKwinSlide>,
/// Present while the window is requesting attention.
activation: Option<Arc<ActivationData>>,
monitor_handler: Option<(gdk::Surface, glib::SignalHandlerId)>,
}
impl Window {
pub fn init(app: &App, host: Rc<dyn WindowHost>) -> Result<Window> {
let win = host.gtk_window().ok_or(Error::NotWaylandSurface)?;
let gdk_surface = super::native_surface(&win).ok_or(Error::NotWaylandSurface)?;
let wl_surface = gdk_surface
.downcast_ref::<gdk4_wayland::WaylandSurface>()
.ok_or(Error::NotWaylandSurface)?
.wl_surface()
.ok_or(Error::NotWaylandSurface)?;
let shared = Rc::clone(&app.shared);
// Get our decoration object so we can control the CSD vs SSD status.
let decoration = shared
.state
.borrow()
.kde_decoration_manager
.as_ref()
.map(|mgr| mgr.create(&wl_surface, &shared.qh, ()));
let monitor_handler = if host.is_quick_terminal() {
let host = Rc::clone(&host);
let id = gdk_surface.connect_enter_monitor(move |_, monitor| {
entered_monitor(&*host, monitor);
});
Some((gdk_surface.clone(), id))
} else {
None
};
Ok(Window {
host,
shared,
surface: wl_surface,
blur_token: None,
decoration,
slide: None,
activation: None,
monitor_handler,
})
}
#[allow(clippy::unused_self, clippy::unnecessary_wraps)]
pub fn resize_event(&mut self) -> Result<()> {
Ok(())
}
#[allow(clippy::unnecessary_wraps)]
pub fn sync_appearance(&mut self) -> Result<()> {
self.sync_blur();
self.sync_decoration();
if self.host.is_quick_terminal()
&& let Err(e) = self.sync_quick_terminal()
{
log::warn!("failed to sync quick terminal appearance={e}");
}
self.shared.flush();
Ok(())
}
pub fn client_side_decoration_enabled(&self) -> bool {
match self.decoration_mode() {
DECO_CLIENT => true,
// If we support SSDs, then we should *not* enable CSDs if we prefer
// SSDs. However, if we do not support SSDs (e.g. GNOME) then we
// should enable CSDs even if the user prefers SSDs.
DECO_SERVER => self.shared.state.borrow().kde_decoration_manager.is_none(),
_ => false,
}
}
#[allow(clippy::unused_self, clippy::unnecessary_wraps)]
pub fn add_subprocess_env(&mut self, _: &mut Vec<(String, String)>) -> Result<()> {
Ok(())
}
pub fn set_urgent(&mut self, urgent: bool) -> Result<()> {
let Some(activation) = self.shared.state.borrow().xdg_activation.clone() else {
return Ok(());
};
// If there already is a token, destroy and unset it
if let Some(old) = self.activation.take()
&& let Some(tok) = old.token.lock().ok().and_then(|mut t| t.take())
{
tok.destroy();
if !old.done.load(Ordering::Relaxed) {
let mut st = self.shared.state.borrow_mut();
st.pending_tokens = st.pending_tokens.saturating_sub(1);
}
}
if urgent {
let data = Arc::new(ActivationData {
surface: self.surface.clone(),
token: Mutex::new(None),
done: AtomicBool::new(false),
});
let token = activation.get_activation_token(&self.shared.qh, Arc::clone(&data));
token.set_surface(&self.surface);
token.commit();
if let Ok(mut t) = data.token.lock() {
*t = Some(token);
}
self.shared.state.borrow_mut().pending_tokens += 1;
self.shared.flush();
self.activation = Some(data);
// Poll our queue until the compositor answers.
let shared = Rc::clone(&self.shared);
glib::timeout_add_local(std::time::Duration::from_millis(50), move || {
shared.dispatch_pending();
if shared.state.borrow().pending_tokens == 0 {
shared.flush();
glib::ControlFlow::Break
} else {
glib::ControlFlow::Continue
}
});
}
Ok(())
}
/// Update the blur state of the window.
fn sync_blur(&mut self) {
let Some(manager) = self.shared.state.borrow().kde_blur_manager.clone() else {
return;
};
let Some(config) = self.host.config() else {
return;
};
let blur = config.background_blur;
if let Some(tok) = &self.blur_token {
// Only release token when transitioning from blurred -> not blurred
if !blur.enabled() {
manager.unset(&self.surface);
tok.release();
self.blur_token = None;
}
} else if blur.enabled() {
// Only acquire token when transitioning from not blurred -> blurred
let tok = manager.create(&self.surface, &self.shared.qh, ());
tok.commit();
self.blur_token = Some(tok);
}
}
fn sync_decoration(&self) {
let Some(deco) = &self.decoration else {
return;
};
deco.request_mode(self.decoration_mode());
}
fn decoration_mode(&self) -> u32 {
match self.host.window_decoration() {
WindowDecoration::Auto => self
.shared
.state
.borrow()
.default_deco_mode
.unwrap_or(DECO_CLIENT),
WindowDecoration::Client => DECO_CLIENT,
WindowDecoration::Server => DECO_SERVER,
WindowDecoration::None => DECO_NONE,
}
}
fn sync_quick_terminal(&mut self) -> Result<()> {
let window = self.host.gtk_window().ok_or(Error::NotWaylandSurface)?;
let Some(config) = self.host.config() else {
return Ok(());
};
window.set_layer(match config.gtk_quick_terminal_layer {
QuickTerminalLayer::Overlay => layer_shell::Layer::Overlay,
QuickTerminalLayer::Top => layer_shell::Layer::Top,
QuickTerminalLayer::Bottom => layer_shell::Layer::Bottom,
QuickTerminalLayer::Background => layer_shell::Layer::Background,
});
window.set_namespace(Some(config.gtk_quick_terminal_namespace.as_str()));
window.set_keyboard_mode(match config.quick_terminal_keyboard_interactivity {
QuickTerminalKeyboardInteractivity::None => layer_shell::KeyboardMode::None,
QuickTerminalKeyboardInteractivity::OnDemand => {
if layer_shell::protocol_version() < 4 {
log::warn!(
"your compositor does not support on-demand keyboard access; falling back to exclusive access"
);
layer_shell::KeyboardMode::Exclusive
} else {
layer_shell::KeyboardMode::OnDemand
}
}
QuickTerminalKeyboardInteractivity::Exclusive => {
layer_shell::KeyboardMode::Exclusive
}
},
);
let anchored_edge = match config.quick_terminal_position {
QuickTerminalPosition::Left => Some(layer_shell::Edge::Left),
QuickTerminalPosition::Right => Some(layer_shell::Edge::Right),
QuickTerminalPosition::Top => Some(layer_shell::Edge::Top),
QuickTerminalPosition::Bottom => Some(layer_shell::Edge::Bottom),
QuickTerminalPosition::Center => None,
};
for edge in [
layer_shell::Edge::Left,
layer_shell::Edge::Right,
layer_shell::Edge::Top,
layer_shell::Edge::Bottom,
] {
if anchored_edge == Some(edge) {
window.set_margin(edge, 0);
window.set_anchor(edge, true);
continue;
}
// Arbitrary margin - could be made customizable?
window.set_margin(edge, 20);
window.set_anchor(edge, false);
}
if let Some(slide) = self.slide.take() {
slide.release();
}
self.slide = anchored_edge.and_then(|anchored| {
let mgr = self.shared.state.borrow().kde_slide_manager.clone()?;
let slide = mgr.create(&self.surface, &self.shared.qh, ());
slide.set_location(match anchored {
layer_shell::Edge::Top => SLIDE_TOP,
layer_shell::Edge::Bottom => SLIDE_BOTTOM,
layer_shell::Edge::Left => SLIDE_LEFT,
_ => SLIDE_RIGHT,
});
slide.commit();
Some(slide)
});
Ok(())
}
}
impl Drop for Window {
fn drop(&mut self) {
if let Some((surface, id)) = self.monitor_handler.take() {
surface.disconnect(id);
}
if let Some(blur) = self.blur_token.take() {
blur.release();
}
if let Some(deco) = self.decoration.take() {
deco.release();
}
if let Some(slide) = self.slide.take() {
slide.release();
}
if let Some(act) = self.activation.take()
&& let Some(tok) = act.token.lock().ok().and_then(|mut t| t.take())
{
tok.destroy();
}
self.shared.flush();
}
}
/// Update the size of the quick terminal based on monitor dimensions.
fn entered_monitor(host: &dyn WindowHost, monitor: &gdk::Monitor) {
let Some(window) = host.gtk_window() else {
return;
};
let Some(config) = host.config() else {
return;
};
let size = monitor.geometry();
let dims = config.quick_terminal_size.calculate(
config.quick_terminal_position,
QuickTerminalDimensions {
width: size.width() as u32,
height: size.height() as u32,
},
);
window.set_default_size(dims.width as i32, dims.height as i32);
}
// ---- wayland-client dispatch plumbing ---------------------------------
impl Dispatch<WlRegistry, GlobalListContents> for State {
fn event(
_: &mut Self,
_: &WlRegistry,
_: wayland_client::protocol::wl_registry::Event,
_: &GlobalListContents,
_: &Connection,
_: &QueueHandle<Self>,
) {
// Globals appearing/disappearing after init are not tracked.
}
}
macro_rules! no_events {
($($t:ty),*) => {$(
impl Dispatch<$t, ()> for State {
fn event(
_: &mut Self,
_: &$t,
_: <$t as Proxy>::Event,
(): &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
)*};
}
no_events!(
OrgKdeKwinBlurManager,
OrgKdeKwinBlur,
OrgKdeKwinSlideManager,
OrgKdeKwinSlide,
XdgActivationV1,
OrgKdeKwinServerDecoration
);
impl Dispatch<OrgKdeKwinServerDecorationManager, ()> for State {
fn event(
state: &mut Self,
_: &OrgKdeKwinServerDecorationManager,
event: org_kde_kwin_server_decoration_manager::Event,
(): &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let org_kde_kwin_server_decoration_manager::Event::DefaultMode { mode } = event {
state.default_deco_mode = Some(mode);
}
}
}
impl Dispatch<XdgActivationTokenV1, Arc<ActivationData>> for State {
fn event(
state: &mut Self,
token: &XdgActivationTokenV1,
event: xdg_activation_token_v1::Event,
data: &Arc<ActivationData>,
_: &Connection,
_: &QueueHandle<Self>,
) {
let xdg_activation_token_v1::Event::Done { token: id } = event else {
return;
};
// Ignore events for tokens we've already dropped.
let is_current = data
.token
.lock()
.is_ok_and(|t| t.as_ref().is_some_and(|t| t.id() == token.id()));
if !is_current {
log::warn!("received event for unknown activation token; ignoring");
return;
}
if let Some(activation) = &state.xdg_activation {
activation.activate(id, &data.surface);
}
token.destroy();
if let Ok(mut t) = data.token.lock() {
t.take();
}
data.done.store(true, Ordering::Relaxed);
state.pending_tokens = state.pending_tokens.saturating_sub(1);
}
}
@@ -0,0 +1,524 @@
//! Port of apprt/gtk/winproto/x11.zig: X11 window protocol implementation.
//!
//! Uses Xlib (through the `x11` crate, sharing GDK's `Display*`) exactly like
//! the Zig code; all raw FFI is confined to small `unsafe` blocks with SAFETY
//! comments.
#![allow(unsafe_code)]
use std::ffi::{c_int, c_long, c_uchar, c_uint, c_ulong, c_void};
use std::rc::Rc;
use gdk4_x11::x11::xlib;
use gdk4_x11::{X11Display, X11Surface};
use gtk::gdk;
use gtk::glib;
use gtk::prelude::*;
use super::{Error, Result, WindowHost};
use ghostty_input::Mods;
// XKB bits we need that the `x11` crate doesn't expose (XKBlib.h / XKB.h).
const XKB_MAJOR_VERSION: c_int = 1;
const XKB_MINOR_VERSION: c_int = 0;
const XKB_USE_CORE_KBD: c_uint = 0x0100;
const XKB_STATE_NOTIFY: c_int = 2;
const QUEUED_AFTER_READING: c_int = 1;
const XKB_MODIFIER_STATE_MASK: c_ulong = 1 << 0;
#[link(name = "X11")]
unsafe extern "C" {
fn XkbQueryExtension(
dpy: *mut c_void,
opcode_rtrn: *mut c_int,
event_rtrn: *mut c_int,
error_rtrn: *mut c_int,
major_in_out: *mut c_int,
minor_in_out: *mut c_int,
) -> c_int;
fn XkbSelectEventDetails(
dpy: *mut c_void,
device_spec: c_uint,
event_type: c_uint,
bits_to_change: c_ulong,
values_for_bits: c_ulong,
) -> c_int;
}
/// Leading part of `XkbStateNotifyEvent` (XKBlib.h) up to `lookup_mods`.
#[repr(C)]
#[allow(dead_code)]
struct XkbStateNotifyEvent {
type_: c_int,
serial: c_ulong,
send_event: c_int,
display: *mut xlib::Display,
time: c_ulong,
xkb_type: c_int,
device: c_int,
changed: c_uint,
group: c_int,
base_group: c_int,
latched_group: c_int,
locked_group: c_int,
mods: c_uint,
base_mods: c_uint,
latched_mods: c_uint,
locked_mods: c_uint,
compat_state: c_int,
grab_mods: c_uchar,
compat_grab_mods: c_uchar,
lookup_mods: c_uchar,
}
#[derive(Debug, Clone, Copy)]
struct Atoms {
kde_blur: xlib::Atom,
motif_wm_hints: xlib::Atom,
}
impl Atoms {
fn new(display: &X11Display) -> Atoms {
Atoms {
kde_blur: gdk4_x11::x11_get_xatom_by_name_for_display(
display,
"_KDE_NET_WM_BLUR_BEHIND_REGION",
),
motif_wm_hints: gdk4_x11::x11_get_xatom_by_name_for_display(display, "_MOTIF_WM_HINTS"),
}
}
}
/// X11 application-wide state.
pub struct App {
xl: Rc<xlib::Xlib>,
display: *mut xlib::Display,
base_event_code: c_int,
atoms: Atoms,
}
impl App {
/// Returns `Ok(None)` if the display isn't X11.
pub fn init(
gdk_display: &gdk::Display,
app_id: &str,
config: &ghostty_config::config::Config,
) -> Result<Option<App>> {
let Some(x11_display) = gdk_display.downcast_ref::<X11Display>() else {
return Ok(None);
};
let xl = xlib::Xlib::open().map_err(|e| {
log::error!("failed to load libX11: {e}");
Error::XkbInitialization
})?;
// SAFETY: the display is a live X11 display; the returned pointer is
// owned by GDK and valid for the lifetime of the display (the whole
// application).
let xlib_display = unsafe { x11_display.xdisplay() };
let program_name = match &config.x11_instance_name {
Some(pn) => pn.clone(),
None if cfg!(debug_assertions) => "ghostty-debug".to_string(),
None => "ghostty".to_string(),
};
// Set the X11 window class property (WM_CLASS); the instance name is
// derived from g_set_prgname. Results in e.g.
// WM_CLASS(STRING) = "ghostty", "com.mitchellh.ghostty"
glib::set_prgname(Some(program_name.as_str()));
x11_display.set_program_class(app_id);
log::debug!("Xkb.init: initializing Xkb");
let mut opcode: c_int = 0;
let mut base_event_code: c_int = 0;
let mut base_error_code: c_int = 0;
let mut major = XKB_MAJOR_VERSION;
let mut minor = XKB_MINOR_VERSION;
// SAFETY: valid display pointer, valid out-pointers to locals.
let ok = unsafe {
XkbQueryExtension(
xlib_display.cast(),
&raw mut opcode,
&raw mut base_event_code,
&raw mut base_error_code,
&raw mut major,
&raw mut minor,
)
};
if ok == 0 {
log::error!(
"Fatal: error initializing Xkb extension: error executing XkbQueryExtension"
);
return Err(Error::XkbInitialization);
}
// SAFETY: valid display pointer.
let ok = unsafe {
XkbSelectEventDetails(
xlib_display.cast(),
XKB_USE_CORE_KBD,
XKB_STATE_NOTIFY as c_uint,
XKB_MODIFIER_STATE_MASK,
XKB_MODIFIER_STATE_MASK,
)
};
if ok == 0 {
log::error!(
"Fatal: error initializing Xkb extension: error executing XkbSelectEventDetails"
);
return Err(Error::XkbInitialization);
}
Ok(Some(App {
xl: Rc::new(xl),
display: xlib_display,
base_event_code,
atoms: Atoms::new(x11_display),
}))
}
/// Checks for an immediate pending XKB state update event and returns the
/// keyboard state based on it. Necessary because the standard GTK X11 API
/// does not include the currently pressed key in the modifier snapshot
/// (paraphrased from Firefox/Gecko widget/gtk/nsGtkKeyUtils.cpp).
///
/// Returns `None` if there is no such event; callers fall back to the GDK
/// modifier state.
pub fn event_mods(&self, _: Option<&gdk::Device>, _: gdk::ModifierType) -> Option<Mods> {
// SAFETY: `display` is valid for the application's lifetime; XEvent is
// plain-old-data, zero-initialised, and filled by XPeekEvent.
let lookup_mods = unsafe {
if (self.xl.XEventsQueued)(self.display, QUEUED_AFTER_READING) == 0 {
return None;
}
let mut next: xlib::XEvent = std::mem::zeroed();
(self.xl.XPeekEvent)(self.display, &raw mut next);
if next.type_ != self.base_event_code {
return None;
}
// XkbStateNotifyEvent is a prefix-compatible view of the XEvent
// union (which is at least as large and aligned as a long).
let ev = &*std::ptr::from_ref(&next).cast::<XkbStateNotifyEvent>();
if ev.xkb_type != XKB_STATE_NOTIFY {
return None;
}
ev.lookup_mods
};
let lookup = c_uint::from(lookup_mods);
let mut mods = Mods::default();
log::debug!("X11: found extra XkbStateNotify event w/lookup_mods: {lookup_mods:b}");
if lookup & xlib::ShiftMask != 0 {
mods.shift = true;
}
if lookup & xlib::ControlMask != 0 {
mods.ctrl = true;
}
if lookup & xlib::Mod1Mask != 0 {
mods.alt = true;
}
if lookup & xlib::Mod4Mask != 0 {
mods.super_ = true;
}
if lookup & xlib::LockMask != 0 {
mods.caps_lock = true;
}
Some(mods)
}
#[allow(clippy::unused_self)]
pub fn supports_quick_terminal(&self) -> bool {
log::warn!("quick terminal is not yet supported on X11");
false
}
#[allow(clippy::unused_self, clippy::unnecessary_wraps)]
pub fn init_quick_terminal(&mut self, _: &gtk::Window) -> Result<()> {
Ok(())
}
}
/// `_KDE_NET_WM_BLUR_BEHIND_REGION` payload: x, y, width, height (32-bit
/// format properties are arrays of `long`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(C)]
struct Region {
x: c_long,
y: c_long,
width: c_long,
height: c_long,
}
/// See Xm/MwmUtil.h, packaged with the Motif Window Manager. All fields are
/// `long`-sized in the 32-bit format.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(C)]
struct MotifWmHints {
flags: c_ulong,
functions: c_ulong,
decorations: c_ulong,
input_mode: c_long,
status: c_ulong,
}
impl MotifWmHints {
const FLAG_DECORATIONS: c_ulong = 1 << 1;
const DECORATIONS_ALL: c_ulong = 1 << 0;
}
/// X11 per-window state.
pub struct Window {
xl: Rc<xlib::Xlib>,
display: *mut xlib::Display,
atoms: Atoms,
host: Rc<dyn WindowHost>,
x11_surface: X11Surface,
blur_region: Region,
// Cache last applied values to avoid redundant X11 property updates, which
// cause visual glitches with some WMs (ghostty-org/ghostty#8075).
last_applied_blur_region: Option<Region>,
last_applied_decoration_hints: Option<MotifWmHints>,
}
impl Window {
pub fn init(app: &App, host: Rc<dyn WindowHost>) -> Result<Window> {
let win = host.gtk_window().ok_or(Error::NotX11Surface)?;
let surface = super::native_surface(&win).ok_or(Error::NotX11Surface)?;
let x11_surface = surface
.downcast::<X11Surface>()
.map_err(|_| Error::NotX11Surface)?;
Ok(Window {
xl: Rc::clone(&app.xl),
display: app.display,
atoms: app.atoms,
host,
x11_surface,
blur_region: Region::default(),
last_applied_blur_region: None,
last_applied_decoration_hints: None,
})
}
pub fn resize_event(&mut self) -> Result<()> {
// The blur region must update with window resizes
self.sync_blur()
}
pub fn sync_appearance(&mut self) -> Result<()> {
// The user could have toggled between CSDs and SSDs, therefore we need
// to recalculate the blur region offset.
if let Some(win) = self.host.gtk_window() {
let (mut x, mut y) = win.upcast_ref::<gtk::Native>().surface_transform();
// Transform surface coordinates to device coordinates.
let scale = f64::from(win.scale_factor());
x *= scale;
y *= scale;
self.blur_region = Region {
x: x as c_long,
y: y as c_long,
..Region::default()
};
}
if let Err(err) = self.sync_blur() {
log::error!("failed to synchronize blur={err}");
}
if let Err(err) = self.sync_decorations() {
log::error!("failed to synchronize decorations={err}");
}
Ok(())
}
pub fn client_side_decoration_enabled(&self) -> bool {
use ghostty_config::types_b::WindowDecoration as D;
match self.host.window_decoration() {
D::Auto | D::Client => true,
D::Server | D::None => false,
}
}
fn sync_blur(&mut self) -> Result<()> {
// FIXME (upstream): doesn't factor in rounded corners on Adwaita.
let Some(win) = self.host.gtk_window() else {
return Ok(());
};
let Some(config) = self.host.config() else {
return Ok(());
};
// When blur is disabled, remove the property if it was previously set
let blur = config.background_blur;
if !blur.enabled() {
if self.last_applied_blur_region.is_some() {
self.delete_property(self.atoms.kde_blur)?;
self.last_applied_blur_region = None;
}
return Ok(());
}
// Transform surface coordinates to device coordinates.
let scale = win.scale_factor();
self.blur_region.width = c_long::from(win.width() * scale);
self.blur_region.height = c_long::from(win.height() * scale);
// Only update X11 properties when the blur region actually changes
if self.last_applied_blur_region == Some(self.blur_region) {
return Ok(());
}
log::debug!(
"set blur={blur:?}, window xid={}, region={:?}",
self.x11_surface.xid(),
self.blur_region
);
let region = self.blur_region;
self.change_property(self.atoms.kde_blur, xlib::XA_CARDINAL, &region)?;
self.last_applied_blur_region = Some(region);
Ok(())
}
fn sync_decorations(&mut self) -> Result<()> {
use ghostty_config::types_b::WindowDecoration as D;
let mut hints = match self.get_window_property::<MotifWmHints>(
self.atoms.motif_wm_hints,
self.atoms.motif_wm_hints,
) {
Ok(h) => h,
// already default-initialised, so this is fine
Err(Error::PropertyNotFound) => MotifWmHints::default(),
Err(e) => return Err(e),
};
hints.flags |= MotifWmHints::FLAG_DECORATIONS;
if matches!(self.host.window_decoration(), D::Server) {
hints.decorations |= MotifWmHints::DECORATIONS_ALL;
} else {
hints.decorations &= !MotifWmHints::DECORATIONS_ALL;
}
// Only update decoration hints when they actually change
if self.last_applied_decoration_hints == Some(hints) {
return Ok(());
}
self.change_property(self.atoms.motif_wm_hints, self.atoms.motif_wm_hints, &hints)?;
self.last_applied_decoration_hints = Some(hints);
Ok(())
}
pub fn add_subprocess_env(&mut self, env: &mut Vec<(String, String)>) -> Result<()> {
env.push(("WINDOWID".to_string(), self.x11_surface.xid().to_string()));
Ok(())
}
#[allow(clippy::unnecessary_wraps)]
pub fn set_urgent(&mut self, urgent: bool) -> Result<()> {
self.x11_surface.set_urgency_hint(urgent);
Ok(())
}
/// `XGetWindowProperty` for a 32-format property that is an array of
/// `long`s whose layout matches `T` (`T` is a `repr(C)` struct of longs).
fn get_window_property<T: Copy>(&self, name: xlib::Atom, typ: xlib::Atom) -> Result<T> {
let mut actual_type: xlib::Atom = 0;
let mut actual_format: c_int = 0;
let mut nitems: c_ulong = 0;
let mut bytes_after: c_ulong = 0;
let mut prop: *mut c_uchar = std::ptr::null_mut();
// SAFETY: valid display and window id; all out-pointers refer to locals.
let code = unsafe {
(self.xl.XGetWindowProperty)(
self.display,
self.x11_surface.xid(),
name,
0,
c_long::MAX,
xlib::False,
typ,
&raw mut actual_type,
&raw mut actual_format,
&raw mut nitems,
&raw mut bytes_after,
&raw mut prop,
)
};
if code != c_int::from(xlib::Success) {
return Err(Error::RequestFailed);
}
if actual_type == 0 {
return Err(Error::PropertyNotFound);
}
let result = if typ != actual_type {
Err(Error::PropertyTypeMismatch)
} else if actual_format != 32 {
Err(Error::PropertyFormatMismatch)
} else if (nitems as usize) * std::mem::size_of::<c_long>() < std::mem::size_of::<T>()
|| prop.is_null()
{
Err(Error::RequestFailed)
} else {
// SAFETY: Xlib returns 32-format data as an array of `long`
// (suitably aligned), at least size_of::<T>() bytes of which we
// just verified; T is a repr(C) aggregate of longs.
Ok(unsafe { prop.cast::<T>().read() })
};
if !prop.is_null() {
// SAFETY: allocated by Xlib.
unsafe { (self.xl.XFree)(prop.cast()) };
}
result
}
fn change_property<T: Copy>(&self, name: xlib::Atom, typ: xlib::Atom, value: &T) -> Result<()> {
let nelements = std::mem::size_of::<T>() / std::mem::size_of::<c_long>();
// SAFETY: valid display/window; `value` points to `nelements` longs
// (T is a repr(C) aggregate of longs); data is copied by Xlib.
let status = unsafe {
(self.xl.XChangeProperty)(
self.display,
self.x11_surface.xid(),
name,
typ,
32,
xlib::PropModeReplace,
std::ptr::from_ref(value).cast::<c_uchar>(),
nelements as c_int,
)
};
// Xlib alternates between error values (0 = success) and booleans
// (1 = success); the Zig code treats 0 as failure here.
if status == 0 {
return Err(Error::RequestFailed);
}
Ok(())
}
fn delete_property(&self, name: xlib::Atom) -> Result<()> {
// SAFETY: valid display and window id.
let status =
unsafe { (self.xl.XDeleteProperty)(self.display, self.x11_surface.xid(), name) };
if status == 0 {
return Err(Error::RequestFailed);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn property_layouts_are_long_arrays() {
let l = std::mem::size_of::<c_long>();
assert_eq!(std::mem::size_of::<Region>(), 4 * l);
assert_eq!(std::mem::size_of::<MotifWmHints>(), 5 * l);
}
}