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

@@ -0,0 +1,401 @@
//! Clipboard handling for a surface (Zig `Surface.zig`: `clipboardWrite`,
//! `copySelectionToClipboards`, clipboard requests, paste, OSC 52 and
//! desktop notifications).
use std::hash::{Hash, Hasher};
use std::rc::Rc;
use std::sync::Arc;
use std::time::{Duration, Instant};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use ghostty_apprt::action::{Action as RtAction, DesktopNotification};
use ghostty_apprt::runtime_api::ClipboardError;
use ghostty_apprt::structs::{Clipboard, ClipboardRequest};
use ghostty_config::types_b::ClipboardAccess;
use ghostty_input::binding::CopyToClipboard;
use ghostty_input::paste;
use ghostty_terminal::formatter::{self, Format, ScreenFormatter};
use ghostty_terminal::selection::Selection;
use ghostty_terminal::terminal::Terminal;
use ghostty_termio::message::Message as IoMessage;
use ghostty_vt::modes::Mode;
use super::Surface;
use super::config::DerivedConfig;
/// `input.paste.Options.fromTerminal`.
#[must_use]
pub fn paste_options_from_terminal(t: &Terminal) -> paste::Options {
paste::Options {
bracketed: t.modes.get(Mode::BracketedPaste),
}
}
impl Surface {
/// Handle an OSC 52 write: `data` is base64 (Zig `clipboardWrite`).
pub(crate) fn clipboard_write(&self, data: &[u8], loc: Clipboard) {
let cfg = self.cfg();
if cfg.clipboard_write == ClipboardAccess::Deny {
log::info!(
"application attempted to write clipboard, but 'clipboard-write' is set to deny"
);
return;
}
// Decode. Zig tolerates a missing-padding error ("It is possible to
// actually have valid data and get this error, so we allow it"), so
// decode leniently without requiring padding.
let lenient = base64::engine::GeneralPurpose::new(
&base64::alphabet::STANDARD,
base64::engine::GeneralPurposeConfig::new()
.with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent),
);
let Ok(decoded) = lenient.decode(data) else {
log::info!("application sent invalid base64 data for OSC 52");
return;
};
let text = String::from_utf8_lossy(&decoded).into_owned();
// When clipboard-write is "ask" a prompt is displayed to the user
// asking them to confirm the clipboard access. Each app runtime
// handles this differently.
let confirm = cfg.clipboard_write == ClipboardAccess::Ask;
self.rt_surface
.set_clipboard(loc, &[("text/plain".to_owned(), text)], confirm);
}
/// Format the selection and put it on the given clipboards (Zig
/// `copySelectionToClipboards`). The caller holds the terminal lock.
pub(crate) fn copy_selection_to_clipboards(
&self,
t: &Terminal,
sel: Selection,
clipboards: &[Clipboard],
format: CopyToClipboard,
) {
let cfg = self.cfg();
// The options we'll use for all formatting. We'll just override the
// emit format.
let codepoint_map: Vec<formatter::CodepointMap> = cfg
.clipboard_codepoint_map
.map
.list
.iter()
.map(|e| formatter::CodepointMap {
range: e.range,
replacement: match &e.replacement {
ghostty_config::clipboard_codepoint_map::Replacement::Codepoint(c) => {
formatter::Replacement::Codepoint(*c)
}
ghostty_config::clipboard_codepoint_map::Replacement::String(s) => {
formatter::Replacement::String(s.clone())
}
},
})
.collect();
let opts = formatter::Options {
emit: Format::Plain, // We'll override this below
unwrap: true,
trim: cfg.clipboard_trim_trailing_spaces,
codepoint_map: Some(codepoint_map),
background: t.colors.background.get(),
foreground: t.colors.foreground.get(),
palette: Some(t.colors.palette.current),
};
let run = |opts: formatter::Options| -> Option<String> {
let mut f = ScreenFormatter::new(t.screen(), opts);
f.content = formatter::Content::Selection(Some(sel));
match f.format_string() {
Ok(s) => Some(s),
Err(err) => {
log::error!("error formatting selection for clipboard err={err:?}");
None
}
}
};
let mut contents: Vec<(String, String)> = Vec::new();
match format {
CopyToClipboard::Plain => {
let Some(s) = run(opts) else { return };
contents.push(("text/plain".to_owned(), s));
}
CopyToClipboard::Vt => {
let mut o = opts;
o.emit = Format::Vt;
// Note: We don't apply codepoint mappings to VT format since
// it contains escape sequences that should be preserved
// as-is
let Some(s) = run(o) else { return };
contents.push(("text/plain".to_owned(), s));
}
CopyToClipboard::Html => {
let mut o = opts;
o.emit = Format::Html;
// Note: We don't apply codepoint mappings to HTML format
// since HTML has its own character encoding and entity
// system
let Some(s) = run(o) else { return };
contents.push(("text/html".to_owned(), s));
}
CopyToClipboard::Mixed => {
// First, generate plain text with codepoint mappings applied
let Some(plain) = run(opts.clone()) else {
return;
};
contents.push(("text/plain".to_owned(), plain));
// Second, generate HTML without codepoint mappings
let mut o = opts;
o.emit = Format::Html;
// We purposely don't emit background/foreground for mixed
// mode because the HTML contents is often used for rich text
// input and with trimmed spaces it looks pretty bad.
o.background = None;
o.foreground = None;
let Some(html) = run(o) else { return };
contents.push(("text/html".to_owned(), html));
}
}
for clipboard in clipboards {
self.rt_surface.set_clipboard(*clipboard, &contents, false);
}
}
/// Start a clipboard request, with some basic validation. For example,
/// an OSC 52 request is not actually requested if OSC 52 is disabled.
///
/// Returns true if the request was started, false if it was not (e.g.,
/// clipboard doesn't contain text for paste requests). This allows
/// performable keybinds to pass through when the action cannot be
/// performed.
pub(crate) fn start_clipboard_request(&self, loc: Clipboard, req: ClipboardRequest) -> bool {
match req {
ClipboardRequest::Paste => {} // always allowed
ClipboardRequest::Osc52Read(_) => {
if self.cfg().clipboard_read == ClipboardAccess::Deny {
log::info!(
"application attempted to read clipboard, but 'clipboard-read' is set to deny"
);
return false;
}
}
// No clipboard write code paths travel through this function
ClipboardRequest::Osc52Write(_) => unreachable!(),
}
self.rt_surface.clipboard_request(loc, req)
}
/// The runtime answers a clipboard request (Zig:
/// `completeClipboardRequest`).
pub fn complete_clipboard_request(
&self,
req: ClipboardRequest,
data: &str,
confirmed: bool,
) -> Result<(), ClipboardError> {
match req {
ClipboardRequest::Paste => self.complete_clipboard_paste(data.as_bytes(), confirmed),
ClipboardRequest::Osc52Read(clipboard) => {
self.complete_clipboard_read_osc52(data.as_bytes(), clipboard, confirmed)
}
ClipboardRequest::Osc52Write(clipboard) => {
self.rt_surface.set_clipboard(
clipboard,
&[("text/plain".to_owned(), data.to_owned())],
!confirmed,
);
Ok(())
}
}
}
/// Paste text (also the target of `text_callback`).
pub(crate) fn complete_clipboard_paste(
&self,
data: &[u8],
allow_unsafe: bool,
) -> Result<(), ClipboardError> {
if data.is_empty() {
return Ok(());
}
let cfg = self.cfg();
let encode_opts = {
let mut t = self.lock_terminal();
let opts = paste_options_from_terminal(&t);
// If we have paste protection enabled, we detect unsafe pastes
// and return an error. The error approach allows apprt to
// attempt to complete the paste before falling back to
// requesting confirmation.
//
// We do not do this for bracketed pastes because bracketed
// pastes are by definition safe since they're framed.
let unsafe_paste = 'unsafe_paste: {
// If we've disabled paste protection then we always allow
// the paste.
if !cfg.clipboard_paste_protection {
break 'unsafe_paste false;
}
// If we're allowed to paste unsafe data then we always allow
// the paste. This is set during confirmation usually.
if allow_unsafe {
break 'unsafe_paste false;
}
if opts.bracketed {
// If we're bracketed and the paste contains and ending
// bracket then something naughty might be going on and
// we never trust it.
if data.windows(6).any(|w| w == b"\x1b[201~") {
break 'unsafe_paste true;
}
// If we are bracketed and configured to trust that then
// the paste is not unsafe.
if cfg.clipboard_paste_bracketed_safe {
break 'unsafe_paste false;
}
}
!paste::is_safe(data)
};
if unsafe_paste {
log::info!("potentially unsafe paste detected, rejecting until confirmation");
return Err(ClipboardError::UnsafePaste);
}
// With the lock held, we must scroll to the bottom. We always
// scroll to the bottom for these inputs.
self.scroll_to_bottom(&mut t);
opts
};
// Encode the data. In most cases this doesn't require any copies,
// so we optimize for that case.
let mut data_duped: Vec<u8>;
let vecs = match paste::encode(data, encode_opts) {
Ok(v) => v,
Err(paste::Error::MutableRequired) => {
data_duped = data.to_vec();
paste::encode_mut(&mut data_duped, encode_opts)
}
};
for vec in vecs {
if !vec.is_empty() {
self.queue_io(IoMessage::write_req(vec));
}
}
Ok(())
}
fn complete_clipboard_read_osc52(
&self,
data: &[u8],
clipboard_type: Clipboard,
confirmed: bool,
) -> Result<(), ClipboardError> {
let cfg = self.cfg();
// We should never get here if clipboard-read is set to deny
debug_assert_ne!(cfg.clipboard_read, ClipboardAccess::Deny);
// If clipboard-read is set to ask and we haven't confirmed with the
// user, do that now
if cfg.clipboard_read == ClipboardAccess::Ask && !confirmed {
return Err(ClipboardError::UnauthorizedPaste);
}
// Even if the clipboard data is empty we reply, since presumably the
// client app is expecting a reply.
let kind = match clipboard_type {
Clipboard::Standard => 'c',
Clipboard::Selection => 's',
Clipboard::Primary => 'p',
};
// Wrap our data with the OSC code
let mut buf = format!("\x1b]52;{kind};").into_bytes();
buf.extend_from_slice(STANDARD.encode(data).as_bytes());
buf.extend_from_slice(b"\x1b\\");
self.queue_io(IoMessage::write_req(&buf));
Ok(())
}
/// Set `clipboard-read` / `clipboard-write` to a fixed value for this
/// surface (the runtime calls this after the user chose "remember").
pub(crate) fn set_config_clipboard(&self, read: bool, access: ClipboardAccess) {
let mut config = (*self.cfg().config).clone();
if read {
config.clipboard_read = access;
} else {
config.clipboard_write = access;
}
match DerivedConfig::new(Arc::new(config)) {
Ok(d) => *self.config.borrow_mut() = Rc::new(d),
Err(err) => log::warn!("failed to update clipboard access err={err}"),
}
}
/// Show a desktop notification, rate limited (Zig
/// `showDesktopNotification`).
pub(crate) fn show_desktop_notification(&self, title: &str, body: &str) {
let now = Instant::now();
// Set a limit of one desktop notification per second so that the OS
// doesn't kill us when we run out of resources.
let last = self.app.last_notification_time.get();
if let Some(last) = last
&& now.duration_since(last) < Duration::from_secs(1)
{
log::warn!("rate limiting desktop notifications");
return;
}
let new_digest = {
let mut hash = std::collections::hash_map::DefaultHasher::new();
title.hash(&mut hash);
body.hash(&mut hash);
hash.finish()
};
// Set a limit of one notification per five seconds for desktop
// notifications with identical content.
if let Some(last) = last
&& self.app.last_notification_digest.get() == new_digest
&& now.duration_since(last) < Duration::from_secs(5)
{
log::warn!("suppressing identical desktop notification");
return;
}
self.app.last_notification_time.set(Some(now));
self.app.last_notification_digest.set(new_digest);
self.perform(RtAction::DesktopNotification(DesktopNotification {
title: title.to_owned(),
body: body.to_owned(),
}));
}
/// `textCallback`: text input that is pasted verbatim.
pub fn text_callback(&self, text: &str) -> Result<(), ClipboardError> {
self.complete_clipboard_paste(text.as_bytes(), true)
}
}