use crate::error::{Error, check};
use libghostty_vt_sys as sys;
use std::mem::MaybeUninit;
use std::os::raw::c_void;
use std::ptr;
#[derive(Debug, Copy, Clone)]
pub struct TerminalOptions {
pub cols: u16,
pub rows: u16,
pub max_scrollback: usize,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum ScrollViewport {
Top,
Bottom,
Delta(isize),
}
pub struct Terminal<'alloc, 'sys> {
handle: sys::GhosttyTerminal,
write_pty_cb: Option<*mut Box<WritePtyCallback>>,
_marker: std::marker::PhantomData<(&'alloc (), &'sys ())>,
}
type WritePtyCallback = dyn FnMut(&[u8]) + 'static;
impl<'alloc, 'sys> Terminal<'alloc, 'sys> {
pub fn new(options: TerminalOptions) -> Result<Self, Error> {
let mut handle: sys::GhosttyTerminal = ptr::null_mut();
let r = unsafe {
sys::ghostty_terminal_new(ptr::null(), &mut handle, options.cols, options.rows)
};
check(r)?;
let scrollback: usize = options.max_scrollback;
let r = unsafe {
sys::ghostty_terminal_set(
handle,
sys::GhosttyTerminalOption::GHOSTTY_TERMINAL_OPT_SCROLLBACK_MAX_LINES,
&scrollback as *const _ as *const c_void,
)
};
if let Err(e) = check(r) {
unsafe { sys::ghostty_terminal_free(handle) };
return Err(e);
}
Ok(Terminal {
handle,
write_pty_cb: None,
_marker: std::marker::PhantomData,
})
}
pub fn on_pty_write<F>(&mut self, f: F) -> Result<(), Error>
where
F: FnMut(&[u8]) + 'static,
{
if let Some(prev) = self.write_pty_cb.take() {
unsafe { drop(Box::from_raw(prev)) };
}
let inner: Box<WritePtyCallback> = Box::new(f);
let outer: *mut Box<WritePtyCallback> = Box::into_raw(Box::new(inner));
let r = unsafe {
sys::ghostty_terminal_set(
self.handle,
sys::GhosttyTerminalOption::GHOSTTY_TERMINAL_OPT_USERDATA,
outer as *const c_void,
)
};
if let Err(e) = check(r) {
unsafe { drop(Box::from_raw(outer)) };
return Err(e);
}
let r = unsafe {
sys::ghostty_terminal_set(
self.handle,
sys::GhosttyTerminalOption::GHOSTTY_TERMINAL_OPT_WRITE_PTY,
write_pty_trampoline as *const c_void,
)
};
if let Err(e) = check(r) {
unsafe {
sys::ghostty_terminal_set(
self.handle,
sys::GhosttyTerminalOption::GHOSTTY_TERMINAL_OPT_USERDATA,
ptr::null(),
);
drop(Box::from_raw(outer));
}
return Err(e);
}
self.write_pty_cb = Some(outer);
Ok(())
}
pub fn vt_write(&mut self, data: &[u8]) -> Result<(), Error> {
if data.is_empty() {
return Ok(());
}
unsafe {
sys::ghostty_terminal_vt_write(self.handle, data.as_ptr(), data.len());
}
Ok(())
}
pub fn resize(
&mut self,
cols: u16,
rows: u16,
cell_width_px: u32,
cell_height_px: u32,
) -> Result<(), Error> {
let r = unsafe {
sys::ghostty_terminal_resize(self.handle, cols, rows, cell_width_px, cell_height_px)
};
check(r)
}
pub fn cols(&self) -> Result<u16, Error> {
let mut out = MaybeUninit::<u16>::uninit();
let r = unsafe {
sys::ghostty_terminal_get(
self.handle,
sys::GhosttyTerminalData::GHOSTTY_TERMINAL_DATA_COLS,
out.as_mut_ptr().cast(),
)
};
check(r)?;
Ok(unsafe { out.assume_init() })
}
pub fn rows(&self) -> Result<u16, Error> {
let mut out = MaybeUninit::<u16>::uninit();
let r = unsafe {
sys::ghostty_terminal_get(
self.handle,
sys::GhosttyTerminalData::GHOSTTY_TERMINAL_DATA_ROWS,
out.as_mut_ptr().cast(),
)
};
check(r)?;
Ok(unsafe { out.assume_init() })
}
pub fn is_mouse_tracking(&self) -> Result<bool, Error> {
let mut out = MaybeUninit::<bool>::uninit();
let r = unsafe {
sys::ghostty_terminal_get(
self.handle,
sys::GhosttyTerminalData::GHOSTTY_TERMINAL_DATA_MOUSE_TRACKING,
out.as_mut_ptr().cast(),
)
};
check(r)?;
Ok(unsafe { out.assume_init() })
}
pub fn title(&self) -> Option<String> {
let mut out = MaybeUninit::<sys::GhosttyString>::uninit();
let r = unsafe {
sys::ghostty_terminal_get(
self.handle,
sys::GhosttyTerminalData::GHOSTTY_TERMINAL_DATA_TITLE,
out.as_mut_ptr().cast(),
)
};
if r != sys::GhosttyResult::GHOSTTY_SUCCESS {
return None;
}
let s = unsafe { out.assume_init() };
if s.ptr.is_null() || s.len == 0 {
return None;
}
let bytes = unsafe { std::slice::from_raw_parts(s.ptr, s.len) };
Some(String::from_utf8_lossy(bytes).into_owned())
}
pub fn scroll_viewport(&mut self, sv: ScrollViewport) {
let (tag, value) = match sv {
ScrollViewport::Top => (
sys::GhosttyTerminalScrollViewportTag::GHOSTTY_SCROLL_VIEWPORT_TOP,
sys::GhosttyTerminalScrollViewportValue { delta: 0 },
),
ScrollViewport::Bottom => (
sys::GhosttyTerminalScrollViewportTag::GHOSTTY_SCROLL_VIEWPORT_BOTTOM,
sys::GhosttyTerminalScrollViewportValue { delta: 0 },
),
ScrollViewport::Delta(d) => (
sys::GhosttyTerminalScrollViewportTag::GHOSTTY_SCROLL_VIEWPORT_DELTA,
sys::GhosttyTerminalScrollViewportValue { delta: d },
),
};
let behavior = sys::GhosttyTerminalScrollViewport { tag, value };
unsafe {
sys::ghostty_terminal_scroll_viewport(self.handle, behavior);
}
}
pub(crate) fn as_raw(&self) -> sys::GhosttyTerminal {
self.handle
}
}
impl Drop for Terminal<'_, '_> {
fn drop(&mut self) {
unsafe {
sys::ghostty_terminal_set(
self.handle,
sys::GhosttyTerminalOption::GHOSTTY_TERMINAL_OPT_WRITE_PTY,
ptr::null(),
);
sys::ghostty_terminal_set(
self.handle,
sys::GhosttyTerminalOption::GHOSTTY_TERMINAL_OPT_USERDATA,
ptr::null(),
);
}
if let Some(outer) = self.write_pty_cb.take() {
unsafe { drop(Box::from_raw(outer)) };
}
unsafe { sys::ghostty_terminal_free(self.handle) };
}
}
unsafe extern "C" fn write_pty_trampoline(
_terminal: sys::GhosttyTerminal,
userdata: *mut c_void,
data: *const u8,
len: usize,
) {
if userdata.is_null() || data.is_null() {
return;
}
let outer = userdata as *mut Box<WritePtyCallback>;
let bytes = unsafe { std::slice::from_raw_parts(data, len) };
unsafe {
(**outer)(bytes);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::rc::Rc;
#[test]
fn on_pty_write_receives_dsr_reply() {
let mut term = Terminal::new(TerminalOptions {
cols: 20,
rows: 5,
max_scrollback: 0,
})
.unwrap();
let sink: Rc<RefCell<Vec<u8>>> = Rc::new(RefCell::new(Vec::new()));
{
let sink = Rc::clone(&sink);
term.on_pty_write(move |bytes| {
sink.borrow_mut().extend_from_slice(bytes);
})
.unwrap();
}
term.vt_write(b"\x1b[6n").unwrap();
let reply = sink.borrow();
assert!(
reply.starts_with(b"\x1b["),
"expected CSI-prefixed DSR reply, got {reply:?}"
);
assert!(
reply.ends_with(b"R"),
"expected DSR reply to terminate with 'R', got {reply:?}"
);
}
#[test]
fn on_pty_write_can_be_reinstalled() {
let mut term = Terminal::new(TerminalOptions {
cols: 20,
rows: 5,
max_scrollback: 0,
})
.unwrap();
let first: Rc<RefCell<Vec<u8>>> = Rc::new(RefCell::new(Vec::new()));
{
let first = Rc::clone(&first);
term.on_pty_write(move |b| first.borrow_mut().extend_from_slice(b))
.unwrap();
}
let second: Rc<RefCell<Vec<u8>>> = Rc::new(RefCell::new(Vec::new()));
{
let second = Rc::clone(&second);
term.on_pty_write(move |b| second.borrow_mut().extend_from_slice(b))
.unwrap();
}
term.vt_write(b"\x1b[6n").unwrap();
assert!(
first.borrow().is_empty(),
"old callback should have been unregistered"
);
assert!(
!second.borrow().is_empty(),
"new callback should have received DSR reply"
);
}
#[test]
fn scrollback_max_lines_round_trips() {
let requested: usize = 123;
let term = Terminal::new(TerminalOptions {
cols: 20,
rows: 5,
max_scrollback: requested,
})
.unwrap();
let mut out = std::mem::MaybeUninit::<usize>::uninit();
let r = unsafe {
sys::ghostty_terminal_get(
term.handle,
sys::GhosttyTerminalData::GHOSTTY_TERMINAL_DATA_SCROLLBACK_MAX_LINES,
out.as_mut_ptr().cast(),
)
};
assert_eq!(
r,
sys::GhosttyResult::GHOSTTY_SUCCESS,
"SCROLLBACK_MAX_LINES query should succeed for a configured limit"
);
assert_eq!(
unsafe { out.assume_init() },
requested,
"configured limit must round-trip through _set/_get"
);
}
#[test]
fn drop_after_on_pty_write_is_clean() {
let mut term = Terminal::new(TerminalOptions {
cols: 20,
rows: 5,
max_scrollback: 0,
})
.unwrap();
let sink: Rc<RefCell<Vec<u8>>> = Rc::new(RefCell::new(Vec::new()));
{
let sink = Rc::clone(&sink);
term.on_pty_write(move |b| sink.borrow_mut().extend_from_slice(b))
.unwrap();
}
term.vt_write(b"\x1b[6n").unwrap();
drop(term);
assert!(!sink.borrow().is_empty());
}
}