Skip to main content

freya_terminal/
handle.rs

1use std::{
2    cell::RefCell,
3    io::Write,
4    path::PathBuf,
5    rc::Rc,
6    time::{
7        Duration,
8        Instant,
9    },
10};
11
12use alacritty_terminal::{
13    grid::{
14        Dimensions,
15        Scroll,
16    },
17    index::{
18        Column,
19        Line,
20        Point,
21        Side,
22    },
23    selection::{
24        Selection,
25        SelectionType,
26    },
27    term::{
28        Term,
29        TermMode,
30    },
31};
32use freya_core::{
33    notify::ArcNotify,
34    prelude::{
35        Platform,
36        TaskHandle,
37        UseId,
38        UserEvent,
39    },
40};
41use keyboard_types::{
42    Key,
43    Modifiers,
44    NamedKey,
45};
46use portable_pty::{
47    MasterPty,
48    PtySize,
49};
50
51use crate::{
52    parser::{
53        TerminalMouseButton,
54        encode_mouse_move,
55        encode_mouse_press,
56        encode_mouse_release,
57        encode_wheel_event,
58    },
59    pty::{
60        EventProxy,
61        TermSize,
62        spawn_pty,
63    },
64};
65
66/// Unique identifier for a terminal instance
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68pub struct TerminalId(pub usize);
69
70impl TerminalId {
71    pub fn new() -> Self {
72        Self(UseId::<TerminalId>::get_in_hook())
73    }
74}
75
76impl Default for TerminalId {
77    fn default() -> Self {
78        Self::new()
79    }
80}
81
82/// Error type for terminal operations
83#[derive(Debug, thiserror::Error)]
84pub enum TerminalError {
85    #[error("Write error: {0}")]
86    WriteError(String),
87
88    #[error("Terminal not initialized")]
89    NotInitialized,
90}
91
92impl From<std::io::Error> for TerminalError {
93    fn from(e: std::io::Error) -> Self {
94        TerminalError::WriteError(e.to_string())
95    }
96}
97
98/// Cleans up the PTY and the reader task when the last handle is dropped.
99pub(crate) struct TerminalCleaner {
100    /// Writer handle for the PTY.
101    pub(crate) writer: Rc<RefCell<Option<Box<dyn Write + Send>>>>,
102    /// PTY reader/parser task.
103    pub(crate) pty_task: TaskHandle,
104    /// Notifier that signals when the terminal should close.
105    pub(crate) closer_notifier: ArcNotify,
106}
107
108/// Handle-local state grouped into a single `RefCell`.
109pub(crate) struct TerminalInner {
110    pub(crate) master: Box<dyn MasterPty + Send>,
111    pub(crate) last_write_time: Instant,
112    pub(crate) pressed_button: Option<TerminalMouseButton>,
113    pub(crate) modifiers: Modifiers,
114}
115
116impl Drop for TerminalCleaner {
117    fn drop(&mut self) {
118        *self.writer.borrow_mut() = None;
119        self.pty_task.try_cancel();
120        self.closer_notifier.notify();
121    }
122}
123
124/// Handle to a running terminal instance.
125///
126/// Multiple `Terminal` components can share the same handle. The PTY is
127/// closed when the last handle is dropped.
128#[derive(Clone)]
129pub struct TerminalHandle {
130    /// Unique identifier for this terminal instance, used for `PartialEq`.
131    pub(crate) id: TerminalId,
132    /// alacritty's terminal model: grid, modes, scrollback. The renderer
133    /// borrows this directly during paint, so there is no parallel snapshot.
134    pub(crate) term: Rc<RefCell<Term<EventProxy>>>,
135    /// Writer for sending input to the PTY process.
136    pub(crate) writer: Rc<RefCell<Option<Box<dyn Write + Send>>>>,
137    /// Handle-local state (PTY master, input tracking).
138    pub(crate) inner: Rc<RefCell<TerminalInner>>,
139    /// Current working directory reported by the shell via OSC 7.
140    pub(crate) cwd: Rc<RefCell<Option<PathBuf>>>,
141    /// Window title reported by the shell via OSC 0 or OSC 2.
142    pub(crate) title: Rc<RefCell<Option<String>>>,
143    /// Notifier that signals when the terminal/PTY closes.
144    pub(crate) closer_notifier: ArcNotify,
145    /// Kept alive purely so its `Drop` runs when the last handle dies.
146    #[allow(dead_code)]
147    pub(crate) cleaner: Rc<TerminalCleaner>,
148    /// Notifier that signals each time new output is received from the PTY.
149    pub(crate) output_notifier: ArcNotify,
150    /// Notifier that signals when the window title changes via OSC 0 or OSC 2.
151    pub(crate) title_notifier: ArcNotify,
152    /// Clipboard content set by the terminal app via OSC 52.
153    pub(crate) clipboard_content: Rc<RefCell<Option<String>>>,
154    /// Notifier that signals when clipboard content changes via OSC 52.
155    pub(crate) clipboard_notifier: ArcNotify,
156}
157
158impl PartialEq for TerminalHandle {
159    fn eq(&self, other: &Self) -> bool {
160        self.id == other.id
161    }
162}
163
164impl TerminalHandle {
165    /// Spawn a PTY for `command` and return a handle. Defaults to 1000 lines
166    /// of scrollback when `scrollback_length` is `None`.
167    ///
168    /// # Example
169    ///
170    /// ```rust,no_run
171    /// use freya_terminal::prelude::*;
172    /// use portable_pty::CommandBuilder;
173    ///
174    /// let mut cmd = CommandBuilder::new("bash");
175    /// cmd.env("TERM", "xterm-256color");
176    ///
177    /// let handle = TerminalHandle::new(TerminalId::new(), cmd, None).unwrap();
178    /// ```
179    pub fn new(
180        id: TerminalId,
181        command: portable_pty::CommandBuilder,
182        scrollback_length: Option<usize>,
183    ) -> Result<Self, TerminalError> {
184        spawn_pty(id, command, scrollback_length.unwrap_or(1000))
185    }
186
187    /// Write data to the PTY, dropping any selection and snapping the
188    /// viewport back to the bottom so the user sees fresh output.
189    pub fn write(&self, data: &[u8]) -> Result<(), TerminalError> {
190        self.write_raw(data)?;
191        let mut term = self.term.borrow_mut();
192        term.selection = None;
193        term.scroll_display(Scroll::Bottom);
194        self.inner.borrow_mut().last_write_time = Instant::now();
195        Ok(())
196    }
197
198    /// Time since the user last wrote input to the PTY.
199    pub fn last_write_elapsed(&self) -> Duration {
200        self.inner.borrow().last_write_time.elapsed()
201    }
202
203    /// Translate a key event into the matching terminal escape sequence and
204    /// write it to the PTY. Returns whether the key was recognised.
205    pub fn write_key(&self, key: &Key, modifiers: Modifiers) -> Result<bool, TerminalError> {
206        let shift = modifiers.contains(Modifiers::SHIFT);
207        let ctrl = modifiers.contains(Modifiers::CONTROL);
208        let alt = modifiers.contains(Modifiers::ALT);
209
210        // CSI u / xterm modifier byte: `1 + shift + alt*2 + ctrl*4`.
211        let modifier = || 1 + shift as u8 + (alt as u8) * 2 + (ctrl as u8) * 4;
212
213        let seq: Vec<u8> = match key {
214            Key::Character(ch) if ctrl && ch.len() == 1 => vec![ch.as_bytes()[0] & 0x1f],
215            Key::Named(NamedKey::Enter) if shift || ctrl => {
216                format!("\x1b[13;{}u", modifier()).into_bytes()
217            }
218            Key::Named(NamedKey::Enter) => b"\r".to_vec(),
219            Key::Named(NamedKey::Backspace) if ctrl => vec![0x08],
220            Key::Named(NamedKey::Backspace) if alt => vec![0x1b, 0x7f],
221            Key::Named(NamedKey::Backspace) => vec![0x7f],
222            Key::Named(NamedKey::Delete) if alt || ctrl || shift => {
223                format!("\x1b[3;{}~", modifier()).into_bytes()
224            }
225            Key::Named(NamedKey::Delete) => b"\x1b[3~".to_vec(),
226            Key::Named(NamedKey::Tab) if shift => b"\x1b[Z".to_vec(),
227            Key::Named(NamedKey::Tab) => b"\t".to_vec(),
228            Key::Named(NamedKey::Escape) => vec![0x1b],
229            Key::Named(
230                dir @ (NamedKey::ArrowUp
231                | NamedKey::ArrowDown
232                | NamedKey::ArrowLeft
233                | NamedKey::ArrowRight),
234            ) => {
235                let ch = match dir {
236                    NamedKey::ArrowUp => 'A',
237                    NamedKey::ArrowDown => 'B',
238                    NamedKey::ArrowRight => 'C',
239                    NamedKey::ArrowLeft => 'D',
240                    _ => unreachable!(),
241                };
242                if shift || ctrl {
243                    format!("\x1b[1;{}{ch}", modifier()).into_bytes()
244                } else {
245                    vec![0x1b, b'[', ch as u8]
246                }
247            }
248            Key::Character(ch) => ch.as_bytes().to_vec(),
249            Key::Named(NamedKey::Shift) => {
250                self.shift_pressed(true);
251                return Ok(true);
252            }
253            _ => return Ok(false),
254        };
255
256        self.write(&seq)?;
257        Ok(true)
258    }
259
260    /// Paste text into the PTY, wrapping in bracketed-paste markers if the
261    /// running app has enabled them.
262    pub fn paste(&self, text: &str) -> Result<(), TerminalError> {
263        let bracketed = self
264            .term
265            .borrow()
266            .mode()
267            .contains(TermMode::BRACKETED_PASTE);
268        if bracketed {
269            let filtered = text.replace(['\x1b', '\x03'], "");
270            self.write_raw(b"\x1b[200~")?;
271            self.write_raw(filtered.as_bytes())?;
272            self.write_raw(b"\x1b[201~")?;
273        } else {
274            let normalized = text.replace("\r\n", "\r").replace('\n', "\r");
275            self.write_raw(normalized.as_bytes())?;
276        }
277        Ok(())
278    }
279
280    /// Write data to the PTY without resetting scroll or selection state.
281    fn write_raw(&self, data: &[u8]) -> Result<(), TerminalError> {
282        let mut writer = self.writer.borrow_mut();
283        let writer = writer.as_mut().ok_or(TerminalError::NotInitialized)?;
284        writer.write_all(data)?;
285        writer.flush()?;
286        Ok(())
287    }
288
289    /// Resize the terminal. alacritty's grid reflows on width changes and
290    /// preserves scrollback on height changes, so this is lossless.
291    pub fn resize(&self, rows: u16, cols: u16) {
292        // PTY first so SIGWINCH reaches the program before we update locally.
293        let _ = self.inner.borrow().master.resize(PtySize {
294            rows,
295            cols,
296            pixel_width: 0,
297            pixel_height: 0,
298        });
299
300        self.term.borrow_mut().resize(TermSize {
301            screen_lines: rows as usize,
302            columns: cols as usize,
303        });
304    }
305
306    /// Scroll the terminal by the specified delta. Positive delta moves the
307    /// viewport up into scrollback history, matching the vt100 convention.
308    pub fn scroll(&self, delta: i32) {
309        self.scroll_to(Scroll::Delta(delta));
310    }
311
312    /// Scroll the terminal to the bottom of the buffer.
313    pub fn scroll_to_bottom(&self) {
314        self.scroll_to(Scroll::Bottom);
315    }
316
317    fn scroll_to(&self, target: Scroll) {
318        let mut term = self.term.borrow_mut();
319        if term.mode().contains(TermMode::ALT_SCREEN) {
320            return;
321        }
322        term.scroll_display(target);
323        Platform::get().send(UserEvent::RequestRedraw);
324    }
325
326    /// Get the current working directory reported by the shell via OSC 7.
327    pub fn cwd(&self) -> Option<PathBuf> {
328        self.cwd.borrow().clone()
329    }
330
331    /// Get the window title reported by the shell via OSC 0 or OSC 2.
332    pub fn title(&self) -> Option<String> {
333        self.title.borrow().clone()
334    }
335
336    /// Get the latest clipboard content set by the terminal app via OSC 52.
337    pub fn clipboard_content(&self) -> Option<String> {
338        self.clipboard_content.borrow().clone()
339    }
340
341    /// Snapshot of the active terminal mode bits.
342    fn mode(&self) -> TermMode {
343        *self.term.borrow().mode()
344    }
345
346    fn pressed_button(&self) -> Option<TerminalMouseButton> {
347        self.inner.borrow().pressed_button
348    }
349
350    fn set_pressed_button(&self, button: Option<TerminalMouseButton>) {
351        self.inner.borrow_mut().pressed_button = button;
352    }
353
354    fn is_shift_held(&self) -> bool {
355        self.inner.borrow().modifiers.contains(Modifiers::SHIFT)
356    }
357
358    /// Handle a mouse move/drag event based on the active mouse mode.
359    pub fn mouse_move(&self, row: usize, col: usize) {
360        let held = self.pressed_button();
361
362        if self.is_shift_held() && held.is_some() {
363            self.update_selection(row, col);
364            return;
365        }
366
367        let mode = self.mode();
368        if mode.contains(TermMode::MOUSE_MOTION) {
369            // Any-motion mode: report regardless of button state.
370            let _ = self.write_raw(encode_mouse_move(row, col, held, mode).as_bytes());
371        } else if mode.contains(TermMode::MOUSE_DRAG)
372            && let Some(button) = held
373        {
374            // Button-motion mode: only while a button is held.
375            let _ = self.write_raw(encode_mouse_move(row, col, Some(button), mode).as_bytes());
376        } else if !mode.intersects(TermMode::MOUSE_MODE) && held.is_some() {
377            self.update_selection(row, col);
378        }
379    }
380
381    /// Handle a mouse button press event.
382    pub fn mouse_down(&self, row: usize, col: usize, button: TerminalMouseButton) {
383        self.set_pressed_button(Some(button));
384
385        let mode = self.mode();
386        if !self.is_shift_held() && mode.intersects(TermMode::MOUSE_MODE) {
387            let _ = self.write_raw(encode_mouse_press(row, col, button, mode).as_bytes());
388        } else {
389            self.start_selection(row, col);
390        }
391    }
392
393    /// Handle a mouse button release event.
394    pub fn mouse_up(&self, row: usize, col: usize, button: TerminalMouseButton) {
395        self.set_pressed_button(None);
396
397        let mode = self.mode();
398        if !self.is_shift_held() && mode.intersects(TermMode::MOUSE_MODE) {
399            let _ = self.write_raw(encode_mouse_release(row, col, button, mode).as_bytes());
400        }
401    }
402
403    /// Handle a mouse button release from outside the terminal viewport.
404    pub fn release(&self) {
405        self.set_pressed_button(None);
406    }
407
408    /// Route a wheel event to scrollback, PTY mouse events, or arrow-key
409    /// sequences depending on whether an app is tracking the mouse and
410    /// whether we're on the alternate screen (matches wezterm/kitty).
411    pub fn wheel(&self, delta_y: f64, row: usize, col: usize) {
412        // Lines per event from the OS delta, capped to keep flings sane.
413        let lines = (delta_y.abs().ceil() as i32).clamp(1, 10);
414        let scroll_delta = if delta_y > 0.0 { lines } else { -lines };
415
416        let mode = self.mode();
417        let scroll_offset = self.term.borrow().grid().display_offset();
418
419        if scroll_offset > 0 {
420            self.scroll(scroll_delta);
421        } else if mode.intersects(TermMode::MOUSE_MODE) {
422            let _ = self.write_raw(encode_wheel_event(row, col, delta_y, mode).as_bytes());
423        } else if mode.contains(TermMode::ALT_SCREEN) {
424            let app_cursor = mode.contains(TermMode::APP_CURSOR);
425            let key = match (delta_y > 0.0, app_cursor) {
426                (true, true) => "\x1bOA",
427                (true, false) => "\x1b[A",
428                (false, true) => "\x1bOB",
429                (false, false) => "\x1b[B",
430            };
431            for _ in 0..lines {
432                let _ = self.write_raw(key.as_bytes());
433            }
434        } else {
435            self.scroll(scroll_delta);
436        }
437    }
438
439    /// Borrow the underlying alacritty `Term` for direct read access. Used
440    /// by the renderer and accessibility code to inspect the grid without
441    /// holding a separate snapshot.
442    pub fn term(&self) -> std::cell::Ref<'_, Term<EventProxy>> {
443        self.term.borrow()
444    }
445
446    /// Future that completes each time new output is received from the PTY.
447    pub fn output_received(&self) -> impl std::future::Future<Output = ()> + '_ {
448        self.output_notifier.notified()
449    }
450
451    /// Future that completes when the window title changes (OSC 0 / OSC 2).
452    pub fn title_changed(&self) -> impl std::future::Future<Output = ()> + '_ {
453        self.title_notifier.notified()
454    }
455
456    /// Future that completes when clipboard content changes (OSC 52).
457    pub fn clipboard_changed(&self) -> impl std::future::Future<Output = ()> + '_ {
458        self.clipboard_notifier.notified()
459    }
460
461    /// Future that completes when the PTY closes.
462    pub fn closed(&self) -> impl std::future::Future<Output = ()> + '_ {
463        self.closer_notifier.notified()
464    }
465
466    /// Returns the unique identifier for this terminal instance.
467    pub fn id(&self) -> TerminalId {
468        self.id
469    }
470
471    /// Track whether shift is currently pressed.
472    pub fn shift_pressed(&self, pressed: bool) {
473        let mods = &mut self.inner.borrow_mut().modifiers;
474        if pressed {
475            mods.insert(Modifiers::SHIFT);
476        } else {
477            mods.remove(Modifiers::SHIFT);
478        }
479    }
480
481    pub fn start_selection(&self, row: usize, col: usize) {
482        let mut term = self.term.borrow_mut();
483        let point = point_at(&term, row, col);
484        term.selection = Some(Selection::new(SelectionType::Simple, point, Side::Left));
485        Platform::get().send(UserEvent::RequestRedraw);
486    }
487
488    /// Extend the in-progress selection, if any.
489    pub fn update_selection(&self, row: usize, col: usize) {
490        let mut term = self.term.borrow_mut();
491        let point = point_at(&term, row, col);
492        if let Some(selection) = term.selection.as_mut() {
493            selection.update(point, Side::Right);
494            Platform::get().send(UserEvent::RequestRedraw);
495        }
496    }
497
498    /// Returns the currently selected text as a string, if any.
499    pub fn get_selected_text(&self) -> Option<String> {
500        self.term.borrow().selection_to_string()
501    }
502}
503
504/// Translate a viewport `(row, col)` into an alacritty `Point` in absolute
505/// grid coordinates, accounting for the current scroll offset.
506fn point_at(term: &Term<EventProxy>, row: usize, col: usize) -> Point {
507    let offset = term.grid().display_offset() as i32;
508    let last_col = term.columns().saturating_sub(1);
509    Point::new(Line(row as i32 - offset), Column(col.min(last_col)))
510}