feat(core): port App/Surface and wire up the runnable ghostty binary

This is the integration step that turns the ported crates into a working
terminal: `cargo run` now opens a GTK window running the user's shell.

ghostty-core ports App.zig, Surface.zig (split into keyboard, mouse,
binding, clipboard and config submodules), surface_mouse.zig and
global.zig, and implements the runtime traits. perform_binding_action
handles every binding action.

Cross-crate integration:
- The core<->runtime traits (CoreApp, CoreSurface, RtApp, RtSurface,
  Waker) move from apprt-gtk into ghostty_apprt::runtime_api so the core
  stays runtime-agnostic; Scrollbar/RendererHealth stand-ins are replaced
  by the real terminal/renderer types.
- termio now uses ghostty_renderer::state::{State, SharedState}; the
  renderer calls kitty_update; image-storage-limit is applied at init
  and on config change.
- The search thread locks the terminal through a TerminalLock trait so
  it can share the render state mutex; termio's Wakeup gained a hook to
  wake the renderer thread.
- apprt-gtk: GL proc addresses are resolved through libepoxy's
  epoxy_glFoo pointer variables (plain glFoo symbols resolved to null),
  default DPI fixed to 96, libadwaita version read without GTK init so
  --version works headless.

The root binary dispatches +actions to ghostty-cli (list-fonts,
show-face and new-window are now wired) and otherwise runs the GTK app
with the real core.

Verified at runtime on Wayland and X11: shell prompt and colored output
render, child exit closes the window cleanly. Not yet verified: keyboard
and mouse input, clipboard, IME, tabs and splits (no key injection was
possible in this environment). GHOSTTY_DEBUG_SCREENSHOT=<png> dumps the
renderer's last frame for headless checks.

Test Plan:
- cargo test --workspace (2890 passed)
- cargo clippy --workspace --all-targets -- -D warnings
- GSETTINGS_BACKEND=memory GHOSTTY_RESOURCES_DIR=/usr/share/ghostty \
  cargo run
This commit is contained in:
ddidderr committed 2026-10-12 01:33:44 +02:00
1 parent 9c3eeeba50
commit 3266108c10
42 files changed
+9301 -179

No files matched your search

