Resolves the 9 gap groups in docs/rewrite/notes/review-rv7-codex.md and
audits every string-typed config field against its consumers
(field -> consumers -> decode status table in final-Z9.md):
- Windows EnvMap keys use a port of Zig's nls.zig uppercase table
(upcaseW); Windows locale init follows Zig's chain (setlocale +
_putenv_s); malformed WTF-8 now fails process launch conversions like
Zig instead of substituting U+FFFD; kitty file paths convert WTF-8.
- bash --rcfile/--init-file, configured TERM and inherited GTK LANG
keep their original bytes.
- C API clipboard completion and ghostty_surface_binding_action take
bytes; complete_clipboard_request is byte-typed through the core.
- text:/csi:/esc: bindings, enquiry-response, input = path:, embedded
initial_input, search needles, titles, link regexes, clipboard
codepoint map, command palette text and GTK font/namespace values
decode the escape encoding before use.
Remaining by design: D-Bus a{ss} values and +new-window arguments are
UTF-8 by D-Bus type. C API key/preedit text follows separately.
Test Plan:
- cargo test --workspace
- just check-macos (both), just check-windows, just check-wasm
2254 lines
72 KiB
Rust
2254 lines
72 KiB
Rust
//! The embedded application runtime (ZIG/src/apprt/embedded.zig): the
|
|
//! runtime side of `ghostty-core` implemented over the C callbacks of
|
|
//! `ghostty_runtime_config_s`, plus the `ghostty_app_*` / `ghostty_surface_*`
|
|
//! exports.
|
|
//!
|
|
//! Everything here is main-thread only, like the Zig embedded runtime: the
|
|
//! host calls the C API from its UI thread. The only cross-thread entry is
|
|
//! the `wakeup` callback, invoked by core threads through [`WakerImpl`].
|
|
//!
|
|
//! Handles: `ghostty_app_t` is a `Box<AppHandle>` and `ghostty_surface_t` a
|
|
//! `Box<Surface>`. The address of the surface object doubles as the
|
|
//! `SurfaceId` core knows the surface by, and as the `ghostty_surface_t` in
|
|
//! `ghostty_target_s` of actions (Zig: `target.cval()` gives `rt_surface`).
|
|
#![allow(unsafe_code)]
|
|
|
|
use std::{
|
|
cell::{Cell, OnceCell, RefCell},
|
|
collections::HashMap,
|
|
ffi::{CStr, CString, c_char, c_int, c_void},
|
|
fmt::Write as _,
|
|
rc::{Rc, Weak},
|
|
sync::Arc,
|
|
};
|
|
|
|
use ghostty_apprt::{
|
|
action::{Action, GotoSplit, ResizeSplit, ResizeSplitDirection, SplitDirection, Target},
|
|
runtime_api::{CoreApp, CoreSurface, RtApp, RtSurface, SurfaceId, Waker},
|
|
structs::{Clipboard, ClipboardRequest, ColorScheme, ContentScale, CursorPos, SurfaceSize},
|
|
surface::{NewSurfaceContext, new_config, should_inherit_working_directory},
|
|
};
|
|
use ghostty_config::{command::Command, config::Config, io::ReadableIO, types_a::WorkingDirectory};
|
|
use ghostty_core::{App as CoreAppImpl, Surface as CoreSurfaceImpl, surface::binding::Text};
|
|
use ghostty_input::{
|
|
Action as KeyAction,
|
|
Key,
|
|
KeyEvent,
|
|
Mods,
|
|
MouseButton,
|
|
MouseButtonState,
|
|
MousePressureStage,
|
|
ScrollMods,
|
|
binding::Action as BindingAction,
|
|
keycodes,
|
|
};
|
|
use ghostty_os::osbytes;
|
|
use ghostty_terminal::{
|
|
point::{Coordinate, Point, Tag as PointTag},
|
|
selection::Selection,
|
|
terminal::Terminal,
|
|
};
|
|
|
|
use crate::{
|
|
action_c::{Keep, action_to_c, target_to_c},
|
|
config_capi::cconfig,
|
|
main_c::{alloc_c_text, free_c_text},
|
|
types::{
|
|
ghostty_app_t,
|
|
ghostty_binding_flags_e,
|
|
ghostty_clipboard_content_s,
|
|
ghostty_config_t,
|
|
ghostty_input_key_s,
|
|
ghostty_point_s,
|
|
ghostty_runtime_config_s,
|
|
ghostty_selection_s,
|
|
ghostty_surface_config_s,
|
|
ghostty_surface_size_s,
|
|
ghostty_surface_t,
|
|
ghostty_text_s,
|
|
platform,
|
|
},
|
|
};
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Waker
|
|
|
|
/// `App.wakeup`: invoked from any core thread; calls the host's `wakeup_cb`.
|
|
struct WakerImpl {
|
|
cb: unsafe extern "C" fn(*mut c_void),
|
|
userdata: usize,
|
|
}
|
|
|
|
impl Waker for WakerImpl {
|
|
fn wake(&self) {
|
|
// SAFETY: the embedder promises `wakeup_cb` may be called from any
|
|
// thread with the `userdata` it supplied (the documented contract of
|
|
// `ghostty_runtime_wakeup_cb`).
|
|
unsafe { (self.cb)(self.userdata as *mut c_void) };
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Runtime app
|
|
|
|
/// The runtime side of the app (Zig: `embedded.App`), handed to core as
|
|
/// `Rc<dyn RtApp>`.
|
|
pub struct AppRt {
|
|
opts: ghostty_runtime_config_s,
|
|
/// The `ghostty_app_t` passed to the action callback.
|
|
app_ptr: Cell<usize>,
|
|
/// The configuration for the app (a clone, owned by the app).
|
|
config: RefCell<Arc<Config>>,
|
|
surfaces: RefCell<HashMap<SurfaceId, Weak<SurfaceRt>>>,
|
|
keymap: RefCell<ghostty_input::Keymap>,
|
|
}
|
|
|
|
impl AppRt {
|
|
fn surface_rt(&self, id: SurfaceId) -> Option<Rc<SurfaceRt>> {
|
|
self.surfaces.borrow().get(&id).and_then(Weak::upgrade)
|
|
}
|
|
|
|
/// Special case certain actions before they are sent to the embedder
|
|
/// (Zig: `performPreAction`).
|
|
fn pre_action(&self, target: &Target<SurfaceId>, action: &Action) {
|
|
match (action, target) {
|
|
(Action::SetTitle(t), Target::Surface(id)) => {
|
|
if let Some(s) = self.surface_rt(*id) {
|
|
*s.title.borrow_mut() = Some(t.title.clone());
|
|
}
|
|
}
|
|
|
|
// For app updates, we update our core config. The caller owns
|
|
// the param so we clone it.
|
|
(Action::ConfigChange(c), Target::App) => {
|
|
*self.config.borrow_mut() = Arc::clone(&c.config);
|
|
}
|
|
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Reload the keyboard layout (`App.reloadKeymap`).
|
|
fn reload_keymap(&self) {
|
|
if let Err(err) = self.keymap.borrow_mut().reload() {
|
|
log::error!("error reloading keyboard map err={err:?}");
|
|
}
|
|
}
|
|
}
|
|
|
|
impl RtApp for AppRt {
|
|
fn perform_action(&self, target: Target<SurfaceId>, action: Action) -> bool {
|
|
self.pre_action(&target, &action);
|
|
|
|
log::debug!(
|
|
"dispatching action target={target:?} action={:?}",
|
|
action.key()
|
|
);
|
|
let Some(cb) = self.opts.action_cb else {
|
|
return false;
|
|
};
|
|
let mut keep = Keep::default();
|
|
let t = target_to_c(&target);
|
|
let a = action_to_c(&action, &mut keep);
|
|
// SAFETY: the embedder supplied the callback; it receives the app
|
|
// handle it knows plus plain-data structs whose pointers stay valid
|
|
// (`keep`) for the duration of the call.
|
|
unsafe { cb(self.app_ptr.get() as ghostty_app_t, t, a) }
|
|
}
|
|
|
|
fn keyboard_layout(&self) -> ghostty_input::KeyboardLayout {
|
|
keyboard_layout(&self.keymap.borrow())
|
|
}
|
|
}
|
|
|
|
/// `App.keyboardLayout`: we only support keyboard layout detection on macOS.
|
|
#[cfg(target_os = "macos")]
|
|
fn keyboard_layout(keymap: &ghostty_input::Keymap) -> ghostty_input::KeyboardLayout {
|
|
keymap
|
|
.source_id()
|
|
.and_then(|id| ghostty_input::KeyboardLayout::map_apple_id(&id))
|
|
.unwrap_or(ghostty_input::KeyboardLayout::Unknown)
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
#[allow(clippy::trivially_copy_pass_by_ref)]
|
|
fn keyboard_layout(_keymap: &ghostty_input::Keymap) -> ghostty_input::KeyboardLayout {
|
|
ghostty_input::KeyboardLayout::Unknown
|
|
}
|
|
|
|
/// The app object behind `ghostty_app_t`.
|
|
pub struct AppHandle {
|
|
core: Rc<CoreAppImpl>,
|
|
rt: Rc<AppRt>,
|
|
}
|
|
|
|
/// Borrow the app behind a handle.
|
|
///
|
|
/// # Safety
|
|
/// `p` must be null or a live handle from `ghostty_app_new`.
|
|
unsafe fn app<'a>(p: ghostty_app_t) -> Option<&'a AppHandle> {
|
|
// SAFETY: per the function contract.
|
|
unsafe { p.cast::<AppHandle>().as_ref() }
|
|
}
|
|
|
|
/// Borrow a C string.
|
|
///
|
|
/// # Safety
|
|
/// `p` must be null or NUL terminated.
|
|
unsafe fn cstr<'a>(p: *const c_char) -> Option<&'a CStr> {
|
|
if p.is_null() {
|
|
None
|
|
} else {
|
|
// SAFETY: per the function contract.
|
|
Some(unsafe { CStr::from_ptr(p) })
|
|
}
|
|
}
|
|
|
|
fn mods_from_c(v: c_int) -> Mods {
|
|
// `@truncate(@as(c_uint, @bitCast(mods)))` to the u16 backing.
|
|
Mods::from_int(v as u32 as u16)
|
|
}
|
|
|
|
/// An owned key event converted from the C struct (Zig: `KeyEvent.core`).
|
|
struct OwnedKey {
|
|
action: KeyAction,
|
|
key: Key,
|
|
mods: Mods,
|
|
consumed_mods: Mods,
|
|
composing: bool,
|
|
text: String,
|
|
unshifted_codepoint: u32,
|
|
}
|
|
|
|
impl OwnedKey {
|
|
/// `None` if the action is invalid.
|
|
///
|
|
/// # Safety
|
|
/// `text` must be null or NUL terminated.
|
|
unsafe fn from_c(e: &ghostty_input_key_s) -> Option<OwnedKey> {
|
|
let action = match e.action {
|
|
0 => KeyAction::Release,
|
|
1 => KeyAction::Press,
|
|
2 => KeyAction::Repeat,
|
|
_ => return None,
|
|
};
|
|
// SAFETY: per the function contract.
|
|
let text = unsafe { cstr(e.text) }
|
|
.map(|c| c.to_string_lossy().into_owned())
|
|
.unwrap_or_default();
|
|
|
|
// We want to get the physical unmapped key to process keybinds.
|
|
let key = keycodes::entries()
|
|
.iter()
|
|
.find(|entry| entry.native == e.keycode)
|
|
.map_or(Key::Unidentified, |entry| entry.key);
|
|
|
|
// The unshifted codepoint is a `u21`; anything larger is 0.
|
|
let unshifted_codepoint = if e.unshifted_codepoint <= 0x1F_FFFF {
|
|
e.unshifted_codepoint
|
|
} else {
|
|
0
|
|
};
|
|
|
|
Some(OwnedKey {
|
|
action,
|
|
key,
|
|
mods: mods_from_c(e.mods),
|
|
consumed_mods: mods_from_c(e.consumed_mods),
|
|
composing: e.composing,
|
|
text,
|
|
unshifted_codepoint,
|
|
})
|
|
}
|
|
|
|
fn event(&self) -> KeyEvent<'_> {
|
|
KeyEvent {
|
|
action: self.action,
|
|
key: self.key,
|
|
mods: self.mods,
|
|
consumed_mods: self.consumed_mods,
|
|
composing: self.composing,
|
|
utf8: &self.text,
|
|
unshifted_codepoint: self.unshifted_codepoint,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// App exports
|
|
|
|
/// Create a new app.
|
|
///
|
|
/// # Safety
|
|
/// `opts` and `config` must be valid pointers (the config a handle from
|
|
/// `ghostty_config_new`/`_clone`).
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_app_new(
|
|
opts: *const ghostty_runtime_config_s,
|
|
config: ghostty_config_t,
|
|
) -> ghostty_app_t {
|
|
// SAFETY: per the function contract.
|
|
let (Some(opts), Some(config)) = (unsafe { opts.as_ref() }, unsafe { cconfig(config) }) else {
|
|
log::error!("error initializing app err=NullArgument");
|
|
return std::ptr::null_mut();
|
|
};
|
|
let Some(wakeup) = opts.wakeup_cb else {
|
|
log::error!("error initializing app err=MissingWakeupCallback");
|
|
return std::ptr::null_mut();
|
|
};
|
|
|
|
let waker = Arc::new(WakerImpl {
|
|
cb: wakeup,
|
|
userdata: opts.userdata as usize,
|
|
});
|
|
let core = match CoreAppImpl::new(waker) {
|
|
Ok(c) => Rc::new(c),
|
|
Err(err) => {
|
|
log::error!("error initializing app err={err}");
|
|
return std::ptr::null_mut();
|
|
}
|
|
};
|
|
let keymap = match ghostty_input::Keymap::new() {
|
|
Ok(k) => k,
|
|
Err(err) => {
|
|
log::error!("error initializing app err={err:?}");
|
|
return std::ptr::null_mut();
|
|
}
|
|
};
|
|
|
|
// We have to clone the config.
|
|
let rt = Rc::new(AppRt {
|
|
opts: *opts,
|
|
app_ptr: Cell::new(0),
|
|
config: RefCell::new(Arc::new(config.config.clone())),
|
|
surfaces: RefCell::default(),
|
|
keymap: RefCell::new(keymap),
|
|
});
|
|
let ptr = Box::into_raw(Box::new(AppHandle { core, rt }));
|
|
// SAFETY: just allocated.
|
|
unsafe { &*ptr }.rt.app_ptr.set(ptr as usize);
|
|
ptr.cast::<c_void>()
|
|
}
|
|
|
|
/// Tick the event loop. This should be called whenever the "wakeup" callback
|
|
/// is invoked for the runtime.
|
|
///
|
|
/// # Safety
|
|
/// `v` must be a live app handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_app_tick(v: ghostty_app_t) {
|
|
// SAFETY: per the function contract.
|
|
let Some(a) = (unsafe { app(v) }) else {
|
|
return;
|
|
};
|
|
if let Err(err) = a.core.tick(&*a.rt) {
|
|
log::error!("error app tick err={err}");
|
|
}
|
|
}
|
|
|
|
/// Return the userdata associated with the app.
|
|
///
|
|
/// # Safety
|
|
/// `v` must be a live app handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_app_userdata(v: ghostty_app_t) -> *mut c_void {
|
|
// SAFETY: per the function contract.
|
|
unsafe { app(v) }.map_or(std::ptr::null_mut(), |a| a.rt.opts.userdata)
|
|
}
|
|
|
|
/// Free the app.
|
|
///
|
|
/// # Safety
|
|
/// `v` must be a live app handle with no surfaces left; it is invalid
|
|
/// afterwards.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_app_free(v: ghostty_app_t) {
|
|
if v.is_null() {
|
|
return;
|
|
}
|
|
// SAFETY: created by `ghostty_app_new` via `Box::into_raw`.
|
|
drop(unsafe { Box::from_raw(v.cast::<AppHandle>()) });
|
|
}
|
|
|
|
/// Update the focused state of the app.
|
|
///
|
|
/// # Safety
|
|
/// `app` must be a live app handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_app_set_focus(handle: ghostty_app_t, focused: bool) {
|
|
// SAFETY: per the function contract.
|
|
if let Some(a) = unsafe { app(handle) } {
|
|
a.core.focus_event(focused);
|
|
}
|
|
}
|
|
|
|
/// Notify the app of a global keypress capture. This will return true if the
|
|
/// key was captured by the app, in which case the caller should not process
|
|
/// the key.
|
|
///
|
|
/// # Safety
|
|
/// `handle` must be a live app handle; `event.text` null or NUL terminated.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_app_key(
|
|
handle: ghostty_app_t,
|
|
event: ghostty_input_key_s,
|
|
) -> bool {
|
|
// SAFETY: per the function contract.
|
|
let Some(a) = (unsafe { app(handle) }) else {
|
|
return false;
|
|
};
|
|
// SAFETY: per the function contract.
|
|
let Some(key) = (unsafe { OwnedKey::from_c(&event) }) else {
|
|
return false;
|
|
};
|
|
// Never hold the borrow across the call: actions call into the host.
|
|
let config = Arc::clone(&a.rt.config.borrow());
|
|
a.core.key_event(&*a.rt, &config, &key.event())
|
|
}
|
|
|
|
/// Returns true if the given key event would trigger a binding if it were
|
|
/// sent to the surface right now. The "right now" is important because
|
|
/// things like trigger sequences are only valid until the next key event.
|
|
///
|
|
/// # Safety
|
|
/// As [`ghostty_app_key`].
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_app_key_is_binding(
|
|
handle: ghostty_app_t,
|
|
event: ghostty_input_key_s,
|
|
) -> bool {
|
|
// SAFETY: per the function contract.
|
|
let Some(a) = (unsafe { app(handle) }) else {
|
|
return false;
|
|
};
|
|
// SAFETY: per the function contract.
|
|
let Some(key) = (unsafe { OwnedKey::from_c(&event) }) else {
|
|
log::warn!("error processing key event");
|
|
return false;
|
|
};
|
|
let config = Arc::clone(&a.rt.config.borrow());
|
|
a.core.key_event_is_binding(&config, &key.event())
|
|
}
|
|
|
|
/// Notify the app that the keyboard was changed. This causes the keyboard
|
|
/// layout to be reloaded from the OS.
|
|
///
|
|
/// # Safety
|
|
/// `v` must be a live app handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_app_keyboard_changed(v: ghostty_app_t) {
|
|
// SAFETY: per the function contract.
|
|
if let Some(a) = unsafe { app(v) } {
|
|
a.rt.reload_keymap();
|
|
}
|
|
}
|
|
|
|
/// Open the configuration.
|
|
///
|
|
/// # Safety
|
|
/// `v` must be a live app handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_app_open_config(v: ghostty_app_t) {
|
|
// SAFETY: per the function contract.
|
|
if let Some(a) = unsafe { app(v) } {
|
|
a.rt.perform_action(Target::App, Action::OpenConfig);
|
|
}
|
|
}
|
|
|
|
/// Update the configuration to the provided config. This will propagate to
|
|
/// all surfaces as well.
|
|
///
|
|
/// # Safety
|
|
/// `v` a live app handle, `config` a config handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_app_update_config(v: ghostty_app_t, config: ghostty_config_t) {
|
|
// SAFETY: per the function contract.
|
|
let (Some(a), Some(c)) = (unsafe { app(v) }, unsafe { cconfig(config) }) else {
|
|
return;
|
|
};
|
|
let cfg = Arc::new(c.config.clone());
|
|
if let Err(err) = a.core.update_config(&*a.rt, &cfg) {
|
|
log::error!("error updating config err={err}");
|
|
}
|
|
}
|
|
|
|
/// Returns true if the app needs to confirm quitting.
|
|
///
|
|
/// # Safety
|
|
/// `v` must be a live app handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_app_needs_confirm_quit(v: ghostty_app_t) -> bool {
|
|
// SAFETY: per the function contract.
|
|
unsafe { app(v) }.is_some_and(|a| a.core.needs_confirm_quit())
|
|
}
|
|
|
|
/// Returns true if the app has global keybinds.
|
|
///
|
|
/// # Safety
|
|
/// `v` must be a live app handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_app_has_global_keybinds(v: ghostty_app_t) -> bool {
|
|
use ghostty_input::binding::set::Value;
|
|
// SAFETY: per the function contract.
|
|
let Some(a) = (unsafe { app(v) }) else {
|
|
return false;
|
|
};
|
|
let config = a.rt.config.borrow();
|
|
config
|
|
.keybind
|
|
.set
|
|
.bindings
|
|
.values()
|
|
.any(|value| match value {
|
|
Value::Leader(_) => false,
|
|
Value::Leaf(leaf) => leaf.flags.global,
|
|
Value::LeafChained(leaf) => leaf.flags.global,
|
|
})
|
|
}
|
|
|
|
fn color_scheme_from_c(v: c_int) -> Option<ColorScheme> {
|
|
match v {
|
|
0 => Some(ColorScheme::Light),
|
|
1 => Some(ColorScheme::Dark),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Update the color scheme of the app.
|
|
///
|
|
/// # Safety
|
|
/// `v` must be a live app handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_app_set_color_scheme(v: ghostty_app_t, scheme_raw: c_int) {
|
|
// SAFETY: per the function contract.
|
|
let Some(a) = (unsafe { app(v) }) else {
|
|
return;
|
|
};
|
|
let Some(scheme) = color_scheme_from_c(scheme_raw) else {
|
|
log::warn!("invalid color scheme to ghostty_app_set_color_scheme value={scheme_raw}");
|
|
return;
|
|
};
|
|
a.core.color_scheme_event(&*a.rt, scheme);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Runtime surface
|
|
|
|
/// The runtime side of a surface (Zig: `embedded.Surface` fields that core
|
|
/// reads), handed to core as `Rc<dyn RtSurface>`.
|
|
pub struct SurfaceRt {
|
|
app: Rc<AppRt>,
|
|
id: SurfaceId,
|
|
userdata: *mut c_void,
|
|
/// The `NSView` / `UIView` of the surface.
|
|
view: *mut c_void,
|
|
content_scale: Cell<ContentScale>,
|
|
size: Cell<SurfaceSize>,
|
|
cursor_pos: Cell<CursorPos>,
|
|
/// The current title of the surface. The embedded apprt saves this so
|
|
/// that `get_title` works without the implementer needing to save it.
|
|
title: RefCell<Option<String>>,
|
|
}
|
|
|
|
impl SurfaceRt {
|
|
fn target(&self) -> Target<SurfaceId> {
|
|
Target::Surface(self.id)
|
|
}
|
|
}
|
|
|
|
impl RtSurface for SurfaceRt {
|
|
fn close(&self, process_active: bool) {
|
|
let Some(cb) = self.app.opts.close_surface_cb else {
|
|
log::info!("runtime embedder does not support closing a surface");
|
|
return;
|
|
};
|
|
// SAFETY: embedder supplied callback with its own userdata.
|
|
unsafe { cb(self.userdata, process_active) };
|
|
}
|
|
|
|
fn cgroup(&self) -> Option<String> {
|
|
None
|
|
}
|
|
|
|
fn get_title(&self) -> Option<String> {
|
|
self.title.borrow().clone()
|
|
}
|
|
|
|
fn get_content_scale(&self) -> ContentScale {
|
|
self.content_scale.get()
|
|
}
|
|
|
|
fn get_size(&self) -> SurfaceSize {
|
|
self.size.get()
|
|
}
|
|
|
|
fn get_cursor_pos(&self) -> CursorPos {
|
|
self.cursor_pos.get()
|
|
}
|
|
|
|
fn supports_clipboard(&self, clipboard: Clipboard) -> bool {
|
|
match clipboard {
|
|
Clipboard::Standard => true,
|
|
Clipboard::Selection | Clipboard::Primary => self.app.opts.supports_selection_clipboard,
|
|
}
|
|
}
|
|
|
|
fn clipboard_request(&self, clipboard: Clipboard, state: ClipboardRequest) -> bool {
|
|
// We need to allocate to get a pointer to store our clipboard
|
|
// request so that it is stable until the read_clipboard callback
|
|
// and call complete_clipboard_request. This sucks but clipboard
|
|
// requests aren't high throughput so it's probably fine.
|
|
let Some(cb) = self.app.opts.read_clipboard_cb else {
|
|
return false;
|
|
};
|
|
let state_ptr = Box::into_raw(Box::new(state));
|
|
// SAFETY: embedder callback; `state_ptr` stays valid until
|
|
// `ghostty_surface_complete_clipboard_request` or we reclaim it.
|
|
let started = unsafe {
|
|
cb(
|
|
self.userdata,
|
|
c_int::from(clipboard as u8),
|
|
state_ptr.cast::<c_void>(),
|
|
)
|
|
};
|
|
if !started {
|
|
// SAFETY: allocated above and not handed over.
|
|
drop(unsafe { Box::from_raw(state_ptr) });
|
|
return false;
|
|
}
|
|
true
|
|
}
|
|
|
|
fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, Vec<u8>)], confirm: bool) {
|
|
let Some(cb) = self.app.opts.write_clipboard_cb else {
|
|
return;
|
|
};
|
|
let owned: Vec<(CString, CString)> = contents
|
|
.iter()
|
|
.map(|(m, d)| {
|
|
(
|
|
CString::new(m.replace('\0', "")).unwrap_or_default(),
|
|
CString::new(d.iter().copied().filter(|b| *b != 0).collect::<Vec<u8>>())
|
|
.unwrap_or_default(),
|
|
)
|
|
})
|
|
.collect();
|
|
let array: Vec<ghostty_clipboard_content_s> = owned
|
|
.iter()
|
|
.map(|(m, d)| ghostty_clipboard_content_s {
|
|
mime: m.as_ptr(),
|
|
data: d.as_ptr(),
|
|
})
|
|
.collect();
|
|
// SAFETY: embedder callback; the array and strings outlive the call.
|
|
unsafe {
|
|
cb(
|
|
self.userdata,
|
|
c_int::from(clipboard as u8),
|
|
array.as_ptr(),
|
|
array.len(),
|
|
confirm,
|
|
);
|
|
}
|
|
}
|
|
|
|
fn default_termio_env(&self) -> Vec<(std::ffi::OsString, std::ffi::OsString)> {
|
|
let mut env = ghostty_os::env::get_env_map();
|
|
|
|
if cfg!(target_vendor = "apple") {
|
|
if env.contains_key("__XCODE_BUILT_PRODUCTS_DIR_PATHS") {
|
|
for k in [
|
|
"__XCODE_BUILT_PRODUCTS_DIR_PATHS",
|
|
"__XPC_DYLD_LIBRARY_PATH",
|
|
"DYLD_FRAMEWORK_PATH",
|
|
"DYLD_INSERT_LIBRARIES",
|
|
"DYLD_LIBRARY_PATH",
|
|
"LD_LIBRARY_PATH",
|
|
"SECURITYSESSIONID",
|
|
"XPC_SERVICE_NAME",
|
|
] {
|
|
env.remove(k);
|
|
}
|
|
}
|
|
|
|
// Remove this so that running `ghostty` within Ghostty works.
|
|
env.remove("GHOSTTY_MAC_LAUNCH_SOURCE");
|
|
|
|
// If we were launched from the desktop then we want to remove
|
|
// the LANGUAGE env var so that we don't inherit our translation
|
|
// settings for Ghostty. If we aren't from the desktop then we
|
|
// didn't set our LANGUAGE var so we don't need to remove it.
|
|
if launched_from_desktop() {
|
|
env.remove("LANGUAGE");
|
|
}
|
|
}
|
|
|
|
env.into_iter().collect()
|
|
}
|
|
|
|
fn redraw_inspector(&self) {
|
|
// `queueInspectorRender`
|
|
self.app
|
|
.perform_action(self.target(), Action::RenderInspector);
|
|
}
|
|
|
|
fn platform_view(&self) -> *mut c_void {
|
|
self.view
|
|
}
|
|
|
|
#[cfg(feature = "test-platform")]
|
|
fn gl_proc_address(&self, name: &str) -> *const c_void {
|
|
test_platform::proc_address(name)
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn launched_from_desktop() -> bool {
|
|
ghostty_os::desktop::launched_from_desktop_lib()
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
fn launched_from_desktop() -> bool {
|
|
ghostty_os::desktop::launched_from_desktop()
|
|
}
|
|
|
|
/// The surface object behind `ghostty_surface_t`.
|
|
pub struct Surface {
|
|
app: Rc<AppHandle>,
|
|
rt: Rc<SurfaceRt>,
|
|
core: OnceCell<Rc<CoreSurfaceImpl>>,
|
|
inspector: RefCell<Option<Box<crate::inspector::Inspector>>>,
|
|
/// Strings handed out by `ghostty_surface_inherited_config`; they stay
|
|
/// valid until the surface is freed.
|
|
inherited_wd: RefCell<Vec<CString>>,
|
|
}
|
|
|
|
impl Surface {
|
|
fn core(&self) -> Option<&Rc<CoreSurfaceImpl>> {
|
|
self.core.get()
|
|
}
|
|
|
|
pub(crate) fn inspector_slot(&self) -> &RefCell<Option<Box<crate::inspector::Inspector>>> {
|
|
&self.inspector
|
|
}
|
|
|
|
pub(crate) fn core_surface(&self) -> Option<&Rc<CoreSurfaceImpl>> {
|
|
self.core()
|
|
}
|
|
|
|
pub(crate) fn rt(&self) -> &Rc<SurfaceRt> {
|
|
&self.rt
|
|
}
|
|
}
|
|
|
|
impl Drop for Surface {
|
|
fn drop(&mut self) {
|
|
// Shut down our inspector
|
|
if let Some(mut i) = self.inspector.get_mut().take() {
|
|
i.deinit(self.core.get().map(Rc::as_ref));
|
|
}
|
|
|
|
// Remove ourselves from the list of known surfaces in the app.
|
|
self.app.core.delete_surface(self.rt.id);
|
|
self.app.rt.surfaces.borrow_mut().remove(&self.rt.id);
|
|
|
|
// Clean up our core surface so that all the rendering and IO stop.
|
|
drop(self.core.take());
|
|
}
|
|
}
|
|
|
|
/// Borrow the surface behind a handle.
|
|
///
|
|
/// # Safety
|
|
/// `p` must be null or a live handle from `ghostty_surface_new`.
|
|
pub(crate) unsafe fn surface<'a>(p: ghostty_surface_t) -> Option<&'a Surface> {
|
|
// SAFETY: per the function contract.
|
|
unsafe { p.cast::<Surface>().as_ref() }
|
|
}
|
|
|
|
/// `ghostty_surface_config_new`.
|
|
#[unsafe(no_mangle)]
|
|
pub extern "C" fn ghostty_surface_config_new() -> ghostty_surface_config_s {
|
|
ghostty_surface_config_s {
|
|
platform_tag: platform::INVALID,
|
|
platform: crate::types::ghostty_platform_u {
|
|
macos: crate::types::ghostty_platform_macos_s {
|
|
nsview: std::ptr::null_mut(),
|
|
},
|
|
},
|
|
userdata: std::ptr::null_mut(),
|
|
scale_factor: 1.0,
|
|
font_size: 0.0,
|
|
working_directory: std::ptr::null(),
|
|
command: std::ptr::null(),
|
|
env_vars: std::ptr::null_mut(),
|
|
env_var_count: 0,
|
|
initial_input: std::ptr::null(),
|
|
wait_after_command: false,
|
|
context: 0,
|
|
}
|
|
}
|
|
|
|
fn context_from_c(v: c_int) -> NewSurfaceContext {
|
|
match v {
|
|
1 => NewSurfaceContext::Tab,
|
|
2 => NewSurfaceContext::Split,
|
|
_ => NewSurfaceContext::Window,
|
|
}
|
|
}
|
|
|
|
/// Test seam (feature `test-platform`): a fake platform whose OpenGL loader
|
|
/// the test installs, so surfaces can be created on Linux.
|
|
#[cfg(feature = "test-platform")]
|
|
pub mod test_platform {
|
|
use std::{cell::RefCell, ffi::c_void, rc::Rc};
|
|
|
|
/// The `platform_tag` that selects the fake platform.
|
|
pub const PLATFORM_TAG: i32 = 1000;
|
|
|
|
/// A GL function loader.
|
|
pub type GlLoader = Rc<dyn Fn(&str) -> *const c_void>;
|
|
|
|
thread_local! {
|
|
static LOADER: RefCell<Option<GlLoader>> = const { RefCell::new(None) };
|
|
}
|
|
|
|
/// Install the GL loader for surfaces created on this thread.
|
|
pub fn set_gl_loader(loader: Option<GlLoader>) {
|
|
LOADER.with(|l| *l.borrow_mut() = loader);
|
|
}
|
|
|
|
pub(super) fn proc_address(name: &str) -> *const c_void {
|
|
LOADER.with(|l| l.borrow().as_ref().map_or(std::ptr::null(), |f| f(name)))
|
|
}
|
|
}
|
|
|
|
/// Initialize the platform view (Zig: `Platform.init`). Darwin only.
|
|
///
|
|
/// # Safety
|
|
/// The union member selected by `tag` must be initialized.
|
|
unsafe fn platform_view_from_c(
|
|
opts: &ghostty_surface_config_s,
|
|
) -> Result<*mut c_void, &'static str> {
|
|
#[cfg(feature = "test-platform")]
|
|
if opts.platform_tag == test_platform::PLATFORM_TAG {
|
|
// Any non-null "view" will do; the renderer is OpenGL here.
|
|
return Ok(std::ptr::NonNull::<c_void>::dangling().as_ptr());
|
|
}
|
|
if !cfg!(target_vendor = "apple") {
|
|
// The platform union has no members off Darwin.
|
|
return Err("UnsupportedPlatform");
|
|
}
|
|
match opts.platform_tag {
|
|
// SAFETY: the tag selects the initialized member.
|
|
platform::MACOS => match unsafe { opts.platform.macos.nsview } {
|
|
v if v.is_null() => Err("NSViewMustBeSet"),
|
|
v => Ok(v),
|
|
},
|
|
// SAFETY: as above.
|
|
platform::IOS => match unsafe { opts.platform.ios.uiview } {
|
|
v if v.is_null() => Err("UIViewMustBeSet"),
|
|
v => Ok(v),
|
|
},
|
|
_ => Err("InvalidEnumTag"),
|
|
}
|
|
}
|
|
|
|
/// Zig's `std.zig.stringEscape` with non-ASCII code points written as
|
|
/// `\u{...}` (the config value is parsed as a Zig string; the bytes of
|
|
/// valid UTF-8 round trip either way). Bytes that are not valid UTF-8 are
|
|
/// emitted in the reversible OS-byte encoding, which the string parser
|
|
/// copies through unchanged, so `string::parse_bytes(string_escape(b)) == b`.
|
|
fn string_escape(input: &[u8]) -> String {
|
|
let mut out = String::with_capacity(input.len());
|
|
for chunk in input.utf8_chunks() {
|
|
for c in chunk.valid().chars() {
|
|
match c {
|
|
'\n' => out.push_str("\\n"),
|
|
'\r' => out.push_str("\\r"),
|
|
'\t' => out.push_str("\\t"),
|
|
'\\' => out.push_str("\\\\"),
|
|
'"' => out.push_str("\\\""),
|
|
' ' | '!' | '#'..='&' | '\'' | '('..='[' | ']'..='~' => out.push(c),
|
|
c if c.is_ascii() => {
|
|
let _ = write!(out, "\\x{:02x}", c as u32);
|
|
}
|
|
c => {
|
|
let _ = write!(out, "\\u{{{:x}}}", c as u32);
|
|
}
|
|
}
|
|
}
|
|
for &b in chunk.invalid() {
|
|
out.push('\u{FDD0}');
|
|
out.push(char::from(b));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Apply the surface options to the config (the first half of Zig's
|
|
/// `Surface.init`).
|
|
///
|
|
/// # Safety
|
|
/// The pointers in `opts` must satisfy the contract of `ghostty.h`.
|
|
unsafe fn apply_surface_options(config: &mut Config, opts: &ghostty_surface_config_s) {
|
|
// If we have a working directory from the options then we set it.
|
|
// SAFETY: per the function contract.
|
|
if let Some(wd) = unsafe { cstr(opts.working_directory) }.map(CStr::to_bytes)
|
|
&& !wd.is_empty()
|
|
{
|
|
// Raw bytes are kept (reversible escape in the String based config).
|
|
let wd_str = osbytes::EncodedOs::from_bytes(wd);
|
|
let wd_os = osbytes::os_from_bytes(wd.to_vec());
|
|
let wd_disp = String::from_utf8_lossy(wd);
|
|
match std::fs::metadata(&wd_os) {
|
|
Err(err) => {
|
|
log::warn!("error opening requested working directory dir={wd_disp} err={err}");
|
|
}
|
|
Ok(m) if !m.is_dir() => {
|
|
log::warn!("requested working directory is not a directory dir={wd_disp}");
|
|
}
|
|
Ok(_) => {
|
|
let mut wd_val = WorkingDirectory::Path(wd_str);
|
|
wd_val.finalize();
|
|
config.working_directory = Some(wd_val);
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we have a command from the options then we set it.
|
|
// SAFETY: per the function contract.
|
|
if let Some(cmd) = unsafe { cstr(opts.command) }.map(CStr::to_bytes)
|
|
&& !cmd.is_empty()
|
|
{
|
|
config.command = Some(Command::Shell(osbytes::bytes_to_config_string(cmd)));
|
|
config.wait_after_command = true;
|
|
}
|
|
|
|
// Apply any environment variables that were requested.
|
|
if opts.env_var_count > 0 && !opts.env_vars.is_null() {
|
|
// SAFETY: the caller supplies `env_var_count` entries.
|
|
let vars = unsafe { std::slice::from_raw_parts(opts.env_vars, opts.env_var_count) };
|
|
for var in vars {
|
|
// SAFETY: entries are NUL terminated strings.
|
|
let (Some(k), Some(v)) = (unsafe { cstr(var.key) }, unsafe { cstr(var.value) }) else {
|
|
continue;
|
|
};
|
|
config.env.put(
|
|
&osbytes::bytes_to_config_string(k.to_bytes()),
|
|
&osbytes::bytes_to_config_string(v.to_bytes()),
|
|
);
|
|
}
|
|
}
|
|
|
|
// If we have an initial input then we set it.
|
|
// SAFETY: per the function contract.
|
|
if let Some(input) = unsafe { cstr(opts.initial_input) } {
|
|
// We need to escape the string because the "raw" field expects a
|
|
// Zig string.
|
|
config.input.list.clear();
|
|
config
|
|
.input
|
|
.list
|
|
.push(ReadableIO::Raw(string_escape(input.to_bytes())));
|
|
}
|
|
|
|
// Wait after command
|
|
if opts.wait_after_command {
|
|
config.wait_after_command = true;
|
|
}
|
|
}
|
|
|
|
/// Create a new surface as part of an app.
|
|
///
|
|
/// # Safety
|
|
/// `app_ptr` a live app handle; `opts` a valid config (see `ghostty.h`).
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_new(
|
|
app_ptr: ghostty_app_t,
|
|
opts: *const ghostty_surface_config_s,
|
|
) -> ghostty_surface_t {
|
|
// SAFETY: per the function contract.
|
|
let (Some(app_ref), Some(opts)) = (unsafe { app(app_ptr) }, unsafe { opts.as_ref() }) else {
|
|
log::error!("error initializing surface err=NullArgument");
|
|
return std::ptr::null_mut();
|
|
};
|
|
// SAFETY: per the function contract.
|
|
let view = match unsafe { platform_view_from_c(opts) } {
|
|
Ok(v) => v,
|
|
Err(err) => {
|
|
log::error!("error initializing surface err={err}");
|
|
return std::ptr::null_mut();
|
|
}
|
|
};
|
|
|
|
// Build the runtime surface. Its address is its id.
|
|
let scale = opts.scale_factor as f32;
|
|
let app_handle = {
|
|
// `AppHandle` is owned by the C handle; the surface only needs
|
|
// shared access to its parts, which are `Rc`s.
|
|
Rc::new(AppHandle {
|
|
core: Rc::clone(&app_ref.core),
|
|
rt: Rc::clone(&app_ref.rt),
|
|
})
|
|
};
|
|
let mut boxed = Box::new(Surface {
|
|
app: app_handle,
|
|
rt: Rc::new(SurfaceRt {
|
|
app: Rc::clone(&app_ref.rt),
|
|
id: 0,
|
|
userdata: opts.userdata,
|
|
view,
|
|
content_scale: Cell::new(ContentScale { x: scale, y: scale }),
|
|
size: Cell::new(SurfaceSize {
|
|
width: 800,
|
|
height: 600,
|
|
}),
|
|
cursor_pos: Cell::new(CursorPos { x: -1.0, y: -1.0 }),
|
|
title: RefCell::new(None),
|
|
}),
|
|
core: OnceCell::new(),
|
|
inspector: RefCell::new(None),
|
|
inherited_wd: RefCell::new(Vec::new()),
|
|
});
|
|
let id = std::ptr::from_mut::<Surface>(&mut *boxed) as usize as SurfaceId;
|
|
// The id is only known now (the address of the heap object).
|
|
if let Some(rt) = Rc::get_mut(&mut boxed.rt) {
|
|
rt.id = id;
|
|
}
|
|
app_ref
|
|
.rt
|
|
.surfaces
|
|
.borrow_mut()
|
|
.insert(id, Rc::downgrade(&boxed.rt));
|
|
let ptr = Box::into_raw(boxed);
|
|
|
|
// Shallow copy the config so that we can modify it.
|
|
let mut config = {
|
|
let app_config = app_ref.rt.config.borrow();
|
|
let prev_pwd = app_ref.core.state().focused_surface().and_then(|s| s.pwd());
|
|
new_config(&app_config, context_from_c(opts.context), prev_pwd)
|
|
};
|
|
// SAFETY: per the function contract.
|
|
unsafe { apply_surface_options(&mut config, opts) };
|
|
|
|
// Initialize our surface right away. We're given a view that is ready
|
|
// to use.
|
|
// SAFETY: `ptr` is the live allocation made above.
|
|
let s = unsafe { &*ptr };
|
|
let rt_app: Rc<dyn RtApp> = Rc::clone(&app_ref.rt) as Rc<dyn RtApp>;
|
|
let rt_surface: Rc<dyn RtSurface> = Rc::clone(&s.rt) as Rc<dyn RtSurface>;
|
|
match app_ref.core.create_surface(id, config, rt_app, rt_surface) {
|
|
Ok(core) => {
|
|
let _ = s.core.set(Rc::clone(&core));
|
|
// If our options requested a specific font-size, set that.
|
|
if opts.font_size != 0.0 {
|
|
let mut font_size = core.font_size();
|
|
font_size.points = opts.font_size;
|
|
if let Err(err) = core.set_font_size(font_size) {
|
|
log::error!("error initializing surface err={err}");
|
|
// SAFETY: allocated by us and not exposed yet.
|
|
drop(unsafe { Box::from_raw(ptr) });
|
|
return std::ptr::null_mut();
|
|
}
|
|
}
|
|
ptr.cast::<c_void>()
|
|
}
|
|
Err(err) => {
|
|
log::error!("error initializing surface err={err}");
|
|
// SAFETY: allocated by us and not exposed yet.
|
|
drop(unsafe { Box::from_raw(ptr) });
|
|
std::ptr::null_mut()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Free a surface.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle; it is invalid afterwards.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_free(ptr: ghostty_surface_t) {
|
|
if ptr.is_null() {
|
|
return;
|
|
}
|
|
// SAFETY: created by `ghostty_surface_new` via `Box::into_raw`.
|
|
drop(unsafe { Box::from_raw(ptr.cast::<Surface>()) });
|
|
}
|
|
|
|
/// Returns the userdata associated with the surface.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_userdata(ptr: ghostty_surface_t) -> *mut c_void {
|
|
// SAFETY: per the function contract.
|
|
unsafe { surface(ptr) }.map_or(std::ptr::null_mut(), |s| s.rt.userdata)
|
|
}
|
|
|
|
/// Returns the app associated with a surface.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_app(ptr: ghostty_surface_t) -> ghostty_app_t {
|
|
// SAFETY: per the function contract.
|
|
unsafe { surface(ptr) }.map_or(std::ptr::null_mut(), |s| {
|
|
s.rt.app.app_ptr.get() as ghostty_app_t
|
|
})
|
|
}
|
|
|
|
/// Returns the config to use for surfaces that inherit from this one.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_inherited_config(
|
|
ptr: ghostty_surface_t,
|
|
source: c_int,
|
|
) -> ghostty_surface_config_s {
|
|
let mut result = ghostty_surface_config_new();
|
|
result.context = source;
|
|
// SAFETY: per the function contract.
|
|
let Some(s) = (unsafe { surface(ptr) }) else {
|
|
return result;
|
|
};
|
|
let Some(core) = s.core() else {
|
|
return result;
|
|
};
|
|
let context = context_from_c(source);
|
|
let config = s.rt.app.config.borrow();
|
|
|
|
// The font size to inherit. If 0, default font size will be used.
|
|
if config.window_inherit_font_size {
|
|
result.font_size = core.font_size().points;
|
|
}
|
|
|
|
if should_inherit_working_directory(context, &config)
|
|
&& let Some(cwd) = core.pwd_bytes()
|
|
&& let Ok(c) = CString::new(cwd)
|
|
{
|
|
let mut keep = s.inherited_wd.borrow_mut();
|
|
result.working_directory = c.as_ptr();
|
|
keep.push(c);
|
|
}
|
|
result
|
|
}
|
|
|
|
/// Update the configuration to the provided config for only this surface.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` a live surface handle, `config` a config handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_update_config(
|
|
ptr: ghostty_surface_t,
|
|
config: ghostty_config_t,
|
|
) {
|
|
// SAFETY: per the function contract.
|
|
let (Some(s), Some(c)) = (unsafe { surface(ptr) }, unsafe { cconfig(config) }) else {
|
|
return;
|
|
};
|
|
let Some(core) = s.core() else {
|
|
return;
|
|
};
|
|
if let Err(err) = core.update_config(&Arc::new(c.config.clone())) {
|
|
log::error!("error updating config err={err}");
|
|
}
|
|
}
|
|
|
|
/// Returns true if the surface needs to confirm quitting.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_needs_confirm_quit(ptr: ghostty_surface_t) -> bool {
|
|
// SAFETY: per the function contract.
|
|
unsafe { surface(ptr) }
|
|
.and_then(Surface::core)
|
|
.is_some_and(|c| c.needs_confirm_quit())
|
|
}
|
|
|
|
/// Returns true if the surface process has exited.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_process_exited(ptr: ghostty_surface_t) -> bool {
|
|
// SAFETY: per the function contract.
|
|
unsafe { surface(ptr) }
|
|
.and_then(Surface::core)
|
|
.is_some_and(|c| CoreSurface::child_exited(c.as_ref()))
|
|
}
|
|
|
|
/// Returns true if the surface has a selection.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_has_selection(ptr: ghostty_surface_t) -> bool {
|
|
// SAFETY: per the function contract.
|
|
unsafe { surface(ptr) }
|
|
.and_then(Surface::core)
|
|
.is_some_and(|c| c.has_selection())
|
|
}
|
|
|
|
/// Fill the C text result from a read.
|
|
fn fill_text(
|
|
core: &CoreSurfaceImpl,
|
|
t: &Terminal,
|
|
sel: Selection,
|
|
result: &mut ghostty_text_s,
|
|
) -> bool {
|
|
// Get our text directly from the core surface.
|
|
let text: Text = match core.dump_text_locked(t, sel) {
|
|
Ok(t) => t,
|
|
Err(err) => {
|
|
log::warn!("error reading text err={err}");
|
|
return false;
|
|
}
|
|
};
|
|
|
|
let (tl_px_x, tl_px_y, offset_start, offset_len) =
|
|
text.viewport.map_or((-1.0, -1.0, 0, 0), |vp| {
|
|
(vp.tl_px_x, vp.tl_px_y, vp.offset_start, vp.offset_len)
|
|
});
|
|
*result = ghostty_text_s {
|
|
tl_px_x,
|
|
tl_px_y,
|
|
offset_start,
|
|
offset_len,
|
|
text: alloc_c_text(text.text.as_bytes()),
|
|
text_len: text.text.len(),
|
|
};
|
|
true
|
|
}
|
|
|
|
/// Same as `ghostty_surface_read_text` but reads from the user selection, if
|
|
/// any.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` a live surface handle, `result` a valid out pointer.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_read_selection(
|
|
ptr: ghostty_surface_t,
|
|
result: *mut ghostty_text_s,
|
|
) -> bool {
|
|
// SAFETY: per the function contract.
|
|
let (Some(core), Some(result)) = (unsafe { surface(ptr) }.and_then(Surface::core), unsafe {
|
|
result.as_mut()
|
|
}) else {
|
|
return false;
|
|
};
|
|
let guard = core.lock_terminal();
|
|
let t: &Terminal = &guard;
|
|
|
|
// If we don't have a selection, do nothing.
|
|
let Some(sel) = t.screen().selection else {
|
|
return false;
|
|
};
|
|
fill_text(core, t, sel, result)
|
|
}
|
|
|
|
/// The pin of a C point (Zig: `Point.pin`).
|
|
fn point_pin(p: &ghostty_point_s, t: &Terminal) -> Option<ghostty_terminal::page_list::Pin> {
|
|
let tag = match p.tag {
|
|
0 => PointTag::Active,
|
|
1 => PointTag::Viewport,
|
|
2 => PointTag::Screen,
|
|
3 => PointTag::History,
|
|
_ => return None,
|
|
};
|
|
let pages = &t.screen().pages;
|
|
|
|
// Clamp our point to the screen bounds.
|
|
let clamped_x = p.x.min(u32::from(pages.cols.saturating_sub(1)));
|
|
let clamped_y = p.y.min(u32::from(pages.rows.saturating_sub(1)));
|
|
|
|
match p.coord {
|
|
// Exact coordinates require a specific pin.
|
|
0 => {
|
|
let x = u16::try_from(clamped_x).unwrap_or(u16::MAX);
|
|
pages.pin(Point::new(tag, Coordinate { x, y: clamped_y }))
|
|
}
|
|
1 => pages.get_top_left(tag),
|
|
2 => pages.get_bottom_right(tag),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn selection_from_c(sel: &ghostty_selection_s, t: &Terminal) -> Option<Selection> {
|
|
Some(Selection::new(
|
|
point_pin(&sel.top_left, t)?,
|
|
point_pin(&sel.bottom_right, t)?,
|
|
sel.rectangle,
|
|
))
|
|
}
|
|
|
|
/// Read some arbitrary text from the surface.
|
|
///
|
|
/// This is an expensive operation so it shouldn't be called too often. We
|
|
/// recommend that callers cache the result and throttle calls to this
|
|
/// function.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` a live surface handle, `result` a valid out pointer.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_read_text(
|
|
ptr: ghostty_surface_t,
|
|
sel: ghostty_selection_s,
|
|
result: *mut ghostty_text_s,
|
|
) -> bool {
|
|
// SAFETY: per the function contract.
|
|
let (Some(core), Some(result)) = (unsafe { surface(ptr) }.and_then(Surface::core), unsafe {
|
|
result.as_mut()
|
|
}) else {
|
|
return false;
|
|
};
|
|
let guard = core.lock_terminal();
|
|
let t: &Terminal = &guard;
|
|
let Some(core_sel) = selection_from_c(&sel, t) else {
|
|
return false;
|
|
};
|
|
fill_text(core, t, core_sel, result)
|
|
}
|
|
|
|
/// Free text read from the surface.
|
|
///
|
|
/// # Safety
|
|
/// `text` must be a result of one of the read functions (or null).
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_free_text(
|
|
_ptr: ghostty_surface_t,
|
|
text: *mut ghostty_text_s,
|
|
) {
|
|
// SAFETY: per the function contract.
|
|
let Some(text) = (unsafe { text.as_mut() }) else {
|
|
return;
|
|
};
|
|
// SAFETY: allocated by `alloc_c_text` with `text_len` bytes + NUL.
|
|
unsafe { free_c_text(text.text, text.text_len, true) };
|
|
text.text = std::ptr::null();
|
|
text.text_len = 0;
|
|
}
|
|
|
|
/// Tell the surface that it needs to schedule a render.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_refresh(ptr: ghostty_surface_t) {
|
|
// SAFETY: per the function contract.
|
|
if let Some(core) = unsafe { surface(ptr) }.and_then(Surface::core)
|
|
&& let Err(err) = core.refresh_callback()
|
|
{
|
|
log::error!("error in refresh callback err={err}");
|
|
}
|
|
}
|
|
|
|
/// Tell the surface that it needs to schedule a render call as soon as
|
|
/// possible (NOW if possible).
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_draw(ptr: ghostty_surface_t) {
|
|
// SAFETY: per the function contract.
|
|
if let Some(core) = unsafe { surface(ptr) }.and_then(Surface::core)
|
|
&& let Err(err) = core.draw()
|
|
{
|
|
log::error!("error in draw err={err}");
|
|
}
|
|
}
|
|
|
|
/// Update the size of a surface. This will trigger resize notifications to
|
|
/// the pty and the renderer.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_set_size(ptr: ghostty_surface_t, w: u32, h: u32) {
|
|
// SAFETY: per the function contract.
|
|
let Some(s) = (unsafe { surface(ptr) }) else {
|
|
return;
|
|
};
|
|
|
|
// Runtimes sometimes generate superfluous resize events even if the
|
|
// size did not actually change (SwiftUI). We check that the size
|
|
// actually changed from what we last recorded since resizes are
|
|
// expensive.
|
|
let cur = s.rt.size.get();
|
|
if cur.width == w && cur.height == h {
|
|
return;
|
|
}
|
|
let size = SurfaceSize {
|
|
width: w,
|
|
height: h,
|
|
};
|
|
s.rt.size.set(size);
|
|
|
|
if let Some(core) = s.core()
|
|
&& let Err(err) = core.size_callback(size)
|
|
{
|
|
log::error!("error in size callback err={err}");
|
|
}
|
|
}
|
|
|
|
/// Return the size information a surface has.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_size(ptr: ghostty_surface_t) -> ghostty_surface_size_s {
|
|
let zero = ghostty_surface_size_s {
|
|
columns: 0,
|
|
rows: 0,
|
|
width_px: 0,
|
|
height_px: 0,
|
|
cell_width_px: 0,
|
|
cell_height_px: 0,
|
|
};
|
|
// SAFETY: per the function contract.
|
|
let Some(core) = (unsafe { surface(ptr) }).and_then(Surface::core) else {
|
|
return zero;
|
|
};
|
|
let size = core.size();
|
|
let grid = size.grid();
|
|
ghostty_surface_size_s {
|
|
columns: grid.columns,
|
|
rows: grid.rows,
|
|
width_px: size.screen.width,
|
|
height_px: size.screen.height,
|
|
cell_width_px: size.cell.width,
|
|
cell_height_px: size.cell.height,
|
|
}
|
|
}
|
|
|
|
/// Update the color scheme of the surface.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_set_color_scheme(
|
|
ptr: ghostty_surface_t,
|
|
scheme_raw: c_int,
|
|
) {
|
|
let Some(scheme) = color_scheme_from_c(scheme_raw) else {
|
|
log::warn!("invalid color scheme to ghostty_surface_set_color_scheme value={scheme_raw}");
|
|
return;
|
|
};
|
|
// SAFETY: per the function contract.
|
|
if let Some(core) = unsafe { surface(ptr) }.and_then(Surface::core)
|
|
&& let Err(err) = core.color_scheme_callback(scheme)
|
|
{
|
|
log::error!("error setting color scheme err={err}");
|
|
}
|
|
}
|
|
|
|
/// Update the content scale of the surface.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_set_content_scale(ptr: ghostty_surface_t, x: f64, y: f64) {
|
|
// SAFETY: per the function contract.
|
|
let Some(s) = (unsafe { surface(ptr) }) else {
|
|
return;
|
|
};
|
|
|
|
// We are an embedded API so the caller can send us all sorts of
|
|
// garbage. We want to make sure that the float values are valid and we
|
|
// don't want to support fractional scaling below 1.
|
|
let scaled = |v: f64| if v.is_nan() { 1.0 } else { v.max(1.0) } as f32;
|
|
let scale = ContentScale {
|
|
x: scaled(x),
|
|
y: scaled(y),
|
|
};
|
|
s.rt.content_scale.set(scale);
|
|
|
|
if let Some(core) = s.core()
|
|
&& let Err(err) = core.content_scale_callback(scale)
|
|
{
|
|
log::error!("error in content scale callback err={err}");
|
|
}
|
|
}
|
|
|
|
/// Update the focused state of a surface.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_set_focus(ptr: ghostty_surface_t, focused: bool) {
|
|
// SAFETY: per the function contract.
|
|
if let Some(core) = unsafe { surface(ptr) }.and_then(Surface::core)
|
|
&& let Err(err) = core.focus_callback(focused)
|
|
{
|
|
log::error!("error in focus callback err={err}");
|
|
}
|
|
}
|
|
|
|
/// Update the occlusion state of a surface.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_set_occlusion(ptr: ghostty_surface_t, visible: bool) {
|
|
// SAFETY: per the function contract.
|
|
if let Some(core) = unsafe { surface(ptr) }.and_then(Surface::core)
|
|
&& let Err(err) = core.occlusion_callback(visible)
|
|
{
|
|
log::error!("error in occlusion callback err={err}");
|
|
}
|
|
}
|
|
|
|
/// Filter the mods if necessary. This handles settings such as
|
|
/// `macos-option-as-alt`. The filtered mods should be used for key
|
|
/// translation but should NOT be sent back via the `_key` function -- the
|
|
/// original mods should be used for that.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_key_translation_mods(
|
|
ptr: ghostty_surface_t,
|
|
mods_raw: c_int,
|
|
) -> c_int {
|
|
let mods = mods_from_c(mods_raw);
|
|
// SAFETY: per the function contract.
|
|
let Some(s) = (unsafe { surface(ptr) }) else {
|
|
return mods_raw;
|
|
};
|
|
let Some(core) = s.core() else {
|
|
return mods_raw;
|
|
};
|
|
let option_as_alt = core
|
|
.config()
|
|
.macos_option_as_alt
|
|
.unwrap_or_else(|| s.rt.app.keyboard_layout().detect_option_as_alt());
|
|
c_int::from(mods.translation(option_as_alt).int())
|
|
}
|
|
|
|
/// Send this for raw keypresses (i.e. the keyDown event on macOS). This will
|
|
/// handle the keymap translation and send the appropriate key and char
|
|
/// events.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` a live surface handle; `event.text` null or NUL terminated.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_key(
|
|
ptr: ghostty_surface_t,
|
|
event: ghostty_input_key_s,
|
|
) -> bool {
|
|
// SAFETY: per the function contract.
|
|
let Some(core) = (unsafe { surface(ptr) }).and_then(Surface::core) else {
|
|
return false;
|
|
};
|
|
// SAFETY: per the function contract.
|
|
let Some(key) = (unsafe { OwnedKey::from_c(&event) }) else {
|
|
return false;
|
|
};
|
|
match CoreSurface::key_callback(core.as_ref(), key.event()) {
|
|
Ok(ghostty_apprt::runtime_api::InputEffect::Closed) => true,
|
|
Ok(ghostty_apprt::runtime_api::InputEffect::Ignored) => false,
|
|
Ok(ghostty_apprt::runtime_api::InputEffect::Consumed) => true,
|
|
Err(err) => {
|
|
log::warn!("error processing key event err={err}");
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns true if the given key event would trigger a binding if it were
|
|
/// sent to the surface right now. The "right now" is important because
|
|
/// things like trigger sequences are only valid until the next key event.
|
|
///
|
|
/// # Safety
|
|
/// As [`ghostty_surface_key`]; `c_flags` null or a valid out pointer.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_key_is_binding(
|
|
ptr: ghostty_surface_t,
|
|
event: ghostty_input_key_s,
|
|
c_flags: *mut ghostty_binding_flags_e,
|
|
) -> bool {
|
|
// SAFETY: per the function contract.
|
|
let Some(core) = (unsafe { surface(ptr) }).and_then(Surface::core) else {
|
|
return false;
|
|
};
|
|
// SAFETY: per the function contract.
|
|
let Some(key) = (unsafe { OwnedKey::from_c(&event) }) else {
|
|
log::warn!("error processing key event");
|
|
return false;
|
|
};
|
|
let Some(flags) = core.key_event_is_binding(&key.event()) else {
|
|
return false;
|
|
};
|
|
if !c_flags.is_null() {
|
|
// Zig writes the `u8` `Flags.C` through the (enum-typed) pointer.
|
|
// SAFETY: the pointer is valid for at least one byte.
|
|
unsafe { c_flags.cast::<u8>().write(flags.cval()) };
|
|
}
|
|
true
|
|
}
|
|
|
|
/// Send raw text to the terminal. This is treated like a paste so this isn't
|
|
/// useful for sending escape sequences. For that, individual key input
|
|
/// should be used.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` a live surface handle, `text` points to `len` bytes.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_text(
|
|
ptr: ghostty_surface_t,
|
|
text: *const c_char,
|
|
len: usize,
|
|
) {
|
|
// SAFETY: per the function contract.
|
|
let Some(core) = (unsafe { surface(ptr) }).and_then(Surface::core) else {
|
|
return;
|
|
};
|
|
let bytes: &[u8] = if text.is_null() {
|
|
&[]
|
|
} else {
|
|
// SAFETY: `text` points to `len` readable bytes.
|
|
unsafe { std::slice::from_raw_parts(text.cast::<u8>(), len) }
|
|
};
|
|
if let Err(err) = core.text_callback_bytes(bytes) {
|
|
log::error!("error in key callback err={err}");
|
|
}
|
|
}
|
|
|
|
/// Set the preedit text for the surface. This is used for IME composition.
|
|
/// If the length is 0, then the preedit text is cleared.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` a live surface handle, `text` points to `len` bytes.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_preedit(
|
|
ptr: ghostty_surface_t,
|
|
text: *const c_char,
|
|
len: usize,
|
|
) {
|
|
// SAFETY: per the function contract.
|
|
let Some(core) = (unsafe { surface(ptr) }).and_then(Surface::core) else {
|
|
return;
|
|
};
|
|
let preedit = if len == 0 || text.is_null() {
|
|
None
|
|
} else {
|
|
// SAFETY: `text` points to `len` readable bytes.
|
|
let bytes = unsafe { std::slice::from_raw_parts(text.cast::<u8>(), len) };
|
|
Some(String::from_utf8_lossy(bytes).into_owned())
|
|
};
|
|
if let Err(err) = core.preedit_callback(preedit.as_deref()) {
|
|
log::error!("error in preedit callback err={err}");
|
|
}
|
|
}
|
|
|
|
/// Returns true if the surface currently has mouse capturing enabled.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_mouse_captured(ptr: ghostty_surface_t) -> bool {
|
|
// SAFETY: per the function contract.
|
|
unsafe { surface(ptr) }
|
|
.and_then(Surface::core)
|
|
.is_some_and(|c| c.mouse_captured())
|
|
}
|
|
|
|
/// Send a mouse button event.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_mouse_button(
|
|
ptr: ghostty_surface_t,
|
|
action: c_int,
|
|
button: c_int,
|
|
mods: c_int,
|
|
) -> bool {
|
|
// SAFETY: per the function contract.
|
|
let Some(core) = (unsafe { surface(ptr) }).and_then(Surface::core) else {
|
|
return false;
|
|
};
|
|
let state = match action {
|
|
0 => MouseButtonState::Release,
|
|
1 => MouseButtonState::Press,
|
|
_ => return false,
|
|
};
|
|
let Some(button) = mouse_button_from_c(button) else {
|
|
return false;
|
|
};
|
|
match core.mouse_button_callback(state, button, mods_from_c(mods)) {
|
|
Ok(v) => v,
|
|
Err(err) => {
|
|
log::error!("error in mouse button callback err={err}");
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn mouse_button_from_c(v: c_int) -> Option<MouseButton> {
|
|
Some(match v {
|
|
0 => MouseButton::Unknown,
|
|
1 => MouseButton::Left,
|
|
2 => MouseButton::Right,
|
|
3 => MouseButton::Middle,
|
|
4 => MouseButton::Four,
|
|
5 => MouseButton::Five,
|
|
6 => MouseButton::Six,
|
|
7 => MouseButton::Seven,
|
|
8 => MouseButton::Eight,
|
|
9 => MouseButton::Nine,
|
|
10 => MouseButton::Ten,
|
|
11 => MouseButton::Eleven,
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
/// Update the mouse position within the view.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_mouse_pos(
|
|
ptr: ghostty_surface_t,
|
|
x: f64,
|
|
y: f64,
|
|
mods: c_int,
|
|
) {
|
|
// SAFETY: per the function contract.
|
|
let Some(s) = (unsafe { surface(ptr) }) else {
|
|
return;
|
|
};
|
|
|
|
// The cursor position from the host directly is in screen coordinates
|
|
// but all our interface works in pixels.
|
|
let scale = s.rt.content_scale.get();
|
|
let pos = CursorPos {
|
|
x: x as f32 * scale.x,
|
|
y: y as f32 * scale.y,
|
|
};
|
|
|
|
// There are cases where the platform reports a mouse motion event
|
|
// without the cursor actually moving. For example, on macOS, updating
|
|
// the window title can trigger a phantom mouse-move event at the same
|
|
// coordinates. This can cause the mouse to incorrectly unhide when
|
|
// mouse-hide-while-typing is enabled (commonly seen with TUI apps like
|
|
// Zellij that frequently update the title). To prevent incorrect
|
|
// behavior, we only continue with callback logic if the cursor has
|
|
// actually moved.
|
|
let prev = s.rt.cursor_pos.get();
|
|
if (prev.x - pos.x).abs() < 1.0 && (prev.y - pos.y).abs() < 1.0 {
|
|
return;
|
|
}
|
|
s.rt.cursor_pos.set(pos);
|
|
|
|
if let Some(core) = s.core()
|
|
&& let Err(err) = core.cursor_pos_callback(pos, Some(mods_from_c(mods)))
|
|
{
|
|
log::error!("error in cursor pos callback err={err}");
|
|
}
|
|
}
|
|
|
|
/// Send a scroll event.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_mouse_scroll(
|
|
ptr: ghostty_surface_t,
|
|
x: f64,
|
|
y: f64,
|
|
scroll_mods: c_int,
|
|
) {
|
|
// SAFETY: per the function contract.
|
|
if let Some(core) = unsafe { surface(ptr) }.and_then(Surface::core)
|
|
&& let Err(err) = core.scroll_callback(x, y, ScrollMods::from_int(scroll_mods as u32 as u8))
|
|
{
|
|
log::error!("error in scroll callback err={err}");
|
|
}
|
|
}
|
|
|
|
/// Send a mouse pressure event.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_mouse_pressure(
|
|
ptr: ghostty_surface_t,
|
|
stage_raw: u32,
|
|
pressure: f64,
|
|
) {
|
|
let stage = match stage_raw {
|
|
0 => MousePressureStage::None,
|
|
1 => MousePressureStage::Normal,
|
|
2 => MousePressureStage::Deep,
|
|
_ => {
|
|
log::warn!("invalid mouse pressure stage value={stage_raw}");
|
|
return;
|
|
}
|
|
};
|
|
// SAFETY: per the function contract.
|
|
if let Some(core) = unsafe { surface(ptr) }.and_then(Surface::core) {
|
|
CoreSurface::mouse_pressure_callback(core.as_ref(), stage, pressure);
|
|
}
|
|
}
|
|
|
|
/// The position of the IME candidate window.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` a live surface handle; the out pointers valid.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_ime_point(
|
|
ptr: ghostty_surface_t,
|
|
x: *mut f64,
|
|
y: *mut f64,
|
|
width: *mut f64,
|
|
height: *mut f64,
|
|
) {
|
|
// SAFETY: per the function contract.
|
|
let Some(core) = (unsafe { surface(ptr) }).and_then(Surface::core) else {
|
|
return;
|
|
};
|
|
let pos = core.ime_point();
|
|
// SAFETY: the out pointers are valid per the contract.
|
|
unsafe {
|
|
if let Some(x) = x.as_mut() {
|
|
*x = pos.x;
|
|
}
|
|
if let Some(y) = y.as_mut() {
|
|
*y = pos.y;
|
|
}
|
|
if let Some(w) = width.as_mut() {
|
|
*w = pos.width;
|
|
}
|
|
if let Some(h) = height.as_mut() {
|
|
*h = pos.height;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Request that the surface become closed. This will go through the normal
|
|
/// trigger process that a close surface input binding would.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_request_close(ptr: ghostty_surface_t) {
|
|
// SAFETY: per the function contract.
|
|
if let Some(core) = unsafe { surface(ptr) }.and_then(Surface::core) {
|
|
core.close();
|
|
}
|
|
}
|
|
|
|
/// Request that the surface split in the given direction.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_split(ptr: ghostty_surface_t, direction: c_int) {
|
|
let direction = match direction {
|
|
0 => SplitDirection::Right,
|
|
1 => SplitDirection::Down,
|
|
2 => SplitDirection::Left,
|
|
3 => SplitDirection::Up,
|
|
_ => return,
|
|
};
|
|
// SAFETY: per the function contract.
|
|
if let Some(s) = unsafe { surface(ptr) } {
|
|
s.rt.app
|
|
.perform_action(s.rt.target(), Action::NewSplit(direction));
|
|
}
|
|
}
|
|
|
|
/// Focus on the next split (if any).
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_split_focus(ptr: ghostty_surface_t, direction: c_int) {
|
|
let direction = match direction {
|
|
0 => GotoSplit::Previous,
|
|
1 => GotoSplit::Next,
|
|
2 => GotoSplit::Up,
|
|
3 => GotoSplit::Left,
|
|
4 => GotoSplit::Down,
|
|
5 => GotoSplit::Right,
|
|
_ => return,
|
|
};
|
|
// SAFETY: per the function contract.
|
|
if let Some(s) = unsafe { surface(ptr) } {
|
|
s.rt.app
|
|
.perform_action(s.rt.target(), Action::GotoSplit(direction));
|
|
}
|
|
}
|
|
|
|
/// Resize the current split by moving the split divider in the given
|
|
/// direction. `direction` specifies which direction the split divider will
|
|
/// move relative to the focused split. `amount` is a fractional value
|
|
/// between 0 and 1 that specifies by how much the divider will move.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_split_resize(
|
|
ptr: ghostty_surface_t,
|
|
direction: c_int,
|
|
amount: u16,
|
|
) {
|
|
let direction = match direction {
|
|
0 => ResizeSplitDirection::Up,
|
|
1 => ResizeSplitDirection::Down,
|
|
2 => ResizeSplitDirection::Left,
|
|
3 => ResizeSplitDirection::Right,
|
|
_ => return,
|
|
};
|
|
// SAFETY: per the function contract.
|
|
if let Some(s) = unsafe { surface(ptr) } {
|
|
s.rt.app.perform_action(
|
|
s.rt.target(),
|
|
Action::ResizeSplit(ResizeSplit { amount, direction }),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Equalize the size of all splits in the current window.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_split_equalize(ptr: ghostty_surface_t) {
|
|
// SAFETY: per the function contract.
|
|
if let Some(s) = unsafe { surface(ptr) } {
|
|
s.rt.app
|
|
.perform_action(s.rt.target(), Action::EqualizeSplits);
|
|
}
|
|
}
|
|
|
|
/// Invoke an action on the surface.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` a live surface handle, `action` points to `len` bytes.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_binding_action(
|
|
ptr: ghostty_surface_t,
|
|
action: *const c_char,
|
|
len: usize,
|
|
) -> bool {
|
|
// SAFETY: per the function contract.
|
|
let Some(core) = (unsafe { surface(ptr) }).and_then(Surface::core) else {
|
|
return false;
|
|
};
|
|
if action.is_null() {
|
|
return false;
|
|
}
|
|
// SAFETY: `action` points to `len` readable bytes.
|
|
let bytes = unsafe { std::slice::from_raw_parts(action.cast::<u8>(), len) };
|
|
// The action text is bytes (Zig `[]const u8`); the parser works on the
|
|
// reversible OS-byte encoding so a `text:` payload keeps invalid UTF-8.
|
|
let action_str = ghostty_os::osbytes::bytes_to_config_string(bytes);
|
|
let action = match BindingAction::parse(&action_str) {
|
|
Ok(a) => a,
|
|
Err(err) => {
|
|
log::error!("error parsing binding action action={action_str} err={err:?}");
|
|
return false;
|
|
}
|
|
};
|
|
match core.perform_binding_action(&action) {
|
|
Ok(v) => v,
|
|
Err(err) => {
|
|
log::error!("error performing binding action action={action} err={err}");
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Complete a clipboard read request started via the read callback. This can
|
|
/// only be called once for a given request. Once it is called with a request
|
|
/// the request pointer will be invalidated.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` a live surface handle; `text` NUL terminated; `state` a pointer
|
|
/// handed to the `read_clipboard` / `confirm_read_clipboard` callbacks.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_complete_clipboard_request(
|
|
ptr: ghostty_surface_t,
|
|
text: *const c_char,
|
|
state: *mut c_void,
|
|
confirmed: bool,
|
|
) {
|
|
// SAFETY: per the function contract.
|
|
let (Some(s), Some(str)) = (unsafe { surface(ptr) }, unsafe { cstr(text) }) else {
|
|
return;
|
|
};
|
|
if state.is_null() {
|
|
return;
|
|
}
|
|
let Some(core) = s.core() else {
|
|
return;
|
|
};
|
|
// The clipboard contents are bytes (Zig `[:0]const u8`), not text.
|
|
let str = str.to_bytes();
|
|
// SAFETY: the pointer is a `Box<ClipboardRequest>` leaked by
|
|
// `clipboard_request`; it stays alive when confirmation is requested.
|
|
let req = unsafe { *state.cast::<ClipboardRequest>() };
|
|
|
|
// Attempt to complete the request, but we may request confirmation.
|
|
match core.complete_clipboard_request(req, str, confirmed) {
|
|
Err(
|
|
ghostty_apprt::runtime_api::ClipboardError::UnsafePaste
|
|
| ghostty_apprt::runtime_api::ClipboardError::UnauthorizedPaste,
|
|
) => {
|
|
if let Some(cb) = s.rt.app.opts.confirm_read_clipboard_cb {
|
|
let ty = match req.request_type() {
|
|
ghostty_apprt::structs::ClipboardRequestType::Paste => 0,
|
|
ghostty_apprt::structs::ClipboardRequestType::Osc52Read => 1,
|
|
ghostty_apprt::structs::ClipboardRequestType::Osc52Write => 2,
|
|
};
|
|
// SAFETY: embedder callback; `text` is the caller's NUL
|
|
// terminated string and `state` is preserved for the
|
|
// confirmation route.
|
|
unsafe { cb(s.rt.userdata, text, state, ty) };
|
|
}
|
|
return;
|
|
}
|
|
Err(err) => log::error!("error completing clipboard request err={err}"),
|
|
Ok(()) => {}
|
|
}
|
|
|
|
// We don't defer this because the clipboard confirmation route
|
|
// preserves the clipboard request.
|
|
// SAFETY: allocated by `clipboard_request`, consumed exactly once here.
|
|
drop(unsafe { Box::from_raw(state.cast::<ClipboardRequest>()) });
|
|
}
|
|
|
|
/// Sets the window background blur on macOS to the desired value. I do this
|
|
/// as an extern function because I don't know how to call these functions
|
|
/// in Swift.
|
|
///
|
|
/// This uses an undocumented, non-public API because this is what every
|
|
/// terminal appears to use, including Terminal.app.
|
|
///
|
|
/// # Safety
|
|
/// `app` a live app handle; `window` an `NSWindow` (macOS).
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_set_window_background_blur(
|
|
app_ptr: ghostty_app_t,
|
|
window: *mut c_void,
|
|
) {
|
|
// This is only supported on macOS
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
use objc2::{msg_send, runtime::AnyObject};
|
|
|
|
#[link(name = "CoreGraphics", kind = "framework")]
|
|
unsafe extern "C" {
|
|
fn CGSSetWindowBackgroundBlurRadius(
|
|
connection: *mut c_void,
|
|
window: usize,
|
|
radius: c_int,
|
|
) -> i32;
|
|
fn CGSDefaultConnectionForThread() -> *mut c_void;
|
|
}
|
|
|
|
// SAFETY: per the function contract.
|
|
let Some(a) = (unsafe { app(app_ptr) }) else {
|
|
return;
|
|
};
|
|
if window.is_null() {
|
|
return;
|
|
}
|
|
let config = a.rt.config.borrow();
|
|
|
|
// Do nothing if we don't have background transparency enabled
|
|
if config.background_opacity >= 1.0 {
|
|
return;
|
|
}
|
|
let radius = match config.c_value("background-blur") {
|
|
Some(ghostty_config::c_value::CValue::Short(v)) => c_int::from(v),
|
|
_ => 0,
|
|
};
|
|
|
|
// SAFETY: `window` is an NSWindow per the contract; the CGS
|
|
// functions are the private window server API Terminal.app uses.
|
|
unsafe {
|
|
let number: isize = msg_send![window.cast::<AnyObject>(), windowNumber];
|
|
CGSSetWindowBackgroundBlurRadius(
|
|
CGSDefaultConnectionForThread(),
|
|
number as usize,
|
|
radius,
|
|
);
|
|
}
|
|
}
|
|
#[cfg(not(target_os = "macos"))]
|
|
{
|
|
let _ = (app_ptr, window);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Darwin only exports
|
|
|
|
#[cfg(target_vendor = "apple")]
|
|
#[allow(clippy::wildcard_imports)]
|
|
mod darwin {
|
|
use super::*;
|
|
use crate::types::ghostty_text_s;
|
|
|
|
/// Set the display the surface is on (for the display link).
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_set_display_id(
|
|
ptr: ghostty_surface_t,
|
|
display_id: u32,
|
|
) {
|
|
// SAFETY: per the function contract.
|
|
if let Some(core) = unsafe { surface(ptr) }.and_then(Surface::core) {
|
|
core.set_macos_display_id(display_id);
|
|
}
|
|
}
|
|
|
|
/// This returns a CTFontRef that should be used for quicklook
|
|
/// highlighted text. This is always the primary font in use regardless
|
|
/// of the selected text.
|
|
///
|
|
/// # Safety
|
|
/// `ptr` must be a live surface handle.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_quicklook_font(ptr: ghostty_surface_t) -> *mut c_void {
|
|
// SAFETY: per the function contract.
|
|
let Some(s) = (unsafe { surface(ptr) }) else {
|
|
return std::ptr::null_mut();
|
|
};
|
|
let Some(core) = s.core() else {
|
|
return std::ptr::null_mut();
|
|
};
|
|
// We need to unscale the content scale. We apply the content scale
|
|
// to our font stack because we are rendering at 1x but callers of
|
|
// this should be using scaled or apply scale themselves.
|
|
let scale = s.rt.get_content_scale();
|
|
core.quicklook_font(scale.y).unwrap_or(std::ptr::null_mut())
|
|
}
|
|
|
|
/// This returns the selected word for quicklook. This will populate the
|
|
/// buffer with the word under the cursor and the selection info so that
|
|
/// quicklook can be rendered.
|
|
///
|
|
/// This does not modify the selection active on the surface (if any).
|
|
///
|
|
/// # Safety
|
|
/// `ptr` a live surface handle, `result` a valid out pointer.
|
|
#[unsafe(no_mangle)]
|
|
pub unsafe extern "C" fn ghostty_surface_quicklook_word(
|
|
ptr: ghostty_surface_t,
|
|
result: *mut ghostty_text_s,
|
|
) -> bool {
|
|
// SAFETY: per the function contract.
|
|
let (Some(s), Some(result)) = (unsafe { surface(ptr) }, unsafe { result.as_mut() }) else {
|
|
return false;
|
|
};
|
|
let Some(core) = s.core() else {
|
|
return false;
|
|
};
|
|
let guard = core.lock_terminal();
|
|
let t: &Terminal = &guard;
|
|
|
|
// Get our word selection
|
|
let pos = s.rt.get_cursor_pos();
|
|
let vp = core.pos_to_viewport(f64::from(pos.x), f64::from(pos.y));
|
|
let Some(pin) = t
|
|
.screen()
|
|
.pages
|
|
.pin(Point::Viewport(Coordinate { x: vp.x, y: vp.y }))
|
|
else {
|
|
return false;
|
|
};
|
|
let Some(sel) = t
|
|
.screen()
|
|
.select_word(pin, &core.cfg().selection_word_chars)
|
|
else {
|
|
return false;
|
|
};
|
|
|
|
// Read the selection
|
|
fill_text(core, t, sel, result)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::os::unix::ffi::OsStrExt;
|
|
|
|
use super::*;
|
|
use crate::types::ghostty_env_var_s;
|
|
|
|
#[test]
|
|
fn initial_input_escape_round_trips_bytes() {
|
|
for raw in [
|
|
&b"a\xffb\n"[..],
|
|
"t\u{fdd0}\u{e9}\"\\".as_bytes(),
|
|
b"\xfd\xfd\x1b[0m",
|
|
"\u{fdd0}\u{ff}".as_bytes(),
|
|
] {
|
|
let esc = string_escape(raw);
|
|
assert_eq!(
|
|
ghostty_config::string::parse_bytes(&esc).unwrap(),
|
|
raw,
|
|
"{esc:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Surface options are raw C bytes in Zig; non-UTF-8 values must reach
|
|
/// the config unchanged (reversible byte escape).
|
|
#[test]
|
|
fn surface_options_keep_non_utf8_bytes() {
|
|
let td = tempfile::tempdir().unwrap();
|
|
let mut dir = td.path().as_os_str().as_bytes().to_vec();
|
|
dir.extend_from_slice(b"/d\xff");
|
|
std::fs::create_dir(std::ffi::OsStr::from_bytes(&dir)).unwrap();
|
|
|
|
let wd = CString::new(dir.clone()).unwrap();
|
|
let cmd = CString::new(&b"echo \xfe"[..]).unwrap();
|
|
let key = CString::new(&b"K\xfd"[..]).unwrap();
|
|
let val = CString::new(&b"v\xfc"[..]).unwrap();
|
|
let input = CString::new(&b"in\xff\n"[..]).unwrap();
|
|
let mut env = [ghostty_env_var_s {
|
|
key: key.as_ptr(),
|
|
value: val.as_ptr(),
|
|
}];
|
|
|
|
// SAFETY: all-zero is a valid value (null pointers, 0 enums).
|
|
let mut opts: ghostty_surface_config_s = unsafe { std::mem::zeroed() };
|
|
opts.working_directory = wd.as_ptr();
|
|
opts.command = cmd.as_ptr();
|
|
opts.env_vars = env.as_mut_ptr();
|
|
opts.env_var_count = 1;
|
|
opts.initial_input = input.as_ptr();
|
|
|
|
let mut config = Config::default();
|
|
// SAFETY: the pointers above are valid NUL terminated strings.
|
|
unsafe { apply_surface_options(&mut config, &opts) };
|
|
|
|
let got_wd = config
|
|
.working_directory
|
|
.as_ref()
|
|
.and_then(|w| w.value())
|
|
.unwrap();
|
|
assert_eq!(got_wd.to_bytes(), dir);
|
|
let Some(Command::Shell(c)) = &config.command else {
|
|
panic!("no command");
|
|
};
|
|
assert_eq!(osbytes::config_str_to_bytes(c), b"echo \xfe");
|
|
let (k, v) = config.env.iter().next().unwrap();
|
|
assert_eq!(osbytes::config_str_to_bytes(k), b"K\xfd");
|
|
assert_eq!(osbytes::config_str_to_bytes(v), b"v\xfc");
|
|
assert!(matches!(
|
|
config.input.list.first(),
|
|
Some(ReadableIO::Raw(_))
|
|
));
|
|
}
|
|
}
|