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