+6 -7
View File
@@ -19,14 +19,14 @@ use std::sync::Arc;
use std::time::Duration;
use ghostty_apprt::action::{self, Action, Target};
use ghostty_apprt::runtime_api::{
AppMessage, CellSize, ClipboardError, CoreApp, CoreError, CoreSurface, DesiredFontSize,
InputEffect, KeyEvent, RtApp, RtSurface, SurfaceId,
};
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};
@@ -204,9 +204,8 @@ impl CoreSurface for DemoSurface {
fn renderer_realized(&self) -> Result<(), CoreError> {
// SAFETY: the GLArea context is current while realizing; the loader
// resolves symbols through libepoxy which dispatches to it.
let gl = unsafe {
glow::Context::from_loader_function(ghostty_apprt_gtk::gl::proc_address)
};
let gl =
unsafe { glow::Context::from_loader_function(ghostty_apprt_gtk::gl::proc_address) };
*self.gl.borrow_mut() = Some(gl);
Ok(())
}
+13 -5
View File
@@ -6,12 +6,20 @@ use crate::gtk_version::Version;
pub const COMPTIME_VERSION: Version = (1, 5, 0);
/// Returns the runtime libadwaita version.
#[allow(unsafe_code)]
pub fn get_runtime_version() -> Version {
(
adw::major_version(),
adw::minor_version(),
adw::micro_version(),
)
// The safe `adw::major_version()` etc. assert that libadwaita was
// initialized, but the C functions don't need it (and `+version` has no
// display to initialize GTK with).
// SAFETY: these functions only return compile-time constants of the
// loaded library and have no preconditions.
unsafe {
(
adw::ffi::adw_get_major_version(),
adw::ffi::adw_get_minor_version(),
adw::ffi::adw_get_micro_version(),
)
}
}
/// Logs the build and runtime libadwaita version.
+1 -1
View File
@@ -14,7 +14,7 @@ use ghostty_apprt::action::{Action, Target};
use ghostty_apprt::ipc;
use crate::class::application::{Application, RunError};
use crate::core::{CoreApp, SurfaceId, Waker};
use ghostty_apprt::runtime_api::{CoreApp, SurfaceId, Waker};
/// This is detected by the Renderer, in which case it sends a
/// `redraw_surface` message so that we can call `drawFrame` ourselves from
@@ -41,9 +41,9 @@ use crate::class::split_tree::SplitTree;
use crate::class::surface::{Size, Surface};
use crate::class::tab::Tab;
use crate::class::window::{SelectTab, TabOverrides, Window};
use crate::core::{AppMessage, CoreApp, RtApp, SurfaceId};
use crate::css::CssProviders;
use crate::winproto;
use ghostty_apprt::runtime_api::{AppMessage, CoreApp, RtApp, SurfaceId};
/// Errors from [`Application::run_loop`].
#[derive(Debug, thiserror::Error)]
+26 -3
View File
@@ -5,7 +5,7 @@
//! scrolled window), embedding a `GtkGLArea` that the core renders into.
//!
//! The "apprt Surface" of Zig (`Surface.zig`, the object the core calls back
//! into) is [`RtSurfaceHandle`], which implements [`crate::core::RtSurface`]
//! into) is [`RtSurfaceHandle`], which implements [`ghostty_apprt::runtime_api::RtSurface`]
//! and holds only a weak reference to the widget.
use std::cell::{Cell, RefCell};
@@ -43,11 +43,11 @@ use crate::class::search_overlay::SearchOverlay;
use crate::class::surface_child_exited::SurfaceChildExited;
use crate::class::title_dialog::{self, TitleDialog};
use crate::class::window::Window;
use crate::core::{
use crate::key as gtk_key;
use ghostty_apprt::runtime_api::{
ClipboardError, CoreSurface, DesiredFontSize, InputEffect, MIN_WINDOW_HEIGHT_CELLS,
MIN_WINDOW_WIDTH_CELLS, RtSurface, SurfaceId,
};
use crate::key as gtk_key;
/// Simple dimensions struct for the surface used by various properties
/// (Zig: `Surface.Size`, GType `GhosttySurfaceSize`).
@@ -1834,6 +1834,29 @@ impl RtSurface for RtSurfaceHandle {
.unwrap_or_default()
}
fn gl_proc_address(&self, name: &str) -> *const std::ffi::c_void {
crate::gl::proc_address(name)
}
fn create_scope_fn(&self) -> Option<ghostty_apprt::runtime_api::CreateScopeFn> {
// GDBusConnection is thread-safe; the closure runs on the IO thread
// right after fork.
let dbus = gtk::gio::Application::default().and_then(|a| a.dbus_connection())?;
Some(std::sync::Arc::new(move |pid, memory_high, tasks_max| {
Some(
crate::cgroup::create_scope(
&dbus,
pid,
crate::cgroup::Options {
memory_high,
tasks_max,
},
)
.map_err(|e| e.to_string()),
)
}))
}
fn redraw_inspector(&self) {
if let Some(s) = self.gobj() {
s.redraw_inspector();
-282
View File
@@ -1,282 +0,0 @@
//! The trait boundary between this app runtime and `ghostty-core`.
//!
//! In Zig, `apprt/gtk` calls directly into `CoreApp` (App.zig) and
//! `CoreSurface` (Surface.zig). `ghostty-core` isn't ported yet (and it can't
//! be a dependency of this crate without a cycle once core implements these
//! traits), so the exact surface of what GTK needs is expressed here:
//!
//! * [`CoreApp`] / [`CoreSurface`]: implemented by ghostty-core, called by GTK.
//! * [`RtApp`] / [`RtSurface`]: implemented by this crate, handed to core so it
//! can call back (Zig: `rt_app.performAction`, `rt_surface.getSize()`, ...).
//!
//! Threading: everything here is **main-thread only** (`!Send`), like the GTK
//! objects behind it. The only cross-thread entry point is [`Waker`]
//! (`App.wakeup`), used by core threads to request an app `tick()`.
//!
//! See `docs/rewrite/notes/ghostty-apprt-gtk.md` for the full spec.
use std::rc::Rc;
use std::sync::Arc;
use ghostty_apprt::action::{Action, Target};
use ghostty_apprt::structs::{
Clipboard, ClipboardRequest, ColorScheme, ContentScale, CursorPos, ImePos, SurfaceSize,
};
use ghostty_config::config::Config;
use ghostty_input::{Mods, MouseButton, MouseButtonState, ScrollMods};
/// Identifier for a core surface (Zig: `*CoreSurface` pointer). Allocated by
/// this crate for each GTK surface; core stores it in its `Target::Surface`.
pub type SurfaceId = u64;
/// Minimum window size in cells (Zig: `CoreSurface.min_window_{width,height}_cells`).
pub const MIN_WINDOW_WIDTH_CELLS: u32 = 10;
/// See [`MIN_WINDOW_WIDTH_CELLS`].
pub const MIN_WINDOW_HEIGHT_CELLS: u32 = 4;
/// Result of `CoreSurface::key_callback` (Zig: `Surface.InputEffect`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputEffect {
/// The key was consumed by a binding that closed the surface; the
/// surface may no longer be valid.
Closed,
/// The key wasn't used.
Ignored,
/// The key was used (binding performed or sent to the pty).
Consumed,
}
/// Errors from `CoreSurface::complete_clipboard_request` that the GTK side
/// reacts to (Zig error set: `UnsafePaste`, `UnauthorizedPaste`).
#[derive(Debug, thiserror::Error)]
pub enum ClipboardError {
#[error("unsafe paste")]
UnsafePaste,
#[error("unauthorized paste")]
UnauthorizedPaste,
#[error("{0}")]
Other(String),
}
/// Generic core error (Zig: `anyerror`).
pub type CoreError = Box<dyn std::error::Error + Send + Sync>;
/// A message sent by the GTK side to the core app mailbox
/// (Zig: `core_app.mailbox.push`).
#[derive(Debug, Clone)]
pub enum AppMessage {
/// Open the config file in the default editor (`App.Message.open_config`).
OpenConfig,
/// `App.Message.new_window` (queued by `activate`; core responds with
/// `rt.perform_action(Target::App, Action::NewWindow)`).
NewWindow,
/// `surface_message{ surface, .present_surface }`; core validates the id.
PresentSurface(SurfaceId),
}
/// Desired font size request used when initializing a surface (Zig:
/// `font.face.DesiredSize` points).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DesiredFontSize {
pub points: f32,
pub xdpi: u16,
pub ydpi: u16,
}
/// Input to `CoreSurface::key_callback`: `ghostty_input::KeyEvent` is used
/// directly.
pub use ghostty_input::KeyEvent;
/// Cell metrics for a config+DPI combo (Zig: `font_grid_set.ref(..)` ->
/// `grid.cellSize()`), used to compute the default window size.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CellSize {
pub width: u32,
pub height: u32,
}
/// Implemented by ghostty-core's `App`.
pub trait CoreApp {
/// `App.tick`: drain the app mailbox and perform pending work. Core calls
/// back into `rt` (e.g. `rt.perform_action`). Called on the main thread
/// whenever a [`Waker`] wake was requested, and after config updates.
fn tick(&self, rt: &dyn RtApp) -> Result<(), CoreError>;
/// `App.updateConfig`: apply a new config to the app and all surfaces
/// (surfaces get `Message::ChangeConfig`). Returns Ok on success.
fn update_config(&self, rt: &dyn RtApp, config: &Arc<Config>) -> Result<(), CoreError>;
/// `App.needsConfirmQuit`: any surface with a running process?
fn needs_confirm_quit(&self) -> bool;
/// `App.colorSchemeEvent`: the system color scheme changed.
fn color_scheme_event(&self, rt: &dyn RtApp, scheme: ColorScheme);
/// `App.performAllAction`: perform `action` on every surface
/// (`rt.perform_action(Target::Surface(id), ..)` for each).
fn perform_all_action(
&self,
rt: &dyn RtApp,
action: &ghostty_input::binding::Action,
) -> Result<(), CoreError>;
/// `App.mailbox.push(.., .forever)`.
fn push_message(&self, msg: AppMessage);
/// `App.addSurface` + `Surface.init`: create a core surface for a GTK
/// surface. `config` is the (possibly modified) surface config. The
/// returned surface is owned by the GTK surface. `rt_surface` is the
/// back-reference core uses (Zig: `&priv.rt_surface`).
fn new_surface(
&self,
id: SurfaceId,
config: Config,
rt_app: Rc<dyn RtApp>,
rt_surface: Rc<dyn RtSurface>,
) -> Result<Rc<dyn CoreSurface>, CoreError>;
/// `App.deleteSurface`: called when the GTK surface is finalized, after
/// the `CoreSurface` was dropped.
fn delete_surface(&self, id: SurfaceId);
/// Ids of all live surfaces (`core_app.surfaces.items`).
fn surface_ids(&self) -> Vec<SurfaceId>;
/// Cell size for the given config and DPI, used for the default window
/// size (`font_grid_set.ref(..).cellSize()`); None if fonts fail.
fn cell_size(&self, config: &Config, font_size: DesiredFontSize) -> Option<CellSize>;
}
/// Implemented by ghostty-core's `Surface`. All methods take `&self`
/// (interior mutability inside core), main thread only.
pub trait CoreSurface {
/// The config the surface was created with / last updated to.
fn config(&self) -> Arc<Config>;
// ---- input -------------------------------------------------------
fn key_callback(&self, event: KeyEvent<'_>) -> Result<InputEffect, CoreError>;
/// `preeditCallback(null)` clears.
fn preedit_callback(&self, text: Option<&str>) -> Result<(), CoreError>;
fn focus_callback(&self, focused: bool) -> Result<(), CoreError>;
/// Returns true if the click was consumed.
fn mouse_button_callback(
&self,
state: MouseButtonState,
button: MouseButton,
mods: Mods,
) -> Result<bool, CoreError>;
/// `pos` is in surface (logical*scale) pixels; `(-1,-1)` = left window.
fn cursor_pos_callback(&self, pos: CursorPos, mods: Option<Mods>) -> Result<(), CoreError>;
fn scroll_callback(&self, xoff: f64, yoff: f64, mods: ScrollMods) -> Result<(), CoreError>;
fn mouse_pressure_callback(&self, _stage: ghostty_input::MousePressureStage, _pressure: f64) {}
fn size_callback(&self, size: SurfaceSize) -> Result<(), CoreError>;
fn content_scale_callback(&self, scale: ContentScale) -> Result<(), CoreError>;
/// IME candidate window position in surface pixels (`imePoint`).
fn ime_point(&self) -> ImePos;
fn has_selection(&self) -> bool;
/// `colorSchemeCallback`: the system color scheme changed.
fn color_scheme_callback(&self, scheme: ColorScheme) -> Result<(), CoreError>;
/// `Surface.updateConfig`: apply a new config to just this surface.
fn update_config(&self, config: &Arc<Config>) -> Result<(), CoreError>;
/// Perform a keybinding action on this surface; returns whether it was
/// performed.
fn perform_binding_action(
&self,
action: &ghostty_input::binding::Action,
) -> Result<bool, CoreError>;
// ---- clipboard ---------------------------------------------------
/// `completeClipboardRequest(req, data, confirmed)`.
fn complete_clipboard_request(
&self,
req: ClipboardRequest,
data: &str,
confirmed: bool,
) -> Result<(), ClipboardError>;
/// After the user confirms (`surface.config.clipboard_read = .allow`).
fn set_clipboard_read_allowed(&self, allow: bool);
/// See [`CoreSurface::set_clipboard_read_allowed`] (`clipboard_write`).
fn set_clipboard_write_allowed(&self, allow: bool);
// ---- lifecycle / rendering ----------------------------------------
/// Whether closing needs confirmation (`needsConfirmQuit`).
fn needs_confirm_quit(&self) -> bool;
/// Whether the child exited (`child_exited`) - used by close logic.
fn child_exited(&self) -> bool;
/// `renderer.displayRealized()`: GLArea realized, GL context current.
fn renderer_realized(&self) -> Result<(), CoreError>;
/// `renderer.displayUnrealized()`: GL context current (if it could be).
fn renderer_unrealized(&self);
/// `renderer.drawFrame(true)`: draw into the currently bound GLArea
/// framebuffer (GL context is current). `default_framebuffer` is the
/// GLArea's FBO id (`GL_FRAMEBUFFER_BINDING`).
fn draw_frame(&self, default_framebuffer: u32) -> Result<(), CoreError>;
// ---- misc state read by the surface widget (added by F5) -------------
/// The `SurfaceId` this core surface was created with (`new_surface`'s
/// `id`); lets the GTK side map a core surface back to its widget.
fn id(&self) -> Option<SurfaceId> {
None
}
/// `surface.readonly` (read-only mode state).
fn readonly(&self) -> bool {
false
}
/// `surface.size.grid()` -> (columns, rows); used by the resize overlay.
fn grid_size(&self) -> (u32, u32) {
(0, 0)
}
/// `surface.font_size` (inherited by child surfaces in `setParent`).
fn font_size(&self) -> DesiredFontSize {
DesiredFontSize {
points: 12.0,
xdpi: 96,
ydpi: 96,
}
}
// ---- inspector (deferred) -------------------------------------------
fn activate_inspector(&self) -> Result<(), CoreError> {
Ok(())
}
fn deactivate_inspector(&self) {}
}
/// Implemented by this crate (GTK `Application`); handed to core.
pub trait RtApp {
/// `App.performAction`. Returns whether the action was performed.
/// Runs on the main thread.
fn perform_action(&self, target: Target<SurfaceId>, action: Action) -> bool;
}
/// Implemented by this crate (GTK `Surface`); handed to core. Mirrors
/// `apprt/gtk/Surface.zig`.
pub trait RtSurface {
/// `close(process_active)`.
fn close(&self, process_active: bool);
/// Cgroup path of the surface (Linux, `gtk-single-instance` scopes).
fn cgroup(&self) -> Option<String>;
fn get_title(&self) -> Option<String>;
fn get_content_scale(&self) -> ContentScale;
fn get_size(&self) -> SurfaceSize;
fn get_cursor_pos(&self) -> CursorPos;
fn supports_clipboard(&self, clipboard: Clipboard) -> bool {
let _ = clipboard;
true
}
/// `clipboardRequest`; the answer arrives later through
/// `CoreSurface::complete_clipboard_request`. Returns true if started.
fn clipboard_request(&self, clipboard: Clipboard, state: ClipboardRequest) -> bool;
/// `setClipboard(type, contents, confirm)`; contents are (mime, data).
fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, String)], confirm: bool);
/// `defaultTermioEnv`: env vars to set for the child (GDK/GTK related
/// cleanups).
fn default_termio_env(&self) -> Vec<(String, String)>;
fn redraw_inspector(&self) {}
}
/// Thread-safe handle to wake the app thread (`App.wakeup`). Core's threads
/// call [`Waker::wake`]; the GTK main loop then runs `CoreApp::tick`.
pub trait Waker: Send + Sync {
fn wake(&self);
}
+16 -8
View File
@@ -1,8 +1,8 @@
//! OpenGL function loader for the `GLArea`.
//!
//! GTK uses libepoxy, which exports dispatching stubs for every GL entry
//! point and resolves them against whichever context (EGL or GLX) is current.
//! So `dlsym` on libepoxy gives us a loader that works on both Wayland and
//! GTK uses libepoxy, which exports a lazily-resolving function pointer for
//! every GL entry point (resolved against whichever context, EGL or GLX, is
//! current). So reading those pointers gives us a loader that works on both Wayland and
//! X11, which is what `ghostty-renderer`'s `prepare_context(|name| ..)` and
//! `glow::Context::from_loader_function` need. Call it only while the GLArea
//! context is current.
@@ -28,17 +28,25 @@ fn epoxy() -> Option<&'static libloading::Library> {
}
/// Resolve an OpenGL function by name (null if unavailable).
///
/// libepoxy does not export `glFoo` symbols; it exports a function pointer
/// *variable* `epoxy_glFoo` per entry point. The variable initially points
/// at a resolver stub which, on first call, resolves the real function for
/// the current context (EGL/GLX) and rewrites the variable. Reading the
/// variable therefore yields a callable function pointer.
#[must_use]
pub fn proc_address(name: &str) -> *const c_void {
let Some(lib) = epoxy() else {
return std::ptr::null();
};
// SAFETY: we only read the symbol's address; the library is kept alive
// for the whole process lifetime by the `static` above, so the returned
// pointer stays valid. Callers cast it to the correct GL signature.
let symbol = format!("epoxy_{name}");
// SAFETY: we read the pointer value stored in the exported variable; the
// library is kept alive for the whole process lifetime by the `static`
// above, so the returned pointer stays valid. Callers cast it to the
// correct GL signature.
#[allow(unsafe_code)]
unsafe {
lib.get::<*const c_void>(name.as_bytes())
.map_or(std::ptr::null(), |sym| *sym)
lib.get::<*const *const c_void>(symbol.as_bytes())
.map_or(std::ptr::null(), |sym| **sym)
}
}
-1
View File
@@ -5,7 +5,6 @@ pub mod adw_version;
pub mod app;
pub mod cgroup;
pub mod class;
pub mod core;
pub mod css;
pub mod ext;
pub mod flatpak;