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
e256c42e53
commit
ad76ae2301
42 files changed
+9301
-179
No files matched your search
@@ -5,14 +5,23 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.23.1"
|
||||
crossbeam-channel = "0.5.17"
|
||||
ghostty-apprt.workspace = true
|
||||
ghostty-config.workspace = true
|
||||
ghostty-terminal.workspace = true
|
||||
ghostty-input.workspace = true
|
||||
ghostty-font.workspace = true
|
||||
ghostty-renderer.workspace = true
|
||||
ghostty-termio.workspace = true
|
||||
ghostty-os.workspace = true
|
||||
ghostty-datastruct.workspace = true
|
||||
ghostty-font.workspace = true
|
||||
ghostty-input.workspace = true
|
||||
ghostty-os.workspace = true
|
||||
ghostty-renderer.workspace = true
|
||||
ghostty-terminal.workspace = true
|
||||
ghostty-termio.workspace = true
|
||||
ghostty-unicode.workspace = true
|
||||
ghostty-vt.workspace = true
|
||||
log = { version = "0.4", features = ["std"] }
|
||||
onig = "6.5.3"
|
||||
png = "0.18.1"
|
||||
thiserror = "2"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,634 @@
|
||||
//! App is the primary GUI application for ghostty (port of `App.zig`).
|
||||
//!
|
||||
//! The Zig `App` is a heap object shared by the runtime and every surface.
|
||||
//! In Rust the runtime holds an [`App`] (it implements
|
||||
//! [`ghostty_apprt::runtime_api::CoreApp`]) and surfaces hold a shared
|
||||
//! [`AppState`]. Everything here is main-thread only except the
|
||||
//! [`Mailbox`], which is how other threads (renderer, IO, search) talk to
|
||||
//! the app thread.
|
||||
//!
|
||||
//! Surfaces are identified by the [`SurfaceId`] the runtime allocates; the
|
||||
//! app keeps weak references to them (the runtime owns the strong
|
||||
//! `Rc<dyn CoreSurface>`).
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::{Rc, Weak};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use crossbeam_channel::{Receiver, Sender, TrySendError};
|
||||
use ghostty_apprt::action::{Action as RtAction, QuitTimer, ReloadConfig, Target};
|
||||
use ghostty_apprt::runtime_api::{
|
||||
AppMessage, CellSize as RtCellSize, CoreApp, CoreError, CoreSurface, DesiredFontSize, RtApp,
|
||||
RtSurface, SurfaceId, Waker,
|
||||
};
|
||||
use ghostty_apprt::structs::ColorScheme;
|
||||
use ghostty_apprt::surface::{AppMailbox, Message as SurfaceMessage};
|
||||
use ghostty_config::conditional;
|
||||
use ghostty_config::config::Config;
|
||||
use ghostty_datastruct::blocking_queue::Timeout;
|
||||
use ghostty_font::SharedGridSet;
|
||||
use ghostty_input::KeyEvent;
|
||||
use ghostty_input::binding::set::Value as BindingValue;
|
||||
use ghostty_input::binding::{Action as BindingAction, Scope};
|
||||
|
||||
use crate::surface::Surface;
|
||||
|
||||
/// Capacity of the app mailbox (Zig: `BlockingQueue(Message, 64)`).
|
||||
const MAILBOX_CAPACITY: usize = 64;
|
||||
|
||||
/// The message types that can be sent to the app thread.
|
||||
#[derive(Debug)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum Message {
|
||||
/// Open the configuration file.
|
||||
OpenConfig,
|
||||
|
||||
/// Create a new terminal window.
|
||||
NewWindow {
|
||||
/// The parent surface.
|
||||
parent: Option<SurfaceId>,
|
||||
},
|
||||
|
||||
/// Close a surface. This notifies the runtime that a surface should
|
||||
/// close.
|
||||
Close(SurfaceId),
|
||||
|
||||
/// Quit.
|
||||
Quit,
|
||||
|
||||
/// A message for a specific surface.
|
||||
SurfaceMessage {
|
||||
surface: SurfaceId,
|
||||
message: SurfaceMessage,
|
||||
},
|
||||
|
||||
/// Redraw a surface. This only has an effect for runtimes that use
|
||||
/// single-threaded draws. To redraw a surface for all runtimes, wake up
|
||||
/// the renderer thread. The renderer thread will send this message if
|
||||
/// it needs to.
|
||||
RedrawSurface(SurfaceId),
|
||||
}
|
||||
|
||||
/// The way that other threads send the app thread messages (Zig:
|
||||
/// `App.Mailbox`). Cheap to clone; every push wakes the runtime.
|
||||
#[derive(Clone)]
|
||||
pub struct Mailbox {
|
||||
tx: Sender<Message>,
|
||||
waker: Arc<dyn Waker>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Mailbox {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Mailbox").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl Mailbox {
|
||||
/// Send a message to the app. Returns the queue size after the push;
|
||||
/// zero means the push failed (full queue + instant/expired timeout).
|
||||
pub fn push(&self, msg: Message, timeout: Timeout) -> usize {
|
||||
let ok = match timeout {
|
||||
Timeout::Instant => match self.tx.try_send(msg) {
|
||||
Ok(()) => true,
|
||||
Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => false,
|
||||
},
|
||||
Timeout::Forever => self.tx.send(msg).is_ok(),
|
||||
Timeout::Ns(ns) => self
|
||||
.tx
|
||||
.send_timeout(msg, std::time::Duration::from_nanos(ns))
|
||||
.is_ok(),
|
||||
};
|
||||
if !ok {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Wake up our app loop
|
||||
self.waker.wake();
|
||||
self.tx.len().max(1)
|
||||
}
|
||||
}
|
||||
|
||||
impl AppMailbox<SurfaceId> for Mailbox {
|
||||
fn push_surface_message(
|
||||
&self,
|
||||
surface: SurfaceId,
|
||||
message: SurfaceMessage,
|
||||
timeout: Timeout,
|
||||
) -> usize {
|
||||
self.push(Message::SurfaceMessage { surface, message }, timeout)
|
||||
}
|
||||
}
|
||||
|
||||
/// State shared between the [`App`] and its surfaces (main thread only).
|
||||
pub struct AppState {
|
||||
/// The list of surfaces that are currently active, in creation order.
|
||||
surfaces: RefCell<Vec<(SurfaceId, Weak<Surface>)>>,
|
||||
|
||||
/// This is true if the app that Ghostty is in is focused. This may mean
|
||||
/// that no surfaces (terminals) are focused but the app is still
|
||||
/// focused, i.e. maybe an about window.
|
||||
focused: Cell<bool>,
|
||||
|
||||
/// The last focused surface. This surface may not be valid; you must
|
||||
/// always validate it with [`AppState::focused_surface`].
|
||||
focused_surface: Cell<Option<SurfaceId>>,
|
||||
|
||||
/// The mailbox other threads use to send this thread messages.
|
||||
pub mailbox: Mailbox,
|
||||
mailbox_rx: Receiver<Message>,
|
||||
|
||||
/// The set of font grids shared by surfaces with the same font
|
||||
/// configuration.
|
||||
pub font_grid_set: Arc<SharedGridSet>,
|
||||
|
||||
/// Used to rate limit desktop notifications.
|
||||
pub last_notification_time: Cell<Option<Instant>>,
|
||||
pub last_notification_digest: Cell<u64>,
|
||||
|
||||
/// The conditional state of the configuration. This applies to the
|
||||
/// app-level config and as a default for new surfaces.
|
||||
pub config_conditional_state: Cell<conditional::State>,
|
||||
|
||||
/// The runtime app, remembered from the first surface so the quit timer
|
||||
/// can be started when the last surface goes away.
|
||||
rt_app: RefCell<Option<Rc<dyn RtApp>>>,
|
||||
|
||||
/// Set to false once we've created at least one surface. This never
|
||||
/// goes true again. Surfaces use it to determine if they are the first.
|
||||
pub first: Cell<bool>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Ids and strong references of all live surfaces.
|
||||
pub fn surfaces(&self) -> Vec<Rc<Surface>> {
|
||||
self.surfaces
|
||||
.borrow()
|
||||
.iter()
|
||||
.filter_map(|(_, w)| w.upgrade())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Look up a live surface.
|
||||
pub fn surface(&self, id: SurfaceId) -> Option<Rc<Surface>> {
|
||||
self.surfaces
|
||||
.borrow()
|
||||
.iter()
|
||||
.find(|(i, _)| *i == id)
|
||||
.and_then(|(_, w)| w.upgrade())
|
||||
}
|
||||
|
||||
/// Whether the surface is known to the app (Zig: `hasSurface`).
|
||||
pub fn has_surface(&self, id: SurfaceId) -> bool {
|
||||
self.surfaces.borrow().iter().any(|(i, _)| *i == id)
|
||||
}
|
||||
|
||||
/// The last focused surface (Zig: `focusedSurface`). Only valid on the
|
||||
/// main thread.
|
||||
pub fn focused_surface(&self) -> Option<Rc<Surface>> {
|
||||
self.surface(self.focused_surface.get()?)
|
||||
}
|
||||
|
||||
/// Mark a surface as the focused one (Zig: `focusSurface`).
|
||||
pub fn focus_surface(&self, id: SurfaceId) {
|
||||
if !self.has_surface(id) {
|
||||
return;
|
||||
}
|
||||
self.focused_surface.set(Some(id));
|
||||
}
|
||||
|
||||
/// Whether the whole app is focused.
|
||||
pub fn focused(&self) -> bool {
|
||||
self.focused.get()
|
||||
}
|
||||
}
|
||||
|
||||
/// The core application. Implements [`CoreApp`] for the runtime.
|
||||
pub struct App {
|
||||
state: Rc<AppState>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Create a new app instance (Zig: `App.create`). `waker` is how
|
||||
/// non-main threads request an app `tick`.
|
||||
pub fn new(waker: Arc<dyn Waker>) -> Result<App, ghostty_font::shared_grid_set::InitError> {
|
||||
let font_grid_set = Arc::new(SharedGridSet::new()?);
|
||||
let (tx, rx) = crossbeam_channel::bounded(MAILBOX_CAPACITY);
|
||||
Ok(App {
|
||||
state: Rc::new(AppState {
|
||||
surfaces: RefCell::new(Vec::new()),
|
||||
focused: Cell::new(true),
|
||||
focused_surface: Cell::new(None),
|
||||
mailbox: Mailbox { tx, waker },
|
||||
mailbox_rx: rx,
|
||||
font_grid_set,
|
||||
last_notification_time: Cell::new(None),
|
||||
last_notification_digest: Cell::new(0),
|
||||
config_conditional_state: Cell::new(conditional::State::default()),
|
||||
rt_app: RefCell::new(None),
|
||||
first: Cell::new(true),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/// The shared state surfaces use.
|
||||
#[must_use]
|
||||
pub fn state(&self) -> &Rc<AppState> {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// A clone of the mailbox for other threads.
|
||||
#[must_use]
|
||||
pub fn mailbox(&self) -> Mailbox {
|
||||
self.state.mailbox.clone()
|
||||
}
|
||||
|
||||
/// Handle an app-level focus event. This should be called whenever the
|
||||
/// focus state of the entire app containing Ghostty changes.
|
||||
pub fn focus_event(&self, focused: bool) {
|
||||
// Prevent redundant focus events
|
||||
if self.state.focused.get() == focused {
|
||||
return;
|
||||
}
|
||||
|
||||
log::debug!("focus event focused={focused}");
|
||||
self.state.focused.set(focused);
|
||||
}
|
||||
|
||||
/// Returns true if the given key event would trigger a keybinding if it
|
||||
/// were to be processed (Zig: `keyEventIsBinding`).
|
||||
#[must_use]
|
||||
pub fn key_event_is_binding(&self, config: &Config, event: &KeyEvent<'_>) -> bool {
|
||||
match event.action {
|
||||
ghostty_input::Action::Release => return false,
|
||||
ghostty_input::Action::Press | ghostty_input::Action::Repeat => {}
|
||||
}
|
||||
|
||||
// If we have a keybinding for this event then we return true.
|
||||
config.keybind.set.get_event(event).is_some()
|
||||
}
|
||||
|
||||
/// Handle a key event at the app-scope. If this key event is used, this
|
||||
/// will return true and the caller shouldn't continue processing the
|
||||
/// event (Zig: `keyEvent`).
|
||||
///
|
||||
/// If the app currently has focus then all key events are processed. If
|
||||
/// the app does not have focus then only global key events are
|
||||
/// processed.
|
||||
pub fn key_event(&self, rt: &dyn RtApp, config: &Config, event: &KeyEvent<'_>) -> bool {
|
||||
match event.action {
|
||||
// We don't care about key release events.
|
||||
ghostty_input::Action::Release => return false,
|
||||
// Continue processing key press events.
|
||||
ghostty_input::Action::Press | ghostty_input::Action::Repeat => {}
|
||||
}
|
||||
|
||||
// Get the keybind entry for this event. We don't support key
|
||||
// sequences so we can look directly in the top-level set.
|
||||
let Some((_, value)) = config.keybind.set.get_event(event) else {
|
||||
return false;
|
||||
};
|
||||
let (actions, flags) = match value {
|
||||
// Sequences aren't supported. Our configuration parser verifies
|
||||
// this for global keybinds but we may still get an entry for a
|
||||
// non-global keybind.
|
||||
BindingValue::Leader(_) => return false,
|
||||
BindingValue::Leaf(leaf) => (vec![leaf.action.clone()], leaf.flags),
|
||||
BindingValue::LeafChained(leaf) => (leaf.actions.clone(), leaf.flags),
|
||||
};
|
||||
|
||||
// If we aren't focused, then we only process global keybinds.
|
||||
if !self.state.focused.get() && !flags.global {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Global keybinds are done using performAll so that they can target
|
||||
// all surfaces too.
|
||||
if flags.global {
|
||||
self.perform_all_chained_action(rt, &actions);
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we are focused, then we process keybinds only if they are
|
||||
// app-scoped. Otherwise, we do nothing. Surface-scoped should be
|
||||
// processed by Surface.keyEvent. For chained actions, all actions
|
||||
// must be app-scoped.
|
||||
if actions.iter().any(|a| a.scope() != Scope::App) {
|
||||
return false;
|
||||
}
|
||||
for action in &actions {
|
||||
if let Err(err) = self.perform_action(rt, action) {
|
||||
log::warn!(
|
||||
"error performing app keybind action action={} err={err}",
|
||||
action.key().name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Perform a binding action. This only accepts actions that are scoped
|
||||
/// to the app. Callers can use [`App::perform_all_action`] to perform
|
||||
/// any action and any non-app-scoped actions will be performed on all
|
||||
/// surfaces.
|
||||
pub fn perform_action(&self, rt: &dyn RtApp, action: &BindingAction) -> Result<(), CoreError> {
|
||||
match action {
|
||||
BindingAction::Unbind => unreachable!("unbind is never performed"),
|
||||
BindingAction::Ignore => {}
|
||||
BindingAction::Quit => {
|
||||
rt.perform_action(Target::App, RtAction::Quit);
|
||||
}
|
||||
BindingAction::NewWindow => self.new_window(rt, None),
|
||||
BindingAction::OpenConfig => {
|
||||
rt.perform_action(Target::App, RtAction::OpenConfig);
|
||||
}
|
||||
BindingAction::ReloadConfig => {
|
||||
rt.perform_action(Target::App, RtAction::ReloadConfig(ReloadConfig::default()));
|
||||
}
|
||||
BindingAction::CloseAllWindows => {
|
||||
rt.perform_action(Target::App, RtAction::CloseAllWindows);
|
||||
}
|
||||
BindingAction::ToggleQuickTerminal => {
|
||||
rt.perform_action(Target::App, RtAction::ToggleQuickTerminal);
|
||||
}
|
||||
BindingAction::ToggleVisibility => {
|
||||
rt.perform_action(Target::App, RtAction::ToggleVisibility);
|
||||
}
|
||||
BindingAction::CheckForUpdates => {
|
||||
rt.perform_action(Target::App, RtAction::CheckForUpdates);
|
||||
}
|
||||
BindingAction::ShowGtkInspector => {
|
||||
rt.perform_action(Target::App, RtAction::ShowGtkInspector);
|
||||
}
|
||||
BindingAction::Undo => {
|
||||
rt.perform_action(Target::App, RtAction::Undo);
|
||||
}
|
||||
BindingAction::Redo => {
|
||||
rt.perform_action(Target::App, RtAction::Redo);
|
||||
}
|
||||
other => {
|
||||
// Not app-scoped; the caller is expected to have checked.
|
||||
return Err(format!("not an app-scoped action: {}", other.key().name()).into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Performs a chained action. We will continue executing each action
|
||||
/// even if there is a failure in a prior action.
|
||||
pub fn perform_all_chained_action(&self, rt: &dyn RtApp, actions: &[BindingAction]) {
|
||||
for action in actions {
|
||||
if let Err(err) = self.perform_all_action(rt, action) {
|
||||
log::warn!(
|
||||
"error performing chained action action={} err={err}",
|
||||
action.key().name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform an app-wide binding action. If the action is surface-specific
|
||||
/// then it will be performed on all surfaces. To perform only
|
||||
/// app-scoped actions, use [`App::perform_action`].
|
||||
pub fn perform_all_action(
|
||||
&self,
|
||||
rt: &dyn RtApp,
|
||||
action: &BindingAction,
|
||||
) -> Result<(), CoreError> {
|
||||
match action.scope() {
|
||||
// App-scoped actions are handled by the app so that they aren't
|
||||
// repeated for each surface (since each surface forwards
|
||||
// app-scoped actions back up).
|
||||
Scope::App => self.perform_action(rt, action),
|
||||
|
||||
// Surface-scoped actions are performed on all surfaces. Errors
|
||||
// are logged but processing continues.
|
||||
Scope::Surface => {
|
||||
for surface in self.state.surfaces() {
|
||||
if let Err(err) = surface.perform_binding_action(action) {
|
||||
log::warn!(
|
||||
"error performing binding action on surface id={} err={err}",
|
||||
surface.id()
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new window (Zig: `newWindow`).
|
||||
pub fn new_window(&self, rt: &dyn RtApp, parent: Option<SurfaceId>) {
|
||||
let target = match parent {
|
||||
Some(p) if self.state.has_surface(p) => Target::Surface(p),
|
||||
_ => Target::App,
|
||||
};
|
||||
|
||||
rt.perform_action(target, RtAction::NewWindow);
|
||||
}
|
||||
|
||||
/// Drain the mailbox.
|
||||
fn drain_mailbox(&self, rt: &dyn RtApp) {
|
||||
while let Ok(message) = self.state.mailbox_rx.try_recv() {
|
||||
match message {
|
||||
Message::OpenConfig => {
|
||||
let _ = self.perform_action(rt, &BindingAction::OpenConfig);
|
||||
}
|
||||
Message::NewWindow { parent } => self.new_window(rt, parent),
|
||||
Message::Close(id) => self.close_surface(id),
|
||||
Message::SurfaceMessage { surface, message } => {
|
||||
self.surface_message(surface, message);
|
||||
}
|
||||
Message::RedrawSurface(id) => {
|
||||
if self.state.has_surface(id) {
|
||||
rt.perform_action(Target::Surface(id), RtAction::Render);
|
||||
}
|
||||
}
|
||||
|
||||
// If we're quitting, then we set the quit flag and stop
|
||||
// draining the mailbox immediately. This lets us defer
|
||||
// mailbox processing to the next tick so that the apprt can
|
||||
// try to quit as quickly as possible.
|
||||
Message::Quit => {
|
||||
log::info!("quit message received, short circuiting mailbox drain");
|
||||
let _ = self.perform_action(rt, &BindingAction::Quit);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Close a surface (Zig: `closeSurface`).
|
||||
pub fn close_surface(&self, id: SurfaceId) {
|
||||
if let Some(surface) = self.state.surface(id) {
|
||||
surface.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a surface message. Messages for surfaces that are gone are
|
||||
/// dropped: the window probably quit before we handled the message.
|
||||
fn surface_message(&self, id: SurfaceId, msg: SurfaceMessage) {
|
||||
if let Some(surface) = self.state.surface(id)
|
||||
&& let Err(err) = surface.handle_message(msg)
|
||||
{
|
||||
log::warn!("error handling surface message id={id} err={err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an initialized surface. Since we have non-zero surfaces, the quit
|
||||
/// timer is cancelled.
|
||||
pub(crate) fn add_surface(&self, id: SurfaceId, surface: &Rc<Surface>, rt: &Rc<dyn RtApp>) {
|
||||
*self.state.rt_app.borrow_mut() = Some(Rc::clone(rt));
|
||||
self.state
|
||||
.surfaces
|
||||
.borrow_mut()
|
||||
.push((id, Rc::downgrade(surface)));
|
||||
|
||||
// It is up to the apprt if there is a quit timer at all and if it
|
||||
// should be canceled.
|
||||
rt.perform_action(Target::App, RtAction::QuitTimer(QuitTimer::Stop));
|
||||
}
|
||||
}
|
||||
|
||||
impl CoreApp for App {
|
||||
fn tick(&self, rt: &dyn RtApp) -> Result<(), CoreError> {
|
||||
// Drain our mailbox
|
||||
self.drain_mailbox(rt);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_config(&self, rt: &dyn RtApp, config: &Arc<Config>) -> Result<(), CoreError> {
|
||||
// Go through and update all of the surface configurations.
|
||||
for surface in self.state.surfaces() {
|
||||
surface.handle_message(SurfaceMessage::ChangeConfig(Arc::clone(config)))?;
|
||||
}
|
||||
|
||||
// Apply our conditional state. If we fail to apply the conditional
|
||||
// state then we log and attempt to move forward with the old config.
|
||||
// We only apply this to the app-level config because the surface
|
||||
// config applies its own conditional state.
|
||||
let applied =
|
||||
match config.change_conditional_state(self.state.config_conditional_state.get()) {
|
||||
Ok(Some(c)) => Arc::new(c),
|
||||
Ok(None) => Arc::clone(config),
|
||||
Err(err) => {
|
||||
log::warn!("failed to apply conditional state to config err={err}");
|
||||
Arc::clone(config)
|
||||
}
|
||||
};
|
||||
|
||||
// Notify the apprt that the app has changed configuration.
|
||||
rt.perform_action(
|
||||
Target::App,
|
||||
RtAction::ConfigChange(ghostty_apprt::action::ConfigChange { config: applied }),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn needs_confirm_quit(&self) -> bool {
|
||||
self.state.surfaces().iter().any(|s| s.needs_confirm_quit())
|
||||
}
|
||||
|
||||
fn color_scheme_event(&self, rt: &dyn RtApp, scheme: ColorScheme) {
|
||||
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.state.config_conditional_state.get();
|
||||
if state.theme == new_scheme {
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup our conditional state which has the current color theme.
|
||||
state.theme = new_scheme;
|
||||
self.state.config_conditional_state.set(state);
|
||||
|
||||
// Request our configuration be reloaded because the new scheme may
|
||||
// impact the colors of the app.
|
||||
rt.perform_action(
|
||||
Target::App,
|
||||
RtAction::ReloadConfig(ReloadConfig { soft: true }),
|
||||
);
|
||||
}
|
||||
|
||||
fn perform_all_action(&self, rt: &dyn RtApp, action: &BindingAction) -> Result<(), CoreError> {
|
||||
App::perform_all_action(self, rt, action)
|
||||
}
|
||||
|
||||
fn push_message(&self, msg: AppMessage) {
|
||||
let m = match msg {
|
||||
AppMessage::OpenConfig => Message::OpenConfig,
|
||||
AppMessage::NewWindow => Message::NewWindow { parent: None },
|
||||
AppMessage::PresentSurface(id) => Message::SurfaceMessage {
|
||||
surface: id,
|
||||
message: SurfaceMessage::PresentSurface,
|
||||
},
|
||||
};
|
||||
self.state.mailbox.push(m, Timeout::Forever);
|
||||
}
|
||||
|
||||
fn new_surface(
|
||||
&self,
|
||||
id: SurfaceId,
|
||||
config: Config,
|
||||
rt_app: Rc<dyn RtApp>,
|
||||
rt_surface: Rc<dyn RtSurface>,
|
||||
) -> Result<Rc<dyn CoreSurface>, CoreError> {
|
||||
let surface = Surface::new(
|
||||
id,
|
||||
Rc::clone(&self.state),
|
||||
Arc::new(config),
|
||||
Rc::clone(&rt_app),
|
||||
rt_surface,
|
||||
)?;
|
||||
self.add_surface(id, &surface, &rt_app);
|
||||
Ok(surface)
|
||||
}
|
||||
|
||||
fn delete_surface(&self, id: SurfaceId) {
|
||||
// If this surface is the focused surface then we need to clear it.
|
||||
if self.state.focused_surface.get() == Some(id) {
|
||||
self.state.focused_surface.set(None);
|
||||
}
|
||||
|
||||
let empty = {
|
||||
let mut surfaces = self.state.surfaces.borrow_mut();
|
||||
surfaces.retain(|(i, _)| *i != id);
|
||||
surfaces.is_empty()
|
||||
};
|
||||
|
||||
// If we have no surfaces, we can start the quit timer. It is up to
|
||||
// the apprt to determine if this is necessary.
|
||||
if empty && let Some(rt) = self.state.rt_app.borrow().clone() {
|
||||
rt.perform_action(Target::App, RtAction::QuitTimer(QuitTimer::Start));
|
||||
}
|
||||
}
|
||||
|
||||
fn surface_ids(&self) -> Vec<SurfaceId> {
|
||||
self.state
|
||||
.surfaces
|
||||
.borrow()
|
||||
.iter()
|
||||
.map(|(i, _)| *i)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn cell_size(&self, config: &Config, font_size: DesiredFontSize) -> Option<RtCellSize> {
|
||||
let derived = ghostty_font::shared_grid_set::DerivedConfig::new(config);
|
||||
let size = ghostty_font::face::DesiredSize {
|
||||
points: font_size.points,
|
||||
xdpi: font_size.xdpi,
|
||||
ydpi: font_size.ydpi,
|
||||
};
|
||||
let (key, grid) = self.state.font_grid_set.ref_(&derived, size).ok()?;
|
||||
let cell = grid.cell_size();
|
||||
self.state.font_grid_set.deref(&key);
|
||||
Some(RtCellSize {
|
||||
width: cell.width,
|
||||
height: cell.height,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Global process state (port of `global.zig`).
|
||||
//!
|
||||
//! The Zig `GlobalState` also owns the allocator and the parsed CLI action;
|
||||
//! in Rust the allocator is the global one and CLI action dispatch happens in
|
||||
//! the root binary before this is initialized, so what remains is: logging,
|
||||
//! signal setup, rlimits and the resources directory.
|
||||
|
||||
use std::io::Write as _;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use ghostty_os::file::Rlimit;
|
||||
use ghostty_os::resourcesdir::ResourcesDir;
|
||||
|
||||
/// Where logging should go (Zig: `GlobalState.Logging`, a packed struct
|
||||
/// parsed from `GHOSTTY_LOG`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Logging {
|
||||
/// Whether to log to stderr.
|
||||
pub stderr: bool,
|
||||
}
|
||||
|
||||
impl Default for Logging {
|
||||
fn default() -> Self {
|
||||
Logging { stderr: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl Logging {
|
||||
/// Parse the `GHOSTTY_LOG` value (comma separated field names, an
|
||||
/// optional `no-` prefix disables; Zig: `cli.args.parsePackedStruct`).
|
||||
/// Invalid values yield the default.
|
||||
#[must_use]
|
||||
pub fn parse(v: &str) -> Logging {
|
||||
let mut out = Logging { stderr: false };
|
||||
for part in v.split(',') {
|
||||
let part = part.trim();
|
||||
let (name, value) = match part.strip_prefix("no-") {
|
||||
Some(n) => (n, false),
|
||||
None => (part, true),
|
||||
};
|
||||
match name {
|
||||
"stderr" => out.stderr = value,
|
||||
"macos" | "" => {}
|
||||
_ => return Logging::default(),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Maintains the Unix resource limits that we set for our process. This can
|
||||
/// be used to restore the limits to their original values.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct ResourceLimits {
|
||||
pub nofile: Option<Rlimit>,
|
||||
}
|
||||
|
||||
impl ResourceLimits {
|
||||
#[must_use]
|
||||
pub fn init() -> ResourceLimits {
|
||||
ResourceLimits {
|
||||
// Maximize the number of file descriptors we can have open
|
||||
// because we can consume a lot of them if we make many terminals.
|
||||
nofile: ghostty_os::file::fix_max_files(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore the original limits (the child does this after fork).
|
||||
pub fn restore(&self) {
|
||||
if let Some(lim) = self.nofile {
|
||||
ghostty_os::file::restore_max_files(lim);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The global state (initialized once by [`init`]).
|
||||
#[derive(Debug)]
|
||||
pub struct GlobalState {
|
||||
pub logging: Logging,
|
||||
pub rlimits: ResourceLimits,
|
||||
|
||||
/// The app resources directory, equivalent to zig-out/share when we
|
||||
/// build from source. Empty if we can't detect it.
|
||||
pub resources_dir: ResourcesDir,
|
||||
}
|
||||
|
||||
static STATE: OnceLock<GlobalState> = OnceLock::new();
|
||||
|
||||
/// Options for [`init`].
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct InitOptions {
|
||||
/// A CLI action is being executed: disable stderr logging by default
|
||||
/// since the action writes to stderr/stdout itself.
|
||||
pub action: bool,
|
||||
}
|
||||
|
||||
/// The global state. If [`init`] was never called, a default state (no
|
||||
/// resources dir, no rlimit changes) is created.
|
||||
pub fn state() -> &'static GlobalState {
|
||||
STATE.get_or_init(|| GlobalState {
|
||||
logging: Logging::default(),
|
||||
rlimits: ResourceLimits::default(),
|
||||
resources_dir: ResourcesDir::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Initialize the global state: logging, rlimits, resources dir and i18n.
|
||||
/// Call once, early (Zig: `GlobalState.init`).
|
||||
pub fn init(opts: InitOptions) -> &'static GlobalState {
|
||||
STATE.get_or_init(|| {
|
||||
let mut logging = Logging::default();
|
||||
|
||||
// If we have an action executing, we disable logging by default
|
||||
// since we write to stderr we don't want logs messing up our
|
||||
// output.
|
||||
if opts.action {
|
||||
logging.stderr = false;
|
||||
}
|
||||
|
||||
// Env vars are useful for logging too because they are easy to set.
|
||||
if let Ok(v) = std::env::var("GHOSTTY_LOG") {
|
||||
logging = Logging::parse(&v);
|
||||
}
|
||||
|
||||
init_logger(logging);
|
||||
|
||||
// SIGPIPE is ignored by the Rust runtime already (it sets SIG_IGN
|
||||
// before main), which is what Zig's `initSignals` does.
|
||||
|
||||
// As early as possible, initialize our resource limits.
|
||||
let rlimits = ResourceLimits::init();
|
||||
|
||||
// Find our resources directory once for the app so every launch
|
||||
// hereafter can use this cached value.
|
||||
let resources_dir = match ghostty_os::resourcesdir::resources_dir() {
|
||||
Ok(d) => d,
|
||||
Err(err) => {
|
||||
log::warn!("failed to find resources dir err={err}");
|
||||
ResourcesDir::default()
|
||||
}
|
||||
};
|
||||
|
||||
// Setup i18n
|
||||
if let Some(v) = resources_dir.app()
|
||||
&& let Err(err) = ghostty_os::i18n::init(v)
|
||||
{
|
||||
log::warn!("failed to init i18n, translations will not be available err={err}");
|
||||
}
|
||||
|
||||
GlobalState {
|
||||
logging,
|
||||
rlimits,
|
||||
resources_dir,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The `log` backend, formatted like Zig's `logFn`:
|
||||
/// `info(scope): message` on stderr.
|
||||
struct Logger {
|
||||
stderr: bool,
|
||||
max: log::LevelFilter,
|
||||
}
|
||||
|
||||
impl log::Log for Logger {
|
||||
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
|
||||
self.stderr && metadata.level() <= self.max
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record<'_>) {
|
||||
if !self.enabled(record.metadata()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let level = match record.level() {
|
||||
log::Level::Error => "error",
|
||||
log::Level::Warn => "warning",
|
||||
log::Level::Info => "info",
|
||||
log::Level::Debug | log::Level::Trace => "debug",
|
||||
};
|
||||
let target = record.target();
|
||||
let scope = target
|
||||
.strip_prefix("ghostty_")
|
||||
.unwrap_or(target)
|
||||
.split("::")
|
||||
.next()
|
||||
.unwrap_or(target);
|
||||
let _ = writeln!(
|
||||
std::io::stderr().lock(),
|
||||
"{level}({scope}): {}",
|
||||
record.args()
|
||||
);
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
fn init_logger(logging: Logging) {
|
||||
// Our log level is always at least info in every build mode; debug
|
||||
// builds (or RUST_LOG=debug) also show debug messages.
|
||||
let max = match std::env::var("RUST_LOG").ok().as_deref() {
|
||||
Some("trace") => log::LevelFilter::Trace,
|
||||
Some("debug") => log::LevelFilter::Debug,
|
||||
Some("warn") => log::LevelFilter::Warn,
|
||||
Some("error") => log::LevelFilter::Error,
|
||||
Some("info") => log::LevelFilter::Info,
|
||||
_ if cfg!(debug_assertions) => log::LevelFilter::Debug,
|
||||
_ => log::LevelFilter::Info,
|
||||
};
|
||||
let logger = Box::new(Logger {
|
||||
stderr: logging.stderr,
|
||||
max,
|
||||
});
|
||||
if log::set_boxed_logger(logger).is_ok() {
|
||||
log::set_max_level(max);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn logging_parse() {
|
||||
assert!(Logging::parse("stderr").stderr);
|
||||
assert!(!Logging::parse("no-stderr").stderr);
|
||||
assert!(!Logging::parse("macos").stderr);
|
||||
assert_eq!(Logging::parse("garbage"), Logging::default());
|
||||
}
|
||||
}
|
||||
@@ -1 +1,33 @@
|
||||
//! ghostty-core: see docs/rewrite/ARCHITECTURE.md for the module map.
|
||||
//! ghostty-core: the terminal application core (`App.zig`, `Surface.zig`,
|
||||
//! `surface_mouse.zig`, `global.zig`). See `docs/rewrite/notes/ghostty-core.md`.
|
||||
//!
|
||||
//! The core is runtime-agnostic: it talks to the app runtime (GTK) through
|
||||
//! the traits in `ghostty_apprt::runtime_api` and implements
|
||||
//! [`CoreApp`](ghostty_apprt::runtime_api::CoreApp) /
|
||||
//! [`CoreSurface`](ghostty_apprt::runtime_api::CoreSurface).
|
||||
|
||||
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::assert_is_empty))]
|
||||
// Faithful port of long Zig functions with many Result-returning callbacks.
|
||||
#![allow(
|
||||
clippy::nonminimal_bool,
|
||||
clippy::question_mark,
|
||||
clippy::unnecessary_wraps,
|
||||
clippy::struct_field_names,
|
||||
clippy::needless_pass_by_value,
|
||||
clippy::needless_pass_by_ref_mut,
|
||||
clippy::collapsible_if,
|
||||
clippy::assigning_clones,
|
||||
clippy::items_after_statements,
|
||||
clippy::type_complexity,
|
||||
clippy::too_many_arguments,
|
||||
clippy::wildcard_imports,
|
||||
clippy::manual_let_else
|
||||
)]
|
||||
|
||||
pub mod app;
|
||||
pub mod global;
|
||||
pub mod surface;
|
||||
pub mod surface_mouse;
|
||||
|
||||
pub use app::App;
|
||||
pub use surface::Surface;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
//! SurfaceMouse represents mouse helper functionality for the core surface
|
||||
//! (port of `surface_mouse.zig`).
|
||||
//!
|
||||
//! It's currently small in scope; its purpose is to isolate mouse logic that
|
||||
//! has gotten a bit complex (e.g. pointer shape handling for key events).
|
||||
|
||||
use ghostty_input::{Key, Mods};
|
||||
use ghostty_terminal::terminal::MouseEvents;
|
||||
use ghostty_vt::mouse_shape::MouseShape;
|
||||
|
||||
/// See the module docs.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SurfaceMouse {
|
||||
/// For processing key events; the key that was physically pressed on the
|
||||
/// keyboard.
|
||||
pub physical_key: Key,
|
||||
|
||||
/// The mouse event tracking mode, if any.
|
||||
pub mouse_event: MouseEvents,
|
||||
|
||||
/// The current terminal's mouse shape.
|
||||
pub mouse_shape: MouseShape,
|
||||
|
||||
/// The last mods state when the last mouse button (whatever it was) was
|
||||
/// pressed or release.
|
||||
pub mods: Mods,
|
||||
|
||||
/// True if the mouse position is currently over a link.
|
||||
pub over_link: bool,
|
||||
|
||||
/// True if the mouse pointer is currently hidden.
|
||||
pub hidden: bool,
|
||||
}
|
||||
|
||||
impl SurfaceMouse {
|
||||
/// Translates key state to mouse shape (cursor) state, based on a state
|
||||
/// machine.
|
||||
///
|
||||
/// There are 4 current states:
|
||||
///
|
||||
/// * text: starting state, displays a text bar.
|
||||
/// * default: default state when in a mouse tracking mode. (e.g. vim,
|
||||
/// etc). Displays an arrow pointer.
|
||||
/// * pointer: default state when over a link. Displays a pointing finger.
|
||||
/// * crosshair: any above state can transition to this when the
|
||||
/// rectangle select keys are pressed (ctrl/super+alt).
|
||||
///
|
||||
/// Additionally, default can transition back to text if one of the shift
|
||||
/// keys are pressed during mouse tracking mode.
|
||||
///
|
||||
/// Any secondary state transitions back to its default state when the
|
||||
/// appropriate keys are released.
|
||||
///
|
||||
/// `None` is returned when the mouse shape does not need changing.
|
||||
#[must_use]
|
||||
pub fn key_to_mouse_shape(&self) -> Option<MouseShape> {
|
||||
// Filter for appropriate key events
|
||||
if !eligible_mouse_shape_key_event(self.physical_key) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Exceptions: link hover or hidden state overrides any other shape
|
||||
// processing and does not change state.
|
||||
if self.over_link || self.hidden {
|
||||
return None;
|
||||
}
|
||||
|
||||
if self.mouse_event == MouseEvents::None {
|
||||
// Default terminal mode, should be text (text bar)
|
||||
if is_rectangle_select_state(self.mods) {
|
||||
// Crosshair (rectangle select)
|
||||
Some(MouseShape::Crosshair)
|
||||
} else {
|
||||
Some(MouseShape::Text)
|
||||
}
|
||||
} else {
|
||||
// In mouse tracking mode, should be default (arrow pointer)
|
||||
if is_mouse_mode_override_state(self.mods) && is_rectangle_select_state(self.mods) {
|
||||
// Crosshair (rectangle select), only set if we are also
|
||||
// overriding (e.g. shift+ctrl+alt)
|
||||
Some(MouseShape::Crosshair)
|
||||
} else if is_mouse_mode_override_state(self.mods) {
|
||||
// Normal override state
|
||||
Some(MouseShape::Text)
|
||||
} else {
|
||||
Some(MouseShape::Default)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn eligible_mouse_shape_key_event(physical_key: Key) -> bool {
|
||||
physical_key.ctrl_or_super()
|
||||
|| physical_key.left_or_right_shift()
|
||||
|| physical_key.left_or_right_alt()
|
||||
}
|
||||
|
||||
fn is_mouse_mode_override_state(mods: Mods) -> bool {
|
||||
mods.shift
|
||||
}
|
||||
|
||||
/// Returns true if our modifiers put us in a state where dragging should
|
||||
/// cause a rectangle select.
|
||||
#[must_use]
|
||||
pub fn is_rectangle_select_state(mods: Mods) -> bool {
|
||||
mods.ctrl_or_super() && mods.alt
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn m(
|
||||
physical_key: Key,
|
||||
mouse_event: MouseEvents,
|
||||
mouse_shape: MouseShape,
|
||||
mods: Mods,
|
||||
over_link: bool,
|
||||
hidden: bool,
|
||||
) -> SurfaceMouse {
|
||||
SurfaceMouse {
|
||||
physical_key,
|
||||
mouse_event,
|
||||
mouse_shape,
|
||||
mods,
|
||||
over_link,
|
||||
hidden,
|
||||
}
|
||||
}
|
||||
|
||||
fn mods(shift: bool, ctrl: bool, alt: bool, super_: bool) -> Mods {
|
||||
Mods {
|
||||
shift,
|
||||
ctrl,
|
||||
alt,
|
||||
super_,
|
||||
..Mods::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_to_mouse_shape() {
|
||||
let none = Mods::default();
|
||||
|
||||
// No specific key pressed
|
||||
let s = m(
|
||||
Key::Unidentified,
|
||||
MouseEvents::None,
|
||||
MouseShape::Progress,
|
||||
none,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(s.key_to_mouse_shape(), None);
|
||||
|
||||
// Over a link.
|
||||
let s = m(
|
||||
Key::ShiftLeft,
|
||||
MouseEvents::None,
|
||||
MouseShape::Progress,
|
||||
none,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
assert_eq!(s.key_to_mouse_shape(), None);
|
||||
|
||||
// Mouse is currently hidden
|
||||
let s = m(
|
||||
Key::ShiftLeft,
|
||||
MouseEvents::None,
|
||||
MouseShape::Progress,
|
||||
none,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
assert_eq!(s.key_to_mouse_shape(), None);
|
||||
|
||||
// default, no mods (mouse tracking)
|
||||
let s = m(
|
||||
Key::ShiftLeft,
|
||||
MouseEvents::X10,
|
||||
MouseShape::Default,
|
||||
none,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(s.key_to_mouse_shape(), Some(MouseShape::Default));
|
||||
|
||||
// default -> crosshair (mouse tracking)
|
||||
let s = m(
|
||||
Key::AltLeft,
|
||||
MouseEvents::X10,
|
||||
MouseShape::Default,
|
||||
mods(true, true, true, true),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(s.key_to_mouse_shape(), Some(MouseShape::Crosshair));
|
||||
|
||||
// default -> text (mouse tracking)
|
||||
let s = m(
|
||||
Key::ShiftLeft,
|
||||
MouseEvents::X10,
|
||||
MouseShape::Default,
|
||||
mods(true, false, false, false),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(s.key_to_mouse_shape(), Some(MouseShape::Text));
|
||||
|
||||
// crosshair -> text (mouse tracking)
|
||||
let s = m(
|
||||
Key::AltLeft,
|
||||
MouseEvents::X10,
|
||||
MouseShape::Crosshair,
|
||||
mods(true, false, false, false),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(s.key_to_mouse_shape(), Some(MouseShape::Text));
|
||||
|
||||
// crosshair -> default (mouse tracking)
|
||||
let s = m(
|
||||
Key::AltLeft,
|
||||
MouseEvents::X10,
|
||||
MouseShape::Crosshair,
|
||||
none,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(s.key_to_mouse_shape(), Some(MouseShape::Default));
|
||||
|
||||
// text -> crosshair (mouse tracking)
|
||||
let s = m(
|
||||
Key::AltLeft,
|
||||
MouseEvents::X10,
|
||||
MouseShape::Text,
|
||||
mods(true, true, true, true),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(s.key_to_mouse_shape(), Some(MouseShape::Crosshair));
|
||||
|
||||
// text -> default (mouse tracking)
|
||||
let s = m(
|
||||
Key::ShiftLeft,
|
||||
MouseEvents::X10,
|
||||
MouseShape::Text,
|
||||
none,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(s.key_to_mouse_shape(), Some(MouseShape::Default));
|
||||
|
||||
// text, no mods (no mouse tracking)
|
||||
let s = m(
|
||||
Key::ShiftLeft,
|
||||
MouseEvents::None,
|
||||
MouseShape::Text,
|
||||
none,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(s.key_to_mouse_shape(), Some(MouseShape::Text));
|
||||
|
||||
// text -> crosshair (no mouse tracking)
|
||||
let s = m(
|
||||
Key::AltLeft,
|
||||
MouseEvents::None,
|
||||
MouseShape::Text,
|
||||
mods(false, true, true, true),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(s.key_to_mouse_shape(), Some(MouseShape::Crosshair));
|
||||
|
||||
// crosshair -> text (no mouse tracking)
|
||||
let s = m(
|
||||
Key::AltLeft,
|
||||
MouseEvents::None,
|
||||
MouseShape::Crosshair,
|
||||
none,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(s.key_to_mouse_shape(), Some(MouseShape::Text));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user