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:
1 parent
9c3eeeba50
commit
3266108c10
42 files changed
+9301
-179
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user