diff --git a/Cargo.lock b/Cargo.lock
index b6648bc..aa06a2c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -571,6 +571,25 @@ dependencies = [
"crossbeam-utils",
]
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
+dependencies = [
+ "crossbeam-utils",
+]
+
[[package]]
name = "crossbeam-utils"
version = "0.8.23"
@@ -1339,11 +1358,20 @@ name = "ghostty-renderer"
version = "0.1.0"
dependencies = [
"bytemuck",
+ "crossbeam-channel",
+ "ghostty-config",
"ghostty-font",
+ "ghostty-input",
+ "ghostty-terminal",
+ "ghostty-unicode",
+ "ghostty-vt",
"glow",
+ "jpeg-decoder",
"khronos-egl",
"log",
"naga",
+ "onig",
+ "png",
"thiserror 2.0.21",
]
@@ -1838,6 +1866,15 @@ dependencies = [
"syn 2.0.119",
]
+[[package]]
+name = "jpeg-decoder"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
+dependencies = [
+ "rayon",
+]
+
[[package]]
name = "js-sys"
version = "0.3.106"
@@ -2777,6 +2814,26 @@ dependencies = [
"unicode-width",
]
+[[package]]
+name = "rayon"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
+dependencies = [
+ "either",
+ "rayon-core",
+]
+
+[[package]]
+name = "rayon-core"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
+dependencies = [
+ "crossbeam-deque",
+ "crossbeam-utils",
+]
+
[[package]]
name = "redox_syscall"
version = "0.5.18"
diff --git a/Cargo.toml b/Cargo.toml
index 0822470..144a8a9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -38,6 +38,8 @@ ghostty-os = { path = "crates/ghostty-os" }
ghostty-datastruct = { path = "crates/ghostty-datastruct" }
ghostty-core = { path = "crates/ghostty-core" }
ghostty-apprt-gtk = { path = "crates/ghostty-apprt-gtk" }
+ghostty-apprt = { path = "crates/ghostty-apprt" }
+ghostty-cli = { path = "crates/ghostty-cli" }
[workspace.lints.rust]
unsafe_code = "forbid"
diff --git a/crates/ghostty-apprt-gtk/Cargo.toml b/crates/ghostty-apprt-gtk/Cargo.toml
index 14f8b80..f87b682 100644
--- a/crates/ghostty-apprt-gtk/Cargo.toml
+++ b/crates/ghostty-apprt-gtk/Cargo.toml
@@ -3,11 +3,64 @@ name = "ghostty-apprt-gtk"
version.workspace = true
edition.workspace = true
license.workspace = true
+build = "build.rs"
[dependencies]
-ghostty-core.workspace = true
+ghostty-apprt.workspace = true
ghostty-config.workspace = true
ghostty-input.workspace = true
+ghostty-datastruct.workspace = true
+ghostty-os.workspace = true
+ghostty-vt.workspace = true
+log = "0.4"
+thiserror = "2"
+bitflags = "2"
+gtk = { package = "gtk4", version = "0.11", features = ["v4_14", "gnome_44"] }
+adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
+gtk4-layer-shell = "0.8"
+gdk4-x11 = { version = "0.11.5", features = ["x11", "xlib"] }
+gdk4-wayland = { version = "0.11.5", features = ["wayland_crate"] }
+wayland-client = "0.31.15"
+wayland-protocols-plasma = { version = "0.3.12", features = ["client"] }
+wayland-protocols = { version = "0.32.13", features = ["client", "staging"] }
+wayland-backend = { version = "0.3.17", features = ["client_system"] }
+half = "2"
+libc = "0.2.190"
+signal-hook = "0.4.5"
+libloading = "0.9.0"
-[lints]
-workspace = true
+[build-dependencies]
+glib-build-tools = "0.22"
+
+[dev-dependencies]
+env_logger = "0.11"
+glow = "0.18"
+
+[lints.rust]
+unsafe_code = "deny"
+
+[lints.clippy]
+pedantic = { level = "warn", priority = -1 }
+todo = "warn"
+unwrap_used = "warn"
+cast_possible_truncation = "allow"
+cast_possible_wrap = "allow"
+cast_sign_loss = "allow"
+cast_precision_loss = "allow"
+cast_lossless = "allow"
+module_name_repetitions = "allow"
+too_many_lines = "allow"
+missing_errors_doc = "allow"
+missing_panics_doc = "allow"
+similar_names = "allow"
+struct_excessive_bools = "allow"
+fn_params_excessive_bools = "allow"
+match_same_arms = "allow"
+unreadable_literal = "allow"
+many_single_char_names = "allow"
+doc_markdown = "allow"
+must_use_candidate = "allow"
+return_self_not_must_use = "allow"
+used_underscore_binding = "allow"
+large_stack_arrays = "allow"
+inline_always = "allow"
diff --git a/crates/ghostty-apprt-gtk/build.rs b/crates/ghostty-apprt-gtk/build.rs
new file mode 100644
index 0000000..ce94ff3
--- /dev/null
+++ b/crates/ghostty-apprt-gtk/build.rs
@@ -0,0 +1,111 @@
+//! Compiles the blueprint UI files with `blueprint-compiler` and bundles them
+//! (plus CSS and icons) into a GResource, mirroring
+//! ZIG/src/apprt/gtk/build/{blueprint,gresource}.zig.
+
+#![allow(clippy::unwrap_used)]
+use std::fmt::Write as _;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+const PREFIX: &str = "/com/mitchellh/ghostty";
+const APP_ID: &str = "com.mitchellh.ghostty";
+const ICON_SIZES: &[u32] = &[16, 32, 128, 256, 512, 1024];
+const CSS: &[&str] = &[
+ "style.css",
+ "style-dark.css",
+ "style-hc.css",
+ "style-hc-dark.css",
+];
+// (major, minor, name). The inspector / imgui blueprints are deferred.
+const BLUEPRINTS: &[(u32, u32, &str)] = &[
+ (1, 0, "clipboard-confirmation-dialog"),
+ (1, 4, "clipboard-confirmation-dialog"),
+ (1, 2, "close-confirmation-dialog"),
+ (1, 2, "config-errors-dialog"),
+ (1, 2, "debug-warning"),
+ (1, 3, "debug-warning"),
+ (1, 2, "resize-overlay"),
+ (1, 2, "search-overlay"),
+ (1, 2, "key-state-overlay"),
+ (1, 5, "split-tree"),
+ (1, 5, "split-tree-split"),
+ (1, 2, "surface"),
+ (1, 5, "surface-scrolled-window"),
+ (1, 3, "surface-child-exited"),
+ (1, 5, "tab"),
+ (1, 5, "title-dialog"),
+ (1, 5, "window"),
+ (1, 5, "command-palette"),
+];
+
+fn main() {
+ let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
+ let out = PathBuf::from(std::env::var("OUT_DIR").unwrap());
+ let res = manifest.join("resources");
+ println!("cargo:rerun-if-changed=resources");
+ println!("cargo:rerun-if-changed=build.rs");
+
+ let mut xml = String::new();
+ xml.push_str("\n\n");
+
+ let _ = writeln!(xml, " ");
+ for name in CSS {
+ let _ = writeln!(
+ xml,
+ " css/{name}"
+ );
+ }
+ xml.push_str(" \n");
+
+ let _ = writeln!(xml, " ");
+ for &s in ICON_SIZES {
+ let _ = writeln!(
+ xml,
+ " icons/{s}.png"
+ );
+ let _ = writeln!(
+ xml,
+ " icons/{}.png",
+ s * 2
+ );
+ }
+ xml.push_str(" \n");
+
+ let _ = writeln!(xml, " ");
+ for &(major, minor, name) in BLUEPRINTS {
+ let rel = format!("{major}.{minor}/{name}");
+ let input = res.join(format!("ui/{rel}.blp"));
+ let output = out.join(format!("ui/{rel}.ui"));
+ compile_blueprint(&input, &output);
+ let _ = writeln!(xml, " ui/{rel}.ui");
+ }
+ xml.push_str(" \n\n");
+
+ let xml_path = out.join("ghostty.gresource.xml");
+ std::fs::write(&xml_path, xml).unwrap();
+
+ glib_build_tools::compile_resources(
+ &[out.as_path(), res.as_path()],
+ xml_path.to_str().unwrap(),
+ "ghostty.gresource",
+ );
+}
+
+fn compile_blueprint(input: &Path, output: &Path) {
+ std::fs::create_dir_all(output.parent().unwrap()).unwrap();
+ let st = Command::new("blueprint-compiler")
+ .arg("compile")
+ .arg("--output")
+ .arg(output)
+ .arg(input)
+ .output()
+ .unwrap_or_else(|e| {
+ panic!("`blueprint-compiler` (>= 0.16) is required to build ghostty-apprt-gtk: {e}")
+ });
+ assert!(
+ st.status.success(),
+ "blueprint-compiler failed for {}:\n{}",
+ input.display(),
+ String::from_utf8_lossy(&st.stderr)
+ );
+}
diff --git a/crates/ghostty-apprt-gtk/examples/demo.rs b/crates/ghostty-apprt-gtk/examples/demo.rs
new file mode 100644
index 0000000..fff865c
--- /dev/null
+++ b/crates/ghostty-apprt-gtk/examples/demo.rs
@@ -0,0 +1,347 @@
+//! Demo for the GTK app runtime with a dummy core.
+//!
+//! The dummy `CoreApp`/`CoreSurface` clear the `GLArea` with the configured
+//! background color and implement just enough key handling (keybindings from
+//! the user's config) to exercise windows, tabs, splits, dialogs, the command
+//! palette, fullscreen, etc.
+//!
+//! Environment:
+//! * `DEMO_QUIT_AFTER_MS=`: quit automatically after n milliseconds.
+//! * `DEMO_CONFIRM=1`: make every surface report "needs confirm quit".
+//!
+//! Run: `cargo run -p ghostty-apprt-gtk --example demo`
+
+#![allow(unsafe_code)] // GL calls through glow
+
+use std::cell::{Cell, RefCell};
+use std::rc::Rc;
+use std::sync::Arc;
+use std::time::Duration;
+
+use ghostty_apprt::action::{self, Action, Target};
+use ghostty_apprt::structs::{
+ ClipboardRequest, ColorScheme, ContentScale, CursorPos, ImePos, SurfaceSize,
+};
+use ghostty_apprt_gtk::app::App;
+use ghostty_apprt_gtk::core::{
+ AppMessage, CellSize, ClipboardError, CoreApp, CoreError, CoreSurface, DesiredFontSize,
+ InputEffect, KeyEvent, RtApp, RtSurface, SurfaceId,
+};
+use ghostty_config::config::Config;
+use ghostty_input::binding::set::Value as BindingValue;
+use ghostty_input::binding::{self, Action as Binding};
+use ghostty_input::{Mods, MouseButton, MouseButtonState, ScrollMods};
+use glow::HasContext;
+
+fn confirm() -> bool {
+ std::env::var("DEMO_CONFIRM").is_ok_and(|v| v == "1")
+}
+
+/// Map the subset of keybinding actions the demo understands to apprt actions.
+fn binding_to_action(b: &Binding) -> Option {
+ use binding::action::{CloseTabMode, SplitDirection, SplitFocusDirection};
+ Some(match b {
+ Binding::NewWindow => Action::NewWindow,
+ Binding::NewTab => Action::NewTab,
+ Binding::PreviousTab => Action::GotoTab(action::GotoTab::Previous),
+ Binding::NextTab => Action::GotoTab(action::GotoTab::Next),
+ Binding::LastTab => Action::GotoTab(action::GotoTab::Last),
+ Binding::GotoTab(n) => Action::GotoTab(action::GotoTab::Index(*n as i32)),
+ Binding::MoveTab(n) => Action::MoveTab(action::MoveTab { amount: *n }),
+ Binding::ToggleTabOverview => Action::ToggleTabOverview,
+ Binding::PromptSurfaceTitle => Action::PromptTitle(action::PromptTitle::Surface),
+ Binding::PromptTabTitle => Action::PromptTitle(action::PromptTitle::Tab),
+ Binding::NewSplit(d) => Action::NewSplit(match d {
+ SplitDirection::Right | SplitDirection::Auto => action::SplitDirection::Right,
+ SplitDirection::Down => action::SplitDirection::Down,
+ SplitDirection::Left => action::SplitDirection::Left,
+ SplitDirection::Up => action::SplitDirection::Up,
+ }),
+ Binding::GotoSplit(d) => Action::GotoSplit(match d {
+ SplitFocusDirection::Previous => action::GotoSplit::Previous,
+ SplitFocusDirection::Next => action::GotoSplit::Next,
+ SplitFocusDirection::Up => action::GotoSplit::Up,
+ SplitFocusDirection::Left => action::GotoSplit::Left,
+ SplitFocusDirection::Down => action::GotoSplit::Down,
+ SplitFocusDirection::Right => action::GotoSplit::Right,
+ }),
+ Binding::ToggleSplitZoom => Action::ToggleSplitZoom,
+ Binding::EqualizeSplits => Action::EqualizeSplits,
+ Binding::OpenConfig => Action::OpenConfig,
+ Binding::ReloadConfig => Action::ReloadConfig(action::ReloadConfig::default()),
+ Binding::CloseTab(m) => Action::CloseTab(match m {
+ CloseTabMode::This => action::CloseTabMode::This,
+ CloseTabMode::Other => action::CloseTabMode::Other,
+ CloseTabMode::Right => action::CloseTabMode::Right,
+ }),
+ Binding::CloseWindow | Binding::CloseSurface => Action::CloseWindow,
+ Binding::ToggleMaximize => Action::ToggleMaximize,
+ Binding::ToggleFullscreen => Action::ToggleFullscreen(action::Fullscreen::Native),
+ Binding::ToggleWindowDecorations => Action::ToggleWindowDecorations,
+ Binding::ToggleCommandPalette => Action::ToggleCommandPalette,
+ Binding::ToggleQuickTerminal => Action::ToggleQuickTerminal,
+ Binding::ShowGtkInspector => Action::ShowGtkInspector,
+ Binding::Quit => Action::Quit,
+ _ => return None,
+ })
+}
+
+// ---------------------------------------------------------------------
+
+struct DemoSurface {
+ id: SurfaceId,
+ config: RefCell>,
+ rt_app: Rc,
+ _rt_surface: Rc,
+ gl: RefCell