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 e256c42e53
commit ad76ae2301
42 files changed
+9301 -179

No files matched your search

+2 -2
View File
@@ -8,7 +8,7 @@ use ghostty_terminal::terminal::Terminal;
use crate::exec::{self, Exec};
use crate::sinks::SurfaceMailbox;
use crate::state::RendererState;
use crate::state::SharedState;
use crate::termio::Output;
/// The kinds of backends.
@@ -51,7 +51,7 @@ impl Backend {
&mut self,
output: Arc<Output>,
surface_mailbox: Arc<dyn SurfaceMailbox>,
renderer_state: Arc<RendererState>,
renderer_state: SharedState,
) -> Result<ThreadData, exec::Error> {
match self {
Backend::Exec(e) => Ok(ThreadData::Exec(e.thread_enter(
+4 -4
View File
@@ -40,7 +40,7 @@ use crate::command::{
use crate::pty::{self, Mode, Pty, Winsize};
use crate::shell_integration::{self, EnvMap, Shell};
use crate::sinks::SurfaceMailbox;
use crate::state::RendererState;
use crate::state::{SharedState, SharedStateExt};
use crate::termio::Output;
use crate::wakeup::Wakeup;
use ghostty_apprt::surface::{ChildExited, Message as SurfaceMessage};
@@ -192,7 +192,7 @@ impl Exec {
&mut self,
output: Arc<Output>,
surface_mailbox: Arc<dyn SurfaceMailbox>,
renderer_state: Arc<RendererState>,
renderer_state: SharedState,
) -> Result<ThreadData, Error> {
// Start our subprocess
let (read_fd, write_fd) = self.subprocess.start()?;
@@ -382,7 +382,7 @@ pub struct ThreadData {
termios_mode: Mode,
surface_mailbox: Arc<dyn SurfaceMailbox>,
renderer_state: Arc<RendererState>,
renderer_state: SharedState,
}
impl ThreadData {
@@ -579,7 +579,7 @@ impl ThreadData {
// If our password input state changed on the terminal then
// we notify the surface.
let changed = {
let t = self.renderer_state.lock();
let t = self.renderer_state.lock_terminal();
t.flags.password_input != password_input
};
+1 -1
View File
@@ -42,7 +42,7 @@ pub use message::Message;
pub use options::Options;
pub use pty::{Pty, Winsize};
pub use sinks::{RendererMailbox, SurfaceMailbox};
pub use state::RendererState;
pub use state::{SharedState, SharedStateExt, State, TerminalGuard};
pub use stream_handler::StreamHandler;
pub use termio::{DerivedConfig, Termio};
pub use thread::Thread;
+39 -27
View File
@@ -1,37 +1,49 @@
//! The shared render state (`renderer/State.zig`, the part termio needs).
//! The shared render state (`renderer/State.zig`).
//!
//! In Zig the terminal is owned by `Termio` and `renderer.State` holds a
//! mutex plus a pointer to it. In Rust the terminal lives inside the mutex,
//! shared via `Arc` between the IO thread, the pty read thread, the
//! surface (core) thread and the renderer thread.
//!
//! TODO(integration): `renderer/State.zig` is deferred in ghostty-renderer;
//! when it lands, this type should move there (and gain `inspector`,
//! `preedit`, `mouse`).
//! The canonical type lives in `ghostty-renderer`
//! ([`ghostty_renderer::state::State`] behind a mutex,
//! [`SharedState`]). This module only provides a convenience for termio:
//! locking the state and borrowing just the [`Terminal`].
use std::sync::{Mutex, MutexGuard, PoisonError};
use std::ops::{Deref, DerefMut};
use std::sync::{MutexGuard, PoisonError};
pub use ghostty_renderer::state::{SharedState, State};
use ghostty_terminal::terminal::Terminal;
/// The state shared with the renderer.
#[derive(Debug)]
pub struct RendererState {
/// The terminal emulator state. Hold the lock for as short as possible;
/// it is contended by the pty read thread.
pub terminal: Mutex<Terminal>,
}
/// A locked [`SharedState`] that dereferences to its [`Terminal`].
pub struct TerminalGuard<'a>(MutexGuard<'a, State>);
impl RendererState {
pub fn new(terminal: Terminal) -> Self {
Self {
terminal: Mutex::new(terminal),
}
impl TerminalGuard<'_> {
/// The whole locked state (preedit, mouse, ...).
pub fn state(&mut self) -> &mut State {
&mut self.0
}
}
/// Lock the terminal. Lock poisoning is ignored: a panic on another
/// thread must not take the whole terminal down with it (Zig mutexes
/// don't poison).
pub fn lock(&self) -> MutexGuard<'_, Terminal> {
self.terminal.lock().unwrap_or_else(PoisonError::into_inner)
impl Deref for TerminalGuard<'_> {
type Target = Terminal;
fn deref(&self) -> &Terminal {
&self.0.terminal
}
}
impl DerefMut for TerminalGuard<'_> {
fn deref_mut(&mut self) -> &mut Terminal {
&mut self.0.terminal
}
}
/// Lock helpers for [`SharedState`].
pub trait SharedStateExt {
/// Lock the state and borrow the terminal. Lock poisoning is ignored: a
/// panic on another thread must not take the whole terminal down with
/// it (Zig mutexes don't poison).
fn lock_terminal(&self) -> TerminalGuard<'_>;
}
impl SharedStateExt for SharedState {
fn lock_terminal(&self) -> TerminalGuard<'_> {
TerminalGuard(self.lock().unwrap_or_else(PoisonError::into_inner))
}
}
+42 -24
View File
@@ -12,10 +12,10 @@
//! * [`Termio`] is owned by the IO thread (`Thread::thread_main` takes it by
//! value) and holds the backend, derived config and cached size.
//! * [`Output`] (an `Arc`, reachable through [`Termio::output`]) is the part
//! the pty read thread needs: the shared [`RendererState`], the VT stream and
//! the pty read thread needs: the shared [`SharedState`], the VT stream and
//! the renderer/termio notification handles. `Termio::process_output` is
//! just `output.process_output`.
//! * The `Terminal` itself lives in [`RendererState`]
//! * The `Terminal` itself lives in [`SharedState`]
//! (`Arc<Mutex<Terminal>>`), shared with the renderer like Zig's
//! `renderer.State`.
//!
@@ -34,12 +34,14 @@ use ghostty_config::types_b::{ClipboardAccess, GraphemeWidthMethod, OSCColorRepo
use ghostty_datastruct::blocking_queue::Timeout;
use ghostty_renderer::message::Message as RendererMessage;
use ghostty_renderer::size::Size;
use ghostty_terminal::kitty::graphics_storage::TerminalInfo;
use ghostty_terminal::point::{Coordinate, Point};
use ghostty_terminal::screen::Scroll;
use ghostty_terminal::screen_set::Key as ScreenKey;
use ghostty_terminal::terminal::{Colors, Options as TerminalOptions, ScrollViewport, Terminal};
use ghostty_vt::color::{DynamicPalette, DynamicRgb, Palette};
use ghostty_vt::csi;
use ghostty_vt::kitty::graphics_command::Delete as KittyDelete;
use ghostty_vt::modes::{Mode, ModePacked};
use std::sync::MutexGuard;
@@ -49,7 +51,7 @@ use crate::mailbox::Mailbox;
use crate::message::{Message, SizeReport};
use crate::options::Options;
use crate::sinks::{RendererMailbox, SurfaceMailbox};
use crate::state::RendererState;
use crate::state::{SharedState, SharedStateExt, State};
use crate::stream_handler::{
HandlerOptions, HandlerState, TerminalStream, cursor_style_from_config,
};
@@ -201,7 +203,7 @@ struct OutputInner {
/// The part of termio used from the pty read thread: parses the pty output
/// and applies it to the shared terminal.
pub struct Output {
state: Arc<RendererState>,
state: SharedState,
inner: Mutex<OutputInner>,
renderer_mailbox: Arc<dyn RendererMailbox>,
mailbox: Mailbox,
@@ -220,7 +222,7 @@ impl Output {
// We are modifying terminal state from here on out and we need
// the lock to grab our read data.
let outbox = {
let mut term = self.state.lock();
let mut term = self.state.lock_terminal();
let mut inner = self.inner();
self.process_output_locked(&mut term, &mut inner, buf);
@@ -275,7 +277,7 @@ pub struct Termio {
config: DerivedConfig,
/// The shared render state (contains the terminal).
renderer_state: Arc<RendererState>,
renderer_state: SharedState,
/// The part shared with the pty read thread.
output: Arc<Output>,
@@ -309,7 +311,7 @@ pub struct Termio {
/// the termio thread. As such, a lock is not necessary.
pub struct ThreadData {
/// The shared render state
pub renderer_state: Arc<RendererState>,
pub renderer_state: SharedState,
/// Mailboxes for different threads
pub surface_mailbox: Arc<dyn SurfaceMailbox>,
@@ -371,8 +373,7 @@ impl Termio {
})?;
// Set the image size limits
// TODO(integration): `screen.kitty_images.setLimit(config.image_storage_limit)`
// once ghostty-terminal ports kitty `ImageStorage` (currently a stub).
set_image_limits(&mut term, config.image_storage_limit);
// Set our default cursor style
term.screen_mut().cursor.cursor_style = cursor_style_from_config(config.cursor_style);
@@ -399,7 +400,7 @@ impl Termio {
let thread_enter_state = ThreadEnterState::create(full_config);
let renderer_state = Arc::new(RendererState::new(term));
let renderer_state = State::new(term).into_shared();
let output = Arc::new(Output {
state: Arc::clone(&renderer_state),
inner: Mutex::new(OutputInner {
@@ -427,7 +428,7 @@ impl Termio {
/// The shared render state (it holds the terminal). Hand this to the
/// renderer.
pub fn renderer_state(&self) -> &Arc<RendererState> {
pub fn renderer_state(&self) -> &SharedState {
&self.renderer_state
}
@@ -522,7 +523,7 @@ impl Termio {
// The remainder of this function is modifying terminal state or
// the read thread data, all of which requires holding the renderer
// state lock.
let mut term = self.renderer_state.lock();
let mut term = self.renderer_state.lock_terminal();
// Update our stream handler. The stream handler uses the same
// renderer mutex so this is safe to do despite being executed
@@ -546,7 +547,7 @@ impl Termio {
term.colors.cursor.default = config.cursor_color.and_then(TerminalColor::to_terminal_rgb);
// Set the image size limits
// TODO(integration): kitty_images.setLimit (see `init`).
set_image_limits(&mut term, config.image_storage_limit);
self.config = config;
}
@@ -562,7 +563,7 @@ impl Termio {
// Enter the critical area that we want to keep small
{
let state = Arc::clone(&self.renderer_state);
let mut term = state.lock();
let mut term = state.lock_terminal();
self.output().inner().stream.state.size = size;
// Update the size of our terminal state
@@ -594,7 +595,7 @@ impl Termio {
/// Make a size report.
pub fn size_report(&mut self, td: &mut ThreadData, style: SizeReport) {
let state = Arc::clone(&self.renderer_state);
let _term = state.lock();
let _term = state.lock_terminal();
self.size_report_locked(td, style);
}
@@ -625,7 +626,7 @@ impl Termio {
/// Reset the synchronized output mode. This is usually called by timer
/// expiration from the termio thread.
pub fn reset_synchronized_output(&mut self) {
let mut term = self.renderer_state.lock();
let mut term = self.renderer_state.lock_terminal();
term.modes.set(Mode::SynchronizedOutput, false);
if let Err(err) = self.renderer_wakeup.notify() {
log::warn!("failed to notify renderer err={err}");
@@ -635,7 +636,7 @@ impl Termio {
/// Clear the screen.
pub fn clear_screen(&mut self, td: &mut ThreadData, history: bool) {
{
let mut term = self.renderer_state.lock();
let mut term = self.renderer_state.lock_terminal();
// If we're on the alternate screen, we do not clear. Since this is an
// emulator-level screen clear, this messes up the running programs
@@ -672,9 +673,11 @@ impl Termio {
// graphics that are placed above the cursor or if it deletes
// all of them. We delete all of them for now but if this behavior
// isn't fully correct we should fix this later.
// TODO(integration): `kitty_images.delete(.{ .all = true })`
// once kitty image storage is ported; only mark dirty for now.
term.screen_mut().kitty_images.dirty = true;
let info = TerminalInfo::from_terminal(&term);
let screen = term.screen_mut();
screen
.kitty_images
.delete(&mut screen.pages, &info, KittyDelete::All(true));
return;
}
@@ -690,13 +693,13 @@ impl Termio {
/// Scroll the viewport
pub fn scroll_viewport(&mut self, scroll: ScrollViewport) {
self.renderer_state.lock().scroll_viewport(scroll);
self.renderer_state.lock_terminal().scroll_viewport(scroll);
}
/// Jump the viewport to the prompt.
pub fn jump_to_prompt(&mut self, delta: isize) {
{
let mut term = self.renderer_state.lock();
let mut term = self.renderer_state.lock_terminal();
term.screen_mut().scroll(Scroll::DeltaPrompt(delta));
}
@@ -707,7 +710,11 @@ impl Termio {
/// Called when focus is gained or lost (when focus events are enabled)
pub fn focus_gained(&mut self, td: &mut ThreadData, focused: bool) {
let focus_event = self.renderer_state.lock().modes.get(Mode::FocusEvent);
let focus_event = self
.renderer_state
.lock_terminal()
.modes
.get(Mode::FocusEvent);
// If we have focus events enabled, we send the focus event.
if focus_event {
@@ -727,7 +734,7 @@ impl Termio {
/// Sends a DSR response for the current color scheme to the pty.
pub fn color_scheme_report(&mut self, td: &mut ThreadData, force: bool) {
let state = Arc::clone(&self.renderer_state);
let term = state.lock();
let term = state.lock_terminal();
self.color_scheme_report_locked(&term, td, force);
}
@@ -747,3 +754,14 @@ impl Termio {
self.queue_write(td, output, false);
}
}
/// Apply `image-storage-limit` to every initialized screen.
fn set_image_limits(term: &mut Terminal, limit: usize) {
for key in [ScreenKey::Primary, ScreenKey::Alternate] {
if let Some(screen) = term.screens.get_mut(key)
&& let Err(err) = screen.kitty_images.set_limit(&mut screen.pages, limit)
{
log::warn!("failed to set kitty image storage limit: {err}");
}
}
}
+2 -1
View File
@@ -31,6 +31,7 @@ use rustix::event::{PollFd, PollFlags};
use crate::backend::ThreadData as BackendThreadData;
use crate::exec;
use crate::message::Message;
use crate::state::SharedStateExt;
use crate::termio::{Error, Termio, ThreadData};
use crate::wakeup::Wakeup;
@@ -376,7 +377,7 @@ impl Thread {
/// in some way but this works for now. Without this, the user would
/// just see a blank terminal window.
fn report_start_error(io: &Termio, err: &Error) {
let mut t = io.renderer_state().lock();
let mut t = io.renderer_state().lock_terminal();
// Hide the cursor
t.modes.set(Mode::CursorVisible, false);
+21 -3
View File
@@ -12,17 +12,35 @@ use std::sync::Arc;
use rustix::event::{EventfdFlags, eventfd};
/// See the module docs. Cloning shares the same underlying eventfd.
#[derive(Clone, Debug)]
pub struct Wakeup(Arc<OwnedFd>);
#[derive(Clone)]
pub struct Wakeup(Arc<OwnedFd>, Option<Arc<dyn Fn() + Send + Sync>>);
impl std::fmt::Debug for Wakeup {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("Wakeup").field(&self.0).finish()
}
}
impl Wakeup {
pub fn new() -> io::Result<Wakeup> {
let fd = eventfd(0, EventfdFlags::CLOEXEC | EventfdFlags::NONBLOCK)?;
Ok(Wakeup(Arc::new(fd)))
Ok(Wakeup(Arc::new(fd), None))
}
/// A wakeup that calls `hook` instead of signalling an eventfd. Used to
/// bridge into waiters that are not `poll(2)`-based (the renderer
/// thread's channel). `as_fd`/`drain` are meaningless for these.
pub fn with_hook(hook: Arc<dyn Fn() + Send + Sync>) -> io::Result<Wakeup> {
let fd = eventfd(0, EventfdFlags::CLOEXEC | EventfdFlags::NONBLOCK)?;
Ok(Wakeup(Arc::new(fd), Some(hook)))
}
/// Wake up the waiter. Multiple notifications coalesce.
pub fn notify(&self) -> io::Result<()> {
if let Some(hook) = &self.1 {
hook();
return Ok(());
}
loop {
match rustix::io::write(&*self.0, &1u64.to_ne_bytes()) {
Ok(_) => return Ok(()),