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. Drops any selection and snaps the viewport to the bottom.
188    pub fn write(&self, data: &[u8]) -> Result<(), TerminalError> {
189        self.write_raw(data)?;
190        let mut term = self.term.borrow_mut();
191        term.selection = None;
192        term.scroll_display(Scroll::Bottom);
193        self.inner.borrow_mut().last_write_time = Instant::now();
194        Ok(())
195    }
196
197    /// Time since the user last wrote input to the PTY.
198    pub fn last_write_elapsed(&self) -> Duration {
199        self.inner.borrow().last_write_time.elapsed()
200    }
201
202    /// Write a key event to the PTY as the matching escape sequence. Returns whether it was recognised.
203    pub fn write_key(&self, key: &Key, modifiers: Modifiers) -> Result<bool, TerminalError> {
204        let shift = modifiers.contains(Modifiers::SHIFT);
205        let ctrl = modifiers.contains(Modifiers::CONTROL);
206        let alt = modifiers.contains(Modifiers::ALT);
207
208        // CSI u / xterm modifier byte: `1 + shift + alt*2 + ctrl*4`.
209        let modifier = || 1 + shift as u8 + (alt as u8) * 2 + (ctrl as u8) * 4;
210
211        let seq: Vec<u8> = match key {
212            Key::Character(ch) if ctrl && ch.len() == 1 => vec![ch.as_bytes()[0] & 0x1f],
213            Key::Named(NamedKey::Enter) if shift || ctrl => {
214                format!("\x1b[13;{}u", modifier()).into_bytes()
215            }
216            Key::Named(NamedKey::Enter) => b"\r".to_vec(),
217            Key::Named(NamedKey::Backspace) if ctrl => vec![0x08],
218            Key::Named(NamedKey::Backspace) if alt => vec![0x1b, 0x7f],
219            Key::Named(NamedKey::Backspace) => vec![0x7f],
220            Key::Named(NamedKey::Delete) if alt || ctrl || shift => {
221                format!("\x1b[3;{}~", modifier()).into_bytes()
222            }
223            Key::Named(NamedKey::Delete) => b"\x1b[3~".to_vec(),
224            Key::Named(NamedKey::Tab) if shift => b"\x1b[Z".to_vec(),
225            Key::Named(NamedKey::Tab) => b"\t".to_vec(),
226            Key::Named(NamedKey::Escape) => vec![0x1b],
227            Key::Named(
228                dir @ (NamedKey::ArrowUp
229                | NamedKey::ArrowDown
230                | NamedKey::ArrowLeft
231                | NamedKey::ArrowRight),
232            ) => {
233                let ch = match dir {
234                    NamedKey::ArrowUp => 'A',
235                    NamedKey::ArrowDown => 'B',
236                    NamedKey::ArrowRight => 'C',
237                    NamedKey::ArrowLeft => 'D',
238                    _ => unreachable!(),
239                };
240                if shift || ctrl {
241                    format!("\x1b[1;{}{ch}", modifier()).into_bytes()
242                } else {
243                    vec![0x1b, b'[', ch as u8]
244                }
245            }
246            Key::Character(ch) => ch.as_bytes().to_vec(),
247            Key::Named(NamedKey::Shift) => {
248                self.shift_pressed(true);
249                return Ok(true);
250            }
251            _ => return Ok(false),
252        };
253
254        self.write(&seq)?;
255        Ok(true)
256    }
257
258    /// Paste text into the PTY, wrapping in bracketed-paste markers if the app enabled them.
259    pub fn paste(&self, text: &str) -> Result<(), TerminalError> {
260        let bracketed = self
261            .term
262            .borrow()
263            .mode()
264            .contains(TermMode::BRACKETED_PASTE);
265        if bracketed {
266            let filtered = text.replace(['\x1b', '\x03'], "");
267            self.write_raw(b"\x1b[200~")?;
268            self.write_raw(filtered.as_bytes())?;
269            self.write_raw(b"\x1b[201~")?;
270        } else {
271            let normalized = text.replace("\r\n", "\r").replace('\n', "\r");
272            self.write_raw(normalized.as_bytes())?;
273        }
274        Ok(())
275    }
276
277    /// Write data to the PTY without resetting scroll or selection state.
278    fn write_raw(&self, data: &[u8]) -> Result<(), TerminalError> {
279        let mut writer = self.writer.borrow_mut();
280        let writer = writer.as_mut().ok_or(TerminalError::NotInitialized)?;
281        writer.write_all(data)?;
282        writer.flush()?;
283        Ok(())
284    }
285
286    /// Resize the terminal. Lossless: the grid reflows on width, preserves scrollback on height.
287    pub fn resize(&self, rows: u16, cols: u16) {
288        // PTY first so SIGWINCH reaches the program before we update locally.
289        let _ = self.inner.borrow().master.resize(PtySize {
290            rows,
291            cols,
292            pixel_width: 0,
293            pixel_height: 0,
294        });
295
296        self.term.borrow_mut().resize(TermSize {
297            screen_lines: rows as usize,
298            columns: cols as usize,
299        });
300    }
301
302    /// Scroll by delta. Positive moves up into scrollback (vt100 convention).
303    pub fn scroll(&self, delta: i32) {
304        self.scroll_to(Scroll::Delta(delta));
305    }
306
307    /// Scroll to the bottom of the buffer.
308    pub fn scroll_to_bottom(&self) {
309        self.scroll_to(Scroll::Bottom);
310    }
311
312    fn scroll_to(&self, target: Scroll) {
313        let mut term = self.term.borrow_mut();
314        if term.mode().contains(TermMode::ALT_SCREEN) {
315            return;
316        }
317        term.scroll_display(target);
318        Platform::get().send(UserEvent::RequestRedraw);
319    }
320
321    /// Current working directory reported via OSC 7.
322    pub fn cwd(&self) -> Option<PathBuf> {
323        self.cwd.borrow().clone()
324    }
325
326    /// Window title reported via OSC 0 / 2.
327    pub fn title(&self) -> Option<String> {
328        self.title.borrow().clone()
329    }
330
331    /// Latest clipboard content set via OSC 52.
332    pub fn clipboard_content(&self) -> Option<String> {
333        self.clipboard_content.borrow().clone()
334    }
335
336    /// Snapshot of the active terminal mode bits.
337    fn mode(&self) -> TermMode {
338        *self.term.borrow().mode()
339    }
340
341    fn pressed_button(&self) -> Option<TerminalMouseButton> {
342        self.inner.borrow().pressed_button
343    }
344
345    fn set_pressed_button(&self, button: Option<TerminalMouseButton>) {
346        self.inner.borrow_mut().pressed_button = button;
347    }
348
349    fn is_shift_held(&self) -> bool {
350        self.inner.borrow().modifiers.contains(Modifiers::SHIFT)
351    }
352
353    /// Handle a mouse move/drag. `row` and `col` are fractional cell units;
354    /// the fraction of `col` picks which cell half anchors the selection.
355    pub fn mouse_move(&self, row: f32, col: f32) {
356        let held = self.pressed_button();
357
358        if self.is_shift_held() && held.is_some() {
359            self.update_selection(row, col);
360            return;
361        }
362
363        let mode = self.mode();
364        if mode.contains(TermMode::MOUSE_MOTION) {
365            // Any-motion mode: report regardless of button state.
366            let _ = self
367                .write_raw(encode_mouse_move(row as usize, col as usize, held, mode).as_bytes());
368        } else if mode.contains(TermMode::MOUSE_DRAG)
369            && let Some(button) = held
370        {
371            // Button-motion mode: only while a button is held.
372            let _ = self.write_raw(
373                encode_mouse_move(row as usize, col as usize, Some(button), mode).as_bytes(),
374            );
375        } else if !mode.intersects(TermMode::MOUSE_MODE) && held.is_some() {
376            self.update_selection(row, col);
377        }
378    }
379
380    /// Handle a mouse button press. `selection_type` picks the selection kind when not in mouse mode:
381    /// [`SelectionType::Semantic`] for double-click (word), [`SelectionType::Lines`] for triple-click.
382    /// See [`Self::mouse_move`] for the fractional coordinates.
383    pub fn mouse_down(
384        &self,
385        row: f32,
386        col: f32,
387        button: TerminalMouseButton,
388        selection_type: SelectionType,
389    ) {
390        self.set_pressed_button(Some(button));
391
392        let mode = self.mode();
393        if !self.is_shift_held() && mode.intersects(TermMode::MOUSE_MODE) {
394            let _ = self
395                .write_raw(encode_mouse_press(row as usize, col as usize, button, mode).as_bytes());
396        } else {
397            self.start_selection(row, col, selection_type);
398        }
399    }
400
401    /// Handle a mouse button release.
402    pub fn mouse_up(&self, row: f32, col: f32, button: TerminalMouseButton) {
403        self.set_pressed_button(None);
404
405        let mode = self.mode();
406        if !self.is_shift_held() && mode.intersects(TermMode::MOUSE_MODE) {
407            let _ = self.write_raw(
408                encode_mouse_release(row as usize, col as usize, button, mode).as_bytes(),
409            );
410        }
411    }
412
413    /// Handle a mouse button release from outside the terminal viewport.
414    pub fn release(&self) {
415        self.set_pressed_button(None);
416    }
417
418    /// Route a wheel event to scrollback, PTY mouse, or arrow-key sequences
419    /// depending on the active mouse mode and alt-screen state (matches wezterm/kitty).
420    pub fn wheel(&self, delta_y: f64, row: f32, col: f32) {
421        // Lines per event from the OS delta, capped to keep flings sane.
422        let lines = (delta_y.abs().ceil() as i32).clamp(1, 10);
423        let scroll_delta = if delta_y > 0.0 { lines } else { -lines };
424
425        let mode = self.mode();
426        let scroll_offset = self.term.borrow().grid().display_offset();
427
428        if scroll_offset > 0 {
429            self.scroll(scroll_delta);
430        } else if mode.intersects(TermMode::MOUSE_MODE) {
431            let _ = self.write_raw(
432                encode_wheel_event(row as usize, col as usize, delta_y, mode).as_bytes(),
433            );
434        } else if mode.contains(TermMode::ALT_SCREEN) {
435            let app_cursor = mode.contains(TermMode::APP_CURSOR);
436            let key = match (delta_y > 0.0, app_cursor) {
437                (true, true) => "\x1bOA",
438                (true, false) => "\x1b[A",
439                (false, true) => "\x1bOB",
440                (false, false) => "\x1b[B",
441            };
442            for _ in 0..lines {
443                let _ = self.write_raw(key.as_bytes());
444            }
445        } else {
446            self.scroll(scroll_delta);
447        }
448    }
449
450    /// Borrow the underlying alacritty `Term` for direct read access.
451    pub fn term(&self) -> std::cell::Ref<'_, Term<EventProxy>> {
452        self.term.borrow()
453    }
454
455    /// Future that completes each time new output is received from the PTY.
456    pub fn output_received(&self) -> impl std::future::Future<Output = ()> + '_ {
457        self.output_notifier.notified()
458    }
459
460    /// Future that completes when the window title changes (OSC 0 / OSC 2).
461    pub fn title_changed(&self) -> impl std::future::Future<Output = ()> + '_ {
462        self.title_notifier.notified()
463    }
464
465    /// Future that completes when clipboard content changes (OSC 52).
466    pub fn clipboard_changed(&self) -> impl std::future::Future<Output = ()> + '_ {
467        self.clipboard_notifier.notified()
468    }
469
470    /// Future that completes when the PTY closes.
471    pub fn closed(&self) -> impl std::future::Future<Output = ()> + '_ {
472        self.closer_notifier.notified()
473    }
474
475    /// Unique identifier for this terminal instance.
476    pub fn id(&self) -> TerminalId {
477        self.id
478    }
479
480    /// Track whether shift is currently pressed.
481    pub fn shift_pressed(&self, pressed: bool) {
482        let mods = &mut self.inner.borrow_mut().modifiers;
483        if pressed {
484            mods.insert(Modifiers::SHIFT);
485        } else {
486            mods.remove(Modifiers::SHIFT);
487        }
488    }
489
490    /// Start a new selection of `selection_type`. See [`Self::mouse_move`] for the fractional coordinates.
491    pub fn start_selection(&self, row: f32, col: f32, selection_type: SelectionType) {
492        let (point, side) = self.point_and_side_at(row, col);
493        self.term.borrow_mut().selection = Some(Selection::new(selection_type, point, side));
494        Platform::get().send(UserEvent::RequestRedraw);
495    }
496
497    /// Extend the in-progress selection, if any.
498    pub fn update_selection(&self, row: f32, col: f32) {
499        let (point, side) = self.point_and_side_at(row, col);
500        if let Some(selection) = self.term.borrow_mut().selection.as_mut() {
501            selection.update(point, side);
502            Platform::get().send(UserEvent::RequestRedraw);
503        }
504    }
505
506    /// Currently selected text, if any.
507    pub fn get_selected_text(&self) -> Option<String> {
508        self.term.borrow().selection_to_string()
509    }
510
511    /// Grid point and cell half (left vs right) for a pointer at fractional cell coordinates.
512    fn point_and_side_at(&self, row: f32, col: f32) -> (Point, Side) {
513        let term = self.term.borrow();
514        let col = col.max(0.0);
515        let side = if col.fract() < 0.5 {
516            Side::Left
517        } else {
518            Side::Right
519        };
520        let point = Point::new(
521            Line(row.max(0.0) as i32 - term.grid().display_offset() as i32),
522            Column((col as usize).min(term.columns().saturating_sub(1))),
523        );
524        (point, side)
525    }
526}