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(()),
+19 -14
View File
@@ -18,12 +18,13 @@ use ghostty_config::types_b::{LinuxCgroup, ShellIntegration};
use ghostty_datastruct::blocking_queue::Timeout;
use ghostty_renderer::message::Message as RendererMessage;
use ghostty_renderer::size::{CellSize, Padding, ScreenSize, Size};
use ghostty_termio::SharedStateExt;
use ghostty_termio::backend::{self, Backend};
use ghostty_termio::exec;
use ghostty_termio::message::Message;
use ghostty_termio::termio::DerivedConfig;
use ghostty_termio::{
Mailbox, Options, RendererMailbox, RendererState, SurfaceMailbox, Termio, Thread, Wakeup,
Mailbox, Options, RendererMailbox, SharedState, SurfaceMailbox, Termio, Thread, Wakeup,
};
struct SurfaceRec(Sender<SurfaceMessage>);
@@ -69,7 +70,7 @@ fn size(cols: u32, rows: u32) -> Size {
}
struct Fixture {
state: Arc<RendererState>,
state: SharedState,
mailbox: Mailbox,
stop: Wakeup,
thread: Option<JoinHandle<()>>,
@@ -136,7 +137,7 @@ fn sh(script: &str) -> ConfigCommand {
impl Fixture {
fn text(&self) -> String {
self.state.lock().plain_string().unwrap()
self.state.lock_terminal().plain_string().unwrap()
}
fn send(&self, msg: Message) {
@@ -218,7 +219,7 @@ fn printf_output_reaches_terminal_and_exit_is_reported() {
// The SGR was applied: the cursor pen is red (palette 1).
{
let t = f.state.lock();
let t = f.state.lock_terminal();
let style = t.screen().cursor.style;
assert!(
!matches!(style.fg_color, ghostty_terminal::style::Color::None),
@@ -308,7 +309,7 @@ fn linefeed_mode_translates_cr() {
f.wait_text("a^M");
// cat -v got "a\r\n", so it printed a newline after the ^M.
f.wait_for("cursor on second row", |f| {
(f.state.lock().screen().cursor.y == 1).then_some(())
(f.state.lock_terminal().screen().cursor.y == 1).then_some(())
});
f.stop();
}
@@ -346,7 +347,7 @@ fn missing_input_file_shows_error_and_thread_keeps_draining() {
f.wait_text("A configured `input` path was not found");
assert!(
!f.state
.lock()
.lock_terminal()
.modes
.get(ghostty_vt::modes::Mode::CursorVisible)
);
@@ -366,7 +367,7 @@ fn resize_messages_are_coalesced_and_applied_to_the_pty() {
f.send(Message::Resize(size(100, 40)));
f.wait_text("40 100");
{
let t = f.state.lock();
let t = f.state.lock_terminal();
assert_eq!((t.cols, t.rows), (100, 40));
assert_eq!(t.width_px, 1000);
assert_eq!(t.height_px, 800);
@@ -385,7 +386,7 @@ fn synchronized_output_is_force_reset_by_timer() {
let mut f = start(sh("printf '\\033[?2026h'; sleep 5"), |_| {});
f.wait_for("sync output on", |f| {
f.state
.lock()
.lock_terminal()
.modes
.get(ghostty_vt::modes::Mode::SynchronizedOutput)
.then_some(())
@@ -393,7 +394,7 @@ fn synchronized_output_is_force_reset_by_timer() {
let started = Instant::now();
f.wait_for("sync output reset", |f| {
(!f.state
.lock()
.lock_terminal()
.modes
.get(ghostty_vt::modes::Mode::SynchronizedOutput))
.then_some(())
@@ -462,7 +463,7 @@ fn focus_events_are_sent_when_enabled() {
);
f.wait_for("focus mode", |f| {
f.state
.lock()
.lock_terminal()
.modes
.get(ghostty_vt::modes::Mode::FocusEvent)
.then_some(())
@@ -529,7 +530,7 @@ fn writes_after_exit_are_ignored_and_thread_stays_alive_until_stopped() {
f.send(Message::Focused(true));
f.send(Message::Resize(size(90, 25)));
f.wait_for("resize after exit", |f| {
(f.state.lock().cols == 90).then_some(())
(f.state.lock_terminal().cols == 90).then_some(())
});
f.stop();
}
@@ -542,11 +543,11 @@ fn change_config_message_updates_palette_and_colors() {
cfg.enquiry_response = "x".into();
f.send(Message::ChangeConfig(Box::new(cfg)));
f.wait_for("background change", |f| {
let t = f.state.lock();
let t = f.state.lock_terminal();
(t.colors.background.default == Some(ghostty_vt::color::Rgb { r: 1, g: 2, b: 3 }))
.then_some(())
});
assert!(f.state.lock().flags.dirty.palette);
assert!(f.state.lock_terminal().flags.dirty.palette);
f.stop();
}
@@ -571,7 +572,11 @@ fn process_output_manual_api() {
termio.process_output(b"manual\x1b[31m!");
assert_eq!(
"manual!",
termio.renderer_state().lock().plain_string().unwrap()
termio
.renderer_state()
.lock_terminal()
.plain_string()
.unwrap()
);
termio.process_output(b"\x1b[5n");
let m = termio.mailbox().pop().unwrap();