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