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 6675db1eeb
commit 69d823cbc7
80 files changed
+17271 -4

No files matched your search

+138
View File
@@ -0,0 +1,138 @@
//! GAction helpers (port of `apprt/gtk/ext/actions.zig`).
use gtk::gio;
use gtk::glib;
use gtk::glib::prelude::*;
use gtk::prelude::*;
fn g_action_name_is_valid(name: &str) -> bool {
!name.is_empty()
&& name
.bytes()
.all(|c| c == b'-' || c == b'.' || c.is_ascii_alphanumeric())
}
/// Callback signature: `(action, parameter, self)`.
pub type Callback<T> = fn(&gio::SimpleAction, Option<&glib::Variant>, &T);
/// An action definition for objects of type `T`.
pub struct Action<T> {
pub name: &'static str,
pub callback: Callback<T>,
pub parameter_type: Option<glib::VariantType>,
pub state: Option<glib::Variant>,
}
impl<T> Action<T> {
pub fn new(
name: &'static str,
callback: Callback<T>,
parameter_type: Option<glib::VariantType>,
) -> Self {
assert!(g_action_name_is_valid(name));
Self {
name,
callback,
parameter_type,
state: None,
}
}
pub fn stateful(
name: &'static str,
callback: Callback<T>,
parameter_type: Option<glib::VariantType>,
state: glib::Variant,
) -> Self {
assert!(g_action_name_is_valid(name));
Self {
name,
callback,
parameter_type,
state: Some(state),
}
}
}
/// Add the actions to `obj` itself (it must be an `ActionMap`).
pub fn add<T: IsA<gio::ActionMap>>(obj: &T, actions: &[Action<T>]) {
add_to_map(obj, obj.upcast_ref::<gio::ActionMap>(), actions);
}
/// Add the actions to `map`; callbacks receive `obj` (held weakly, so no
/// reference cycle is created).
pub fn add_to_map<T: ObjectType>(obj: &T, map: &impl IsA<gio::ActionMap>, actions: &[Action<T>]) {
for entry in actions {
assert!(g_action_name_is_valid(entry.name));
let action = if let Some(state) = &entry.state {
gio::SimpleAction::new_stateful(entry.name, entry.parameter_type.as_deref(), state)
} else {
gio::SimpleAction::new(entry.name, entry.parameter_type.as_deref())
};
let weak = obj.downgrade();
let callback = entry.callback;
action.connect_activate(move |a, p| {
if let Some(obj) = weak.upgrade() {
callback(a, p, &obj);
}
});
map.add_action(&action);
}
}
/// Collect the actions into a group inserted into the widget under `name`.
pub fn add_as_group<T: IsA<gtk::Widget>>(
obj: &T,
name: &str,
actions: &[Action<T>],
) -> gio::SimpleActionGroup {
assert!(g_action_name_is_valid(name));
// We're a plain widget that doesn't implement ActionGroup directly.
let group = gio::SimpleActionGroup::new();
add_to_map(obj, &group, actions);
obj.insert_action_group(name, Some(&group));
group
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn g_action_name_is_valid_test() {
assert!(g_action_name_is_valid("ring-bell"));
assert!(!g_action_name_is_valid("ring_bell"));
assert!(!g_action_name_is_valid(""));
}
fn cb(_: &gio::SimpleAction, v: Option<&glib::Variant>, b: &gtk::Box) {
if let Some(v) = v.and_then(glib::Variant::get::<i32>) {
b.set_spacing(v);
}
}
#[test]
fn adding_actions_to_an_object() {
// This test requires a connection to an active display environment.
if gtk::init().is_err() {
return;
}
let b = gtk::Box::new(gtk::Orientation::Vertical, 0);
let actions = [Action::new(
"test",
cb,
Some(i32::static_variant_type().into_owned()),
)];
let _group = add_as_group(&b, "test", &actions);
let expected = 12345;
assert!(
b.activate_action("test.test", Some(&expected.to_variant()))
.is_ok()
);
while glib::MainContext::default().iteration(false) {}
assert_eq!(b.spacing(), expected);
}
}
+37
View File
@@ -0,0 +1,37 @@
//! Extensions/helpers for GTK objects (port of `apprt/gtk/ext.zig`).
//!
//! `boxedCopy`/`boxedFree`/`listFind` are subsumed by glib-rs (`Clone`,
//! `Drop`, iterators) and are not ported.
pub mod actions;
pub mod slice;
pub use slice::StringList;
use gtk::glib;
use gtk::glib::prelude::*;
use gtk::prelude::*;
/// Get the first ancestor of `widget` of type `T`.
pub fn get_ancestor<T: IsA<gtk::Widget>>(widget: &impl IsA<gtk::Widget>) -> Option<T> {
widget.ancestor(T::static_type()).and_downcast::<T>()
}
/// Whether the value holds (a subtype of) the given type.
pub fn g_value_holds(value: Option<&glib::Value>, g_type: glib::Type) -> bool {
let Some(value) = value else { return false };
value.type_() == g_type || value.type_().is_a(g_type)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn g_value_holds_test() {
let v = 5i32.to_value();
assert!(g_value_holds(Some(&v), glib::Type::I32));
assert!(!g_value_holds(Some(&v), glib::Type::STRING));
assert!(!g_value_holds(None, glib::Type::I32));
}
}
+48
View File
@@ -0,0 +1,48 @@
//! Port of `apprt/gtk/ext/slice.zig`: a boxed list of strings.
use gtk::glib;
/// A boxed list of strings (GType `GhosttyStringList`). Copy/free semantics
/// are provided by `Clone`/`Drop`.
#[derive(Debug, Clone, Default, PartialEq, Eq, glib::Boxed)]
#[boxed_type(name = "GhosttyStringList")]
pub struct StringList {
pub strings: Vec<String>,
}
impl StringList {
pub fn create<S: AsRef<str>>(strings: &[S]) -> Self {
Self {
strings: strings.iter().map(|s| s.as_ref().to_owned()).collect(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use gtk::glib::prelude::*;
#[test]
fn string_list_create_and_destroy() {
let list = StringList::create(&["hello", "world"]);
assert_eq!(list.strings.len(), 2);
assert_eq!(list.strings[0], "hello");
assert_eq!(list.strings[1], "world");
}
#[test]
fn string_list_create_empty_list() {
let list = StringList::create::<&str>(&[]);
assert_eq!(list.strings.len(), 0);
}
#[test]
fn string_list_boxed_copy_and_free() {
let original = StringList::create(&["foo", "bar", "baz"]);
let value = original.to_value();
let copied = value.get::<StringList>().expect("boxed value");
assert_eq!(copied.strings, ["foo", "bar", "baz"]);
assert_ne!(original.strings.as_ptr(), copied.strings.as_ptr());
}
}