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

+1327
View File
@@ -0,0 +1,1327 @@
//! `performBindingAction`, search, and screen file dumps (Zig `Surface.zig`:
//! `Search`, `searchCallback`, `dumpText`, `performBindingAction`,
//! `closingAction`, `writeScreenFile`).
use std::io::Write as _;
use std::sync::Arc;
use std::thread::JoinHandle;
use ghostty_apprt::action::{
Action as RtAction, CloseTabMode as RtCloseTabMode, FloatWindow, Fullscreen,
GotoSplit as RtGotoSplit, GotoTab, GotoWindow as RtGotoWindow, Inspector as RtInspector,
KeyTable, MoveTab, OpenUrl, OpenUrlKind, PromptTitle, Readonly, ReloadConfig,
ResizeSplit as RtResizeSplit, ResizeSplitDirection, SecureInput, SetTitle,
SplitDirection as RtSplit, StartSearch, Target,
};
use ghostty_apprt::runtime_api::{CoreError, RtApp, SurfaceId};
use ghostty_apprt::structs::{Clipboard, ClipboardRequest};
use ghostty_apprt::surface::Message as SurfaceMessage;
use ghostty_config::types_a::NonNativeFullscreen;
use ghostty_datastruct::blocking_queue::Timeout;
use ghostty_input::binding::{
Action, AdjustSelection, CloseTabMode, CrashThread, InspectorMode, NavigateSearch, Scope,
SplitDirection, SplitFocusDirection, SplitResizeDirection, WriteScreen, WriteScreenAction,
WriteScreenFormat,
};
use ghostty_renderer::message::Message as RendererMessage;
use ghostty_renderer::state::SharedState;
use ghostty_renderer::thread::Handle as RendererHandle;
use ghostty_terminal::formatter::{self, Content, Format, ScreenFormatter};
use ghostty_terminal::point::Tag;
use ghostty_terminal::screen::Scroll;
use ghostty_terminal::search::screen::Select;
use ghostty_terminal::search::thread::{
Event, Message as SearchMessage, Options as SearchOptions, TerminalLock, TerminalRef,
Thread as SearchThread,
};
use ghostty_terminal::selection::{Adjustment, Order, Selection};
use ghostty_terminal::terminal::{ScrollViewport, Terminal};
use ghostty_termio::message::Message as IoMessage;
use ghostty_termio::{SharedStateExt, TerminalGuard};
use super::Surface;
use crate::app::Mailbox as AppMailbox;
/// The maximum number of key tables that can be active at any given time
/// (Zig: `max_active_key_tables`). Enforced by the keyboard module.
pub const MAX_ACTIVE_KEY_TABLES: usize = 8;
// ----------------------------------------------------------------------
// Search
/// The search state for the surface (Zig: `Surface.Search`).
pub struct Search {
handle: ghostty_terminal::search::thread::Handle,
thread: Option<JoinHandle<()>>,
}
/// Lets the search thread lock the terminal inside the shared render state.
struct StateLock(SharedState);
impl TerminalLock for StateLock {
fn lock_terminal(&self) -> Box<dyn TerminalRef + '_> {
Box::new(self.0.lock_terminal())
}
}
// `TerminalGuard` derefs mutably to the terminal.
fn _assert_guard(g: TerminalGuard<'_>) -> impl TerminalRef + '_ {
g
}
impl Search {
/// Start the search thread (nothing is searched until a needle is sent).
fn start(surface: &Surface) -> Result<Search, CoreError> {
let renderer = surface.renderer_handle.clone();
let mailbox = surface.surface_mailbox();
let cb = Arc::new(move |event: Event| search_callback(&renderer, &mailbox, event));
let (handle, thread) = SearchThread::spawn(SearchOptions {
terminal: Arc::new(StateLock(Arc::clone(&surface.renderer_state))),
event_cb: Some(cb),
})?;
Ok(Search {
handle,
thread: Some(thread),
})
}
/// Stop the search thread and free the state.
pub fn deinit(mut self) {
// Notify the thread to stop
self.handle.stop();
// Wait for the OS thread to quit
if let Some(t) = self.thread.take()
&& t.join().is_err()
{
log::warn!("search thread panicked");
}
}
}
/// Events from the search thread (Zig: `searchCallback_`).
///
/// IMPORTANT: this runs on the SEARCH THREAD! It must only touch things that
/// are safe from any thread.
fn search_callback(
renderer: &RendererHandle,
mailbox: &ghostty_apprt::surface::Mailbox<SurfaceId, AppMailbox>,
event: Event,
) {
match event {
Event::ViewportMatches(matches) => {
renderer.send(RendererMessage::SearchViewportMatches(matches));
}
Event::SelectedMatch(selected) => {
if let Some(sel) = selected {
renderer.send(RendererMessage::SearchSelectedMatch(Some(sel.highlight)));
// Send the selected index to the surface mailbox
mailbox.push(
SurfaceMessage::SearchSelected(Some(sel.idx)),
Timeout::Forever,
);
} else {
// Reset our selected match
renderer.send(RendererMessage::SearchSelectedMatch(None));
// Reset the selected index
mailbox.push(SurfaceMessage::SearchSelected(None), Timeout::Forever);
}
}
Event::TotalMatches(total) => {
mailbox.push(SurfaceMessage::SearchTotal(Some(total)), Timeout::Forever);
}
// When we quit, tell our renderer to reset any search state.
Event::Quit => {
renderer.send(RendererMessage::SearchSelectedMatch(None));
renderer.send(RendererMessage::SearchViewportMatches(Vec::new()));
// Reset search totals in the surface
mailbox.push(SurfaceMessage::SearchTotal(None), Timeout::Forever);
mailbox.push(SurfaceMessage::SearchSelected(None), Timeout::Forever);
}
// Unhandled, so far.
Event::Complete => {}
}
}
// ----------------------------------------------------------------------
// Text dumping
/// Represents text read from the terminal and some metadata about it that is
/// often useful to apprts (Zig: `Surface.Text`).
#[derive(Debug, Clone, PartialEq)]
pub struct Text {
/// The text that was read from the terminal.
pub text: String,
/// The viewport information about this text, if it is visible in the
/// viewport.
pub viewport: Option<TextViewport>,
}
/// See [`Text::viewport`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TextViewport {
/// The top-left corner of the selection in pixels within the viewport.
pub tl_px_x: f64,
pub tl_px_y: f64,
/// The linear offset of the start of the selection and the length. This
/// is "linear" in the sense that it is the offset in the flattened
/// viewport as a single array of text.
///
/// Note: these values are currently wrong if there is a partially
/// visible selection in the viewport (i.e. the top-left or bottom-right
/// of the selection is outside the viewport). The wrong values will
/// always be within the text bounds so we aren't risking an overflow.
pub offset_start: u32,
pub offset_len: u32,
}
/// Pixel metrics needed to place a text selection within the viewport.
#[derive(Debug, Clone, Copy)]
pub struct ViewportMetrics {
pub cell_width: u32,
pub cell_height: u32,
pub cell_baseline: u32,
pub padding_left: u32,
pub padding_top: u32,
pub scale_x: f32,
pub scale_y: f32,
}
/// The viewport placement of `sel` (the second half of Zig's
/// `dumpTextLocked`).
#[must_use]
pub fn text_viewport(t: &Terminal, sel: Selection, m: ViewportMetrics) -> Option<TextViewport> {
let screen = t.screen();
let pages = &screen.pages;
// If our bottom right pin is before the viewport, then we can't
// possibly have this text be within the viewport.
let vp_tl_pin = pages.get_top_left(Tag::Viewport)?;
let br_pin = sel.bottom_right(pages);
if br_pin.before(pages, vp_tl_pin) {
return None;
}
// If our top-left pin is after the viewport, then we can't possibly have
// this text be within the viewport.
let Some(vp_br_pin) = pages.get_bottom_right(Tag::Viewport) else {
log::warn!("viewport bottom-right pin not found, bug?");
return None;
};
let tl_pin = sel.top_left(pages);
if vp_br_pin.before(pages, tl_pin) {
return None;
}
// We established that our top-left somewhere before the viewport
// bottom-right and that our bottom-right is somewhere after the
// top-left. This means that at least some portion of our selection is
// within the viewport.
// Our top-left point. If it doesn't exist in the viewport it must be
// before and we can return (0,0).
let tl_coord = pages
.point_from_pin(Tag::Viewport, tl_pin)
.map_or((0u32, 0u32), |p| {
let c = p.coord();
(u32::from(c.x), c.y)
});
// Our bottom-right point. If it doesn't exist in the viewport it must be
// the bottom-right of the viewport.
let br_coord = pages
.point_from_pin(Tag::Viewport, br_pin)
.or_else(|| pages.point_from_pin(Tag::Viewport, vp_br_pin))
.map(|p| {
let c = p.coord();
(u32::from(c.x), c.y)
})?;
// Our sizes are all scaled so we need to send the unscaled values back.
let x = {
// Simple x * cell width gives the left
let mut x = f64::from(tl_coord.0 * m.cell_width);
// Add padding
x += f64::from(m.padding_left);
// Scale
x / f64::from(m.scale_x)
};
let y = {
// Simple y * cell height gives the top
let mut y = f64::from(tl_coord.1 * m.cell_height);
// We want the text baseline
y += f64::from(m.cell_height);
y -= f64::from(m.cell_baseline);
// Add padding
y += f64::from(m.padding_top);
// Scale
y / f64::from(m.scale_y)
};
// Utilize viewport sizing to convert to offsets
let cols = u32::from(pages.cols);
let start = tl_coord.1 * cols + tl_coord.0;
let end = br_coord.1 * cols + br_coord.0;
Some(TextViewport {
tl_px_x: x,
tl_px_y: y,
offset_start: start,
offset_len: end.saturating_sub(start),
})
}
// ----------------------------------------------------------------------
// Screen files
/// The portion of the screen to write for `writeScreenFile`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WriteScreenLoc {
/// Full screen
Screen,
/// History (scrollback)
History,
/// Selected text
Selection,
}
impl WriteScreenLoc {
fn name(self) -> &'static str {
match self {
WriteScreenLoc::Screen => "screen",
WriteScreenLoc::History => "history",
WriteScreenLoc::Selection => "selection",
}
}
}
/// The file name used for a screen dump.
#[must_use]
pub fn screen_file_name(loc: WriteScreenLoc, emit: WriteScreenFormat) -> String {
format!(
"{}.{}",
loc.name(),
match emit {
WriteScreenFormat::Plain | WriteScreenFormat::Vt => "txt",
WriteScreenFormat::Html => "html",
}
)
}
/// Format the requested portion of the active screen. Returns `None` when
/// there is no data (no selection, no history, ...).
pub fn screen_file_contents(
t: &Terminal,
loc: WriteScreenLoc,
emit: WriteScreenFormat,
) -> Result<Option<String>, CoreError> {
// We only dump history if we have history. We still keep the file and
// write the empty file to the pty so that this command always works on
// the primary screen.
let screen = t.screen();
let pages = &screen.pages;
let sel = match loc {
WriteScreenLoc::History => {
// We do not support this for alternate screens because they
// don't have scrollback anyways.
if t.screens.active_key == ghostty_terminal::screen_set::Key::Alternate {
None
} else {
match (
pages.get_top_left(Tag::History),
pages.get_bottom_right(Tag::History),
) {
(Some(tl), Some(br)) => Some(Selection::new(tl, br, false)),
_ => None,
}
}
}
WriteScreenLoc::Screen => match (
pages.get_top_left(Tag::Screen),
pages.get_bottom_right(Tag::Screen),
) {
(Some(tl), Some(br)) => Some(Selection::new(tl, br, false)),
_ => None,
},
WriteScreenLoc::Selection => screen.selection,
};
// If we have no selection we have no data so we do nothing.
let Some(sel) = sel else {
return Ok(None);
};
let mut opts = formatter::Options::new(match emit {
WriteScreenFormat::Plain => Format::Plain,
WriteScreenFormat::Vt => Format::Vt,
WriteScreenFormat::Html => Format::Html,
});
opts.unwrap = true;
opts.trim = false;
opts.background = t.colors.background.get();
opts.foreground = t.colors.foreground.get();
opts.palette = Some(t.colors.palette.current);
let mut f = ScreenFormatter::new(screen, opts);
f.content = Content::Selection(Some(sel.ordered(pages, Order::Forward)));
Ok(Some(
f.format_string()
.map_err(|e| format!("error formatting screen: {e:?}"))?,
))
}
/// Write `contents` to `<tempdir>/<loc>.<ext>` (mode 0600) and return the
/// canonical path. The temporary directory is intentionally left behind so
/// the file stays around for the user (Zig does not delete it either).
pub fn write_screen_temp_file(
contents: &str,
loc: WriteScreenLoc,
emit: WriteScreenFormat,
) -> Result<String, CoreError> {
use std::os::unix::fs::OpenOptionsExt as _;
// Create a temporary directory to store our scrollback.
let tmp_dir = ghostty_os::temp_dir::TempDir::init()?;
let filename = screen_file_name(loc, emit);
let file_path = tmp_dir.path().join(&filename);
let result = (|| -> Result<String, CoreError> {
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&file_path)?;
file.write_all(contents.as_bytes())?;
file.flush()?;
Ok(std::fs::canonicalize(&file_path)?
.to_string_lossy()
.into_owned())
})();
match result {
Ok(path) => {
// Keep the directory (and thus the file) on disk.
std::mem::forget(tmp_dir);
Ok(path)
}
Err(err) => {
tmp_dir.deinit();
Err(err)
}
}
}
/// Returns true if performing the given action result in closing the
/// surface. This is used to determine if our self pointer is still valid
/// after performing some binding action.
#[must_use]
#[allow(dead_code)]
pub(crate) fn closing_action(action: &Action) -> bool {
matches!(
action,
Action::CloseSurface | Action::CloseWindow | Action::CloseTab(_)
)
}
/// The number of rows to scroll for `scroll_page_fractional`.
#[must_use]
pub fn fractional_scroll_delta(fraction: f32, rows: u16) -> isize {
(fraction * f32::from(rows)).trunc() as isize
}
/// Clamp a font size delta like Zig (`@max(0, @min(255, delta))`).
#[must_use]
pub fn clamp_font_delta(delta: f32) -> f32 {
delta.clamp(0.0, 255.0)
}
fn adjustment_for(a: AdjustSelection) -> Adjustment {
match a {
AdjustSelection::Left => Adjustment::Left,
AdjustSelection::Right => Adjustment::Right,
AdjustSelection::Up => Adjustment::Up,
AdjustSelection::Down => Adjustment::Down,
AdjustSelection::PageUp => Adjustment::PageUp,
AdjustSelection::PageDown => Adjustment::PageDown,
AdjustSelection::Home => Adjustment::Home,
AdjustSelection::End => Adjustment::End,
AdjustSelection::BeginningOfLine => Adjustment::BeginningOfLine,
AdjustSelection::EndOfLine => Adjustment::EndOfLine,
}
}
/// Adjust the selection of the active screen and scroll the endpoint into
/// view. Returns false if there is no selection (the action isn't
/// performed).
pub fn adjust_selection(t: &mut Terminal, direction: AdjustSelection) -> bool {
let screen = t.screen_mut();
let Some(mut sel) = screen.selection else {
// If we don't have a selection we do not perform this action,
// allowing the keybind to fall through to the terminal.
return false;
};
sel.adjust(&mut screen.pages, adjustment_for(direction));
screen.selection = Some(sel);
// If the selection endpoint is outside of the current viewpoint, scroll
// it in to view. Note we always specifically use sel.end because that is
// what adjust modifies.
'scroll: {
let viewport_tl = screen
.pages
.get_top_left(Tag::Viewport)
.expect("viewport top left");
let Some(viewport_br) = screen.pages.get_bottom_right(Tag::Viewport) else {
break 'scroll;
};
let end = sel.end(&screen.pages);
if end.is_between(&screen.pages, viewport_tl, viewport_br) {
break 'scroll;
}
// Our end point is not within the viewport. If the end point is
// after the br then we need to adjust the end so that it is at the
// bottom right of the viewport.
let target = if end.before(&screen.pages, viewport_tl) {
end
} else {
let rows = usize::from(screen.pages.rows);
end.up(&screen.pages, rows - 1).unwrap_or(end)
};
screen.scroll(Scroll::Pin(target));
}
// Queue a render so its shown
screen.dirty.selection = true;
true
}
/// Perform an app-scoped binding action (Zig: `App.performAction`).
fn perform_app_action(rt: &dyn RtApp, action: &Action) {
let a = match action {
Action::Ignore => return,
Action::Unbind => {
log::warn!("unbind action reached the app");
return;
}
Action::Quit => RtAction::Quit,
Action::NewWindow => RtAction::NewWindow,
Action::OpenConfig => RtAction::OpenConfig,
Action::ReloadConfig => RtAction::ReloadConfig(ReloadConfig::default()),
Action::CloseAllWindows => RtAction::CloseAllWindows,
Action::ToggleQuickTerminal => RtAction::ToggleQuickTerminal,
Action::ToggleVisibility => RtAction::ToggleVisibility,
Action::CheckForUpdates => RtAction::CheckForUpdates,
Action::ShowGtkInspector => RtAction::ShowGtkInspector,
Action::Undo => RtAction::Undo,
Action::Redo => RtAction::Redo,
_ => return,
};
rt.perform_action(Target::App, a);
}
impl Surface {
/// Grab the value of text at the given selection point. Note that the
/// selection structure is used as a way to determine the area of the
/// screen to read from, it doesn't have to match the user's current
/// selection state (Zig: `dumpText`).
pub fn dump_text(&self, sel: Selection) -> Result<Text, CoreError> {
let t = self.lock_terminal();
self.dump_text_locked(&t, sel)
}
/// Same as [`Surface::dump_text`] but assumes the renderer state mutex
/// is already held.
pub fn dump_text_locked(&self, t: &Terminal, sel: Selection) -> Result<Text, CoreError> {
// Read out the text
let text = t
.screen()
.selection_string(ghostty_terminal::screen::SelectionString { sel, trim: false });
let scale = self.rt_surface.get_content_scale();
let size = self.size.get();
let baseline = self.font.borrow().metrics.cell_baseline;
let viewport = text_viewport(
t,
sel,
ViewportMetrics {
cell_width: size.cell.width,
cell_height: size.cell.height,
cell_baseline: baseline,
padding_left: size.padding.left,
padding_top: size.padding.top,
scale_x: scale.x,
scale_y: scale.y,
},
);
Ok(Text { text, viewport })
}
/// Open a URL through the runtime, falling back to the default opener
/// (Zig: `openUrl`).
pub(crate) fn open_url_with_fallback(&self, action: OpenUrl) {
// If the apprt handles it then we're done.
let kind = action.kind;
let url = action.url.clone();
if self.perform(RtAction::OpenUrl(action)) {
return;
}
// apprt didn't handle it, fallback to our simple cross-platform URL
// opener. We log a warning because we want well-behaved apprts to
// handle this themselves.
log::warn!("apprt did not handle open URL action, falling back to default opener");
let kind = match kind {
OpenUrlKind::Unknown => ghostty_os::open::OpenUrlKind::Unknown,
OpenUrlKind::Text => ghostty_os::open::OpenUrlKind::Text,
OpenUrlKind::Html => ghostty_os::open::OpenUrlKind::Html,
};
if let Err(err) = ghostty_os::open::open(kind, &url) {
log::warn!("failed to open url err={err}");
}
}
/// Write the screen/history/selection to a temp file and paste/copy/open
/// the path (Zig: `writeScreenFile`).
fn write_screen_file(
&self,
loc: WriteScreenLoc,
write_screen: WriteScreen,
) -> Result<(), CoreError> {
// Write the scrollback contents. This requires a lock.
let contents = {
let t = self.lock_terminal();
screen_file_contents(&t, loc, write_screen.emit)?
};
let Some(contents) = contents else {
return Ok(());
};
let path = write_screen_temp_file(&contents, loc, write_screen.emit)?;
match write_screen.action {
WriteScreenAction::Copy => {
self.rt_surface.set_clipboard(
Clipboard::Standard,
&[("text/plain".to_owned(), path)],
false,
);
}
WriteScreenAction::Open => self.open_url_with_fallback(OpenUrl {
kind: match write_screen.emit {
WriteScreenFormat::Plain | WriteScreenFormat::Vt => OpenUrlKind::Text,
WriteScreenFormat::Html => OpenUrlKind::Html,
},
url: path,
}),
WriteScreenAction::Paste => self.queue_io(IoMessage::write_req(path.as_bytes())),
}
Ok(())
}
/// Update `mouse-reporting` on this surface's config (Zig mutates the
/// derived config in place).
fn toggle_mouse_reporting(&self) {
let mut config = (*self.cfg().config).clone();
config.mouse_reporting = !config.mouse_reporting;
log::debug!("mouse reporting toggled: {}", config.mouse_reporting);
match super::config::DerivedConfig::new(Arc::new(config)) {
Ok(d) => *self.config.borrow_mut() = std::rc::Rc::new(d),
Err(err) => log::warn!("failed to toggle mouse reporting err={err}"),
}
}
/// Adjust the font size and mark whether it was manually adjusted.
fn adjust_font_size(
&self,
f: impl FnOnce(f32, f32) -> f32,
adjusted: bool,
) -> Result<(), CoreError> {
let mut size = self.font.borrow().size;
size.points = f(size.points, self.cfg().original_font_size);
self.set_font_size(size)?;
self.font.borrow_mut().adjusted = adjusted;
Ok(())
}
/// Perform a binding action. A binding is a keybinding. This function
/// must be called from the GUI thread.
///
/// This function returns true if the binding action was performed. This
/// may return false if the binding action is not supported or if the
/// binding action would do nothing (i.e. previous tab with no tabs).
///
/// NOTE: At the time of writing this comment, only previous/next tab
/// will ever return false. We can expand this in the future if it
/// becomes useful. We did previous/next tab so we could implement #498.
pub fn perform_binding_action(&self, action: &Action) -> Result<bool, CoreError> {
// Forward app-scoped actions to the app. Some app-scoped actions are
// special-cased here because they do some special things when
// performed from the surface.
if action.scope() == Scope::App {
match action {
// The new window has this surface as its parent.
Action::NewWindow => {
self.rt_app
.perform_action(Target::Surface(self.id), RtAction::NewWindow);
}
// Undo and redo both support both surface and app targeting.
// If we are triggering on a surface then we perform the
// action with the surface target.
Action::Undo => return Ok(self.perform(RtAction::Undo)),
Action::Redo => return Ok(self.perform(RtAction::Redo)),
_ => perform_app_action(self.rt_app.as_ref(), action),
}
return Ok(true);
}
match action {
Action::Csi(data) | Action::Esc(data) => {
// We need to send the CSI/ESC sequence as a single write
// request. If you split it across two then the shell can
// interpret it as two literals.
let full = if matches!(action, Action::Csi(_)) {
format!("\x1b[{data}")
} else {
format!("\x1b{data}")
};
self.queue_io(IoMessage::write_req(full.as_bytes()));
// CSI/ESC triggers a scroll.
self.scroll_to_bottom(&mut self.lock_terminal());
}
Action::Text(data) => {
let text = match ghostty_config::string::parse(data) {
Ok(t) => t,
Err(err) => {
log::warn!("error parsing text binding text={data} err={err:?}");
return Ok(true);
}
};
self.queue_io(IoMessage::write_req(text.as_bytes()));
// Text triggers a scroll.
self.scroll_to_bottom(&mut self.lock_terminal());
}
Action::CursorKey(ck) => {
// We send a different sequence depending on if we're in
// cursor keys mode. We're in "normal" mode if cursor keys
// mode is NOT set.
let normal = {
let mut t = self.lock_terminal();
// 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);
!t.modes.get(ghostty_vt::modes::Mode::CursorKeys)
};
if normal {
self.queue_io(IoMessage::write_req(ck.normal.as_bytes()));
} else {
self.queue_io(IoMessage::write_req(ck.application.as_bytes()));
}
}
Action::Reset => {
self.lock_terminal().full_reset();
}
Action::StartSearch => {
// To save resources, we don't actually start a search here,
// we just notify the apprt. The real thread will start when
// the first needles are set.
return Ok(self.perform(RtAction::StartSearch(StartSearch {
needle: String::new(),
})));
}
Action::SearchSelection => {
let Some(selection) = self.selection_string() else {
return Ok(false);
};
return Ok(self.perform(RtAction::StartSearch(StartSearch { needle: selection })));
}
Action::EndSearch => {
// We only return that this was performed if we actually
// stopped a search, but we also send the apprt end_search so
// that GUIs can clean up stale stuff.
let search = self.search.borrow_mut().take();
let performed = search.is_some();
if let Some(s) = search {
s.deinit();
}
self.perform(RtAction::EndSearch);
return Ok(performed);
}
Action::Search(text) => {
if self.search.borrow().is_none() {
// If we're stopping the search and we had no prior
// search, then there is nothing to do.
if text.is_empty() {
return Ok(false);
}
let s = Search::start(self)?;
*self.search.borrow_mut() = Some(s);
}
// Zero-length text means stop searching.
if text.is_empty() {
let s = self.search.borrow_mut().take();
if let Some(s) = s {
s.deinit();
}
} else if let Some(s) = self.search.borrow().as_ref() {
s.handle
.send(SearchMessage::ChangeNeedle(text.as_bytes().to_vec()));
s.handle.wakeup();
}
}
Action::NavigateSearch(nav) => {
let search = self.search.borrow();
let Some(s) = search.as_ref() else {
return Ok(false);
};
s.handle.send(SearchMessage::Select(match nav {
NavigateSearch::Next => Select::Next,
NavigateSearch::Previous => Select::Prev,
}));
s.handle.wakeup();
}
Action::CopyToClipboard(format) => {
let cfg = self.cfg();
let mut t = self.lock_terminal();
if let Some(sel) = t.screen().selection {
self.copy_selection_to_clipboards(&t, sel, &[Clipboard::Standard], *format);
// Clear the selection if configured to do so.
if cfg.selection_clear_on_copy {
if let Err(err) = self.set_selection(&mut t, None) {
log::warn!("failed to clear selection after copy err={err}");
} else {
self.queue_render();
}
}
return Ok(true);
}
return Ok(false);
}
Action::CopyUrlToClipboard => {
// If the mouse isn't over a link, nothing we can do.
let Some(url) = self.link_url_at_cursor() else {
return Ok(false);
};
self.rt_surface.set_clipboard(
Clipboard::Standard,
&[("text/plain".to_owned(), url)],
false,
);
return Ok(true);
}
Action::CopyTitleToClipboard => {
return Ok(self.perform(RtAction::CopyTitleToClipboard));
}
Action::PasteFromClipboard => {
return Ok(
self.start_clipboard_request(Clipboard::Standard, ClipboardRequest::Paste)
);
}
Action::PasteFromSelection => {
return Ok(
self.start_clipboard_request(Clipboard::Selection, ClipboardRequest::Paste)
);
}
Action::IncreaseFontSize(delta) => {
// Max delta is somewhat arbitrary.
let clamped = clamp_font_delta(*delta);
log::debug!("increase font size={clamped}");
// Max point size is somewhat arbitrary.
self.adjust_font_size(|p, _| (p + clamped).min(255.0), true)?;
}
Action::DecreaseFontSize(delta) => {
// Max delta is somewhat arbitrary.
let clamped = clamp_font_delta(*delta);
log::debug!("decrease font size={clamped}");
self.adjust_font_size(|p, _| (p - clamped).max(1.0), true)?;
}
Action::ResetFontSize => {
log::debug!("reset font size");
// Reset font size also resets the manual adjustment state
self.adjust_font_size(|_, original| original, false)?;
}
Action::SetFontSize(points) => {
log::debug!("set font size={points}");
let points = *points;
self.adjust_font_size(|_, _| points.clamp(1.0, 255.0), true)?;
}
Action::PromptSurfaceTitle => {
return Ok(self.perform(RtAction::PromptTitle(PromptTitle::Surface)));
}
Action::PromptTabTitle => {
return Ok(self.perform(RtAction::PromptTitle(PromptTitle::Tab)));
}
Action::SetSurfaceTitle(v) => {
return Ok(self.perform(RtAction::SetTitle(SetTitle { title: v.clone() })));
}
Action::SetTabTitle(v) => {
return Ok(self.perform(RtAction::SetTabTitle(SetTitle { title: v.clone() })));
}
Action::ClearScreen => {
// This is a duplicate of some of the logic in
// termio.clearScreen but we need to do this here so we can
// know the answer before we send the message. If the
// currently active screen is on the alternate screen then
// clear screen does nothing so we want to return false so
// the keybind can be unconsumed.
if self.lock_terminal().screens.active_key
== ghostty_terminal::screen_set::Key::Alternate
{
return Ok(false);
}
self.queue_io(IoMessage::ClearScreen { history: true });
}
Action::ScrollToTop => {
self.queue_io(IoMessage::ScrollViewport(ScrollViewport::Top));
}
Action::ScrollToBottom => {
self.queue_io(IoMessage::ScrollViewport(ScrollViewport::Bottom));
}
Action::ScrollToRow(n) => {
self.lock_terminal().screen_mut().scroll(Scroll::Row(*n));
self.queue_render();
}
Action::ScrollToSelection => {
{
let mut t = self.lock_terminal();
let screen = t.screen_mut();
let Some(sel) = screen.selection else {
return Ok(false);
};
let tl = sel.top_left(&screen.pages);
screen.scroll(Scroll::Pin(tl));
}
self.queue_render();
}
Action::ScrollPageUp => {
let rows = self.size.get().grid().rows as isize;
self.queue_io(IoMessage::ScrollViewport(ScrollViewport::Delta(-rows)));
}
Action::ScrollPageDown => {
let rows = self.size.get().grid().rows as isize;
self.queue_io(IoMessage::ScrollViewport(ScrollViewport::Delta(rows)));
}
Action::ScrollPageFractional(fraction) => {
let delta = fractional_scroll_delta(*fraction, self.size.get().grid().rows);
self.queue_io(IoMessage::ScrollViewport(ScrollViewport::Delta(delta)));
}
Action::ScrollPageLines(lines) => {
self.queue_io(IoMessage::ScrollViewport(ScrollViewport::Delta(
isize::from(*lines),
)));
}
Action::JumpToPrompt(delta) => {
self.queue_io(IoMessage::JumpToPrompt(isize::from(*delta)));
}
Action::WriteScreenFile(v) => self.write_screen_file(WriteScreenLoc::Screen, *v)?,
Action::WriteScrollbackFile(v) => {
self.write_screen_file(WriteScreenLoc::History, *v)?;
}
Action::WriteSelectionFile(v) => {
self.write_screen_file(WriteScreenLoc::Selection, *v)?;
}
Action::NewTab => return Ok(self.perform(RtAction::NewTab)),
Action::CloseTab(v) => {
return Ok(self.perform(RtAction::CloseTab(match v {
CloseTabMode::This => RtCloseTabMode::This,
CloseTabMode::Other => RtCloseTabMode::Other,
CloseTabMode::Right => RtCloseTabMode::Right,
})));
}
Action::PreviousTab => return Ok(self.perform(RtAction::GotoTab(GotoTab::Previous))),
Action::NextTab => return Ok(self.perform(RtAction::GotoTab(GotoTab::Next))),
Action::LastTab => return Ok(self.perform(RtAction::GotoTab(GotoTab::Last))),
Action::GotoTab(v) => {
return Ok(self.perform(RtAction::GotoTab(GotoTab::Index(*v as i32))));
}
Action::MoveTab(position) => {
return Ok(self.perform(RtAction::MoveTab(MoveTab { amount: *position })));
}
Action::NewSplit(direction) => {
let size = self.size.get();
return Ok(self.perform(RtAction::NewSplit(match direction {
SplitDirection::Right => RtSplit::Right,
SplitDirection::Left => RtSplit::Left,
SplitDirection::Down => RtSplit::Down,
SplitDirection::Up => RtSplit::Up,
SplitDirection::Auto => {
if size.screen.width > size.screen.height {
RtSplit::Right
} else {
RtSplit::Down
}
}
})));
}
Action::GotoSplit(direction) => {
return Ok(self.perform(RtAction::GotoSplit(match direction {
SplitFocusDirection::Previous => RtGotoSplit::Previous,
SplitFocusDirection::Next => RtGotoSplit::Next,
SplitFocusDirection::Up => RtGotoSplit::Up,
SplitFocusDirection::Left => RtGotoSplit::Left,
SplitFocusDirection::Down => RtGotoSplit::Down,
SplitFocusDirection::Right => RtGotoSplit::Right,
})));
}
Action::GotoWindow(direction) => {
return Ok(self.perform(RtAction::GotoWindow(match direction {
ghostty_input::binding::GotoWindow::Previous => RtGotoWindow::Previous,
ghostty_input::binding::GotoWindow::Next => RtGotoWindow::Next,
})));
}
Action::ResizeSplit(value) => {
return Ok(self.perform(RtAction::ResizeSplit(RtResizeSplit {
amount: value.1,
direction: match value.0 {
SplitResizeDirection::Up => ResizeSplitDirection::Up,
SplitResizeDirection::Down => ResizeSplitDirection::Down,
SplitResizeDirection::Left => ResizeSplitDirection::Left,
SplitResizeDirection::Right => ResizeSplitDirection::Right,
},
})));
}
Action::EqualizeSplits => return Ok(self.perform(RtAction::EqualizeSplits)),
Action::ToggleSplitZoom => return Ok(self.perform(RtAction::ToggleSplitZoom)),
Action::ToggleReadonly => {
let ro = !self.readonly.get();
self.readonly.set(ro);
self.perform(RtAction::Readonly(if ro {
Readonly::On
} else {
Readonly::Off
}));
return Ok(true);
}
Action::ResetWindowSize => return Ok(self.perform(RtAction::ResetWindowSize)),
Action::ToggleMaximize => return Ok(self.perform(RtAction::ToggleMaximize)),
Action::ToggleFullscreen => {
return Ok(self.perform(RtAction::ToggleFullscreen(
match self.cfg().macos_non_native_fullscreen {
NonNativeFullscreen::False => Fullscreen::Native,
NonNativeFullscreen::True => Fullscreen::MacosNonNative,
NonNativeFullscreen::VisibleMenu => Fullscreen::MacosNonNativeVisibleMenu,
NonNativeFullscreen::PaddedNotch => Fullscreen::MacosNonNativePaddedNotch,
},
)));
}
Action::ToggleWindowDecorations => {
return Ok(self.perform(RtAction::ToggleWindowDecorations));
}
Action::ToggleTabOverview => return Ok(self.perform(RtAction::ToggleTabOverview)),
Action::ToggleWindowFloatOnTop => {
return Ok(self.perform(RtAction::FloatWindow(FloatWindow::Toggle)));
}
Action::ToggleSecureInput => {
return Ok(self.perform(RtAction::SecureInput(SecureInput::Toggle)));
}
Action::ToggleMouseReporting => self.toggle_mouse_reporting(),
Action::ToggleCommandPalette => {
return Ok(self.perform(RtAction::ToggleCommandPalette));
}
Action::ToggleBackgroundOpacity => {
return Ok(self.perform(RtAction::ToggleBackgroundOpacity));
}
Action::ShowOnScreenKeyboard => {
return Ok(self.perform(RtAction::ShowOnScreenKeyboard));
}
Action::SelectAll => {
let mut t = self.lock_terminal();
let sel = t.screen().select_all();
if let Some(s) = sel {
self.set_selection(&mut t, Some(s))?;
self.queue_render();
}
}
Action::Inspector(mode) => {
return Ok(self.perform(RtAction::Inspector(match mode {
InspectorMode::Toggle => RtInspector::Toggle,
InspectorMode::Show => RtInspector::Show,
InspectorMode::Hide => RtInspector::Hide,
})));
}
Action::CloseSurface => self.close(),
Action::CloseWindow => return Ok(self.perform(RtAction::CloseWindow)),
Action::ActivateKeyTable(name) => return self.activate_key_table(name, false),
Action::ActivateKeyTableOnce(name) => return self.activate_key_table(name, true),
Action::DeactivateKeyTable => return Ok(self.deactivate_key_table()),
Action::DeactivateAllKeyTables => return Ok(self.deactivate_all_key_tables()),
Action::EndKeySequence => {
// End the key sequence and flush queued keys to the
// terminal, but don't encode the key that triggered this
// action. This will do that because leaf keys (keys with
// bindings) aren't in the queued encoding list.
self.end_key_sequence(
super::keyboard::SequenceAction::Flush,
super::keyboard::SequenceFree::Retain,
);
}
Action::Crash(location) => match location {
CrashThread::Main => panic!("crash binding action, crashing intentionally"),
CrashThread::Render => {
self.renderer_handle.send(RendererMessage::Crash);
self.queue_render();
}
CrashThread::Io => self.queue_io(IoMessage::Crash),
},
Action::AdjustSelection(direction) => {
let performed = adjust_selection(&mut self.lock_terminal(), *direction);
if !performed {
return Ok(false);
}
self.queue_render();
}
// App-scoped actions were handled above.
Action::Ignore
| Action::Unbind
| Action::NewWindow
| Action::OpenConfig
| Action::ReloadConfig
| Action::CloseAllWindows
| Action::ToggleQuickTerminal
| Action::ToggleVisibility
| Action::CheckForUpdates
| Action::ShowGtkInspector
| Action::Undo
| Action::Redo
| Action::Quit => unreachable!("app-scoped actions are handled above"),
}
Ok(true)
}
/// Notify the runtime that a key table was activated (used by the
/// keyboard module when it implements `activate_key_table`).
#[allow(dead_code)]
pub(crate) fn notify_key_table(&self, kt: KeyTable) {
self.perform(RtAction::KeyTable(kt));
}
}
#[cfg(test)]
mod tests {
use super::*;
use ghostty_terminal::terminal::Options;
fn term(cols: u16, rows: u16) -> Terminal {
Terminal::new(Options::new(cols, rows)).unwrap()
}
#[test]
fn closing_action_table() {
assert!(closing_action(&Action::CloseSurface));
assert!(closing_action(&Action::CloseWindow));
assert!(closing_action(&Action::CloseTab(CloseTabMode::This)));
assert!(!closing_action(&Action::NewTab));
assert!(!closing_action(&Action::Reset));
}
#[test]
fn scroll_and_font_math() {
assert_eq!(fractional_scroll_delta(0.5, 24), 12);
assert_eq!(fractional_scroll_delta(-0.5, 25), -12);
assert_eq!(fractional_scroll_delta(1.0, 10), 10);
assert!((clamp_font_delta(-3.0) - 0.0).abs() < f32::EPSILON);
assert!((clamp_font_delta(300.0) - 255.0).abs() < f32::EPSILON);
}
#[test]
fn screen_file_names() {
assert_eq!(
screen_file_name(WriteScreenLoc::Screen, WriteScreenFormat::Plain),
"screen.txt"
);
assert_eq!(
screen_file_name(WriteScreenLoc::History, WriteScreenFormat::Vt),
"history.txt"
);
assert_eq!(
screen_file_name(WriteScreenLoc::Selection, WriteScreenFormat::Html),
"selection.html"
);
}
#[test]
fn screen_file_contents_and_write() {
let mut t = term(20, 3);
t.print_string("hello\r\nworld").unwrap();
let s = screen_file_contents(&t, WriteScreenLoc::Screen, WriteScreenFormat::Plain)
.unwrap()
.unwrap();
assert!(s.contains("hello"));
assert!(s.contains("world"));
// No selection and no history: nothing to write.
assert!(
screen_file_contents(&t, WriteScreenLoc::Selection, WriteScreenFormat::Plain)
.unwrap()
.is_none()
);
let path =
write_screen_temp_file(&s, WriteScreenLoc::Screen, WriteScreenFormat::Plain).unwrap();
assert!(path.ends_with("screen.txt"));
let read = std::fs::read_to_string(&path).unwrap();
assert_eq!(read, s);
let mode = std::os::unix::fs::PermissionsExt::mode(
&std::fs::metadata(&path).unwrap().permissions(),
);
assert_eq!(mode & 0o777, 0o600);
let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap());
}
#[test]
fn adjust_selection_without_selection() {
let mut t = term(10, 3);
assert!(!adjust_selection(&mut t, AdjustSelection::Left));
}
#[test]
fn adjust_selection_moves_end() {
let mut t = term(10, 3);
t.print_string("abcdef").unwrap();
let sel = {
let pages = &t.screen().pages;
let a = pages
.pin(ghostty_terminal::point::Point::Active(
ghostty_terminal::point::Coordinate { x: 0, y: 0 },
))
.unwrap();
let b = pages
.pin(ghostty_terminal::point::Point::Active(
ghostty_terminal::point::Coordinate { x: 2, y: 0 },
))
.unwrap();
Selection::new(a, b, false)
};
t.screen_mut().select(Some(sel)).unwrap();
assert!(adjust_selection(&mut t, AdjustSelection::Right));
let s = t.screen();
let end = s.selection.unwrap().end(&s.pages);
assert_eq!(end.x, 3);
assert!(s.dirty.selection);
}
#[test]
fn text_viewport_placement() {
let mut t = term(10, 3);
t.print_string("abcdef").unwrap();
let (a, b) = {
let pages = &t.screen().pages;
let p = |x| {
pages
.pin(ghostty_terminal::point::Point::Active(
ghostty_terminal::point::Coordinate { x, y: 0 },
))
.unwrap()
};
(p(1), p(3))
};
let m = ViewportMetrics {
cell_width: 10,
cell_height: 20,
cell_baseline: 4,
padding_left: 2,
padding_top: 3,
scale_x: 2.0,
scale_y: 1.0,
};
let v = text_viewport(&t, Selection::new(a, b, false), m).unwrap();
assert!((v.tl_px_x - 6.0).abs() < 1e-9); // (10 + 2) / 2
assert!((v.tl_px_y - 19.0).abs() < 1e-9); // 0 + 20 - 4 + 3
assert_eq!(v.offset_start, 1);
assert_eq!(v.offset_len, 2);
}
}
@@ -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)
}
}
+87
View File
@@ -0,0 +1,87 @@
//! The configuration a surface has (Zig: `Surface.DerivedConfig`).
//!
//! Zig copies a subset of the main config so no pointer to it needs to stay
//! alive. In Rust the whole (conditional-state-applied) [`Config`] is kept
//! behind an `Arc`, which is the same thing, and `DerivedConfig` derefs to
//! it so the fields keep their Zig names (`cfg.copy_on_select`, ...). The
//! few values that are *computed* in Zig live as fields of this struct.
use std::ops::Deref;
use std::sync::Arc;
use ghostty_config::config::Config;
use ghostty_input::link::{Action as LinkAction, Highlight};
use onig::Regex;
use ghostty_renderer::size::Padding;
/// A link with its compiled regex (Zig: `DerivedConfig.Link`).
pub struct Link {
pub regex: Regex,
pub action: LinkAction,
pub highlight: Highlight,
}
/// See the module docs.
pub struct DerivedConfig {
/// The config this was derived from (conditional state already applied).
pub config: Arc<Config>,
/// `font-size`.
pub original_font_size: f32,
/// `click-repeat-interval` in nanoseconds (the config is in ms).
pub mouse_interval: u64,
/// `abnormal-command-exit-runtime` (ms).
pub abnormal_command_exit_runtime_ms: u32,
/// `selection-word-chars` codepoints.
pub selection_word_chars: Vec<u32>,
/// The compiled `link` entries.
pub links: Vec<Link>,
}
impl Deref for DerivedConfig {
type Target = Config;
fn deref(&self) -> &Config {
&self.config
}
}
impl DerivedConfig {
/// Derive the surface configuration. Fails if a link regex is invalid.
pub fn new(config: Arc<Config>) -> Result<DerivedConfig, onig::Error> {
// Build all of our links
let mut links = Vec::with_capacity(config.link.links.len());
for link in &config.link.links {
links.push(Link {
regex: Regex::new(&link.regex)?,
action: link.action,
highlight: link.highlight,
});
}
Ok(DerivedConfig {
original_font_size: config.font_size,
mouse_interval: u64::from(config.click_repeat_interval) * 1_000_000, // 500ms
abnormal_command_exit_runtime_ms: config.abnormal_command_exit_runtime,
selection_word_chars: config.selection_word_chars.codepoints.clone(),
links,
config,
})
}
/// Padding scaled by the DPI (Zig: `scaledPadding`).
#[must_use]
pub fn scaled_padding(&self, x_dpi: f32, y_dpi: f32) -> Padding {
let scale = |v: u32, dpi: f32| -> u32 { (v as f32 * dpi / 72.0).floor() as u32 };
Padding {
top: scale(self.window_padding_y.top_left, y_dpi),
bottom: scale(self.window_padding_y.bottom_right, y_dpi),
left: scale(self.window_padding_x.top_left, x_dpi),
right: scale(self.window_padding_x.bottom_right, x_dpi),
}
}
}
+1138
View File
@@ -0,0 +1,1138 @@
//! Keyboard input for a surface (Zig `Surface.zig`: `keyEventIsBinding`,
//! `keyCallback`, `maybeHandleBinding`, key sequences/tables, `encodeKey`,
//! the key-table binding actions and the key release on focus loss).
//!
//! # Deviations from Zig
//!
//! Zig keeps raw pointers into the config (`*const Binding.Set`) for the
//! current sequence and the key table stack. Here the sequence is stored as
//! a *path* (where it started plus the triggers pressed so far) and tables
//! are stored by name; both are resolved against the current config each
//! time. A config change drops them anyway (`update_config` calls
//! [`Surface::end_key_sequence`] and [`Surface::deactivate_all_key_tables`]).
use ghostty_apprt::action::{Action as RtAction, KeySequence, KeyTable};
use ghostty_apprt::runtime_api::{CoreError, InputEffect};
use ghostty_input::binding::set::Value;
use ghostty_input::binding::{Action as BindingAction, Flags, Scope, Set, Trigger, TriggerKey};
use ghostty_input::key_encode;
use ghostty_input::{Action as KeyAction, Key, KeyEvent, Mods};
use ghostty_terminal::terminal::{MouseEvents, ScrollViewport, Terminal};
use ghostty_termio::message::Message as IoMessage;
use ghostty_vt::modes::Mode;
use ghostty_vt::mouse_shape::MouseShape;
use super::Surface;
use super::config::DerivedConfig;
/// The maximum number of key tables that can be active at any given time.
/// `activate_key_table` calls after this are ignored.
pub const MAX_ACTIVE_KEY_TABLES: usize = 8;
/// Where a sequence (or a lookup) is rooted.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SetRoot {
/// The root keybinding set.
Root,
/// A named key table.
Table(String),
}
/// The currently active key sequence: the set it started in plus the
/// triggers (leader keys) pressed so far.
#[derive(Debug, Clone, PartialEq)]
pub struct SequencePath {
pub root: SetRoot,
pub path: Vec<Trigger>,
}
/// An entry of the key table stack.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableEntry {
pub name: String,
pub once: bool,
}
/// Keyboard state for the surface (Zig: `Surface.Keyboard`).
#[derive(Default)]
pub struct Keyboard {
/// The currently active key sequence for the surface. If this is `None`
/// then we're not currently in a key sequence.
pub sequence: Option<SequencePath>,
/// The queued keys when we're in the middle of a sequenced binding.
/// These are flushed when the sequence is completed and unconsumed or
/// invalid. Naturally bounded by the configuration maximum sequence
/// length.
pub sequence_queued: Vec<IoMessage>,
/// The stack of tables that is currently active. The first value in this
/// is the first activated table (NOT the default keybinding set).
/// Bounded by [`MAX_ACTIVE_KEY_TABLES`].
pub table_stack: Vec<TableEntry>,
/// The hash value of the last keybinding trigger that we performed. Only
/// set if the last key input matched a keybinding, consumed it, and
/// performed it. Used to prevent sending release/repeat events for
/// handled bindings.
pub last_trigger: Option<u64>,
}
/// An owned copy of a [`KeyEvent`] (the `utf8` text is owned). Used for the
/// "currently pressed key" so a release can be sent on focus loss.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OwnedKeyEvent {
pub action: KeyAction,
pub key: Key,
pub mods: Mods,
pub consumed_mods: Mods,
pub composing: bool,
pub utf8: String,
pub unshifted_codepoint: u32,
}
impl Default for OwnedKeyEvent {
fn default() -> Self {
Self::from_event(&KeyEvent::default())
}
}
impl OwnedKeyEvent {
/// Copy an event.
#[must_use]
pub fn from_event(e: &KeyEvent<'_>) -> Self {
OwnedKeyEvent {
action: e.action,
key: e.key,
mods: e.mods,
consumed_mods: e.consumed_mods,
composing: e.composing,
utf8: e.utf8.to_owned(),
unshifted_codepoint: e.unshifted_codepoint,
}
}
/// Borrow as a [`KeyEvent`].
#[must_use]
pub fn as_event(&self) -> KeyEvent<'_> {
KeyEvent {
action: self.action,
key: self.key,
mods: self.mods,
consumed_mods: self.consumed_mods,
composing: self.composing,
utf8: &self.utf8,
unshifted_codepoint: self.unshifted_codepoint,
}
}
}
/// What to do with queued sequence inputs (Zig: `KeySequenceQueued`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SequenceAction {
Drop,
Flush,
}
/// Memory handling of the queue afterwards (Zig: `KeySequenceMemory`). Both
/// release the queued entries in Rust; kept for parity with the Zig API.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SequenceFree {
Free,
Retain,
}
/// `key_encode.Options.fromTerminal`. The macOS option-as-alt value can't be
/// known from the terminal state.
#[must_use]
pub fn encode_options_from_terminal(t: &Terminal) -> key_encode::Options {
key_encode::Options {
alt_esc_prefix: t.modes.get(Mode::AltEscPrefix),
cursor_key_application: t.modes.get(Mode::CursorKeys),
keypad_key_application: t.modes.get(Mode::KeypadKeys),
ignore_keypad_with_numlock: t.modes.get(Mode::IgnoreKeypadWithNumlock),
modify_other_keys_state_2: t.flags.modify_other_keys_2,
kitty_flags: t.screens.active().kitty_keyboard.current(),
macos_option_as_alt: ghostty_input::OptionAsAlt::False,
}
}
/// Resolve a set by root (Zig pointer) against a config.
fn root_set<'a>(cfg: &'a DerivedConfig, root: &SetRoot) -> Option<&'a Set> {
match root {
SetRoot::Root => Some(&cfg.keybind.set),
SetRoot::Table(name) => cfg
.keybind
.tables
.iter()
.find(|(n, _)| n == name)
.map(|(_, s)| s),
}
}
/// Resolve the set a sequence path points at.
fn sequence_set<'a>(cfg: &'a DerivedConfig, seq: &SequencePath) -> Option<&'a Set> {
let mut set = root_set(cfg, &seq.root)?;
for trigger in &seq.path {
match set.get_entry(*trigger)?.1 {
Value::Leader(next) => set = next,
_ => return None,
}
}
Some(set)
}
/// What a lookup found, with everything borrowed copied out.
enum Found {
/// A leader key: the new sequence path.
Leader(Trigger, SequencePath),
/// A leaf (possibly chained).
Leaf {
actions: Vec<BindingAction>,
flags: Flags,
},
}
fn found_from(trigger: Trigger, value: &Value, origin: SetRoot, parent_path: &[Trigger]) -> Found {
match value {
Value::Leader(_) => {
let mut path = parent_path.to_vec();
path.push(trigger);
Found::Leader(trigger, SequencePath { root: origin, path })
}
Value::Leaf(l) => Found::Leaf {
actions: vec![l.action.clone()],
flags: l.flags,
},
Value::LeafChained(l) => Found::Leaf {
actions: l.actions.clone(),
flags: l.flags,
},
}
}
/// `SurfaceMouse.keyToMouseShape` (surface_mouse.zig), needed for the
/// cursor shape update when modifier keys change.
fn key_to_mouse_shape(
physical_key: Key,
mouse_event: MouseEvents,
mods: Mods,
over_link: bool,
hidden: bool,
) -> Option<MouseShape> {
let eligible = physical_key.ctrl_or_super()
|| physical_key.left_or_right_shift()
|| physical_key.left_or_right_alt();
if !eligible {
return None;
}
// Link hover or hidden state overrides any other shape processing.
if over_link || hidden {
return None;
}
let rect_select = mods.ctrl_or_super() && mods.alt;
if mouse_event == MouseEvents::None {
// Default terminal mode: text
Some(if rect_select {
MouseShape::Crosshair
} else {
MouseShape::Text
})
} else if mods.shift && rect_select {
// Mouse tracking mode, overriding with shift + rectangle select.
Some(MouseShape::Crosshair)
} else if mods.shift {
Some(MouseShape::Text)
} else {
Some(MouseShape::Default)
}
}
fn closing_action(action: &BindingAction) -> bool {
matches!(
action,
BindingAction::CloseSurface | BindingAction::CloseWindow | BindingAction::CloseTab(_)
)
}
impl Surface {
/// Apply key remappings (Zig: at the top of `keyEventIsBinding` and
/// `keyCallback`). Returns the event with remapped mods.
fn remap_event<'a>(cfg: &DerivedConfig, event: &KeyEvent<'a>) -> KeyEvent<'a> {
let mut event = event.clone();
if cfg.key_remap.is_remapped(event.mods) {
event.mods = cfg.key_remap.apply(event.mods);
}
event
}
/// Returns the flags of the binding the given key event would trigger,
/// if any. This is useful for determining if a key event should be sent
/// to the terminal or not.
///
/// Note that this function does not check if the binding itself is
/// performable, only if the key event would trigger a binding.
pub fn key_event_is_binding(&self, event: &KeyEvent<'_>) -> Option<Flags> {
let cfg = self.cfg();
// Apply key remappings for consistency with key_callback
let event = Self::remap_event(&cfg, event);
match event.action {
KeyAction::Release => return None,
KeyAction::Press | KeyAction::Repeat => {}
}
let (seq, tables) = {
let kb = self.keyboard.borrow();
(kb.sequence.clone(), kb.table_stack.clone())
};
let value = 'entry: {
// If we're in a sequence, check the sequence set
if let Some(seq) = &seq {
let set = sequence_set(&cfg, seq)?;
break 'entry set.get_event(&event)?.1;
}
// Check active key tables (inner-most to outer-most)
for t in tables.iter().rev() {
if let Some(set) = root_set(&cfg, &SetRoot::Table(t.name.clone()))
&& let Some((_, v)) = set.get_event(&event)
{
break 'entry v;
}
}
// Check the root set
cfg.keybind.set.get_event(&event)?.1
};
Some(match value {
Value::Leader(_) => Flags {
// Zig: `.{}` which are the struct defaults.
..Flags::default()
},
Value::Leaf(l) => l.flags,
Value::LeafChained(l) => l.flags,
})
}
/// Called for any key events. This handles keybindings, encoding and
/// sending to the terminal, etc.
#[allow(clippy::needless_pass_by_value)]
pub fn key_callback(&self, event_orig: KeyEvent<'_>) -> Result<InputEffect, CoreError> {
let cfg = self.cfg();
// Apply key remappings to transform modifiers before any
// processing. This allows users to remap modifier keys at the app
// level.
let event = Self::remap_event(&cfg, &event_orig);
// Handle keybindings first. We need to handle this on all events
// (press, repeat, release) because a press may perform a binding but
// a release should not encode if we consumed the press.
if let Some(v) = self.maybe_handle_binding(&cfg, &event) {
return Ok(v);
}
// If we allow KAM and KAM is enabled then we do nothing.
if cfg.vt_kam_allowed && self.lock_terminal().modes.get(Mode::DisableKeyboard) {
return Ok(InputEffect::Consumed);
}
// If this input event has text, then we hide the mouse if
// configured. We only do this on pressed events to avoid hiding the
// mouse when we change focus due to a keybinding (i.e. switching
// tabs).
if cfg.mouse_hide_while_typing
&& event.action == KeyAction::Press
&& !self.mouse.borrow().hidden
&& !event.utf8.is_empty()
{
self.hide_mouse();
}
// If our mouse modifiers change we may need to change our link
// highlight state.
let mouse_mods = self.mouse.borrow().mods;
if !mouse_mods.equal(event.mods) {
self.refresh_mouse_for_mods_change(&event);
}
// Process the cursor state logic. This will update the cursor shape
// if needed, depending on the key state.
let shape = {
let mouse_event = self.lock_terminal().flags.mouse_event;
let m = self.mouse.borrow();
key_to_mouse_shape(event.key, mouse_event, m.mods, m.over_link, m.hidden)
};
if let Some(shape) = shape {
self.perform(RtAction::MouseShape(shape));
}
// We've processed a key event that produced some data so we want to
// track the last pressed key.
{
let mut pressed = self.pressed_key.borrow_mut();
let prev = pressed.take();
*pressed = 'event: {
// We need to unset the allocated fields that will become
// invalid
let mut copy = OwnedKeyEvent::from_event(&event);
copy.utf8.clear();
// If we have a previous pressed key and we're releasing it
// then we set it to invalid to prevent repeating the
// release event.
if event.action == KeyAction::Release {
// if we didn't have a previous event and this is a
// release event then we just want to set it to null.
let Some(prev) = prev else {
break 'event None;
};
if prev.key == copy.key {
copy.key = Key::Unidentified;
}
}
// If our key is invalid and we have no mods, then we're
// done! This helps catch the state that we naturally
// released all keys.
if copy.key == Key::Unidentified && copy.mods.empty() {
break 'event None;
}
Some(copy)
};
}
// Encode and send our key. If we didn't encode anything, then we
// return the effect as ignored.
let Some(write_req) = self.encode_key(&event) else {
// No valid request means that we didn't encode anything.
return Ok(InputEffect::Ignored);
};
// If our process is exited and we press a key that results in an
// encoded value, we close the surface. We want to eventually move
// this behavior to the apprt probably.
if self.child_exited.get() {
self.close();
return Ok(InputEffect::Closed);
}
self.queue_io(write_req);
// If our event is any keypress that isn't a modifier and we
// generated some data to send to the pty, then we move the viewport
// down to the bottom. We also clear the selection for any key other
// then modifiers.
if !event.key.modifier() {
{
let mut t = self.lock_terminal();
if cfg.selection_clear_on_typing || event.key == Key::Escape {
let _ = self.set_selection(&mut t, None);
}
if cfg.scroll_to_bottom.keystroke {
t.scroll_viewport(ScrollViewport::Bottom);
}
}
self.queue_render();
}
Ok(InputEffect::Consumed)
}
/// The modifier-change block of `keyCallback` (`mouse_mods` label).
fn refresh_mouse_for_mods_change(&self, event: &KeyEvent<'_>) {
// Update our modifiers, this will update mouse mods too
self.mods_changed(event.mods);
let (mouse_event, mouse_shape) = {
let t = self.lock_terminal();
(t.flags.mouse_event, t.mouse_shape)
};
let shift = self.mouse.borrow().mods.shift;
// We only refresh links if
// 1. mouse reporting is off
// OR
// 2. mouse reporting is on and we are not reporting shift to the
// terminal
if mouse_event == MouseEvents::None
|| (shift && !self.mouse_shift_capture(&self.lock_terminal()))
{
// Refresh our link state
let pos = self.rt_surface.get_cursor_pos();
let over_link = self.mouse.borrow().over_link;
let vp = self.pos_to_viewport(f64::from(pos.x), f64::from(pos.y));
if let Err(err) = self.mouse_refresh_links(pos, vp, over_link) {
log::warn!("failed to refresh links err={err}");
}
} else if mouse_event != MouseEvents::None && !shift {
// If we have mouse reports on and we don't have shift pressed,
// we reset state
self.perform(RtAction::MouseShape(mouse_shape));
self.perform(RtAction::MouseOverLink(
ghostty_apprt::action::MouseOverLink { url: String::new() },
));
self.queue_render();
}
}
/// Maybe handles a binding for a given event and if so returns the
/// effect. Returns `None` if the event is not handled in any way and
/// processing should continue.
fn maybe_handle_binding(
&self,
cfg: &DerivedConfig,
event: &KeyEvent<'_>,
) -> Option<InputEffect> {
match event.action {
// Release events never trigger a binding but we need to check
// if we consumed the press event so we don't encode the
// release.
KeyAction::Release => {
if let Some(last) = self.keyboard.borrow().last_trigger
&& last == event.binding_hash()
{
// We don't reset the last trigger on release because an
// apprt may send multiple release events for a single
// press event.
return Some(InputEffect::Consumed);
}
return None;
}
// Carry on processing.
KeyAction::Press | KeyAction::Repeat => {}
}
let (seq, tables) = {
let kb = self.keyboard.borrow();
(kb.sequence.clone(), kb.table_stack.clone())
};
// Find an entry in the keybind set that matches our event.
let found = 'entry: {
// Handle key sequences first.
if let Some(seq) = &seq {
// Get our entry from the set for the given event.
if let Some(set) = sequence_set(cfg, seq)
&& let Some((t, v)) = set.get_event(event)
{
break 'entry found_from(*t, v, seq.root.clone(), &seq.path);
}
// No entry found. We need to encode everything up to this
// point and send to the pty since we're in a sequence.
// We ignore modifiers so that nested sequences such as
// ctrl+a>ctrl+b>c work.
if event.key.modifier() {
return None;
}
// If we have a catch-all of ignore, then we special case
// our invalid sequence handling to ignore it.
if self.catch_all_is_ignore(cfg) {
self.end_key_sequence(SequenceAction::Drop, SequenceFree::Retain);
return Some(InputEffect::Ignored);
}
// Encode everything up to this point
self.end_key_sequence(SequenceAction::Flush, SequenceFree::Retain);
return None;
}
// No currently active sequence, move on to tables. For tables,
// we search inner-most table to outer-most. The table stack
// does NOT include the root set.
for (i, table) in tables.iter().rev().enumerate() {
let root = SetRoot::Table(table.name.clone());
if let Some(set) = root_set(cfg, &root)
&& let Some((t, v)) = set.get_event(event)
{
// If this is a one-shot activation AND its the
// currently active table, then we deactivate it after
// this. Note: we may want to change the semantics here
// to remove this table no matter where it is in the
// stack, maybe.
let f = found_from(*t, v, root, &[]);
if table.once && i == 0 {
self.deactivate_key_table();
}
break 'entry f;
}
}
// No table, use our default set
let (t, v) = cfg.keybind.set.get_event(event)?;
found_from(*t, v, SetRoot::Root, &[])
};
// Determine if this entry has an action or if its a leader key.
let (actions, flags) = match found {
Found::Leader(trigger, path) => {
// Setup the next set we'll look at.
self.keyboard.borrow_mut().sequence = Some(path);
// Store this event so that we can drain and encode on
// invalid. We don't need to cap this because it is
// naturally capped by the config validation.
if let Some(req) = self.encode_key(event) {
self.keyboard.borrow_mut().sequence_queued.push(req);
}
// Start or continue our key sequence
self.perform(RtAction::KeySequence(KeySequence::Trigger(trigger)));
return Some(InputEffect::Consumed);
}
Found::Leaf { actions, flags } => (actions, flags),
};
// consumed determines if the input is consumed or if we continue
// encoding the key (if we have a key to encode).
//
// If the consumed flag is explicitly set, then we are consumed. If
// the global or all flag is set, we always consume.
let consumed = flags.consumed || flags.global || flags.all;
// We have an action, so at this point we're handling SOMETHING so we
// reset the last trigger to null. We only set this if we actually
// perform an action (below). An action also always resets the
// sequence set.
{
let mut kb = self.keyboard.borrow_mut();
kb.last_trigger = None;
kb.sequence = None;
}
// Attempt to perform the action
log::debug!("key event binding flags={flags:?} action={actions:?}");
let performed = 'performed: {
// If this is a global or all action, then we perform it on the
// app and it applies to every surface.
if flags.global || flags.all {
self.perform_all_chained_action(&actions);
// "All" actions are always performed since they are global.
break 'performed true;
}
// Perform each action. We are performed if ANY of the chained
// actions perform.
let mut performed = false;
for action in &actions {
match self.perform_binding_action(action) {
Ok(v) => performed = performed || v,
Err(err) => log::info!(
"key binding action failed action={} err={err}",
action.key().name()
),
}
}
performed
};
if performed {
// If we performed an action and it was a closing action, our
// "self" is not safe to use anymore so we need to just exit
// immediately.
if actions.iter().any(closing_action) {
log::debug!("key binding is a closing binding, halting key event processing");
return Some(InputEffect::Closed);
}
// If our action was "ignore" then we return the special input
// effect of "ignored".
if actions.iter().any(|a| matches!(a, BindingAction::Ignore)) {
// If we're in a sequence, clear it.
self.end_key_sequence(SequenceAction::Drop, SequenceFree::Retain);
return Some(InputEffect::Ignored);
}
}
// If we have the performable flag and the action was not performed,
// then we act as though a binding didn't exist.
if flags.performable && !performed {
// If we're in a sequence, we treat this as if we pressed a key
// that doesn't exist in the sequence. Reset our sequence and
// flush any queued events.
self.end_key_sequence(SequenceAction::Flush, SequenceFree::Retain);
return None;
}
// If we consume this event, then we are done. If we don't consume
// it, we processed the action but we still want to process our
// encodings, too.
if consumed {
// If we had queued events, we deinit them since we consumed
self.end_key_sequence(SequenceAction::Drop, SequenceFree::Retain);
// Store our last trigger so we don't encode the release event
self.keyboard.borrow_mut().last_trigger = Some(event.binding_hash());
return Some(InputEffect::Consumed);
}
// If we didn't perform OR we didn't consume, then we want to encode
// any queued events for a sequence.
self.end_key_sequence(SequenceAction::Flush, SequenceFree::Retain);
None
}
/// Perform actions on the app and every surface (Zig:
/// `App.performAllChainedAction`; reimplemented here because the app
/// handle isn't reachable from a surface).
fn perform_all_chained_action(&self, actions: &[BindingAction]) {
for action in actions {
match action.scope() {
Scope::App => self.perform_app_action(action),
Scope::Surface => {
for surface in self.app.surfaces() {
if let Err(err) = surface.perform_binding_action(action) {
log::warn!(
"error performing chained action action={} err={err}",
action.key().name()
);
}
}
}
}
}
}
/// `App.performAction` for app-scoped binding actions.
fn perform_app_action(&self, action: &BindingAction) {
use ghostty_apprt::action::{ReloadConfig, Target};
let rt = match action {
BindingAction::Quit => RtAction::Quit,
BindingAction::NewWindow => RtAction::NewWindow,
BindingAction::OpenConfig => RtAction::OpenConfig,
BindingAction::ReloadConfig => RtAction::ReloadConfig(ReloadConfig::default()),
BindingAction::CloseAllWindows => RtAction::CloseAllWindows,
BindingAction::ToggleQuickTerminal => RtAction::ToggleQuickTerminal,
BindingAction::ToggleVisibility => RtAction::ToggleVisibility,
BindingAction::CheckForUpdates => RtAction::CheckForUpdates,
BindingAction::ShowGtkInspector => RtAction::ShowGtkInspector,
BindingAction::Undo => RtAction::Undo,
BindingAction::Redo => RtAction::Redo,
_ => return,
};
let target = if matches!(rt, RtAction::NewWindow) {
Target::Surface(self.id)
} else {
Target::App
};
self.rt_app.perform_action(target, rt);
}
/// Deactivate every key table. Returns whether any was active.
pub(crate) fn deactivate_all_key_tables(&self) -> bool {
{
let mut kb = self.keyboard.borrow_mut();
// No key table active. This does nothing.
if kb.table_stack.is_empty() {
return false;
}
// Clear the entire table stack.
kb.table_stack.clear();
}
// Notify the UI.
self.perform(RtAction::KeyTable(KeyTable::DeactivateAll));
true
}
/// Activate a key table by name (Zig: `activate_key_table[_once]`).
/// Returns whether the table was activated.
#[allow(dead_code, clippy::unnecessary_wraps)] // used by the binding-actions module
pub(crate) fn activate_key_table(&self, name: &str, once: bool) -> Result<bool, CoreError> {
let cfg = self.cfg();
// Look up the table in our config
if root_set(&cfg, &SetRoot::Table(name.to_owned())).is_none() {
log::debug!("key table not found: {name}");
return Ok(false);
}
{
let mut kb = self.keyboard.borrow_mut();
// If this is the same table as is currently active, then do
// nothing.
if kb.table_stack.last().is_some_and(|t| t.name == name) {
log::debug!("ignoring duplicate activate table: {name}");
return Ok(false);
}
// If we're already at the max, ignore it.
if kb.table_stack.len() >= MAX_ACTIVE_KEY_TABLES {
log::info!("ignoring activate table, max depth reached: {name}");
return Ok(false);
}
// Add the table to the stack.
kb.table_stack.push(TableEntry {
name: name.to_owned(),
once,
});
}
// Notify the UI.
self.perform(RtAction::KeyTable(KeyTable::Activate(name.to_owned())));
log::debug!("key table activated: {name}");
Ok(true)
}
/// Deactivate the inner-most key table (Zig: `deactivate_key_table`).
/// Returns whether a table was active.
pub(crate) fn deactivate_key_table(&self) -> bool {
{
let mut kb = self.keyboard.borrow_mut();
// No key table active. This does nothing.
if kb.table_stack.pop().is_none() {
return false;
}
}
// Notify the UI.
self.perform(RtAction::KeyTable(KeyTable::Deactivate));
true
}
/// This checks if the current keybinding sets have a catch_all binding
/// with `ignore`. This is used to determine some special input cases.
fn catch_all_is_ignore(&self, cfg: &DerivedConfig) -> bool {
let trigger = Trigger {
key: TriggerKey::CatchAll,
mods: Mods::default(),
};
let tables = self.keyboard.borrow().table_stack.clone();
let mut value = None;
for t in tables.iter().rev() {
if let Some(set) = root_set(cfg, &SetRoot::Table(t.name.clone()))
&& let Some(v) = set.get(trigger)
{
value = Some(v);
break;
}
}
let Some(value) = value.or_else(|| cfg.keybind.set.get(trigger)) else {
return false;
};
// We have a catch-all entry, see if its an ignore
match value {
Value::Leader(_) => false,
Value::Leaf(l) => matches!(l.action, BindingAction::Ignore),
Value::LeafChained(l) => l.actions.iter().any(|a| matches!(a, BindingAction::Ignore)),
}
}
/// End a key sequence. Safe to call if no key sequence is active.
///
/// `action` and `free` determine the behavior of the queued inputs up
/// to this point.
pub(crate) fn end_key_sequence(&self, action: SequenceAction, _free: SequenceFree) {
// Notify apprt key sequence ended
self.perform(RtAction::KeySequence(KeySequence::End));
// No matter what we clear our current sequence set. This restores
// the set we look at to the root set.
let queued = {
let mut kb = self.keyboard.borrow_mut();
kb.sequence = None;
std::mem::take(&mut kb.sequence_queued)
};
// If we have no queued data, there is nothing else to do. Run the
// proper action otherwise.
if action == SequenceAction::Flush {
for req in queued {
self.queue_io(req);
}
}
}
/// Encodes the key event into a write request. Returns `None` if
/// nothing was encoded.
fn encode_key(&self, event: &KeyEvent<'_>) -> Option<IoMessage> {
// Build our encoding options, which requires the lock.
let opts = {
let t = self.lock_terminal();
encode_options_from_terminal(&t)
};
let mut data: Vec<u8> = Vec::with_capacity(64);
if let Err(err) = key_encode::encode(&mut data, event, &opts) {
log::warn!("error encoding key event err={err}");
return None;
}
// Special-case: we did nothing.
if data.is_empty() {
return None;
}
Some(IoMessage::write_req(&data))
}
/// Called from `focus_callback` when focus is lost: if we have a
/// pressed key, send a key release event for it (and for all
/// modifiers). Depending on the apprt, this CAN result in duplicate key
/// release events, but that is better than not sending a key release
/// event at all.
pub(crate) fn release_pressed_key_on_unfocus(&self) {
let Some(mut pressed_key) = self.pressed_key.borrow_mut().take() else {
return;
};
// All our actions will be releases
pressed_key.action = KeyAction::Release;
// Release the full key first
if pressed_key.key != Key::Unidentified {
let ev = pressed_key.as_event();
match self.key_callback(ev) {
Ok(InputEffect::Closed) => return,
Ok(_) => {}
Err(err) => log::warn!("error releasing key on focus loss err={err}"),
}
}
// Release any modifiers if set
if pressed_key.mods.empty() {
return;
}
// Go through all the modifiers and if they're set, release both the
// left and right sides of the modifier. This may not match the exact
// input event but it ensures a full reset.
let original_key = pressed_key.key;
let release =
|pk: &mut OwnedKeyEvent, which: fn(&mut Mods) -> &mut bool, keys: [Key; 2]| {
if !*which(&mut pk.mods) {
return true;
}
*which(&mut pk.mods) = false;
for key in keys {
pk.key = key;
if pk.key != original_key {
let ev = pk.as_event();
match self.key_callback(ev) {
Ok(InputEffect::Closed) => return false,
Ok(_) => {}
Err(err) => log::warn!("error releasing key on focus loss err={err}"),
}
}
}
true
};
if !release(
&mut pressed_key,
|m| &mut m.shift,
[Key::ShiftRight, Key::ShiftLeft],
) {
return;
}
if !release(
&mut pressed_key,
|m| &mut m.ctrl,
[Key::ControlRight, Key::ControlLeft],
) {
return;
}
if !release(
&mut pressed_key,
|m| &mut m.alt,
[Key::AltRight, Key::AltLeft],
) {
return;
}
release(
&mut pressed_key,
|m| &mut m.super_,
[Key::MetaRight, Key::MetaLeft],
);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use ghostty_config::cli_args::ConfigField;
use ghostty_config::config::Config;
use ghostty_terminal::terminal::Options;
fn cfg_with(binds: &[&str]) -> DerivedConfig {
let mut c = Config::default();
for b in binds {
c.keybind.parse_cli(Some(b)).unwrap();
}
DerivedConfig::new(std::sync::Arc::new(c)).unwrap()
}
#[test]
fn owned_key_event_round_trip() {
let e = KeyEvent {
action: KeyAction::Press,
key: Key::KeyA,
mods: Mods {
ctrl: true,
..Mods::default()
},
utf8: "a",
unshifted_codepoint: 'a' as u32,
..KeyEvent::default()
};
let o = OwnedKeyEvent::from_event(&e);
assert_eq!(o.as_event(), e);
}
#[test]
fn closing_actions() {
assert!(closing_action(&BindingAction::CloseSurface));
assert!(closing_action(&BindingAction::CloseWindow));
assert!(!closing_action(&BindingAction::Ignore));
}
#[test]
fn sequence_path_resolution() {
let cfg = cfg_with(&["ctrl+a>b=new_window", "ctrl+a>c>d=quit"]);
let a = Trigger {
key: TriggerKey::Unicode('a' as u32),
mods: Mods {
ctrl: true,
..Mods::default()
},
};
let seq = SequencePath {
root: SetRoot::Root,
path: vec![a],
};
let set = sequence_set(&cfg, &seq).expect("leader set");
let b = Trigger {
key: TriggerKey::Unicode('b' as u32),
mods: Mods::default(),
};
assert!(matches!(set.get(b), Some(Value::Leaf(_))));
let c = Trigger {
key: TriggerKey::Unicode('c' as u32),
mods: Mods::default(),
};
let seq2 = SequencePath {
root: SetRoot::Root,
path: vec![a, c],
};
assert!(sequence_set(&cfg, &seq2).is_some());
// A path through a leaf is invalid
let seq3 = SequencePath {
root: SetRoot::Root,
path: vec![a, b],
};
assert!(sequence_set(&cfg, &seq3).is_none());
}
#[test]
fn root_set_tables() {
let cfg = cfg_with(&["foo/x=new_window"]);
assert!(root_set(&cfg, &SetRoot::Table("foo".into())).is_some());
assert!(root_set(&cfg, &SetRoot::Table("bar".into())).is_none());
}
#[test]
fn found_from_leader_extends_path() {
let cfg = cfg_with(&["ctrl+a>b=new_window"]);
let (t, v) = cfg
.keybind
.set
.bindings
.iter()
.find(|(_, v)| matches!(v, Value::Leader(_)))
.unwrap();
match found_from(t.0, v, SetRoot::Root, &[]) {
Found::Leader(_, p) => assert_eq!(p.path.len(), 1),
Found::Leaf { .. } => panic!("expected leader"),
}
}
#[test]
fn encode_options_follow_terminal_modes() {
let mut t = Terminal::new(Options::new(10, 5)).unwrap();
let o = encode_options_from_terminal(&t);
assert!(!o.cursor_key_application);
t.modes.set(Mode::CursorKeys, true);
t.modes.set(Mode::AltEscPrefix, false);
t.flags.modify_other_keys_2 = true;
let o = encode_options_from_terminal(&t);
assert!(o.cursor_key_application);
assert!(!o.alt_esc_prefix);
assert!(o.modify_other_keys_state_2);
assert_eq!(o.kitty_flags.int(), 0);
}
#[test]
fn mouse_shape_for_keys() {
let none = Mods::default();
// No specific key pressed
assert_eq!(
key_to_mouse_shape(Key::Unidentified, MouseEvents::None, none, false, false),
None
);
// Over a link
assert_eq!(
key_to_mouse_shape(Key::ShiftLeft, MouseEvents::None, none, true, false),
None
);
// Hidden
assert_eq!(
key_to_mouse_shape(Key::ShiftLeft, MouseEvents::None, none, false, true),
None
);
// Shift in mouse tracking mode -> text
let shift = Mods {
shift: true,
..Mods::default()
};
assert_eq!(
key_to_mouse_shape(Key::ShiftLeft, MouseEvents::Normal, shift, false, false),
Some(MouseShape::Text)
);
// Released in tracking mode -> default
assert_eq!(
key_to_mouse_shape(Key::ShiftLeft, MouseEvents::Normal, none, false, false),
Some(MouseShape::Default)
);
// ctrl+alt in text mode -> crosshair
let ca = Mods {
ctrl: true,
alt: true,
..Mods::default()
};
assert_eq!(
key_to_mouse_shape(Key::AltLeft, MouseEvents::None, ca, false, false),
Some(MouseShape::Crosshair)
);
assert_eq!(
key_to_mouse_shape(Key::AltLeft, MouseEvents::None, none, false, false),
Some(MouseShape::Text)
);
}
}
+1941
View File
@@ -0,0 +1,1941 @@
//! Surface represents a single terminal "surface" (port of `Surface.zig`).
//!
//! A terminal surface is a minimal "widget" where the terminal is drawn and
//! responds to events such as keyboard and mouse. Each surface also creates
//! and owns its pty session.
//!
//! The word "surface" is used because it is left to the higher level
//! application runtime to determine if the surface is a window, a tab, a
//! split, a preview pane in a larger window, etc. This struct doesn't care:
//! it just draws and responds to events. The events come from the
//! application runtime so the runtime can determine when and how those are
//! delivered (i.e. with focus, without focus, and so on).
//!
//! # Structure
//!
//! The Zig file is split into several Rust modules that all add `impl
//! Surface` blocks:
//!
//! * this file: the struct, creation/teardown (threads), the message
//! handler, config updates, sizing, fonts, focus, preedit, selection and
//! the [`CoreSurface`] implementation;
//! * [`clipboard`]: OSC 52, paste (safety, bracketed), desktop notifications;
//! * [`keyboard`]: key events, bindings, sequences, key tables, key encoding;
//! * [`mouse`]: mouse buttons/motion/scroll, mouse reporting, selection by
//! mouse, links;
//! * [`binding`]: `performBindingAction`, search, screen file dumps;
//! * [`config`]: the derived configuration.
//!
//! # Threading and interior mutability
//!
//! Every method runs on the runtime's main thread. Other threads (renderer,
//! IO, search) talk to the surface through the app [`Mailbox`]. Methods take
//! `&self`; state lives in `Cell`/`RefCell`. **Never hold a `RefCell` borrow
//! or the terminal lock across a call into the runtime** (`rt_app`,
//! `rt_surface`): the runtime may call straight back into this surface.
pub mod binding;
pub mod clipboard;
pub mod config;
pub mod keyboard;
pub mod mouse;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::Arc;
use std::thread::JoinHandle;
use std::time::{Duration as StdDuration, Instant};
use ghostty_apprt::action::{
Action as RtAction, CellSize as ActionCellSize, ColorChange, ColorKind, CommandFinished,
ConfigChange, InitialSize, MouseVisibility, Pwd, ReloadConfig, SecureInput, SetTitle,
SizeLimit, Target,
};
use ghostty_apprt::runtime_api::{
ClipboardError, CoreError, CoreSurface, DesiredFontSize, InputEffect, RtApp, RtSurface,
SurfaceId,
};
use ghostty_apprt::structs::{
ClipboardRequest, ColorScheme, ContentScale, CursorPos, ImePos, SurfaceSize,
};
use ghostty_apprt::surface::{Mailbox as SurfaceMailboxImpl, Message};
use ghostty_config::conditional;
use ghostty_config::config::Config;
use ghostty_config::types_a::ConfirmCloseSurface;
use ghostty_datastruct::blocking_queue::Timeout;
use ghostty_font::face::DesiredSize;
use ghostty_font::metrics::Metrics;
use ghostty_font::shared_grid_set::{DerivedConfig as FontDerivedConfig, Key as FontGridKey};
use ghostty_input::{Mods, MouseButton, MouseButtonState, ScrollMods};
use ghostty_renderer::generic::Host as RendererHost;
use ghostty_renderer::message::{FontGridChange, Message as RendererMessage};
use ghostty_renderer::opengl::OpenGL;
use ghostty_renderer::options::Options as RendererOptions;
use ghostty_renderer::size::{CellSize, Coordinate, Padding, ScreenSize, Size};
use ghostty_renderer::state::{Preedit, PreeditCodepoint, State as RenderStateData};
use ghostty_renderer::thread::{Handle as RendererHandle, Running as RendererRunning};
use ghostty_renderer::{Health, Renderer};
use ghostty_terminal::page_list::Scrollbar;
use ghostty_terminal::point::Coordinate as PointCoordinate;
use ghostty_terminal::selection::Selection;
use ghostty_termio::exec;
use ghostty_termio::message::Message as IoMessage;
use ghostty_termio::{
Backend, DerivedConfig as IoDerivedConfig, Exec, Mailbox as IoMailbox, Options as IoOptions,
RendererMailbox, SharedState, SharedStateExt, TerminalGuard, Termio, Thread as IoThread,
Wakeup,
};
use crate::app::{AppState, Mailbox, Message as AppMessage};
use crate::global;
use config::DerivedConfig;
/// Minimum window size in cells (Zig: `min_window_width_cells`).
pub const MIN_WINDOW_WIDTH_CELLS: u32 = ghostty_apprt::runtime_api::MIN_WINDOW_WIDTH_CELLS;
/// See [`MIN_WINDOW_WIDTH_CELLS`].
pub const MIN_WINDOW_HEIGHT_CELLS: u32 = ghostty_apprt::runtime_api::MIN_WINDOW_HEIGHT_CELLS;
/// The default DPI (Zig: `font.face.default_dpi`).
pub const DEFAULT_DPI: f32 = 96.0;
/// The effect of an input event (Zig: `Surface.InputEffect`).
pub type InputEffectT = InputEffect;
/// Mouse button count (Zig: `input.MouseButton.max`).
pub const MOUSE_BUTTON_COUNT: usize = 12;
/// The surface mailbox type termio and the renderer use to message us.
type SurfaceMailboxT = SurfaceMailboxImpl<SurfaceId, Mailbox>;
/// Bridge from termio to the renderer thread's mailbox. The IO side is
/// created before the renderer thread exists (the renderer needs the shared
/// terminal state which termio creates), so the handle is filled in later.
#[derive(Default)]
struct RendererLink {
handle: std::sync::OnceLock<RendererHandle>,
}
impl RendererMailbox for RendererLink {
fn push(&self, msg: RendererMessage, timeout: Timeout) -> usize {
match self.handle.get() {
Some(h) => usize::from(h.push(msg, timeout)),
None => 0,
}
}
}
/// What the renderer needs from us (thread-safe, non-blocking).
struct HostImpl {
id: SurfaceId,
mailbox: Mailbox,
}
impl RendererHost for HostImpl {
fn scrollbar(&self, scrollbar: Scrollbar) -> bool {
self.mailbox.push(
AppMessage::SurfaceMessage {
surface: self.id,
message: Message::Scrollbar(scrollbar),
},
Timeout::Instant,
) > 0
}
fn renderer_health(&self, health: Health) {
self.mailbox.push(
AppMessage::SurfaceMessage {
surface: self.id,
message: Message::RendererHealth(health),
},
Timeout::Forever,
);
}
fn redraw_surface(&self) -> bool {
self.mailbox
.push(AppMessage::RedrawSurface(self.id), Timeout::Instant)
> 0
}
}
/// Font state of the surface.
pub struct FontState {
/// The key of the font grid we hold a reference on.
pub grid_key: FontGridKey,
/// The desired size (points + DPI).
pub size: DesiredSize,
/// The metrics of the current grid.
pub metrics: Metrics,
/// This keeps track of if the font size was ever modified. If it wasn't,
/// then config reloading will change the font. If it was manually
/// adjusted, we don't change it on config reload since we assume the
/// user wants a specific size.
pub adjusted: bool,
}
/// A single terminal surface. See the module docs.
pub struct Surface {
/// The identifier the runtime gave us (Zig: the `*Surface` pointer).
pub(crate) id: SurfaceId,
/// The app that this surface is attached to.
pub(crate) app: Rc<AppState>,
/// The windowing system surface and app.
pub(crate) rt_app: Rc<dyn RtApp>,
pub(crate) rt_surface: Rc<dyn RtSurface>,
/// The font structures.
pub(crate) font: RefCell<FontState>,
/// The renderer for this surface. It is created and destroyed on the
/// main thread (GL context current) and drawn from the main thread.
pub(crate) renderer: Arc<Renderer>,
/// The render state (terminal + preedit + mouse) behind the renderer
/// mutex (`renderer_state.mutex` in Zig).
pub(crate) renderer_state: SharedState,
/// The renderer thread.
pub(crate) renderer_handle: RendererHandle,
renderer_thread: RefCell<Option<RendererRunning>>,
/// The IO thread and the mailbox used to send it messages.
pub(crate) io_mailbox: IoMailbox,
io_stop: Wakeup,
io_thread: RefCell<Option<JoinHandle<()>>>,
/// The command arguments the IO backend was started with (for the
/// abnormal exit message).
command_args: Vec<String>,
/// Mouse state.
pub(crate) mouse: RefCell<mouse::Mouse>,
/// Keyboard input state.
pub(crate) keyboard: RefCell<keyboard::Keyboard>,
/// A currently pressed key. This is used so that we can send a keyboard
/// release event when the surface is unfocused. See Zig docs.
pub(crate) pressed_key: RefCell<Option<keyboard::OwnedKeyEvent>>,
/// All our sizing information.
pub(crate) size: Cell<Size>,
/// The configuration derived from the main config.
config: RefCell<Rc<DerivedConfig>>,
/// The conditional state of the configuration.
pub(crate) config_conditional_state: Cell<conditional::State>,
/// This is set to true if our IO thread notifies us our child exited.
pub(crate) child_exited: Cell<bool>,
/// We maintain our focus state and assume we're focused by default.
pub(crate) focused: Cell<bool>,
/// Used to determine whether to continuously scroll.
pub(crate) selection_scroll_active: Cell<bool>,
/// True if the surface is in read-only mode. When read-only, no input is
/// sent to the PTY but terminal-level operations like selections,
/// (native) scrolling, and copy keybinds still work.
pub(crate) readonly: Cell<bool>,
/// Used to send notifications that long running commands have finished.
command_timer: Cell<Option<Instant>>,
/// Search state.
pub(crate) search: RefCell<Option<binding::Search>>,
/// Used to rate limit BEL handling.
last_bell_time: Cell<Option<Instant>>,
/// Debug screenshot state (see `debug_screenshot`).
created: Instant,
debug_shot_done: Cell<bool>,
}
impl Surface {
/// Create a new surface (Zig: `Surface.init`). This must be called from
/// the main thread with the GL context current.
pub fn new(
id: SurfaceId,
app: Rc<AppState>,
config_original: Arc<Config>,
rt_app: Rc<dyn RtApp>,
rt_surface: Rc<dyn RtSurface>,
) -> Result<Rc<Surface>, CoreError> {
// Apply our conditional state. If we fail to apply the conditional
// state then we log and attempt to move forward with the old config.
let config: Arc<Config> =
match config_original.change_conditional_state(app.config_conditional_state.get()) {
Ok(Some(mut c)) => {
// We want to preserve our original working directory. We
// preserve this so directory inheritance works.
c.working_directory = config_original.working_directory.clone();
Arc::new(c)
}
Ok(None) => Arc::clone(&config_original),
Err(err) => {
log::warn!("failed to apply conditional state to config err={err}");
Arc::clone(&config_original)
}
};
// Get our configuration
let derived_config = Rc::new(DerivedConfig::new(Arc::clone(&config))?);
// Initialize our renderer with our initialized surface (Zig:
// `Renderer.surfaceInit`): load the GL functions for the current
// context.
let mut api = OpenGL::init(config.alpha_blending);
{
let rt = Rc::clone(&rt_surface);
api.prepare_context(|name| rt.gl_proc_address(name))
.map_err(|e| format!("failed to prepare the OpenGL context: {e}"))?;
}
// Determine our DPI configurations so we can properly configure
// font points to pixels and handle other high-DPI scaling factors.
let content_scale = rt_surface.get_content_scale();
let x_dpi = content_scale.x * DEFAULT_DPI;
let y_dpi = content_scale.y * DEFAULT_DPI;
log::debug!(
"xscale={} yscale={} xdpi={x_dpi} ydpi={y_dpi}",
content_scale.x,
content_scale.y
);
// The font size we desire along with the DPI determined for the
// surface.
let font_size = DesiredSize {
points: config.font_size,
xdpi: x_dpi as u16,
ydpi: y_dpi as u16,
};
// Setup our font group. This will reuse an existing font group if it
// was already loaded.
let font_derived = FontDerivedConfig::new(&config);
let (font_grid_key, font_grid) = app.font_grid_set.ref_(&font_derived, font_size)?;
// Build our size struct which has all the sizes we need.
let size = {
let surface_size = rt_surface.get_size();
let mut size = Size {
screen: ScreenSize {
width: surface_size.width,
height: surface_size.height,
},
cell: {
let c = font_grid.cell_size();
CellSize {
width: c.width,
height: c.height,
}
},
padding: Padding::default(),
};
let explicit = derived_config.scaled_padding(x_dpi, y_dpi);
if derived_config.window_padding_balance {
size.balance_padding(explicit);
} else {
size.padding = explicit;
}
size
};
let app_mailbox = app.mailbox.clone();
let host: Arc<dyn RendererHost> = Arc::new(HostImpl {
id,
mailbox: app_mailbox.clone(),
});
// The renderer itself.
let renderer = Arc::new(Renderer::new(
RendererOptions {
config: ghostty_renderer::DerivedConfig::new(&config)?,
font_grid: Arc::clone(&font_grid),
size,
host: Arc::clone(&host),
},
api,
)?);
// The command we're going to execute
let command = if app.first.get() {
config
.initial_command
.clone()
.or_else(|| config.command.clone())
} else {
config.command.clone()
};
// Start our IO implementation
let renderer_link = Arc::new(RendererLink::default());
let renderer_wakeup = {
let link = Arc::clone(&renderer_link);
Wakeup::with_hook(Arc::new(move || {
if let Some(h) = link.handle.get() {
h.wakeup();
}
}))?
};
let io_mailbox = IoMailbox::new()?;
let surface_mailbox: Arc<dyn ghostty_termio::SurfaceMailbox> = Arc::new(SurfaceMailboxT {
surface: id,
app: app_mailbox.clone(),
});
let (termio, command_args) = {
let mut env = rt_surface_env(rt_surface.as_ref());
// don't leak GHOSTTY_LOG to any subprocesses
env.remove("GHOSTTY_LOG");
let mut ecfg = exec::Config::new(env, config.term.clone());
ecfg.command = command.clone();
ecfg.env_override = config.env.clone();
ecfg.shell_integration = config.shell_integration;
ecfg.shell_integration_features = config.shell_integration_features;
ecfg.cursor_blink = config.cursor_style_blink;
ecfg.working_directory = config
.working_directory
.as_ref()
.and_then(|wd| wd.value())
.map(str::to_owned);
ecfg.resources_dir = global::state().resources_dir.host().map(str::to_owned);
ecfg.rt_pre_exec_info = ghostty_termio::command::RtPreExecInfo::init(&config);
ecfg.rt_post_fork_info = ghostty_termio::command::RtPostForkInfo::init(&config);
ecfg.create_scope = rt_surface.create_scope_fn();
ecfg.restore_rlimit = global::state().rlimits.nofile;
let exec = Exec::new(ecfg)?;
let args = exec.args().to_vec();
let termio = Termio::init(IoOptions {
size,
full_config: &config,
config: IoDerivedConfig::new(&config),
backend: Backend::Exec(exec),
mailbox: io_mailbox.clone(),
renderer_wakeup,
renderer_mailbox: Arc::clone(&renderer_link) as Arc<dyn RendererMailbox>,
surface_mailbox: Arc::clone(&surface_mailbox),
})?;
(termio, args)
};
let renderer_state = Arc::clone(termio.renderer_state());
// Create the renderer thread
let render_thread = ghostty_renderer::Thread::new(
&config,
Arc::clone(&renderer),
Arc::clone(&renderer_state),
host,
);
let renderer_handle = render_thread.handle();
let _ = renderer_link.handle.set(renderer_handle.clone());
// Create the IO thread
let io_thread = IoThread::new()?;
let io_stop = io_thread.stop_handle();
let surface = Rc::new(Surface {
id,
app: Rc::clone(&app),
rt_app: Rc::clone(&rt_app),
rt_surface: Rc::clone(&rt_surface),
font: RefCell::new(FontState {
grid_key: font_grid_key,
size: font_size,
metrics: font_grid.metrics(),
adjusted: false,
}),
renderer,
renderer_state,
renderer_handle,
renderer_thread: RefCell::new(None),
io_mailbox,
io_stop,
io_thread: RefCell::new(None),
command_args,
mouse: RefCell::new(mouse::Mouse::default()),
keyboard: RefCell::new(keyboard::Keyboard::default()),
pressed_key: RefCell::new(None),
size: Cell::new(size),
config: RefCell::new(derived_config),
config_conditional_state: Cell::new(app.config_conditional_state.get()),
child_exited: Cell::new(false),
focused: Cell::new(true),
selection_scroll_active: Cell::new(false),
readonly: Cell::new(false),
command_timer: Cell::new(None),
search: RefCell::new(None),
last_bell_time: Cell::new(None),
created: Instant::now(),
debug_shot_done: Cell::new(false),
});
// Report initial cell size on surface creation
rt_app.perform_action(
Target::Surface(id),
RtAction::CellSize(ActionCellSize {
width: size.cell.width,
height: size.cell.height,
}),
);
rt_app.perform_action(
Target::Surface(id),
RtAction::SizeLimit(SizeLimit {
min_width: size.cell.width * MIN_WINDOW_WIDTH_CELLS,
min_height: size.cell.height * MIN_WINDOW_HEIGHT_CELLS,
// No max:
max_width: 0,
max_height: 0,
}),
);
// Call our size callback which handles all our retina setup
// Note: this shouldn't be necessary and when we clean up the surface
// init stuff we should get rid of this. But this is required because
// sizeCallback does retina-aware stuff we don't do here and don't
// want to duplicate.
surface.resize(size.screen);
// Start our renderer thread
*surface.renderer_thread.borrow_mut() = Some(render_thread.spawn()?);
// Start our IO thread
*surface.io_thread.borrow_mut() = Some(
std::thread::Builder::new()
.name("io".into())
.spawn(move || io_thread.thread_main(termio))?,
);
// Determine our initial window size if configured. We need to do
// this quite late in the process because our height/width are in
// grid dimensions, so we need to know our cell sizes first.
//
// Note: it is important to do this after the renderer is setup
// above. This allows the apprt to fully initialize the surface
// before we start messing with the window.
surface.recompute_initial_size();
if let Some(title) = &config.title {
rt_app.perform_action(
Target::Surface(id),
RtAction::SetTitle(SetTitle {
title: title.clone(),
}),
);
} else if config.xdg_terminal_exec
&& let Some(cmd) = &command
{
// For xdg-terminal-exec execution we special-case and set the
// window title to the command being executed. This allows window
// managers to set custom styling based on the command being
// executed.
rt_app.perform_action(
Target::Surface(id),
RtAction::SetTitle(SetTitle {
title: cmd.string(),
}),
);
} else if let Some(cmd) = &command {
// If a user specifies a command it is appropriate to set the
// title as argv[0]. We know in the case of a direct command it
// has been supplied by the user. We won't set the title in the
// case the shell expands the command as that should typically be
// used to launch a shell which should set its own titles.
if let ghostty_config::command::Command::Direct(args) = cmd
&& let Some(first) = args.first()
&& !first.is_empty()
{
rt_app.perform_action(
Target::Surface(id),
RtAction::SetTitle(SetTitle {
title: first.clone(),
}),
);
}
}
// We are no longer the first surface
app.first.set(false);
Ok(surface)
}
// ------------------------------------------------------------------
// Small shared helpers used by every submodule
/// The runtime-assigned id of this surface.
#[must_use]
pub fn id(&self) -> SurfaceId {
self.id
}
/// The current derived config (cheap `Rc` clone; no borrow is kept).
#[must_use]
pub fn cfg(&self) -> Rc<DerivedConfig> {
Rc::clone(&self.config.borrow())
}
/// Lock the renderer state and borrow the terminal (the Zig
/// `renderer_state.mutex.lock()` + `io.terminal`). Do not hold across
/// calls into the runtime.
pub fn lock_terminal(&self) -> TerminalGuard<'_> {
self.renderer_state.lock_terminal()
}
/// Lock the full render state (terminal + preedit + mouse).
pub fn lock_state(&self) -> std::sync::MutexGuard<'_, RenderStateData> {
self.renderer_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Perform a runtime action targeted at this surface, ignoring the
/// result (most callers only log failures in Zig).
pub(crate) fn perform(&self, action: RtAction) -> bool {
self.rt_app.perform_action(Target::Surface(self.id), action)
}
/// Returns a mailbox that can be used to send messages to this surface.
pub(crate) fn surface_mailbox(&self) -> SurfaceMailboxT {
SurfaceMailboxT {
surface: self.id,
app: self.app.mailbox.clone(),
}
}
/// Queue a message for the IO thread.
///
/// We centralize all our logic into this spot so we can intercept
/// messages for example in readonly mode. Do NOT call this while holding
/// the terminal lock (use [`Surface::queue_io_locked`]).
pub(crate) fn queue_io(&self, msg: IoMessage) {
if self.readonly.get() && matches!(msg, IoMessage::Write(_)) {
return;
}
self.io_mailbox.send(msg);
self.io_mailbox.notify();
}
#[allow(dead_code)]
/// Like [`Surface::queue_io`] for callers holding the renderer lock: if
/// the queue is full the lock is released while blocking.
pub(crate) fn queue_io_locked<'a>(
&'a self,
msg: IoMessage,
guard: std::sync::MutexGuard<'a, RenderStateData>,
) -> std::sync::MutexGuard<'a, RenderStateData> {
if self.readonly.get() && matches!(msg, IoMessage::Write(_)) {
return guard;
}
let guard = self
.io_mailbox
.send_locked(msg, &self.renderer_state, guard);
self.io_mailbox.notify();
guard
}
/// This queues a render operation with the renderer thread. The render
/// isn't guaranteed to happen immediately but it will happen as soon as
/// practical.
pub(crate) fn queue_render(&self) {
self.renderer_handle.wakeup();
}
#[allow(dead_code)]
/// Push a message to the renderer thread (blocking if full) and wake it.
pub(crate) fn send_renderer(&self, msg: RendererMessage) {
self.renderer_handle.send(msg);
}
/// Current sizes.
#[must_use]
pub fn size(&self) -> Size {
self.size.get()
}
/// Convert surface-relative pixel coordinates to a viewport cell
/// (Zig: `posToViewport`).
#[must_use]
pub fn pos_to_viewport(&self, xpos: f64, ypos: f64) -> PointCoordinate {
// Get our grid cell
let coord = Coordinate::Surface(ghostty_renderer::size::SurfaceCoord { x: xpos, y: ypos });
match coord.convert(ghostty_renderer::size::CoordinateTag::Grid, self.size.get()) {
Coordinate::Grid(g) => PointCoordinate {
x: g.x,
y: u32::from(g.y),
},
_ => PointCoordinate { x: 0, y: 0 },
}
}
/// Scroll to the bottom of the viewport. The caller holds the terminal
/// lock.
pub(crate) fn scroll_to_bottom(&self, t: &mut ghostty_terminal::terminal::Terminal) {
t.scroll_viewport(ghostty_terminal::terminal::ScrollViewport::Bottom);
self.queue_render();
}
pub(crate) fn hide_mouse(&self) {
{
let mut m = self.mouse.borrow_mut();
if m.hidden {
return;
}
m.hidden = true;
}
self.perform(RtAction::MouseVisibility(MouseVisibility::Hidden));
}
pub(crate) fn show_mouse(&self) {
{
let mut m = self.mouse.borrow_mut();
if !m.hidden {
return;
}
m.hidden = false;
}
self.perform(RtAction::MouseVisibility(MouseVisibility::Visible));
}
// ------------------------------------------------------------------
// Lifecycle
/// Close this surface. This will trigger the runtime to start the close
/// process, which should ultimately deinitialize this surface.
pub fn close(&self) {
self.rt_surface.close(self.needs_confirm_quit());
}
/// True if the surface requires confirmation to quit. This should be
/// called by apprt to determine if the surface should confirm before
/// quitting.
#[must_use]
pub fn needs_confirm_quit(&self) -> bool {
// If the surface is in read-only mode, always require confirmation
if self.readonly.get() {
return true;
}
// If the child has exited, then our process is certainly not alive.
// We check this first to avoid the locking overhead below.
if self.child_exited.get() {
return false;
}
// Check the configuration for confirming close behavior.
match self.cfg().confirm_close_surface {
ConfirmCloseSurface::Always => true,
ConfirmCloseSurface::False => false,
ConfirmCloseSurface::True => !self.lock_terminal().cursor_is_at_prompt(),
}
}
/// Forces the surface to render (Zig: `draw`). Renderers are required
/// to support `draw_frame` being called from the main thread.
pub fn draw(&self) -> Result<(), CoreError> {
self.renderer.draw_frame(true)?;
Ok(())
}
// ------------------------------------------------------------------
// Messages (called from the app thread)
/// Called from the app thread to handle mailbox messages to our specific
/// surface.
pub fn handle_message(&self, msg: Message) -> Result<(), CoreError> {
match msg {
Message::ChangeConfig(config) => self.update_config(&config)?,
Message::SetTitle(buf) => {
// We ignore the message in case the title was set via config.
if self.cfg().title.is_some() {
log::debug!(
"ignoring title change request since static title is set via config"
);
return Ok(());
}
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
let title = String::from_utf8_lossy(&buf[..end]).into_owned();
log::debug!("changing title \"{title}\"");
self.perform(RtAction::SetTitle(SetTitle { title }));
}
Message::ReportTitle(style) => {
if !self.cfg().title_report {
log::info!("report_title requested, but disabled via config");
return Ok(());
}
let title = self.rt_surface.get_title();
let data = match style {
ghostty_apprt::surface::ReportTitleStyle::Csi21T => {
format!("\x1b]l{}\x1b\\", title.unwrap_or_default())
}
};
self.queue_io(IoMessage::write_req(data.as_bytes()));
}
Message::ColorChange(change) => {
// Notify our apprt, but don't send a mode 2031 DSR report
// because VT sequences were used to change the color.
use ghostty_vt::color::Dynamic as D;
use ghostty_vt::osc::color::Target;
let kind = match change.target {
Target::Palette(v) => ColorKind::Palette(i32::from(v)),
Target::Dynamic(dyn_) => match dyn_ {
D::Foreground => ColorKind::Foreground,
D::Background => ColorKind::Background,
D::Cursor => ColorKind::Cursor,
// Unsupported dynamic color change notification type
_ => return Ok(()),
},
// Special colors aren't supported for change notification
Target::Special(_) => return Ok(()),
};
self.perform(RtAction::ColorChange(ColorChange {
kind,
r: change.color.r,
g: change.color.g,
b: change.color.b,
}));
}
Message::SetMouseShape(shape) => {
log::debug!("changing mouse shape: {shape:?}");
self.perform(RtAction::MouseShape(shape));
}
Message::ClipboardRead(clipboard) => {
if self.cfg().clipboard_read == ghostty_config::types_b::ClipboardAccess::Deny {
log::info!(
"application attempted to read clipboard, but 'clipboard-read' is set to deny"
);
return Ok(());
}
self.start_clipboard_request(
ghostty_apprt::structs::Clipboard::Standard,
ClipboardRequest::Osc52Read(clipboard),
);
}
Message::ClipboardWrite {
clipboard_type,
req,
} => {
self.clipboard_write(req.slice(), clipboard_type);
}
Message::PwdChange(w) => {
let pwd = String::from_utf8_lossy(w.slice()).into_owned();
self.perform(RtAction::Pwd(Pwd { pwd }));
}
Message::Close => self.close(),
Message::ChildExited(info) => self.child_exited(info),
Message::DesktopNotification(notification) => {
if !self.cfg().desktop_notifications {
log::info!(
"application attempted to display a desktop notification, but 'desktop-notifications' is disabled"
);
return Ok(());
}
let title = String::from_utf8_lossy(notification.title()).into_owned();
let body = String::from_utf8_lossy(notification.body()).into_owned();
self.show_desktop_notification(&title, &body);
}
Message::RendererHealth(health) => self.update_renderer_health(health),
Message::Scrollbar(scrollbar) => self.update_scrollbar(scrollbar),
Message::PresentSurface => {
self.perform(RtAction::PresentTerminal);
}
Message::PasswordInput(v) => self.password_input(v),
Message::RingBell => {
let now = Instant::now();
if let Some(last) = self.last_bell_time.get()
&& now.duration_since(last) < StdDuration::from_millis(100)
{
return Ok(());
}
self.last_bell_time.set(Some(now));
self.perform(RtAction::RingBell);
}
Message::ProgressReport(v) => {
self.perform(RtAction::ProgressReport(v));
}
Message::SelectionScrollTick(active) => {
self.selection_scroll_active.set(active);
self.selection_scroll_tick()?;
}
Message::StartCommand => {
self.command_timer.set(Some(Instant::now()));
}
Message::StopCommand(code) => {
let end = Instant::now();
let Some(start) = self.command_timer.take() else {
return Ok(());
};
let duration = ghostty_config::types_b::Duration {
duration: end.duration_since(start).as_nanos() as u64,
};
log::debug!("command took {duration:?}");
self.perform(RtAction::CommandFinished(CommandFinished {
exit_code: code,
duration,
}));
}
Message::SearchTotal(v) => {
self.perform(RtAction::SearchTotal(ghostty_apprt::action::SearchTotal {
total: v,
}));
}
Message::SearchSelected(v) => {
self.perform(RtAction::SearchSelected(
ghostty_apprt::action::SearchSelected { selected: v },
));
}
}
Ok(())
}
/// The child process exited.
fn child_exited(&self, info: ghostty_apprt::surface::ChildExited) {
// Mark our flag that we exited immediately
self.child_exited.set(true);
// If our runtime was below some threshold then we assume that this
// was an abnormal exit and we show an error message.
if info.runtime_ms <= u64::from(self.cfg().abnormal_command_exit_runtime_ms) {
// If the exit code is 0 then it was a good exit.
if info.exit_code != 0 {
log::warn!("abnormal process exit detected, showing error message");
// Try and show a GUI message. If it returns true, don't do
// anything else.
if self.perform(RtAction::ShowChildExited(info)) {
return;
}
// If a native GUI notification was not shown, update our
// terminal to note the abnormal exit.
if let Err(err) = self.child_exited_abnormally(info) {
log::error!("error handling abnormal child exit err={err}");
}
return;
}
}
// We output a message so that the user knows what's going on and
// doesn't think their terminal just froze. We show this
// unconditionally on close even if `wait_after_command` is false and
// the surface closes immediately because if a user does an `undo` to
// restore a closed surface then they will see this message and know
// the process has completed.
//
// First try and show a native GUI message. If the native GUI can't
// be shown, display a text message in the terminal.
if !self.perform(RtAction::ShowChildExited(info)) {
let mut t = self.lock_terminal();
t.carriage_return();
let ok = t.linefeed().is_ok()
&& t.print_string("Process exited. Press any key to close the terminal.")
.is_ok();
if ok {
t.modes.set(ghostty_vt::modes::Mode::CursorVisible, false);
// We also want to ensure that normal keyboard encoding is on
// so that we can close the terminal. We close the terminal
// on any key press that encodes a character.
t.modes.set(ghostty_vt::modes::Mode::DisableKeyboard, false);
t.screen_mut().kitty_keyboard.set(
ghostty_vt::kitty::key::SetMode::Set,
ghostty_vt::kitty::key::Flags::DISABLED,
);
}
}
// Waiting after command we stop here. The terminal is updated, our
// state is updated, and now its up to the user to decide what to do.
if self.cfg().wait_after_command {
return;
}
// If we aren't waiting after the command, then we exit immediately
// with no confirmation.
self.close();
}
/// Called when the child process exited abnormally.
fn child_exited_abnormally(
&self,
info: ghostty_apprt::surface::ChildExited,
) -> Result<(), ghostty_terminal::page::Error> {
use ghostty_vt::sgr::Attribute;
// Build up our command for the error message
let command = self.command_args.join(" ");
let runtime_str = format!("{} ms", info.runtime_ms);
let mut t = self.lock_terminal();
// No matter what move the cursor back to the column 0.
t.carriage_return();
// Reset styles
t.set_attribute(Attribute::Unset)?;
// If there is data in the viewport, we want to scroll down a little
// bit and write a horizontal rule before writing our message. This
// lets the use see the error message the command may have output.
let viewport_str = t.plain_string()?;
if !viewport_str.is_empty() {
t.linefeed()?;
for _ in 0..t.cols {
t.print(0x2501)?;
}
t.carriage_return();
t.linefeed()?;
t.linefeed()?;
}
// Output our error message
t.set_attribute(Attribute::Fg8(ghostty_vt::color::Name::BRIGHT_RED))?;
t.set_attribute(Attribute::Bold)?;
t.print_string("Ghostty failed to launch the requested command:")?;
t.set_attribute(Attribute::Unset)?;
t.carriage_return();
t.linefeed()?;
t.linefeed()?;
t.print_string(&command)?;
t.set_attribute(Attribute::Unset)?;
t.carriage_return();
t.linefeed()?;
t.linefeed()?;
t.print_string("Runtime: ")?;
t.set_attribute(Attribute::Fg8(ghostty_vt::color::Name::RED))?;
t.print_string(&runtime_str)?;
t.set_attribute(Attribute::Unset)?;
let exit_code_str = info.exit_code.to_string();
t.carriage_return();
t.linefeed()?;
t.print_string("Exit Code: ")?;
t.set_attribute(Attribute::Fg8(ghostty_vt::color::Name::RED))?;
t.print_string(&exit_code_str)?;
t.set_attribute(Attribute::Unset)?;
t.carriage_return();
t.linefeed()?;
t.linefeed()?;
t.print_string("Press any key to close the window.")?;
// Hide the cursor
t.modes.set(ghostty_vt::modes::Mode::CursorVisible, false);
Ok(())
}
/// Called when the terminal detects there is a password input prompt.
fn password_input(&self, v: bool) {
{
let mut t = self.lock_terminal();
// If our password input state is unchanged then we don't waste
// time doing anything more.
if t.flags.password_input == v {
return;
}
t.flags.password_input = v;
}
// Notify our apprt so it can do whatever it wants.
self.perform(RtAction::SecureInput(if v {
SecureInput::On
} else {
SecureInput::Off
}));
self.queue_render();
}
/// Called when our renderer health state changes.
fn update_renderer_health(&self, health: Health) {
log::warn!("renderer health status change status={health:?}");
self.perform(RtAction::RendererHealth(health));
}
/// Called when the scrollbar state changes.
fn update_scrollbar(&self, scrollbar: Scrollbar) {
self.perform(RtAction::Scrollbar(scrollbar));
}
/// This should be called anytime `config_conditional_state` changes so
/// that the apprt can reload the configuration.
fn notify_config_conditional_state(&self) {
self.perform(RtAction::ReloadConfig(ReloadConfig { soft: true }));
}
// ------------------------------------------------------------------
// Configuration
/// Update our configuration at runtime. This can be called by the apprt
/// to set a surface-specific configuration that differs from the app or
/// other surfaces.
pub fn update_config(&self, original: &Arc<Config>) -> Result<(), CoreError> {
// Apply our conditional state. If we fail to apply the conditional
// state then we log and attempt to move forward with the old config.
let config: Arc<Config> =
match original.change_conditional_state(self.config_conditional_state.get()) {
Ok(Some(c)) => Arc::new(c),
Ok(None) => Arc::clone(original),
Err(err) => {
log::warn!("failed to apply conditional state to config err={err}");
Arc::clone(original)
}
};
// Update our new derived config immediately
let derived = match DerivedConfig::new(Arc::clone(&config)) {
Ok(d) => Rc::new(d),
Err(err) => {
// If the derivation fails then we just log and return. We
// don't hard fail in this case because we don't want to
// error the surface when config fails we just want to keep
// using the old config.
log::error!("error updating configuration err={err}");
return Ok(());
}
};
*self.config.borrow_mut() = Rc::clone(&derived);
// If our mouse is hidden but we disabled mouse hiding, then show it
// again.
if !derived.mouse_hide_while_typing && self.mouse.borrow().hidden {
self.show_mouse();
}
// If we are in the middle of a key sequence, clear it.
self.end_key_sequence(keyboard::SequenceAction::Drop, keyboard::SequenceFree::Free);
// Deactivate all key tables since they may have changed. Importantly,
// we store references into the config as part of our table stack so
// we can't keep them active across config changes. But this behavior
// also matches key sequences.
self.deactivate_all_key_tables();
// Before sending any other config changes, we give the renderer a
// new font grid. We could check to see if there was an actual change
// to the font, but this is easier and pretty rare so it's not a
// performance concern.
//
// (Calling set_font_size builds and sends a new font grid to the
// renderer.)
let font_size = {
let f = self.font.borrow();
// If we have manually adjusted the font size, keep it that way.
if f.adjusted {
log::info!(
"font size manually adjusted, preserving previous size on config reload"
);
f.size
} else {
// If we haven't, then we update to the configured font size.
// This allows config changes to update the font size.
let mut size = f.size;
size.points = config.font_size.clamp(1.0, 255.0);
size
}
};
self.set_font_size(font_size)?;
// We need to store our configs in a heap-allocated pointer so that
// our messages aren't huge.
let renderer_message = RendererMessage::init_change_config(&config)?;
self.renderer_handle.send(renderer_message);
self.queue_io(IoMessage::ChangeConfig(Box::new(IoDerivedConfig::new(
&config,
))));
// With mailbox messages sent, we have to wake them up so they
// process it.
self.queue_render();
// If we have a title set then we update our window to have the newly
// configured title.
if let Some(title) = &config.title {
self.perform(RtAction::SetTitle(SetTitle {
title: title.clone(),
}));
}
// Notify the window
self.perform(RtAction::ConfigChange(ConfigChange { config }));
Ok(())
}
/// Recalculate the initial size of the window based on the
/// configuration and invoke the apprt `initial_size` action if
/// necessary.
fn recompute_initial_size(&self) {
let cfg = self.cfg();
// Both width and height must be set for this to work, as documented
// on the config options.
if cfg.window_height == 0 || cfg.window_width == 0 {
return;
}
let scale = self.rt_surface.get_content_scale();
let size = self.size.get();
let height = cfg.window_height.max(MIN_WINDOW_HEIGHT_CELLS) * size.cell.height;
let width = cfg.window_width.max(MIN_WINDOW_WIDTH_CELLS) * size.cell.width;
let width_f32 = width as f32;
let height_f32 = height as f32;
// The final values are affected by content scale and we need to
// account for the padding so we get the exact correct grid size.
let final_width =
(width_f32 / scale.x).ceil() as u32 + size.padding.left + size.padding.right;
let final_height =
(height_f32 / scale.y).ceil() as u32 + size.padding.top + size.padding.bottom;
self.perform(RtAction::InitialSize(InitialSize {
width: final_width,
height: final_height,
}));
}
// ------------------------------------------------------------------
// Text access
/// Returns true if the terminal has a selection.
#[must_use]
pub fn has_selection(&self) -> bool {
self.lock_terminal().screen().selection.is_some()
}
/// Returns the selected text.
#[must_use]
pub fn selection_string(&self) -> Option<String> {
let t = self.lock_terminal();
let sel = t.screen().selection?;
Some(
t.screen()
.selection_string(ghostty_terminal::screen::SelectionString { sel, trim: false }),
)
}
/// Returns the pwd of the terminal, if any. This is always copied
/// because the pwd can change at any point from termio.
#[must_use]
pub fn pwd(&self) -> Option<String> {
let t = self.lock_terminal();
let pwd = t.get_pwd()?;
Some(String::from_utf8_lossy(pwd).into_owned())
}
/// Returns the x/y coordinate of where the IME (Input Method Editor)
/// keyboard should be rendered.
#[must_use]
pub fn ime_point(&self) -> ImePos {
let (cursor_x, cursor_y, preedit_width) = {
let st = self.lock_state();
let cursor = &st.terminal.screen().cursor;
(
u32::from(cursor.x),
u32::from(cursor.y),
st.preedit.as_ref().map_or(0, Preedit::width) as u32,
)
};
// TODO: need to handle when scrolling and the cursor is not in the
// visible portion of the screen.
// Our sizes are all scaled so we need to send the unscaled values
// back.
let content_scale = self.rt_surface.get_content_scale();
let size = self.size.get();
// Simple x * cell width gives the top-left corner, then add padding
// offset
let mut x = f64::from(cursor_x * size.cell.width + size.padding.left);
// We want the midpoint
x += f64::from(size.cell.width) / 2.0;
// And scale it
x /= f64::from(content_scale.x);
// Simple y * cell height gives the top-left corner, then add padding
// offset
let mut y = f64::from(cursor_y * size.cell.height + size.padding.top);
// We want the bottom
y += f64::from(size.cell.height);
// And scale it
y /= f64::from(content_scale.y);
// Our height for now is always just the cell height because our
// preedit rendering only renders in a single line.
let height = f64::from(size.cell.height) / f64::from(content_scale.y);
let mut width = f64::from(preedit_width * size.cell.width);
// Our max width is the remaining screen width after the cursor. We
// don't have to deal with wrapping because the preedit doesn't wrap
// right now.
let screen_width = f64::from(size.terminal().width);
let x_offset = f64::from((cursor_x + 1) * size.cell.width);
let max = screen_width - x_offset;
width = width.min(max);
ImePos {
x,
y,
width,
height,
}
}
// ------------------------------------------------------------------
// Selection
/// Set the selection contents. This must be called with the renderer
/// mutex held (pass the locked terminal). Copy-on-select clipboard
/// writes happen here too.
pub(crate) fn set_selection(
&self,
t: &mut ghostty_terminal::terminal::Terminal,
sel: Option<Selection>,
) -> Result<(), ghostty_terminal::page::Error> {
let prev = t.screen().selection;
t.screen_mut().select(sel)?;
let cfg = self.cfg();
// If copy on select is false then exit early.
use ghostty_config::types_b::CopyOnSelect;
if cfg.copy_on_select == CopyOnSelect::False {
return Ok(());
}
// Set our selection clipboard. If the selection is cleared we do not
// clear the clipboard. If the selection is set, we only set the
// clipboard again if it changed, since setting the clipboard can be
// an expensive operation.
let Some(sel) = sel else {
return Ok(());
};
if let Some(prev) = prev
&& sel.eql(&t.screen().pages, &prev)
{
return Ok(());
}
use ghostty_apprt::structs::Clipboard;
match cfg.copy_on_select {
CopyOnSelect::False => unreachable!("handled above with an early exit"),
// Both standard and selection clipboards are set.
CopyOnSelect::Clipboard => self.copy_selection_to_clipboards(
t,
sel,
&[Clipboard::Standard, Clipboard::Selection],
ghostty_input::binding::CopyToClipboard::Mixed,
),
// The selection clipboard is set if supported, otherwise the
// standard.
CopyOnSelect::True => {
let clipboard = if self.rt_surface.supports_clipboard(Clipboard::Selection) {
Clipboard::Selection
} else {
Clipboard::Standard
};
self.copy_selection_to_clipboards(
t,
sel,
&[clipboard],
ghostty_input::binding::CopyToClipboard::Mixed,
);
}
}
Ok(())
}
// ------------------------------------------------------------------
// Fonts and sizes
/// Change the cell size for the terminal grid. This can happen as a
/// result of changing the font size at runtime.
fn set_cell_size(&self, size: CellSize) {
// Update our cell size within our size struct
let mut s = self.size.get();
s.cell = size;
self.size.set(s);
self.balance_padding_if_needed();
// Notify the terminal
self.queue_io(IoMessage::Resize(self.size.get()));
// Update our terminal default size if necessary.
self.recompute_initial_size();
// Notify the window
self.perform(RtAction::CellSize(ActionCellSize {
width: size.width,
height: size.height,
}));
}
/// Change the font size. This can only be called from the main thread.
pub fn set_font_size(&self, size: DesiredSize) -> Result<(), CoreError> {
log::debug!("set font size size={}", size.points);
// Update our font size so future changes work
self.font.borrow_mut().size = size;
// We need to build up a new font stack for this font size.
let derived = FontDerivedConfig::new(&self.cfg().config);
let (font_grid_key, font_grid) = self.app.font_grid_set.ref_(&derived, size)?;
// Set our cell size
let metrics = font_grid.metrics();
self.set_cell_size(CellSize {
width: metrics.cell_width,
height: metrics.cell_height,
});
// Notify our render thread of the new font stack. The renderer MUST
// accept the new font grid and deref the old.
let old_key = {
let mut f = self.font.borrow_mut();
let old = std::mem::replace(&mut f.grid_key, font_grid_key.clone());
f.metrics = metrics;
old
};
self.renderer_handle
.send(RendererMessage::FontGrid(Box::new(FontGridChange {
grid: font_grid,
set: Arc::clone(&self.app.font_grid_set),
new_key: font_grid_key,
old_key,
})));
// Schedule render which also drains our mailbox
self.queue_render();
Ok(())
}
/// The current font size (points + dpi) for runtime queries.
#[must_use]
pub fn font_size(&self) -> DesiredSize {
self.font.borrow().size
}
/// Called when the runtime's surface size changes.
pub fn size_callback(&self, size: SurfaceSize) -> Result<(), CoreError> {
let new_screen_size = ScreenSize {
width: size.width,
height: size.height,
};
// Update our screen size, but only if it actually changed. And if
// the screen size didn't change, then our grid size could not have
// changed, so we just return.
if self.size.get().screen.equals(new_screen_size) {
return Ok(());
}
self.resize(new_screen_size);
Ok(())
}
fn resize(&self, size: ScreenSize) {
// Save our screen size
let mut s = self.size.get();
s.screen = size;
self.size.set(s);
self.balance_padding_if_needed();
let s = self.size.get();
// Recalculate our grid size. Because Ghostty supports fluid
// resizing, its possible the grid doesn't change at all even if the
// screen size changes. We have to update the IO thread no matter
// what because we send pixel-level sizing to the subprocess.
let grid_size = s.grid();
if grid_size.columns < 5 && (s.padding.left > 0 || s.padding.right > 0) {
log::warn!(
"WARNING: very small terminal grid detected with padding set. Is your padding reasonable?"
);
}
if grid_size.rows < 2 && (s.padding.top > 0 || s.padding.bottom > 0) {
log::warn!(
"WARNING: very small terminal grid detected with padding set. Is your padding reasonable?"
);
}
// Mail the IO thread
self.queue_io(IoMessage::Resize(s));
}
/// Recalculate the balanced padding if needed.
fn balance_padding_if_needed(&self) {
let cfg = self.cfg();
if !cfg.window_padding_balance {
return;
}
let content_scale = self.rt_surface.get_content_scale();
let x_dpi = content_scale.x * DEFAULT_DPI;
let y_dpi = content_scale.y * DEFAULT_DPI;
let mut s = self.size.get();
s.balance_padding(cfg.scaled_padding(x_dpi, y_dpi));
self.size.set(s);
}
/// Called to set the preedit state for character input. Preedit is used
/// with dead key states, for example, when typing an accent character.
/// This should be called with `None` to reset the preedit state.
///
/// The core surface will NOT reset the preedit state on charCallback or
/// keyCallback and we rely completely on the apprt implementation to
/// track the preedit state correctly.
pub fn preedit_callback(&self, preedit: Option<&str>) -> Result<(), CoreError> {
let cfg = self.cfg();
let mut st = self.lock_state();
// We clear our selection when ANY OF:
// 1. We have an existing preedit
// 2. We have preedit text
if (st.preedit.is_some() || preedit.is_some()) && cfg.selection_clear_on_typing {
let _ = self.set_selection(&mut st.terminal, None);
}
// We always clear our prior preedit
st.preedit = None;
// Mark preedit dirty flag
st.terminal.flags.dirty.preedit = true;
// If we have no text, we're done. We queue a render in case we
// cleared a prior preedit (likely).
let Some(text) = preedit else {
drop(st);
self.queue_render();
return Ok(());
};
// We convert the UTF-8 text to codepoints.
let mut codepoints: Vec<PreeditCodepoint> = Vec::new();
for cp in text.chars() {
let width = usize::from(ghostty_unicode::table().get(u32::from(cp)).width);
// I've never seen a preedit text with a zero-width character. In
// theory its possible but we can't really handle it right now.
// Let's just ignore it.
if width == 0 {
continue;
}
codepoints.push(PreeditCodepoint {
codepoint: u32::from(cp),
wide: width >= 2,
});
}
if !codepoints.is_empty() {
st.preedit = Some(Preedit { codepoints });
}
drop(st);
self.queue_render();
Ok(())
}
/// Callback for when the surface is fully visible or not, regardless of
/// focus state.
pub fn occlusion_callback(&self, visible: bool) -> Result<(), CoreError> {
self.renderer_handle.send(RendererMessage::Visible(visible));
self.queue_render();
Ok(())
}
/// Called when the surface gains or loses focus.
pub fn focus_callback(&self, focused: bool) -> Result<(), CoreError> {
// If our focus state is the same we do nothing.
if self.focused.get() == focused {
return Ok(());
}
self.focused.set(focused);
// Notify our render thread of the new state
self.renderer_handle.send(RendererMessage::Focus(focused));
if focused {
// Notify our app if we gained focus.
self.app.focus_surface(self.id);
} else {
// If we lost focus and we have a keypress, then we want to send
// a key release event for it.
self.release_pressed_key_on_unfocus();
}
// Schedule render which also drains our mailbox
self.queue_render();
// Whenever our focus changes we unhide the mouse. The mouse will be
// hidden again if the user starts typing. This helps alleviate some
// buggy behavior upstream in macOS with the mouse never becoming
// visible again when tabbing between programs (see #2525).
self.show_mouse();
// Update the focus state and notify the terminal
self.lock_terminal().flags.focused = focused;
self.queue_io(IoMessage::Focused(focused));
Ok(())
}
/// Schedule a render (Zig: `refreshCallback`).
pub fn refresh_callback(&self) -> Result<(), CoreError> {
// The point of this callback is to schedule a render, so do that.
self.queue_render();
Ok(())
}
/// The content scale (DPI) of the surface changed.
pub fn content_scale_callback(&self, content_scale: ContentScale) -> Result<(), CoreError> {
// Calculate the new DPI
let x_dpi = content_scale.x * DEFAULT_DPI;
let y_dpi = content_scale.y * DEFAULT_DPI;
// Update our font size which is dependent on the DPI
let current = self.font.borrow().size;
let mut size = current;
size.xdpi = x_dpi as u16;
size.ydpi = y_dpi as u16;
// If our DPI didn't actually change, save a lot of work by doing
// nothing.
if size.xdpi == current.xdpi && size.ydpi == current.ydpi {
return Ok(());
}
self.set_font_size(size)?;
// Update our padding which is dependent on DPI. We only do this for
// unbalanced padding since balanced padding is not dependent on DPI.
let cfg = self.cfg();
if !cfg.window_padding_balance {
let mut s = self.size.get();
s.padding = cfg.scaled_padding(x_dpi, y_dpi);
self.size.set(s);
}
// Force a resize event because the change in padding will affect
// pixel-level changes to the renderer and viewport.
self.resize(self.size.get().screen);
Ok(())
}
/// The system color scheme changed.
pub fn color_scheme_callback(&self, scheme: ColorScheme) -> Result<(), CoreError> {
let new_scheme = match scheme {
ColorScheme::Light => conditional::Theme::Light,
ColorScheme::Dark => conditional::Theme::Dark,
};
// If our scheme didn't change, then we don't do anything.
let mut state = self.config_conditional_state.get();
if state.theme == new_scheme {
return Ok(());
}
// Setup our conditional state which has the current color theme.
state.theme = new_scheme;
self.config_conditional_state.set(state);
self.notify_config_conditional_state();
// If mode 2031 is on, then we report the change live.
self.queue_io(IoMessage::ColorSchemeReport { force: false });
Ok(())
}
/// Tell the surface to present itself to the user.
pub fn present_surface(&self) {
self.perform(RtAction::PresentTerminal);
}
// ------------------------------------------------------------------
// Renderer display lifecycle (GTK GLArea realize/unrealize)
/// Developer aid: if `GHOSTTY_DEBUG_SCREENSHOT=<path.png>` is set, write
/// the last presented frame to that path once, `GHOSTTY_DEBUG_SCREENSHOT_MS`
/// (default 2500) after the surface was created. Must be called with the
/// GL context current (from `draw_frame`).
fn debug_screenshot(&self) {
let Some(path) = std::env::var_os("GHOSTTY_DEBUG_SCREENSHOT") else {
return;
};
let delay = std::env::var("GHOSTTY_DEBUG_SCREENSHOT_MS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(2500);
if self.debug_shot_done.get() || self.created.elapsed().as_millis() < u128::from(delay) {
return;
}
let Some((w, h, mut px)) = self.renderer.last_frame_pixels() else {
return;
};
self.debug_shot_done.set(true);
// Bottom-up RGBA -> top-down.
let stride = w * 4;
let mut flipped = Vec::with_capacity(px.len());
for row in px.chunks_exact_mut(stride).rev() {
flipped.extend_from_slice(row);
}
let write = || -> Result<(), Box<dyn std::error::Error>> {
let file = std::io::BufWriter::new(std::fs::File::create(&path)?);
let mut enc = png::Encoder::new(file, w as u32, h as u32);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
enc.write_header()?.write_image_data(&flipped)?;
Ok(())
};
match write() {
Ok(()) => log::info!("wrote debug screenshot to {}", path.to_string_lossy()),
Err(err) => log::warn!("debug screenshot failed: {err}"),
}
}
/// The GLArea was (re)realized; the GL context is current.
pub fn renderer_realized(&self) -> Result<(), CoreError> {
let rt = Rc::clone(&self.rt_surface);
self.renderer
.display_realized(|name| rt.gl_proc_address(name))?;
Ok(())
}
/// The GLArea is being unrealized; the GL context is current.
pub fn renderer_unrealized(&self) {
self.renderer.display_unrealized();
}
}
impl Drop for Surface {
/// Zig: `Surface.deinit`.
fn drop(&mut self) {
// Stop search thread
if let Some(s) = self.search.get_mut().take() {
s.deinit();
}
// Stop rendering thread
if let Some(running) = self.renderer_thread.get_mut().take() {
running.stop_and_join();
}
// Stop our IO thread
if let Err(err) = self.io_stop.notify() {
log::error!("error notifying io thread to stop, may stall err={err}");
}
if let Some(join) = self.io_thread.get_mut().take()
&& join.join().is_err()
{
log::warn!("io thread panicked");
}
// Clean up our font grid
let key = self.font.get_mut().grid_key.clone();
self.app.font_grid_set.deref(&key);
log::info!("surface closed id={}", self.id);
}
}
/// The environment for the child: whatever the runtime wants plus the
/// process environment (Zig: `rt_surface.defaultTermioEnv()` falling back to
/// `getEnvMap`).
fn rt_surface_env(rt: &dyn RtSurface) -> std::collections::HashMap<String, String> {
let mut env = ghostty_os::env::get_env_map();
for (k, v) in rt.default_termio_env() {
env.insert(k, v);
}
env
}
// ----------------------------------------------------------------------
// CoreSurface (what the runtime calls)
impl CoreSurface for Surface {
fn config(&self) -> Arc<Config> {
Arc::clone(&self.cfg().config)
}
fn key_callback(&self, event: ghostty_input::KeyEvent<'_>) -> Result<InputEffect, CoreError> {
Surface::key_callback(self, event)
}
fn preedit_callback(&self, text: Option<&str>) -> Result<(), CoreError> {
Surface::preedit_callback(self, text)
}
fn focus_callback(&self, focused: bool) -> Result<(), CoreError> {
Surface::focus_callback(self, focused)
}
fn mouse_button_callback(
&self,
state: MouseButtonState,
button: MouseButton,
mods: Mods,
) -> Result<bool, CoreError> {
Surface::mouse_button_callback(self, state, button, mods)
}
fn cursor_pos_callback(&self, pos: CursorPos, mods: Option<Mods>) -> Result<(), CoreError> {
Surface::cursor_pos_callback(self, pos, mods)
}
fn scroll_callback(&self, xoff: f64, yoff: f64, mods: ScrollMods) -> Result<(), CoreError> {
Surface::scroll_callback(self, xoff, yoff, mods)
}
fn mouse_pressure_callback(&self, stage: ghostty_input::MousePressureStage, pressure: f64) {
Surface::mouse_pressure_callback(self, stage, pressure);
}
fn size_callback(&self, size: SurfaceSize) -> Result<(), CoreError> {
Surface::size_callback(self, size)
}
fn content_scale_callback(&self, scale: ContentScale) -> Result<(), CoreError> {
Surface::content_scale_callback(self, scale)
}
fn ime_point(&self) -> ImePos {
Surface::ime_point(self)
}
fn has_selection(&self) -> bool {
Surface::has_selection(self)
}
fn color_scheme_callback(&self, scheme: ColorScheme) -> Result<(), CoreError> {
Surface::color_scheme_callback(self, scheme)
}
fn update_config(&self, config: &Arc<Config>) -> Result<(), CoreError> {
Surface::update_config(self, config)
}
fn perform_binding_action(
&self,
action: &ghostty_input::binding::Action,
) -> Result<bool, CoreError> {
Surface::perform_binding_action(self, action)
}
fn complete_clipboard_request(
&self,
req: ClipboardRequest,
data: &str,
confirmed: bool,
) -> Result<(), ClipboardError> {
Surface::complete_clipboard_request(self, req, data, confirmed)
}
fn set_clipboard_read_allowed(&self, allow: bool) {
use ghostty_config::types_b::ClipboardAccess;
self.set_config_clipboard(
true,
if allow {
ClipboardAccess::Allow
} else {
ClipboardAccess::Deny
},
);
}
fn set_clipboard_write_allowed(&self, allow: bool) {
use ghostty_config::types_b::ClipboardAccess;
self.set_config_clipboard(
false,
if allow {
ClipboardAccess::Allow
} else {
ClipboardAccess::Deny
},
);
}
fn needs_confirm_quit(&self) -> bool {
Surface::needs_confirm_quit(self)
}
fn child_exited(&self) -> bool {
self.child_exited.get()
}
fn renderer_realized(&self) -> Result<(), CoreError> {
Surface::renderer_realized(self)
}
fn renderer_unrealized(&self) {
Surface::renderer_unrealized(self);
}
fn draw_frame(&self, _default_framebuffer: u32) -> Result<(), CoreError> {
// GTK's GLArea binds its own FBO before `render`; the renderer
// queries GL_FRAMEBUFFER_BINDING itself.
self.renderer.draw_frame(false)?;
self.debug_screenshot();
Ok(())
}
fn id(&self) -> Option<SurfaceId> {
Some(self.id)
}
fn readonly(&self) -> bool {
self.readonly.get()
}
fn grid_size(&self) -> (u32, u32) {
let g = self.size.get().grid();
(u32::from(g.columns), u32::from(g.rows))
}
fn font_size(&self) -> DesiredFontSize {
let s = self.font.borrow().size;
DesiredFontSize {
points: s.points,
xdpi: s.xdpi,
ydpi: s.ydpi,
}
}
}
+2472
View File
@@ -0,0 +1,2472 @@
//! Mouse handling for a surface (Zig `Surface.zig`: mouse reporting, button,
//! cursor position, scroll, selection by mouse, links).
//!
//! Locking rules: the renderer-state lock is only held while touching the
//! terminal. Anything that calls into the runtime (actions, clipboard
//! requests, opening URLs) and any message for the IO thread is collected in
//! a [`Deferred`] and executed after the lock is released.
use std::time::Instant;
use ghostty_apprt::action::{Action as RtAction, MouseOverLink, OpenUrl, OpenUrlKind};
use ghostty_apprt::runtime_api::CoreError;
use ghostty_apprt::structs::{Clipboard, ClipboardRequest, CursorPos};
use ghostty_config::types_a::LinkPreviews;
use ghostty_config::types_b::{
CopyOnSelect, MouseShiftCapture as ConfigShiftCapture, RightClickAction,
};
use ghostty_input::link::{Action as LinkAction, Highlight};
use ghostty_input::{Mods, MouseButton, MouseButtonState, MousePressureStage, ScrollMods};
use ghostty_renderer::size::{Coordinate as SizeCoordinate, CoordinateTag, Size, SurfaceCoord};
use ghostty_renderer::state::State as RenderStateData;
use ghostty_terminal::page_list::{Direction, PageList, Pin, TrackedPin};
use ghostty_terminal::point::{Coordinate as PointCoordinate, Point};
use ghostty_terminal::screen::{Screen, SelectLine, SelectionString, SemanticClick};
use ghostty_terminal::screen_set::Key as ScreenKey;
use ghostty_terminal::selection::Selection;
use ghostty_terminal::terminal::{
MouseEvents, MouseFormat, MouseShiftCapture as TerminalShiftCapture, ScrollViewport, Terminal,
};
use ghostty_termio::message::Message as IoMessage;
use ghostty_vt::modes::Mode;
use super::{MOUSE_BUTTON_COUNT, Surface};
use crate::surface_mouse;
/// Mouse state for the surface (Zig: `Surface.Mouse`).
pub struct Mouse {
/// The last tracked mouse button state by button.
pub click_state: [MouseButtonState; MOUSE_BUTTON_COUNT],
/// The last mods state when the last mouse button (whatever it was) was
/// pressed or release.
pub mods: Mods,
/// The point at which the left mouse click happened. This is in screen
/// coordinates so that scrolling preserves the location. Tracked in the
/// page list of `left_click_screen`.
pub left_click_pin: Option<TrackedPin>,
pub left_click_screen: ScreenKey,
/// The starting xpos/ypos of the left click. Note that if scrolling
/// occurs, these will point to different "cells", but the xpos/ypos will
/// stay stable during scrolling relative to the surface.
pub left_click_xpos: f64,
pub left_click_ypos: f64,
/// The count of clicks to count double and triple clicks and so on. The
/// left click time was the last time the left click was done. This is
/// always set on the first left click.
pub left_click_count: u8,
pub left_click_time: Option<Instant>,
/// The last x/y sent for mouse reports.
pub event_point: Option<PointCoordinate>,
/// The pressure stage for the mouse. This should always be none if the
/// mouse is not pressed.
pub pressure_stage: MousePressureStage,
/// Pending scroll amounts for high-precision scrolls
pub pending_scroll_x: f64,
pub pending_scroll_y: f64,
/// True if the mouse is hidden
pub hidden: bool,
/// True if the mouse position is currently over a link.
pub over_link: bool,
/// The last x/y in the cursor position for links. We use this to only
/// process link hover events when the mouse actually moves cells.
pub link_point: Option<PointCoordinate>,
}
impl Default for Mouse {
fn default() -> Self {
Mouse {
click_state: [MouseButtonState::Release; MOUSE_BUTTON_COUNT],
mods: Mods::default(),
left_click_pin: None,
left_click_screen: ScreenKey::Primary,
left_click_xpos: 0.0,
left_click_ypos: 0.0,
left_click_count: 0,
left_click_time: None,
event_point: None,
pressure_stage: MousePressureStage::None,
pending_scroll_x: 0.0,
pending_scroll_y: 0.0,
hidden: false,
over_link: false,
link_point: None,
}
}
}
/// The type of action to report for a mouse event.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MouseReportAction {
Press,
Release,
Motion,
}
/// The amount to scroll. This structure is always normalized so that
/// negative is down, left and positive is up, right. Note that INTERNALLY,
/// vertical scroll on our terminal uses positive for down.
#[derive(Debug, Clone, Copy, Default)]
struct ScrollAmount {
delta: isize,
}
impl ScrollAmount {
fn up_right(self) -> bool {
self.delta >= 0
}
fn magnitude(self) -> usize {
self.delta.unsigned_abs()
}
}
/// A link found under the mouse.
#[derive(Debug, Clone, Copy)]
struct Link {
action: LinkAction,
selection: Selection,
}
/// Work that must happen after the renderer lock is released.
#[derive(Default)]
struct Deferred {
actions: Vec<RtAction>,
io: Vec<IoMessage>,
render: bool,
}
impl Deferred {
fn run(self, s: &Surface) {
for m in self.io {
s.queue_io(m);
}
for a in self.actions {
s.perform(a);
}
if self.render {
s.queue_render();
}
}
}
// ----------------------------------------------------------------------
// Pure selection logic
/// Calculates the appropriate selection given pins and pixel x positions for
/// the click point and the drag point, as well as mouse mods and screen size.
#[must_use]
pub fn mouse_selection(
list: &PageList,
click_pin: Pin,
drag_pin: Pin,
click_x: u32,
drag_x: u32,
mods: Mods,
size: Size,
) -> Option<Selection> {
// Explanation:
//
// # Normal selections
//
// ## Left-to-right selections
// - The clicked cell is included if it was clicked to the left of its
// threshold point and the drag location is right of the threshold
// point.
// - The cell under the cursor (the "drag cell") is included if the drag
// location is right of its threshold point.
//
// ## Right-to-left selections
// - The clicked cell is included if it was clicked to the right of its
// threshold point and the drag location is left of the threshold
// point.
// - The cell under the cursor (the "drag cell") is included if the drag
// location is left of its threshold point.
//
// # Rectangular selections
//
// Rectangular selections are handled similarly, except that entire
// columns are considered rather than individual cells.
// We only include cells in the selection if the threshold point lies
// between the start and end points of the selection. A threshold of 60%
// of the cell width was chosen empirically because it felt good.
let threshold_point = (f64::from(size.cell.width) * 0.6).round() as u32;
// We use this to clamp the pixel positions below.
let max_x = (u32::from(size.grid().columns) * size.cell.width).saturating_sub(1);
// We need to know how far across in the cell the drag pos is, so we
// subtract the padding and then take it modulo the cell width.
let drag_x_frac = max_x.min(drag_x.saturating_sub(size.padding.left)) % size.cell.width;
// We figure out the fractional part of the click x position similarly.
let click_x_frac = max_x.min(click_x.saturating_sub(size.padding.left)) % size.cell.width;
// Whether or not this is a rectangular selection.
let rectangle_selection = surface_mouse::is_rectangle_select_state(mods);
// Whether the click pin and drag pin are equal.
let same_pin = drag_pin.eql(click_pin);
// Whether or not the end point of our selection is before the start
// point.
let end_before_start = if same_pin {
drag_x_frac < click_x_frac
} else if rectangle_selection {
// Special handling for rectangular selections, we only use x
// position.
match drag_pin.x.cmp(&click_pin.x) {
std::cmp::Ordering::Equal => drag_x_frac < click_x_frac,
std::cmp::Ordering::Less => true,
std::cmp::Ordering::Greater => false,
}
} else {
drag_pin.before(list, click_pin)
};
// Whether or not the the click pin cell should be included in the
// selection.
let include_click_cell = if end_before_start {
click_x_frac >= threshold_point
} else {
click_x_frac < threshold_point
};
// Whether or not the the drag pin cell should be included in the
// selection.
let include_drag_cell = if end_before_start {
drag_x_frac < threshold_point
} else {
drag_x_frac >= threshold_point
};
// If the click cell should be included in the selection then it's the
// start, otherwise we get the previous or next cell to it depending on
// the type and direction of the selection.
let start_pin = if include_click_cell {
click_pin
} else if end_before_start {
if rectangle_selection {
click_pin.left_clamp(1)
} else {
click_pin.left_wrap(list, 1).unwrap_or(click_pin)
}
} else if rectangle_selection {
click_pin.right_clamp(list, 1)
} else {
click_pin.right_wrap(list, 1).unwrap_or(click_pin)
};
// Likewise for the end pin with the drag cell.
let end_pin = if include_drag_cell {
drag_pin
} else if end_before_start {
if rectangle_selection {
drag_pin.right_clamp(list, 1)
} else {
drag_pin.right_wrap(list, 1).unwrap_or(drag_pin)
}
} else if rectangle_selection {
drag_pin.left_clamp(1)
} else {
drag_pin.left_wrap(list, 1).unwrap_or(drag_pin)
};
// If the click cell is the same as the drag cell and the click cell
// shouldn't be included, or if the cells are adjacent such that the
// start or end pin becomes the other cell, and that cell should not be
// included, then we have no selection, so we set it to null.
//
// If in rectangular selection mode, we compare columns as well.
if (!include_click_cell && same_pin)
|| (!include_click_cell && rectangle_selection && click_pin.x == drag_pin.x)
|| (!include_click_cell && end_pin.eql(click_pin))
|| (!include_click_cell && rectangle_selection && end_pin.x == click_pin.x)
|| (!include_drag_cell && start_pin.eql(drag_pin))
|| (!include_drag_cell && rectangle_selection && start_pin.x == drag_pin.x)
{
return None;
}
// TODO: Clamp selection to the screen area, don't let it extend past the
// last written row.
Some(Selection::new(start_pin, end_pin, rectangle_selection))
}
/// Double-click dragging moves the selection one "word" at a time. Returns
/// `None` when the selection should be cleared.
fn drag_left_click_double_selection(
screen: &Screen,
click_pin: Pin,
drag_pin: Pin,
word_chars: &[u32],
) -> Option<Selection> {
let list = &screen.pages;
// Get the word closest to our starting click.
let word_start = screen.select_word_between(click_pin, drag_pin, word_chars)?;
// Get the word closest to our current point.
let word_current = screen.select_word_between(drag_pin, click_pin, word_chars)?;
// If our current mouse position is before the starting position, then
// the selection start is the word nearest our current position.
if drag_pin.before(list, click_pin) {
Some(Selection::new(
word_current.start(list),
word_start.end(list),
false,
))
} else {
Some(Selection::new(
word_start.start(list),
word_current.end(list),
false,
))
}
}
/// Triple-click dragging moves the selection one "line" at a time. `None`
/// means do nothing.
fn drag_left_click_triple_selection(
screen: &Screen,
click_pin: Pin,
drag_pin: Pin,
) -> Option<Selection> {
let list = &screen.pages;
// Get the line selection under our current drag point. If there isn't a
// line, do nothing.
let line = screen.select_line(SelectLine::new(drag_pin))?;
// Get the selection under our click point. We first try to trim
// whitespace if we've selected a word. But if no word exists then we
// select the blank line.
let sel = screen.select_line(SelectLine::new(click_pin)).or_else(|| {
screen.select_line(SelectLine {
whitespace: None,
..SelectLine::new(click_pin)
})
})?;
if drag_pin.before(list, click_pin) {
Some(Selection::new(
line.start(list),
sel.end(list),
sel.rectangle,
))
} else {
Some(Selection::new(
sel.start(list),
line.end(list),
sel.rectangle,
))
}
}
/// Resolve a relative file path to an absolute path using `pwd` (Zig
/// `resolvePathForOpening`). Returns `None` for absolute paths (use as-is),
/// when there's no pwd, or when the resolved path doesn't exist.
fn resolve_path_for_opening(pwd: Option<&str>, path: &str) -> Option<String> {
use std::path::{Component, Path, PathBuf};
if Path::new(path).is_absolute() {
return None;
}
let pwd = pwd?;
let joined = Path::new(pwd).join(path);
// Lexically normalize (std.fs.path.resolve).
let mut out = PathBuf::new();
for c in joined.components() {
match c {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
if !out.exists() {
return None;
}
Some(out.to_string_lossy().into_owned())
}
impl Surface {
// ------------------------------------------------------------------
// Helpers
/// Returns true if mouse reporting is enabled both in the config and
/// the terminal state.
fn is_mouse_reporting(&self, t: &Terminal) -> bool {
self.cfg().mouse_reporting && t.flags.mouse_event != MouseEvents::None
}
/// Returns true if the shift modifier is allowed to be captured by
/// modifier events. It is up to the caller to still verify it is a
/// situation in which shift capture makes sense (i.e. left button, mouse
/// click, etc.)
pub(crate) fn mouse_shift_capture(&self, t: &Terminal) -> bool {
let cfg = self.cfg();
// Handle our never/always case where we don't need the terminal.
match cfg.mouse_shift_capture {
ConfigShiftCapture::Never => return false,
ConfigShiftCapture::Always => return true,
ConfigShiftCapture::False | ConfigShiftCapture::True => {}
}
// If the terminal explicitly requests it then we always allow it
// since we processed never/always at this point.
match t.flags.mouse_shift_capture {
TerminalShiftCapture::False => return false,
TerminalShiftCapture::True => return true,
TerminalShiftCapture::Null => {}
}
// Otherwise, go with the user's preference
matches!(cfg.mouse_shift_capture, ConfigShiftCapture::True)
}
/// Returns true if the mouse is currently captured by the terminal
/// (i.e. reporting events).
pub fn mouse_captured(&self) -> bool {
self.lock_terminal().flags.mouse_event != MouseEvents::None
}
/// This returns the mouse mods to consider for link highlighting or
/// other purposes taking into account when shift is pressed for
/// releasing the mouse from capture. Takes the renderer lock.
#[allow(dead_code)]
pub(crate) fn mouse_mods_with_capture(&self, mods: Mods) -> Mods {
let t = self.lock_terminal();
self.mouse_mods_with_capture_locked(&t, mods)
}
fn mouse_mods_with_capture_locked(&self, t: &Terminal, mods: Mods) -> Mods {
// In any of these scenarios, whatever mods are set (even shift) are
// preserved.
if t.flags.mouse_event == MouseEvents::None {
return mods;
}
if !mods.shift {
return mods;
}
if self.mouse_shift_capture(t) {
return mods;
}
// We have mouse capture, shift set, and we're not allowed to
// capture shift, so we can clear shift.
let mut final_ = mods;
final_.shift = false;
final_
}
/// Call this when modifiers change. This is safe to call even if
/// modifiers match the previous state.
///
/// The renderer state mutex MUST NOT be held.
pub(crate) fn mods_changed(&self, mods: Mods) {
// The only place we keep track of mods currently is on the mouse.
let new_mods = {
let mut m = self.mouse.borrow_mut();
if m.mods.equal(mods) {
return;
}
// The mouse mods only contain binding modifiers since we don't
// want caps/num lock or sided modifiers to affect the mouse.
m.mods = mods.binding();
m.mods
};
// We also need to update the renderer so it knows if it should
// highlight links. Additionally, mark the screen as dirty so that
// the highlight state of all links is properly updated.
{
let mut st = self.lock_state();
let capture = self.mouse_mods_with_capture_locked(&st.terminal, new_mods);
st.mouse.mods = capture;
// We use the clear screen dirty flag to force a rebuild of all
// rows because changing mouse mods can affect the highlight
// state of a link. If there is no link this seems very wasteful
// but its really only one frame so it's not so bad.
st.terminal.flags.dirty.clear = true;
}
self.queue_render();
}
// ------------------------------------------------------------------
// Mouse reporting
/// Build the bytes for a mouse report (Zig `mouseReport`). Returns
/// `None` if nothing should be sent. The caller holds the terminal
/// lock and `m` is the (borrowed) mouse state.
fn mouse_report(
&self,
t: &Terminal,
m: &mut Mouse,
button: Option<MouseButton>,
action: MouseReportAction,
mods: Mods,
pos: CursorPos,
) -> Option<Vec<u8>> {
// Mouse reporting must be enabled by both config and terminal state
debug_assert!(self.cfg().mouse_reporting);
debug_assert_ne!(t.flags.mouse_event, MouseEvents::None);
// Depending on the event, we may do nothing at all.
match t.flags.mouse_event {
MouseEvents::None => return None,
// X10 only reports clicks with mouse button 1, 2, 3. We verify
// the button later.
MouseEvents::X10 => {
if action != MouseReportAction::Press
|| !matches!(
button,
Some(MouseButton::Left | MouseButton::Right | MouseButton::Middle)
)
{
return None;
}
}
// Doesn't report motion
MouseEvents::Normal => {
if action == MouseReportAction::Motion {
return None;
}
}
// Button must be pressed
MouseEvents::Button => {
button?;
}
// Everything
MouseEvents::Any => {}
}
let size = self.size.get();
// Handle scenarios where the mouse position is outside the
// viewport. We always report release events no matter where they
// happen.
if action != MouseReportAction::Release {
let max_x = size.screen.width as f32;
let max_y = size.screen.height as f32;
let pos_out_viewport = pos.x < 0.0 || pos.y < 0.0 || pos.x > max_x || pos.y > max_y;
if pos_out_viewport {
// If we don't have a motion-tracking event mode, do nothing.
if !t.flags.mouse_event.motion() {
return None;
}
// If any button is pressed, we still do the report.
// Otherwise, we do not do the report.
if m.click_state
.iter()
.all(|s| *s == MouseButtonState::Release)
{
return None;
}
}
}
// This format reports X/Y
let viewport_point = self.pos_to_viewport(f64::from(pos.x), f64::from(pos.y));
// Record our new point. We only want to send a mouse event if the
// cell changed, unless we're tracking raw pixels.
if action == MouseReportAction::Motion
&& t.flags.mouse_format != MouseFormat::SgrPixels
&& let Some(last) = m.event_point
&& last == viewport_point
{
return None;
}
m.event_point = Some(viewport_point);
let format = t.flags.mouse_format;
// Get the code we'll actually write
let button_code: u8 = {
// Determine our initial button value
let mut acc: u8 = match button {
// Null button means motion without a button pressed
None => 3,
// Release is 3. It is NOT 3 in SGR mode because SGR can tell
// the application what button was released.
Some(_)
if action == MouseReportAction::Release
&& format != MouseFormat::Sgr
&& format != MouseFormat::SgrPixels =>
{
3
}
Some(b) => match b {
MouseButton::Left => 0,
MouseButton::Middle => 1,
MouseButton::Right => 2,
MouseButton::Four => 64,
MouseButton::Five => 65,
MouseButton::Six => 66,
MouseButton::Seven => 67,
MouseButton::Eight => 128,
MouseButton::Nine => 129,
_ => return None, // unsupported
},
};
// X10 doesn't have modifiers
if t.flags.mouse_event != MouseEvents::X10 {
if mods.shift {
acc += 4;
}
if mods.alt {
acc += 8;
}
if mods.ctrl {
acc += 16;
}
}
// Motion adds another bit
if action == MouseReportAction::Motion {
acc += 32;
}
acc
};
let x = u32::from(viewport_point.x);
let y = viewport_point.y;
match format {
MouseFormat::X10 => {
if x > 222 || y > 222 {
log::info!("X10 mouse format can only encode X/Y up to 223");
return None;
}
// + 1 below is because our x/y is 0-indexed and the protocol
// wants 1
Some(vec![
0x1b,
b'[',
b'M',
32 + button_code,
32 + x as u8 + 1,
32 + y as u8 + 1,
])
}
MouseFormat::Utf8 => {
let mut data = vec![0x1b, b'[', b'M', 32 + button_code];
// UTF-8 encode the x/y
let mut buf = [0u8; 4];
for v in [32 + x + 1, 32 + y + 1] {
let c = char::from_u32(v)?;
data.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
}
Some(data)
}
MouseFormat::Sgr => {
// Final character to send in the CSI
let final_ = if action == MouseReportAction::Release {
'm'
} else {
'M'
};
Some(format!("\x1b[<{button_code};{};{}{final_}", x + 1, y + 1).into_bytes())
}
MouseFormat::Urxvt => Some(
format!("\x1b[{};{};{}M", 32 + u32::from(button_code), x + 1, y + 1).into_bytes(),
),
MouseFormat::SgrPixels => {
// Final character to send in the CSI
let final_ = if action == MouseReportAction::Release {
'm'
} else {
'M'
};
// The position has to be adjusted to the terminal space.
let coord = SizeCoordinate::Surface(SurfaceCoord {
x: f64::from(pos.x),
y: f64::from(pos.y),
})
.convert(CoordinateTag::Terminal, size);
let (cx, cy) = match coord {
SizeCoordinate::Terminal(c) => (c.x, c.y),
_ => (0.0, 0.0),
};
Some(
format!(
"\x1b[<{button_code};{};{}{final_}",
cx.round() as i32,
cy.round() as i32
)
.into_bytes(),
)
}
}
}
// ------------------------------------------------------------------
// Scrolling
/// Mouse scroll event. Negative is down, left. Positive is up, right.
///
/// "Natural scrolling" is a macOS term for inverting the scroll
/// direction. This should be handled by the apprt implementation. At
/// this layer, negative is always down, left.
pub fn scroll_callback(
&self,
xoff: f64,
yoff: f64,
scroll_mods: ScrollMods,
) -> Result<(), CoreError> {
// Always show the mouse again if it is hidden
let hidden = self.mouse.borrow().hidden;
if hidden {
self.show_mouse();
}
let cfg = self.cfg();
let size = self.size.get();
let (y, x) = {
let mut m = self.mouse.borrow_mut();
let y = if yoff == 0.0 {
ScrollAmount::default()
} else {
// We use cell_size to determine if we have accumulated
// enough to trigger a scroll
let cell_size = f64::from(size.cell.height);
// If we have precision scroll, yoff is the number of pixels
// to scroll. In non-precision scroll, yoff is the number of
// wheel ticks. Some mice are capable of reporting fractional
// wheel ticks, which don't necessarily get reported as
// precision scrolls. We normalize all scroll events to
// pixels by multiplying the wheel tick value and the cell
// size. This means that a wheel tick of 1 results in single
// scroll event.
let yoff_adjusted = if scroll_mods.precision {
yoff * cfg.mouse_scroll_multiplier.precision
} else {
// Round out the yoff to an absolute minimum of 1. macos
// tries to simulate precision scrolling with non
// precision events by ramping up the magnitude of the
// offsets as it detects faster scrolling. Single click
// (very slow) scrolls are reported with a magnitude of
// 0.1 which would normally require a few clicks before
// we register an actual scroll event (depending on cell
// height and the mouse_scroll_multiplier setting).
let yoff_max = if yoff > 0.0 {
yoff.max(1.0)
} else {
yoff.min(-1.0)
};
yoff_max * cell_size * cfg.mouse_scroll_multiplier.discrete
};
// Add our previously saved pending amount to the offset to
// get the new offset value. The signs of the pending and
// yoff should match so that we move further away from zero,
// but we don't assert this because in theory a user could
// scroll in the opposite direction and undo a pending
// scroll.
let poff = m.pending_scroll_y + yoff_adjusted;
// If the new offset is less than a single unit of scroll,
// we save the new pending value and do not scroll yet.
if poff.abs() < cell_size {
m.pending_scroll_y = poff;
ScrollAmount::default()
} else {
// We scroll by the number of rows in the offset and save
// the remainder
let amount = poff / cell_size;
m.pending_scroll_y = poff - (amount * cell_size);
// Round towards zero.
ScrollAmount {
delta: amount.trunc() as isize,
}
}
};
// For detailed comments see the y calculation above.
let x = if xoff == 0.0 {
ScrollAmount::default()
} else if !scroll_mods.precision {
ScrollAmount {
delta: xoff.round() as isize,
}
} else {
let poff = m.pending_scroll_x + xoff;
let cell_size = f64::from(size.cell.width);
if poff.abs() < cell_size {
m.pending_scroll_x = poff;
ScrollAmount::default()
} else {
let amount = poff / cell_size;
m.pending_scroll_x = poff - (amount * cell_size);
ScrollAmount {
delta: amount.trunc() as isize,
}
}
};
(y, x)
};
// The cursor position is only needed when reporting; fetch it before
// taking the lock.
let pos = self.rt_surface.get_cursor_pos();
let mut writes: Vec<IoMessage> = Vec::new();
{
let mut t = self.lock_terminal();
// If we have an active mouse reporting mode, clear the
// selection. The selection can occur if the user uses the shift
// mod key to override mouse grabbing from the window.
if self.is_mouse_reporting(&t) {
let _ = self.set_selection(&mut t, None);
}
// If we're in alternate screen with alternate scroll enabled,
// then we convert to cursor keys. This only happens if we're:
// (1) alt screen (2) no explicit mouse reporting and (3)
// alt scroll mode enabled.
if t.screens.active_key == ScreenKey::Alternate
&& t.flags.mouse_event == MouseEvents::None
&& t.modes.get(Mode::MouseAlternateScroll)
{
if y.delta != 0 {
// When we send mouse events as cursor keys we always
// clear the selection.
let _ = self.set_selection(&mut t, None);
let seq: &'static [u8] = if t.modes.get(Mode::CursorKeys) {
// cursor key: application mode
if y.up_right() { b"\x1bOA" } else { b"\x1bOB" }
} else {
// cursor key: normal mode
if y.up_right() { b"\x1b[A" } else { b"\x1b[B" }
};
for _ in 0..y.magnitude() {
writes.push(IoMessage::write_stable(seq));
}
}
drop(t);
for w in writes {
self.queue_io(w);
}
return Ok(());
}
// We have mouse events, are not in an alternate scroll buffer,
// or have alternate scroll disabled. In this case, we just run
// the normal logic.
// If we're scrolling up or down, then send a mouse event.
if self.is_mouse_reporting(&t) {
let mods = self.mouse.borrow().mods;
for _ in 0..y.magnitude() {
let button = if y.up_right() {
MouseButton::Four
} else {
MouseButton::Five
};
let mut m = self.mouse.borrow_mut();
if let Some(b) = self.mouse_report(
&t,
&mut m,
Some(button),
MouseReportAction::Press,
mods,
pos,
) {
writes.push(IoMessage::write_req(&b));
}
}
for _ in 0..x.magnitude() {
let button = if x.up_right() {
MouseButton::Six
} else {
MouseButton::Seven
};
let mut m = self.mouse.borrow_mut();
if let Some(b) = self.mouse_report(
&t,
&mut m,
Some(button),
MouseReportAction::Press,
mods,
pos,
) {
writes.push(IoMessage::write_req(&b));
}
}
// If mouse reporting is on, we do not want to scroll the
// viewport.
drop(t);
for w in writes {
self.queue_io(w);
}
return Ok(());
}
if y.delta != 0 {
// Modify our viewport, this requires a lock since it affects
// rendering. We have to switch signs here because our delta
// is negative down but our viewport is positive down.
t.scroll_viewport(ScrollViewport::Delta(-y.delta));
}
}
self.queue_render();
Ok(())
}
// ------------------------------------------------------------------
// Button
/// Called for mouse button press/release events. This will return true
/// if the mouse event was consumed in some way (i.e. the program is
/// capturing mouse events). If the event was not consumed, then false is
/// returned.
pub fn mouse_button_callback(
&self,
action: MouseButtonState,
button: MouseButton,
mods: Mods,
) -> Result<bool, CoreError> {
let cfg = self.cfg();
// Always record our latest mouse state
let hidden = {
let mut m = self.mouse.borrow_mut();
let idx = button as usize;
if idx < MOUSE_BUTTON_COUNT {
m.click_state[idx] = action;
}
m.hidden
};
// Always show the mouse again if it is hidden
if hidden {
self.show_mouse();
}
// Update our modifiers if they changed
self.mods_changed(mods);
// This is set to true if the terminal is allowed to capture the
// shift modifier. Note we can do this more efficiently probably with
// less locking/unlocking but clicking isn't that frequent enough to
// be a bottleneck.
let shift_capture = {
let t = self.lock_terminal();
self.mouse_shift_capture(&t)
};
// Shift-click continues the previous mouse state if we have a
// selection. cursorPosCallback will also do a mouse report so we
// don't need to do any of the logic below.
if button == MouseButton::Left && action == MouseButtonState::Press {
let click_count = self.mouse.borrow().left_click_count;
if mods.shift && click_count > 0 && !shift_capture && self.has_selection() {
// If we are within the interval that the click would
// register an increment then we do not extend the selection.
let within_interval = self
.mouse
.borrow()
.left_click_time
.is_some_and(|t| t.elapsed().as_nanos() as u64 <= cfg.mouse_interval);
if !within_interval {
let pos = self.rt_surface.get_cursor_pos();
self.cursor_pos_callback(pos, None)?;
return Ok(true);
}
}
}
if button == MouseButton::Left && action == MouseButtonState::Release {
// Stop selection scrolling when releasing the left mouse button
// but only when selection scrolling is active.
if self.selection_scroll_active.get() {
self.queue_io(IoMessage::SelectionScroll(false));
}
// The selection clipboard is only updated for left-click drag
// when the left button is released. This is to avoid the
// clipboard being updated on every mouse move which would be
// noisy.
if cfg.copy_on_select != CopyOnSelect::False {
let mut t = self.lock_terminal();
if let Some(prev) = t.screen().selection {
let list = &t.screen().pages;
let sel = Selection::new(prev.start(list), prev.end(list), prev.rectangle);
self.copy_on_select(&mut t, sel);
}
}
// Handle link clicking. We want to do this before we do mouse
// reporting or any other mouse handling because a successfully
// clicked link will swallow the event.
let over_link = self.mouse.borrow().over_link;
if over_link {
let pos = self.rt_surface.get_cursor_pos();
match self.process_links(pos) {
Ok(true) => return Ok(true),
Ok(false) => {}
Err(err) => log::warn!("error processing links err={err}"),
}
}
// Handle prompt clicking. If we released our mouse on a prompt
// and we support some kind of click events, then we need to move
// to it.
if self.maybe_prompt_click() {
return Ok(true);
}
}
// Report mouse events if enabled
{
let mut t = self.lock_terminal();
if self.is_mouse_reporting(&t) {
// If we have shift-pressed and we aren't allowed to capture
// it, then we do not do a mouse report.
if !(mods.shift && !shift_capture) {
// In any other mouse button scenario without shift
// pressed we clear the selection since the underlying
// application can handle that in any way (i.e.
// "scrolling").
let _ = self.set_selection(&mut t, None);
// We also set the left click count to 0 so that if mouse
// reporting is disabled in the middle of press (before
// release) we don't suddenly start selecting text.
let pos = self.rt_surface.get_cursor_pos();
let report_action = match action {
MouseButtonState::Press => MouseReportAction::Press,
MouseButtonState::Release => MouseReportAction::Release,
};
let bytes = {
let mut m = self.mouse.borrow_mut();
m.left_click_count = 0;
let mods = m.mods;
self.mouse_report(&t, &mut m, Some(button), report_action, mods, pos)
};
drop(t);
if let Some(b) = bytes {
self.queue_io(IoMessage::write_req(&b));
}
// If we're doing mouse reporting, we do not support any
// other selection or highlighting.
return Ok(true);
}
}
}
// For left button clicks we always record some information for
// selection/highlighting purposes.
if button == MouseButton::Left && action == MouseButtonState::Press {
self.left_press(mods, &cfg);
}
// Middle-click pastes from our selection clipboard
if button == MouseButton::Middle && action == MouseButtonState::Press {
let clipboard = if self.rt_surface.supports_clipboard(Clipboard::Selection) {
Clipboard::Selection
} else {
Clipboard::Standard
};
self.start_clipboard_request(clipboard, ClipboardRequest::Paste);
}
// Right-click down selects word for context menus. If the apprt
// doesn't implement context menus this can be a bit weird but they
// are supported by our two main apprts so we always do this. If we
// want to be careful in the future we can add a function to apprts
// that let's us know.
if button == MouseButton::Right
&& action == MouseButtonState::Press
&& let Some(consumed) = self.right_press(&cfg)
{
return Ok(consumed);
}
Ok(false)
}
/// Copy the selection per `copy-on-select` (the clipboard half of Zig
/// `setSelection`) without changing the selection.
fn copy_on_select(&self, t: &mut Terminal, sel: Selection) {
let cfg = self.cfg();
let format = ghostty_input::binding::CopyToClipboard::Mixed;
match cfg.copy_on_select {
CopyOnSelect::False => {}
CopyOnSelect::Clipboard => self.copy_selection_to_clipboards(
t,
sel,
&[Clipboard::Standard, Clipboard::Selection],
format,
),
CopyOnSelect::True => {
let clipboard = if self.rt_surface.supports_clipboard(Clipboard::Selection) {
Clipboard::Selection
} else {
Clipboard::Standard
};
self.copy_selection_to_clipboards(t, sel, &[clipboard], format);
}
}
}
/// Left button press: record click state and start/extend selection by
/// click count.
fn left_press(&self, mods: Mods, cfg: &super::config::DerivedConfig) {
let pos = self.rt_surface.get_cursor_pos();
let (xpos, ypos) = (f64::from(pos.x), f64::from(pos.y));
let size = self.size.get();
let vp = self.pos_to_viewport(xpos, ypos);
let mut t = self.lock_terminal();
let active_key = t.screens.active_key;
let Some(pin) = t.screen().pages.pin(Point::Viewport(vp)) else {
// Weird... our viewport x/y that we just converted isn't found in
// our pages. This is probably a bug but we don't want to crash
// in releases because its harmless.
debug_assert!(false, "viewport pin not found");
return;
};
let Ok(tracked) = t.screen_mut().pages.track_pin(pin) else {
return;
};
let count = {
let mut m = self.mouse.borrow_mut();
// If we move our cursor too much between clicks then we reset
// the multi-click state.
if m.left_click_count > 0 {
let max_distance = f64::from(size.cell.width);
let distance = ((xpos - m.left_click_xpos).powi(2)
+ (ypos - m.left_click_ypos).powi(2))
.sqrt();
if distance > max_distance {
m.left_click_count = 0;
}
}
if let Some(prev) = m.left_click_pin.take() {
let key = m.left_click_screen;
if let Some(pin_screen) = t.screens.get_mut(key) {
pin_screen.pages.untrack_pin(prev);
}
}
// Store it
m.left_click_pin = Some(tracked);
m.left_click_screen = active_key;
m.left_click_xpos = xpos;
m.left_click_ypos = ypos;
// Setup our click counter and timer. If we have mouse clicks,
// then we check if the time elapsed is less than and our
// interval and if so, increase the count.
let now = Instant::now();
if m.left_click_count > 0 {
let since = m
.left_click_time
.map_or(u64::MAX, |t| now.duration_since(t).as_nanos() as u64);
if since > cfg.mouse_interval {
m.left_click_count = 0;
}
}
m.left_click_time = Some(now);
m.left_click_count += 1;
// We only support up to triple-clicks.
if m.left_click_count > 3 {
m.left_click_count = 1;
}
m.left_click_count
};
// In all cases below, we set the selection directly rather than use
// `set_selection` because we want to avoid copying the selection to
// the selection clipboard. For left mouse clicks we only set the
// clipboard on release.
match count {
// Single click
1 => {
// If we have a selection, clear it. This always happens.
if t.screen().selection.is_some() {
let _ = t.screen_mut().select(None);
self.queue_render();
}
}
// Double click, select the word under our mouse. First try to
// detect if we're clicking on a URL to select the entire URL.
2 => {
// Try link detection without requiring modifier keys
let sel = self
.link_at_pin(t.screen(), pin, None)
.map(|l| l.selection)
.or_else(|| t.screen().select_word(pin, &cfg.selection_word_chars));
if let Some(sel) = sel {
let _ = t.screen_mut().select(Some(sel));
self.queue_render();
}
}
// Triple click, select the line under our mouse
3 => {
let screen = t.screen();
let sel = if mods.ctrl_or_super() {
screen.select_output(pin)
} else {
screen.select_line(SelectLine::new(pin))
};
if let Some(sel) = sel {
let _ = t.screen_mut().select(Some(sel));
self.queue_render();
}
}
// We should be bounded by 1 to 3
_ => unreachable!(),
}
}
/// Right button press. Returns `Some(consumed)` if the event is
/// finished, `None` to fall through.
fn right_press(&self, cfg: &super::config::DerivedConfig) -> Option<bool> {
let pos = self.rt_surface.get_cursor_pos();
let vp = self.pos_to_viewport(f64::from(pos.x), f64::from(pos.y));
let mut paste = false;
let consumed;
{
let mut t = self.lock_terminal();
// Get our viewport pin
let Some(pin) = t.screen().pages.pin(Point::Viewport(vp)) else {
debug_assert!(false, "viewport pin not found");
return None;
};
match cfg.right_click_action {
RightClickAction::Ignore => consumed = true,
RightClickAction::ContextMenu => {
// If we already have a selection and the selection
// contains where we clicked then we don't want to modify
// the selection.
if let Some(prev_sel) = t.screen().selection
&& prev_sel.contains(&t.screen().pages, pin)
{
return None;
}
// The selection doesn't contain our pin, so we
// create a new word selection where we clicked.
// If there is a link at this position, we want to select
// the link. Otherwise, select the word.
let link = {
let mods = self.mouse.borrow().mods;
let mods = self.mouse_mods_with_capture_locked(&t, mods);
self.link_at_pos(&t, vp, mods)
};
if let Some(link) = link {
let _ = self.set_selection(&mut t, Some(link.selection));
} else {
let Some(sel) = t.screen().select_word(pin, &cfg.selection_word_chars)
else {
return None;
};
let _ = self.set_selection(&mut t, Some(sel));
}
drop(t);
self.queue_render();
// Don't consume so that we show the context menu in
// apprt.
return Some(false);
}
RightClickAction::Copy => {
if let Some(sel) = t.screen().selection {
self.copy_selection_to_clipboards(
&t,
sel,
&[Clipboard::Standard],
ghostty_input::binding::CopyToClipboard::Mixed,
);
}
let _ = self.set_selection(&mut t, None);
self.queue_render();
consumed = true;
}
RightClickAction::CopyOrPaste => {
if let Some(sel) = t.screen().selection {
self.copy_selection_to_clipboards(
&t,
sel,
&[Clipboard::Standard],
ghostty_input::binding::CopyToClipboard::Mixed,
);
let _ = self.set_selection(&mut t, None);
self.queue_render();
} else {
// We don't need to clear selection because we didn't
// have one to begin with.
paste = true;
}
consumed = true;
}
RightClickAction::Paste => {
// Before we yield the lock, clear our selection if we
// have one.
let _ = self.set_selection(&mut t, None);
self.queue_render();
paste = true;
consumed = true;
}
}
}
// Pasting can trigger a lock grab in complete clipboard request so
// we must not hold the lock.
if paste {
self.start_clipboard_request(Clipboard::Standard, ClipboardRequest::Paste);
}
// Consume the event such that the context menu is not displayed.
Some(consumed)
}
/// Handle a click on the prompt (Zig `maybePromptClick`). Returns true
/// if handled.
fn maybe_prompt_click(&self) -> bool {
let cfg = self.cfg();
let pos = self.rt_surface.get_cursor_pos();
let pos_vp = self.pos_to_viewport(f64::from(pos.x), f64::from(pos.y));
let t = self.lock_terminal();
let screen = t.screen();
// If our screen doesn't handle any prompt clicks, then we never do
// anything.
if screen.semantic_prompt.click == SemanticClick::None {
return false;
}
// If cursor-click-to-move is disabled, we don't do any prompt
// clicking.
if !cfg.cursor_click_to_move {
return false;
}
// If our cursor isn't currently at a prompt then we don't handle
// prompt clicks because we can't move if we're not in a prompt!
if !t.cursor_is_at_prompt() {
return false;
}
// If we have a selection currently, then releasing the mouse
// completes the selection and we don't do prompt moving.
if screen.selection.is_some() {
return false;
}
// Get the pin for our mouse click.
let Some(click_pin) = screen.pages.pin(Point::Viewport(pos_vp)) else {
debug_assert!(false, "viewport pin not found");
return false;
};
// Get our cursor's most current prompt.
let Some(cursor_pin) = screen.pages.tracked_pin(screen.cursor.page_pin) else {
return false;
};
let Some(prompt_pin) = cursor_pin
.prompt_iterator(&screen.pages, Direction::LeftUp, None)
.next()
else {
// This shouldn't be possible because we asserted we're at a
// prompt above, so we MUST find some prompt in a left_up search.
log::warn!("cursor is at prompt but no prompt found");
return false;
};
// If our mouse click is before the prompt, we don't move. We DO
// ALLOW clicks AFTER the prompt, specifically with Kitty's
// click_events=1 since we rely on the shell to validate out of
// bounds clicks. This matches Kitty's logic as best I can tell.
if click_pin.before(&screen.pages, prompt_pin) {
return false;
}
// At this point we've established:
// - Screen supports prompt clicks
// - Cursor is at a prompt
// - Click is at or below our prompt
let mut writes: Vec<IoMessage> = Vec::new();
match screen.semantic_prompt.click {
// Guarded at the start of this function
SemanticClick::None => unreachable!(),
SemanticClick::ClickEvents => {
// For the event, we always send a left-click press event.
// This matches what Kitty sends.
let resp = format!("\x1b[<0;{};{}M", u32::from(pos_vp.x) + 1, pos_vp.y + 1);
// Not that noisy since this only happens on prompt clicks.
log::debug!("sending click_events=1 event=ESC{}", &resp[1..]);
writes.push(IoMessage::write_req(resp.as_bytes()));
}
SemanticClick::Cl(_) => {
let app = t.modes.get(Mode::CursorKeys);
let left_arrow: &'static [u8] = if app { b"\x1bOD" } else { b"\x1b[D" };
let right_arrow: &'static [u8] = if app { b"\x1bOC" } else { b"\x1b[C" };
let mv = screen.prompt_click_move(click_pin);
for _ in 0..mv.left {
writes.push(IoMessage::write_stable(left_arrow));
}
for _ in 0..mv.right {
writes.push(IoMessage::write_stable(right_arrow));
}
}
}
drop(t);
for w in writes {
self.queue_io(w);
}
true
}
// ------------------------------------------------------------------
// Links
/// Returns the link at the given viewport point, if any. Requires the
/// renderer lock (pass the locked terminal) and the capture-adjusted
/// mods.
fn link_at_pos(&self, t: &Terminal, vp: PointCoordinate, mouse_mods: Mods) -> Option<Link> {
// Convert our cursor position to a screen point.
let screen = t.screen();
let Some(mouse_pin) = screen.pages.pin(Point::Viewport(vp)) else {
log::warn!("failed to get pin for clicked point");
return None;
};
// If we have the proper modifiers set then we can check for OSC8
// links.
if mouse_mods.equal(ghostty_input::ctrl_or_super(Mods::default()))
&& let Some((_, cell)) = mouse_pin.row_and_cell(&screen.pages)
&& cell.hyperlink()
{
let sel = Selection::new(mouse_pin, mouse_pin, false);
return Some(Link {
action: LinkAction::_OpenOsc8,
selection: sel,
});
}
// Fall back to configured links
self.link_at_pin(screen, mouse_pin, Some(mouse_mods))
}
/// Detects if a link is present at the given pin.
///
/// If mouse mods is `None` then mouse mod requirements are ignored (all
/// configured links are checked).
fn link_at_pin(
&self,
screen: &Screen,
mouse_pin: Pin,
mouse_mods: Option<Mods>,
) -> Option<Link> {
let cfg = self.cfg();
if cfg.links.is_empty() {
return None;
}
let line = screen.select_line(SelectLine {
pin: mouse_pin,
whitespace: None,
semantic_prompt_boundary: false,
})?;
let strmap = screen.selection_string_map(SelectionString {
sel: line,
trim: false,
});
for link in &cfg.links {
// Skip highlight/mods check when mouse_mods is None
// (double-click mode)
if let Some(mods) = mouse_mods {
match link.highlight {
Highlight::Always | Highlight::Hover => {}
Highlight::AlwaysMods(v) | Highlight::HoverMods(v) => {
if !v.equal(mods) {
continue;
}
}
}
}
let mut it = strmap.search_iterator(&link.regex);
while let Some(m) = it.next_match() {
let sel = m.selection();
if !sel.contains(&screen.pages, mouse_pin) {
continue;
}
return Some(Link {
action: link.action,
selection: sel,
});
}
}
None
}
/// Return the URI for an OSC8 hyperlink at the given position or `None`
/// if there is no hyperlink.
fn osc8_uri(screen: &Screen, pin: Pin) -> Option<String> {
let node = screen.pages.node(pin.node)?;
let page = &node.data;
let idx = page
.cell_index(usize::from(pin.x), usize::from(pin.y))
.ok()?;
let id = page.lookup_hyperlink(idx)?;
let entry = page.hyperlink_set.get(id)?;
Some(String::from_utf8_lossy(&entry.uri).into_owned())
}
/// The URL of the link (`open` regex link or OSC8) under the current
/// cursor position, if the mouse is over a link (Zig
/// `copy_url_to_clipboard`). Takes the renderer lock.
pub(crate) fn link_url_at_cursor(&self) -> Option<String> {
// If the mouse isn't over a link, nothing we can do.
if !self.mouse.borrow().over_link {
return None;
}
let pos = self.rt_surface.get_cursor_pos();
let vp = self.pos_to_viewport(f64::from(pos.x), f64::from(pos.y));
let trim = self.cfg().clipboard_trim_trailing_spaces;
let t = self.lock_terminal();
let mods = self.mouse.borrow().mods;
let mods = self.mouse_mods_with_capture_locked(&t, mods);
let link = self.link_at_pos(&t, vp, mods)?;
let screen = t.screen();
match link.action {
LinkAction::Open => Some(screen.selection_string(SelectionString {
sel: link.selection,
trim,
})),
LinkAction::_OpenOsc8 => {
let uri = Self::osc8_uri(screen, link.selection.start(&screen.pages));
if uri.is_none() {
log::warn!("failed to get URI for OSC8 hyperlink");
}
uri
}
}
}
/// Attempt to invoke the action of any link that is under the given
/// position. Takes the renderer lock.
fn process_links(&self, pos: CursorPos) -> Result<bool, CoreError> {
let vp = self.pos_to_viewport(f64::from(pos.x), f64::from(pos.y));
let url = {
let t = self.lock_terminal();
let mods = self.mouse.borrow().mods;
let mods = self.mouse_mods_with_capture_locked(&t, mods);
let Some(link) = self.link_at_pos(&t, vp, mods) else {
return Ok(false);
};
let screen = t.screen();
match link.action {
LinkAction::Open => {
let str = screen.selection_string(SelectionString {
sel: link.selection,
trim: false,
});
let pwd = t.get_pwd().map(|p| String::from_utf8_lossy(p).into_owned());
resolve_path_for_opening(pwd.as_deref(), &str).unwrap_or(str)
}
LinkAction::_OpenOsc8 => {
let start = link.selection.start(&screen.pages);
let Some(uri) = Self::osc8_uri(screen, start) else {
log::warn!("failed to get URI for OSC8 hyperlink");
return Ok(false);
};
uri
}
}
};
self.open_url(OpenUrl {
kind: OpenUrlKind::Unknown,
url,
})?;
Ok(true)
}
fn open_url(&self, action: OpenUrl) -> Result<(), CoreError> {
// If the apprt handles it then we're done.
let kind = action.kind;
let url = action.url.clone();
if self.perform(RtAction::OpenUrl(action)) {
return Ok(());
}
// apprt didn't handle it, fallback to our simple cross-platform URL
// opener. We log a warning because we want well-behaved apprts to
// handle this themselves.
log::warn!("apprt did not handle open URL action, falling back to default opener");
let kind = match kind {
OpenUrlKind::Unknown => ghostty_os::open::OpenUrlKind::Unknown,
OpenUrlKind::Text => ghostty_os::open::OpenUrlKind::Text,
OpenUrlKind::Html => ghostty_os::open::OpenUrlKind::Html,
};
ghostty_os::open::open(kind, &url)?;
Ok(())
}
/// Call this whenever the mouse moves or mods changed. Takes the
/// renderer lock; must not be called with it held.
pub(crate) fn mouse_refresh_links(
&self,
pos: CursorPos,
pos_vp: PointCoordinate,
over_link: bool,
) -> Result<(), CoreError> {
let mut deferred = Deferred::default();
{
let mut st = self.lock_state();
self.mouse_refresh_links_locked(&mut st, pos, pos_vp, over_link, &mut deferred);
}
deferred.run(self);
Ok(())
}
fn mouse_refresh_links_locked(
&self,
st: &mut RenderStateData,
pos: CursorPos,
pos_vp: PointCoordinate,
over_link: bool,
out: &mut Deferred,
) {
// If the position is outside our viewport, do nothing
if pos.x < 0.0 || pos.y < 0.0 {
return;
}
let cfg = self.cfg();
// Update the last point that we checked for links so we don't
// recheck if the mouse moves some pixels to the same point.
self.mouse.borrow_mut().link_point = Some(pos_vp);
// Get our link at the current position. This returns None if there
// isn't a link OR if we shouldn't be showing links for some reason
// (see further comments for cases).
let (link, preview): (Option<MouseOverLink>, bool) = 'link: {
// If we clicked and our mouse moved cells then we never
// highlight links until the mouse is unclicked. This follows
// standard macOS and Linux behavior where a click and drag
// cancels mouse actions.
{
let m = self.mouse.borrow();
if m.click_state[MouseButton::Left as usize] == MouseButtonState::Press
&& let Some(h) = m.left_click_pin
{
let screen = st.terminal.screen();
if let Some(pin) = screen.pages.tracked_pin(h)
&& let Some(pt) = screen
.pages
.point_from_pin(ghostty_terminal::point::Tag::Viewport, pin)
&& pt.coord() != pos_vp
{
log::debug!("mouse moved while left click held, ignoring link hover");
break 'link (None, false);
}
}
}
let mods = self.mouse.borrow().mods;
let mods = self.mouse_mods_with_capture_locked(&st.terminal, mods);
let Some(link) = self.link_at_pos(&st.terminal, pos_vp, mods) else {
break 'link (None, false);
};
let screen = st.terminal.screen();
match link.action {
LinkAction::Open => {
let str = screen.selection_string(SelectionString {
sel: link.selection,
trim: false,
});
(
Some(MouseOverLink { url: str }),
cfg.link_previews == LinkPreviews::True,
)
}
LinkAction::_OpenOsc8 => {
// Show the URL in the status bar
let pin = link.selection.start(&screen.pages);
let Some(uri) = Self::osc8_uri(screen, pin) else {
log::warn!("failed to get URI for OSC8 hyperlink");
break 'link (None, false);
};
(
Some(MouseOverLink { url: uri }),
cfg.link_previews != LinkPreviews::False,
)
}
}
};
// If we found a link, setup our internal state and notify the apprt
// so it can highlight it.
if let Some(link) = link {
st.mouse.point = Some(pos_vp);
self.mouse.borrow_mut().over_link = true;
st.terminal.screen_mut().dirty.hyperlink_hover = true;
out.actions.push(RtAction::MouseShape(
ghostty_vt::mouse_shape::MouseShape::Pointer,
));
if preview {
out.actions.push(RtAction::MouseOverLink(link));
}
out.render = true;
return;
}
// No link, if we're previously over a link then we need to clear the
// over-link apprt state.
if over_link {
out.actions
.push(RtAction::MouseShape(st.terminal.mouse_shape));
out.actions.push(RtAction::MouseOverLink(MouseOverLink {
url: String::new(),
}));
out.render = true;
}
}
// ------------------------------------------------------------------
// Pressure
/// Mouse pressure (force click) callback.
pub fn mouse_pressure_callback(&self, stage: MousePressureStage, _pressure: f64) {
// We don't currently use the pressure value for anything. In the
// future, we could report this to applications using new mouse
// events or utilize it for some custom UI.
// If the pressure stage is the same as what we already have do
// nothing
let left_pressed = {
let mut m = self.mouse.borrow_mut();
if m.pressure_stage == stage {
return;
}
// Update our pressure stage.
m.pressure_stage = stage;
m.click_state[MouseButton::Left as usize] == MouseButtonState::Press
};
// If our left mouse button is pressed and we're entering a deep
// click then we want to start a selection. We treat this as a word
// selection since that is typical macOS behavior.
if left_pressed && stage == MousePressureStage::Deep {
let handle = self.mouse.borrow().left_click_pin;
let word_chars = self.cfg().selection_word_chars.clone();
let mut t = self.lock_terminal();
// This should always be set in this state but we don't want to
// handle state inconsistency here.
let Some(h) = handle else { return };
let Some(pin) = t.screen().pages.tracked_pin(h) else {
return;
};
let Some(sel) = t.screen().select_word(pin, &word_chars) else {
return;
};
if t.screen_mut().select(Some(sel)).is_ok() {
self.queue_render();
}
}
}
// ------------------------------------------------------------------
// Cursor position
/// Cursor position callback.
///
/// Send negative x or y values to indicate the cursor is outside the
/// viewport. The magnitude of the negative values are meaningless; they
/// are only used to indicate the cursor is outside the viewport. It's
/// important to do this to ensure hover states are cleared.
///
/// The mods parameter is optional because some apprts do not provide
/// modifier information on cursor position events. If mods is `None`
/// then we'll use the last known mods.
pub fn cursor_pos_callback(&self, pos: CursorPos, mods: Option<Mods>) -> Result<(), CoreError> {
let mut deferred = Deferred::default();
// If the position is negative, it is outside our viewport and we
// need to clear any hover states.
if pos.x < 0.0 || pos.y < 0.0 {
// Reset our hyperlink state
let was_over_link = {
let mut m = self.mouse.borrow_mut();
m.link_point = None;
let was = m.over_link;
m.over_link = false;
was
};
if was_over_link {
let shape = self.lock_terminal().mouse_shape;
self.perform(RtAction::MouseShape(shape));
self.perform(RtAction::MouseOverLink(MouseOverLink {
url: String::new(),
}));
self.queue_render();
}
let mut st = self.lock_state();
// No mouse point so we don't highlight links
st.mouse.point = None;
// Mark the link's row as dirty, but continue with updating the
// mouse state below so we can scroll when our position is
// negative.
st.terminal.screen_mut().dirty.hyperlink_hover = true;
}
// Always show the mouse again if it is hidden
let hidden = self.mouse.borrow().hidden;
if hidden {
self.show_mouse();
}
// Update our modifiers if they changed
if let Some(v) = mods {
self.mods_changed(v);
}
// The mouse position in the viewport
let pos_vp = self.pos_to_viewport(f64::from(pos.x), f64::from(pos.y));
// We always reset the over link status because it will be
// reprocessed below. But we need the old value to know if we need to
// undo mouse shape changes.
let over_link = {
let mut m = self.mouse.borrow_mut();
let o = m.over_link;
m.over_link = false;
o
};
let selection_scroll_active = self.selection_scroll_active.get();
let size = self.size.get();
{
// We are reading/writing state for the remainder
let mut st = self.lock_state();
// Stop selection scrolling when inside the viewport within a 1px
// buffer for fullscreen windows, but only when selection
// scrolling is active.
if pos.y >= 1.0 && selection_scroll_active {
deferred.io.push(IoMessage::SelectionScroll(false));
}
// Update our mouse state. We set this to null initially because
// we only want to set it when we're not selecting or doing any
// other mouse event.
st.mouse.point = None;
// Handle link hovering. We refresh links when
// 1. we were previously over a link
// OR
// 2. the cursor position has changed (either we have no
// previous state, or the state has changed)
// AND
// 1. mouse reporting is off
// OR
// 2. mouse reporting is on and we are not reporting shift to the
// terminal
let (link_point, mouse_mods) = {
let m = self.mouse.borrow();
(m.link_point, m.mods)
};
if (over_link || link_point.is_none_or(|p| p != pos_vp))
&& (st.terminal.flags.mouse_event == MouseEvents::None
|| (mouse_mods.shift && !self.mouse_shift_capture(&st.terminal)))
{
// If we were previously over a link, we always update. We do
// this so that if the text changed underneath us, even if the
// mouse didn't move, we update the URL hints and state
self.mouse_refresh_links_locked(&mut st, pos, pos_vp, over_link, &mut deferred);
}
// Do a mouse report
if self.is_mouse_reporting(&st.terminal) {
// Shift overrides mouse "grabbing" in the window, taken from
// Kitty. This only applies if there is a mouse button
// pressed so that movement reports are not affected.
let skip = mouse_mods.shift
&& !self.mouse_shift_capture(&st.terminal)
&& self
.mouse
.borrow()
.click_state
.iter()
.any(|s| *s != MouseButtonState::Release);
if !skip {
let mut m = self.mouse.borrow_mut();
// We use the first mouse button we find pressed in order
// to report since the spec (afaict) does not say...
let button = m
.click_state
.iter()
.position(|s| *s == MouseButtonState::Press)
.and_then(button_from_index);
let mods = m.mods;
if let Some(b) = self.mouse_report(
&st.terminal,
&mut m,
button,
MouseReportAction::Motion,
mods,
pos,
) {
deferred.io.push(IoMessage::write_req(&b));
}
// If we're doing mouse motion tracking, we do not
// support text selection.
drop(m);
drop(st);
deferred.run(self);
return Ok(());
}
}
// Handle cursor position for text selection
let (left_pressed, count, click_screen, handle, click_xpos, sel_mods) = {
let m = self.mouse.borrow();
(
m.click_state[MouseButton::Left as usize] == MouseButtonState::Press,
m.left_click_count,
m.left_click_screen,
m.left_click_pin,
m.left_click_xpos,
m.mods,
)
};
// Left click pressed but count zero can happen if mouse
// reporting is on. In this scenario, we mark the click state
// because we need that to properly make some mouse reports, but
// we don't keep track of the count because we don't want to
// handle selection.
//
// If our terminal screen changed then we don't process this. We
// don't invalidate our pin or mouse state because if the screen
// switches back then we can continue our selection.
if left_pressed && count != 0 && click_screen == st.terminal.screens.active_key {
// All roads lead to requiring a re-render at this point.
deferred.render = true;
// If our y is negative, we're above the window. In this
// case, we scroll up. The amount we scroll up is dependent
// on how negative we are. We allow for a 1 pixel buffer at
// the top and bottom to detect scroll even in full screen
// windows.
let max_y = size.screen.height as f32;
// If the mouse is outside the viewport and we have the left
// mouse button pressed then we need to start the scroll
// timer.
if (pos.y <= 1.0 || pos.y > max_y - 1.0) && !selection_scroll_active {
deferred.io.push(IoMessage::SelectionScroll(true));
}
// Convert to points
let t = &mut st.terminal;
let Some(pin) = t.screen().pages.pin(Point::Viewport(pos_vp)) else {
debug_assert!(false, "viewport pin not found");
drop(st);
deferred.run(self);
return Ok(());
};
// Handle dragging depending on click count
if let Some(click_pin) = handle.and_then(|h| t.screen().pages.tracked_pin(h)) {
let word_chars = self.cfg().selection_word_chars.clone();
match count {
1 => Self::drag_left_click_single(
t,
click_pin,
pin,
click_xpos,
f64::from(pos.x),
sel_mods,
size,
),
2 => {
let sel = drag_left_click_double_selection(
t.screen(),
click_pin,
pin,
&word_chars,
);
match sel {
Some(sel) => {
let _ = t.screen_mut().select(Some(sel));
}
None => {
let _ = self.set_selection(t, None);
}
}
}
3 => {
if let Some(sel) =
drag_left_click_triple_selection(t.screen(), click_pin, pin)
{
let _ = t.screen_mut().select(Some(sel));
}
}
_ => unreachable!(),
}
}
}
}
deferred.run(self);
Ok(())
}
fn drag_left_click_single(
t: &mut Terminal,
click_pin: Pin,
drag_pin: Pin,
click_xpos: f64,
drag_x: f64,
mods: Mods,
size: Size,
) {
// This logic is in a separate function so that it can be unit
// tested.
let sel = mouse_selection(
&t.screen().pages,
click_pin,
drag_pin,
click_xpos.max(0.0) as u32,
drag_x.max(0.0) as u32,
mods,
size,
);
let _ = t.screen_mut().select(sel);
}
/// Selection scrolling tick (Zig `selectionScrollTick`).
pub(crate) fn selection_scroll_tick(&self) -> Result<(), CoreError> {
// If we're no longer active then we don't do anything.
if !self.selection_scroll_active.get() {
return Ok(());
}
// If we don't have a left mouse button down then we don't do
// anything.
let (count, screen_key, handle, xpos, mods) = {
let m = self.mouse.borrow();
(
m.left_click_count,
m.left_click_screen,
m.left_click_pin,
m.left_click_xpos,
m.mods,
)
};
if count == 0 {
return Ok(());
}
let pos = self.rt_surface.get_cursor_pos();
let pos_vp = self.pos_to_viewport(f64::from(pos.x), f64::from(pos.y));
let delta: isize = if pos.y < 0.0 { -1 } else { 1 };
let size = self.size.get();
let mut t = self.lock_terminal();
// If our screen changed while this is happening, we stop our
// selection scroll.
if screen_key != t.screens.active_key {
drop(t);
self.queue_io(IoMessage::SelectionScroll(false));
return Ok(());
}
// Scroll the viewport as required
t.scroll_viewport(ScrollViewport::Delta(delta));
// Next, trigger our drag behavior
let Some(pin) = t.screen().pages.pin(Point::Viewport(pos_vp)) else {
debug_assert!(false, "viewport pin not found");
return Ok(());
};
if let Some(click_pin) = handle.and_then(|h| t.screen().pages.tracked_pin(h)) {
Self::drag_left_click_single(
&mut t,
click_pin,
pin,
xpos,
f64::from(pos.x),
mods,
size,
);
}
// We modified our viewport and selection so we need to queue a
// render.
drop(t);
self.queue_render();
Ok(())
}
}
/// `@enumFromInt(i)` for the click state index.
fn button_from_index(i: usize) -> Option<MouseButton> {
Some(match i {
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,
})
}
#[cfg(test)]
mod tests {
use super::*;
use ghostty_renderer::size::{CellSize, Padding, ScreenSize};
use ghostty_terminal::screen::Options as ScreenOptions;
/// Our screen size is 10x5 cells that are 10x20 px, with 5px padding on
/// all sides.
fn test_size() -> Size {
Size {
cell: CellSize {
width: 10,
height: 20,
},
padding: Padding {
left: 5,
top: 5,
right: 5,
bottom: 5,
},
screen: ScreenSize {
width: 110,
height: 110,
},
}
}
struct Setup {
screen: Screen,
mods: Mods,
click_pin: Pin,
drag_pin: Pin,
click_x_pos: u32,
drag_x_pos: u32,
}
fn setup(click_x: f64, click_y: u32, drag_x: f64, drag_y: u32, rect: bool) -> Setup {
let size = test_size();
let screen = Screen::new(ScreenOptions::new(10, 5, 0)).unwrap();
// We hold both ctrl and alt for rectangular select so that this
// test is platform agnostic.
let mods = Mods {
ctrl: rect,
alt: rect,
..Mods::default()
};
assert_eq!(rect, surface_mouse::is_rectangle_select_state(mods));
let vp = |x: f64, y: u32| {
Point::Viewport(PointCoordinate {
x: x.floor() as u16,
y,
})
};
let click_pin = screen.pages.pin(vp(click_x, click_y)).unwrap();
let drag_pin = screen.pages.pin(vp(drag_x, drag_y)).unwrap();
let cell_width = f64::from(size.cell.width);
let click_x_pos = (click_x * cell_width).floor() as u32 + size.padding.left;
let drag_x_pos = (drag_x * cell_width).floor() as u32 + size.padding.left;
Setup {
screen,
mods,
click_pin,
drag_pin,
click_x_pos,
drag_x_pos,
}
}
#[allow(clippy::too_many_arguments)]
fn test_mouse_selection(
click_x: f64,
click_y: u32,
drag_x: f64,
drag_y: u32,
start_x: u16,
start_y: u32,
end_x: u16,
end_y: u32,
rect: bool,
) {
let s = setup(click_x, click_y, drag_x, drag_y, rect);
let start_pin = s
.screen
.pages
.pin(Point::Viewport(PointCoordinate {
x: start_x,
y: start_y,
}))
.unwrap();
let end_pin = s
.screen
.pages
.pin(Point::Viewport(PointCoordinate { x: end_x, y: end_y }))
.unwrap();
assert_eq!(
Some(Selection::new(start_pin, end_pin, rect)),
mouse_selection(
&s.screen.pages,
s.click_pin,
s.drag_pin,
s.click_x_pos,
s.drag_x_pos,
s.mods,
test_size(),
)
);
}
/// Like `test_mouse_selection` but checks that the resulting selection
/// is null.
fn test_mouse_selection_is_null(
click_x: f64,
click_y: u32,
drag_x: f64,
drag_y: u32,
rect: bool,
) {
let s = setup(click_x, click_y, drag_x, drag_y, rect);
assert_eq!(
None,
mouse_selection(
&s.screen.pages,
s.click_pin,
s.drag_pin,
s.click_x_pos,
s.drag_x_pos,
s.mods,
test_size(),
)
);
}
#[test]
fn surface_selection_logic() {
// -- LTR
// single cell selection
test_mouse_selection(3.0, 3, 3.9, 3, 3, 3, 3, 3, false);
// including click and drag pin cells
test_mouse_selection(3.0, 3, 5.9, 3, 3, 3, 5, 3, false);
// including click pin cell but not drag pin cell
test_mouse_selection(3.0, 3, 5.0, 3, 3, 3, 4, 3, false);
// including drag pin cell but not click pin cell
test_mouse_selection(3.9, 3, 5.9, 3, 4, 3, 5, 3, false);
// including neither click nor drag pin cells
test_mouse_selection(3.9, 3, 5.0, 3, 4, 3, 4, 3, false);
// empty selection (single cell on only left half)
test_mouse_selection_is_null(3.0, 3, 3.1, 3, false);
// empty selection (single cell on only right half)
test_mouse_selection_is_null(3.8, 3, 3.9, 3, false);
// empty selection (between two cells, not crossing threshold)
test_mouse_selection_is_null(3.9, 3, 4.0, 3, false);
// -- RTL
// single cell selection
test_mouse_selection(3.9, 3, 3.0, 3, 3, 3, 3, 3, false);
// including click and drag pin cells
test_mouse_selection(5.9, 3, 3.0, 3, 5, 3, 3, 3, false);
// including click pin cell but not drag pin cell
test_mouse_selection(5.9, 3, 3.9, 3, 5, 3, 4, 3, false);
// including drag pin cell but not click pin cell
test_mouse_selection(5.0, 3, 3.0, 3, 4, 3, 3, 3, false);
// including neither click nor drag pin cells
test_mouse_selection(5.0, 3, 3.9, 3, 4, 3, 4, 3, false);
// empty selection (single cell on only left half)
test_mouse_selection_is_null(3.1, 3, 3.0, 3, false);
// empty selection (single cell on only right half)
test_mouse_selection_is_null(3.9, 3, 3.8, 3, false);
// empty selection (between two cells, not crossing threshold)
test_mouse_selection_is_null(4.0, 3, 3.9, 3, false);
// -- Wrapping
// LTR, wrap excluded cells
test_mouse_selection(9.9, 2, 0.0, 4, 0, 3, 9, 3, false);
// RTL, wrap excluded cells
test_mouse_selection(0.0, 4, 9.9, 2, 9, 3, 0, 3, false);
}
#[test]
fn surface_rectangle_selection_logic() {
// -- LTR
// single column selection
test_mouse_selection(3.0, 2, 3.9, 4, 3, 2, 3, 4, true);
// including click and drag pin columns
test_mouse_selection(3.0, 2, 5.9, 4, 3, 2, 5, 4, true);
// including click pin column but not drag pin column
test_mouse_selection(3.0, 2, 5.0, 4, 3, 2, 4, 4, true);
// including drag pin column but not click pin column
test_mouse_selection(3.9, 2, 5.9, 4, 4, 2, 5, 4, true);
// including neither click nor drag pin columns
test_mouse_selection(3.9, 2, 5.0, 4, 4, 2, 4, 4, true);
// empty selection (single column on only left half)
test_mouse_selection_is_null(3.0, 2, 3.1, 4, true);
// empty selection (single column on only right half)
test_mouse_selection_is_null(3.8, 2, 3.9, 4, true);
// empty selection (between two columns, not crossing threshold)
test_mouse_selection_is_null(3.9, 2, 4.0, 4, true);
// -- RTL
// single column selection
test_mouse_selection(3.9, 2, 3.0, 4, 3, 2, 3, 4, true);
// including click and drag pin columns
test_mouse_selection(5.9, 2, 3.0, 4, 5, 2, 3, 4, true);
// including click pin column but not drag pin column
test_mouse_selection(5.9, 2, 3.9, 4, 5, 2, 4, 4, true);
// including drag pin column but not click pin column
test_mouse_selection(5.0, 2, 3.0, 4, 4, 2, 3, 4, true);
// including neither click nor drag pin columns
test_mouse_selection(5.0, 2, 3.9, 4, 4, 2, 4, 4, true);
// empty selection (single column on only left half)
test_mouse_selection_is_null(3.1, 2, 3.0, 4, true);
// empty selection (single column on only right half)
test_mouse_selection_is_null(3.9, 2, 3.8, 4, true);
// empty selection (between two columns, not crossing threshold)
test_mouse_selection_is_null(4.0, 2, 3.9, 4, true);
// -- Wrapping
// LTR, do not wrap
test_mouse_selection(9.9, 2, 0.0, 4, 9, 2, 0, 4, true);
// RTL, do not wrap
test_mouse_selection(0.0, 4, 9.9, 2, 0, 4, 9, 2, true);
}
#[test]
fn resolve_path_for_opening_semantics() {
// Absolute paths are used as-is.
assert_eq!(resolve_path_for_opening(Some("/tmp"), "/etc/passwd"), None);
// No pwd, no resolution.
assert_eq!(resolve_path_for_opening(None, "foo"), None);
// Non-existent relative paths are not resolved.
assert_eq!(
resolve_path_for_opening(Some("/tmp"), "does-not-exist-xyz"),
None
);
// Existing relative path resolves, including `..`.
assert_eq!(
resolve_path_for_opening(Some("/tmp"), "../etc"),
Some("/etc".to_owned())
);
}
#[test]
fn scroll_amount_direction() {
let a = ScrollAmount { delta: -3 };
assert!(!a.up_right());
assert_eq!(a.magnitude(), 3);
assert!(ScrollAmount { delta: 2 }.up_right());
}
}