freya_terminal/
handle.rs

1use std::{
2    cell::{
3        Ref,
4        RefCell,
5    },
6    io::Write,
7    path::PathBuf,
8    rc::Rc,
9    time::Instant,
10};
11
12use freya_core::{
13    notify::ArcNotify,
14    prelude::{
15        Platform,
16        TaskHandle,
17        UseId,
18        UserEvent,
19    },
20};
21use keyboard_types::Modifiers;
22use portable_pty::{
23    MasterPty,
24    PtySize,
25};
26use vt100::Parser;
27
28use crate::{
29    buffer::{
30        TerminalBuffer,
31        TerminalSelection,
32    },
33    parser::{
34        TerminalMouseButton,
35        encode_mouse_move,
36        encode_mouse_press,
37        encode_mouse_release,
38        encode_wheel_event,
39    },
40    pty::{
41        extract_buffer,
42        query_max_scrollback,
43        spawn_pty,
44    },
45};
46
47/// Unique identifier for a terminal instance
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
49pub struct TerminalId(pub usize);
50
51impl TerminalId {
52    pub fn new() -> Self {
53        Self(UseId::<TerminalId>::get_in_hook())
54    }
55}
56
57impl Default for TerminalId {
58    fn default() -> Self {
59        Self::new()
60    }
61}
62
63/// Error type for terminal operations
64#[derive(Debug, thiserror::Error)]
65pub enum TerminalError {
66    #[error("PTY error: {0}")]
67    PtyError(String),
68
69    #[error("Write error: {0}")]
70    WriteError(String),
71
72    #[error("Terminal not initialized")]
73    NotInitialized,
74}
75
76/// Internal cleanup handler for terminal resources.
77pub(crate) struct TerminalCleaner {
78    /// Writer handle for the PTY.
79    pub(crate) writer: Rc<RefCell<Option<Box<dyn Write + Send>>>>,
80    /// Async tasks
81    pub(crate) reader_task: TaskHandle,
82    pub(crate) pty_task: TaskHandle,
83    /// Notifier that signals when the terminal should close.
84    pub(crate) closer_notifier: ArcNotify,
85}
86
87impl Drop for TerminalCleaner {
88    fn drop(&mut self) {
89        *self.writer.borrow_mut() = None;
90        self.reader_task.try_cancel();
91        self.pty_task.try_cancel();
92        self.closer_notifier.notify();
93    }
94}
95
96/// Handle to a running terminal instance.
97///
98/// The handle allows you to write input to the terminal and resize it.
99/// Multiple Terminal components can share the same handle.
100///
101/// The PTY is automatically closed when the handle is dropped.
102#[derive(Clone)]
103#[allow(dead_code)]
104pub struct TerminalHandle {
105    /// Unique identifier for this terminal instance.
106    pub(crate) id: TerminalId,
107    /// Terminal buffer containing the current screen state.
108    pub(crate) buffer: Rc<RefCell<TerminalBuffer>>,
109    /// VT100 parser for accessing full scrollback content.
110    pub(crate) parser: Rc<RefCell<Parser>>,
111    /// Writer for sending input to the PTY process.
112    pub(crate) writer: Rc<RefCell<Option<Box<dyn Write + Send>>>>,
113    /// PTY master handle for resizing.
114    pub(crate) master: Rc<RefCell<Box<dyn MasterPty + Send>>>,
115    /// Current working directory reported by the shell via OSC 7.
116    pub(crate) cwd: Rc<RefCell<Option<PathBuf>>>,
117    /// Window title reported by the shell via OSC 0 or OSC 2.
118    pub(crate) title: Rc<RefCell<Option<String>>>,
119    /// Notifier that signals when the terminal/PTY closes.
120    pub(crate) closer_notifier: ArcNotify,
121    /// Handles cleanup when the terminal is dropped.
122    pub(crate) cleaner: Rc<TerminalCleaner>,
123    /// Notifier that signals when new output is received from the PTY.
124    pub(crate) output_notifier: ArcNotify,
125    /// Notifier that signals when the window title changes via OSC 0 or OSC 2.
126    pub(crate) title_notifier: ArcNotify,
127    /// Tracks when user last wrote input to the PTY.
128    pub(crate) last_write_time: Rc<RefCell<Instant>>,
129    /// Currently pressed mouse button (for drag/motion tracking).
130    pub(crate) pressed_button: Rc<RefCell<Option<TerminalMouseButton>>>,
131    /// Current modifier keys state (shift, ctrl, alt, etc.).
132    pub(crate) modifiers: Rc<RefCell<Modifiers>>,
133}
134
135impl PartialEq for TerminalHandle {
136    fn eq(&self, other: &Self) -> bool {
137        self.id == other.id
138    }
139}
140
141impl TerminalHandle {
142    /// Create a new terminal with the specified command and default scrollback size (1000 lines).
143    ///
144    /// # Example
145    ///
146    /// ```rust,no_run
147    /// use freya_terminal::prelude::*;
148    /// use portable_pty::CommandBuilder;
149    ///
150    /// let mut cmd = CommandBuilder::new("bash");
151    /// cmd.env("TERM", "xterm-256color");
152    ///
153    /// let handle = TerminalHandle::new(TerminalId::new(), cmd, None).unwrap();
154    /// ```
155    pub fn new(
156        id: TerminalId,
157        command: portable_pty::CommandBuilder,
158        scrollback_length: Option<usize>,
159    ) -> Result<Self, TerminalError> {
160        spawn_pty(id, command, scrollback_length.unwrap_or(1000))
161    }
162
163    /// Refresh the terminal buffer from the parser, preserving selection state.
164    fn refresh_buffer(&self) {
165        let mut parser = self.parser.borrow_mut();
166        let total_scrollback = query_max_scrollback(&mut parser);
167
168        let mut buffer = self.buffer.borrow_mut();
169        buffer.scroll_offset = buffer.scroll_offset.min(total_scrollback);
170
171        parser.screen_mut().set_scrollback(buffer.scroll_offset);
172        let mut new_buffer = extract_buffer(&parser, buffer.scroll_offset, total_scrollback);
173        parser.screen_mut().set_scrollback(0);
174
175        new_buffer.selection = buffer.selection.take();
176        *buffer = new_buffer;
177    }
178
179    /// Write data to the terminal.
180    ///
181    /// # Example
182    ///
183    /// ```rust,no_run
184    /// # use freya_terminal::prelude::*;
185    /// # let handle: TerminalHandle = unimplemented!();
186    /// handle.write(b"ls -la\n").unwrap();
187    /// ```
188    pub fn write(&self, data: &[u8]) -> Result<(), TerminalError> {
189        self.write_raw(data)?;
190        let mut buffer = self.buffer.borrow_mut();
191        buffer.selection = None;
192        buffer.scroll_offset = 0;
193        drop(buffer);
194        *self.last_write_time.borrow_mut() = Instant::now();
195        self.scroll_to_bottom();
196        Ok(())
197    }
198
199    /// Write data to the PTY without resetting scroll or selection state.
200    fn write_raw(&self, data: &[u8]) -> Result<(), TerminalError> {
201        match &mut *self.writer.borrow_mut() {
202            Some(w) => {
203                w.write_all(data)
204                    .map_err(|e| TerminalError::WriteError(e.to_string()))?;
205                w.flush()
206                    .map_err(|e| TerminalError::WriteError(e.to_string()))?;
207                Ok(())
208            }
209            None => Err(TerminalError::NotInitialized),
210        }
211    }
212
213    /// Resize the terminal to the specified rows and columns.
214    ///
215    /// # Example
216    ///
217    /// ```rust,no_run
218    /// # use freya_terminal::prelude::*;
219    /// # let handle: TerminalHandle = unimplemented!();
220    /// handle.resize(24, 80);
221    /// ```
222    pub fn resize(&self, rows: u16, cols: u16) {
223        self.parser.borrow_mut().screen_mut().set_size(rows, cols);
224        self.refresh_buffer();
225        let _ = self.master.borrow().resize(PtySize {
226            rows,
227            cols,
228            pixel_width: 0,
229            pixel_height: 0,
230        });
231    }
232
233    /// Scroll the terminal by the specified delta.
234    ///
235    /// # Example
236    ///
237    /// ```rust,no_run
238    /// # use freya_terminal::prelude::*;
239    /// # let handle: TerminalHandle = unimplemented!();
240    /// handle.scroll(-3); // Scroll up 3 lines
241    /// handle.scroll(3); // Scroll down 3 lines
242    /// ```
243    pub fn scroll(&self, delta: i32) {
244        if self.parser.borrow().screen().alternate_screen() {
245            return;
246        }
247
248        {
249            let mut buffer = self.buffer.borrow_mut();
250            let new_offset = (buffer.scroll_offset as i64 + delta as i64).max(0) as usize;
251            buffer.scroll_offset = new_offset.min(buffer.total_scrollback);
252        }
253
254        self.refresh_buffer();
255        Platform::get().send(UserEvent::RequestRedraw);
256    }
257
258    /// Scroll the terminal to the bottom.
259    ///
260    /// # Example
261    ///
262    /// ```rust,no_run
263    /// # use freya_terminal::prelude::*;
264    /// # let handle: TerminalHandle = unimplemented!();
265    /// handle.scroll_to_bottom();
266    /// ```
267    pub fn scroll_to_bottom(&self) {
268        if self.parser.borrow().screen().alternate_screen() {
269            return;
270        }
271
272        self.buffer.borrow_mut().scroll_offset = 0;
273        self.refresh_buffer();
274        Platform::get().send(UserEvent::RequestRedraw);
275    }
276
277    /// Get the current scrollback position (scroll offset from buffer).
278    ///
279    /// # Example
280    ///
281    /// ```rust,no_run
282    /// # use freya_terminal::prelude::*;
283    /// # let handle: TerminalHandle = unimplemented!();
284    /// let position = handle.scrollback_position();
285    /// ```
286    pub fn scrollback_position(&self) -> usize {
287        self.buffer.borrow().scroll_offset
288    }
289
290    /// Get the current working directory reported by the shell via OSC 7.
291    ///
292    /// Returns `None` if the shell hasn't reported a CWD yet.
293    pub fn cwd(&self) -> Option<PathBuf> {
294        self.cwd.borrow().clone()
295    }
296
297    /// Get the window title reported by the shell via OSC 0 or OSC 2.
298    ///
299    /// Returns `None` if the shell hasn't reported a title yet.
300    pub fn title(&self) -> Option<String> {
301        self.title.borrow().clone()
302    }
303
304    /// Send a wheel event to the PTY.
305    ///
306    /// This sends mouse wheel events as escape sequences to the running process.
307    /// Uses the currently active mouse protocol encoding based on what
308    /// the application has requested via DECSET sequences.
309    pub fn send_wheel_to_pty(&self, row: usize, col: usize, delta_y: f64) {
310        let encoding = self.parser.borrow().screen().mouse_protocol_encoding();
311        let seq = encode_wheel_event(row, col, delta_y, encoding);
312        let _ = self.write_raw(seq.as_bytes());
313    }
314
315    /// Send a mouse move/drag event to the PTY based on the active mouse mode.
316    ///
317    /// - `AnyMotion` (DECSET 1003): sends motion events regardless of button state.
318    /// - `ButtonMotion` (DECSET 1002): sends motion events only while a button is held.
319    ///
320    /// When dragging, the held button is encoded in the motion event so TUI apps
321    /// can implement their own text selection.
322    ///
323    /// If shift is held and a button is pressed, updates the text selection instead
324    /// of sending events to the PTY.
325    pub fn mouse_move(&self, row: usize, col: usize) {
326        let is_dragging = self.pressed_button.borrow().is_some();
327
328        if self.modifiers.borrow().contains(Modifiers::SHIFT) && is_dragging {
329            // Shift+drag updates text selection (raw mode, bypasses PTY)
330            self.update_selection(row, col);
331            return;
332        }
333
334        let parser = self.parser.borrow();
335        let mouse_mode = parser.screen().mouse_protocol_mode();
336        let encoding = parser.screen().mouse_protocol_encoding();
337
338        let held = *self.pressed_button.borrow();
339
340        match mouse_mode {
341            vt100::MouseProtocolMode::AnyMotion => {
342                let seq = encode_mouse_move(row, col, held, encoding);
343                let _ = self.write_raw(seq.as_bytes());
344            }
345            vt100::MouseProtocolMode::ButtonMotion => {
346                if let Some(button) = held {
347                    let seq = encode_mouse_move(row, col, Some(button), encoding);
348                    let _ = self.write_raw(seq.as_bytes());
349                }
350            }
351            vt100::MouseProtocolMode::None => {
352                // No mouse tracking - do text selection if dragging
353                if is_dragging {
354                    self.update_selection(row, col);
355                }
356            }
357            _ => {}
358        }
359    }
360
361    /// Returns whether the running application has enabled mouse tracking.
362    fn is_mouse_tracking_enabled(&self) -> bool {
363        let parser = self.parser.borrow();
364        parser.screen().mouse_protocol_mode() != vt100::MouseProtocolMode::None
365    }
366
367    /// Handle a mouse button press event.
368    ///
369    /// When the running application has enabled mouse tracking (e.g. vim,
370    /// helix, htop), this sends the press escape sequence to the PTY.
371    /// Otherwise it starts a text selection.
372    ///
373    /// If shift is held, text selection is always performed regardless of
374    /// the application's mouse tracking state.
375    pub fn mouse_down(&self, row: usize, col: usize, button: TerminalMouseButton) {
376        *self.pressed_button.borrow_mut() = Some(button);
377
378        if self.modifiers.borrow().contains(Modifiers::SHIFT) {
379            // Shift+drag always does raw text selection
380            self.start_selection(row, col);
381        } else if self.is_mouse_tracking_enabled() {
382            let encoding = self.parser.borrow().screen().mouse_protocol_encoding();
383            let seq = encode_mouse_press(row, col, button, encoding);
384            let _ = self.write_raw(seq.as_bytes());
385        } else {
386            self.start_selection(row, col);
387        }
388    }
389
390    /// Handle a mouse button release event.
391    ///
392    /// When the running application has enabled mouse tracking, this sends the
393    /// release escape sequence to the PTY. Only `PressRelease`, `ButtonMotion`,
394    /// and `AnyMotion` modes receive release events — `Press` mode does not.
395    /// Otherwise it ends the current text selection.
396    ///
397    /// If shift is held, always ends the text selection instead of sending
398    /// events to the PTY.
399    pub fn mouse_up(&self, row: usize, col: usize, button: TerminalMouseButton) {
400        *self.pressed_button.borrow_mut() = None;
401
402        if self.modifiers.borrow().contains(Modifiers::SHIFT) {
403            // Shift+drag ends text selection
404            self.end_selection();
405            return;
406        }
407
408        let parser = self.parser.borrow();
409        let mouse_mode = parser.screen().mouse_protocol_mode();
410        let encoding = parser.screen().mouse_protocol_encoding();
411
412        match mouse_mode {
413            vt100::MouseProtocolMode::PressRelease
414            | vt100::MouseProtocolMode::ButtonMotion
415            | vt100::MouseProtocolMode::AnyMotion => {
416                let seq = encode_mouse_release(row, col, button, encoding);
417                let _ = self.write_raw(seq.as_bytes());
418            }
419            vt100::MouseProtocolMode::Press => {
420                // Press-only mode doesn't send release events
421            }
422            vt100::MouseProtocolMode::None => {
423                self.end_selection();
424            }
425        }
426    }
427
428    /// Number of arrow key presses to send per wheel tick in alternate scroll mode.
429    const ALTERNATE_SCROLL_LINES: usize = 3;
430
431    /// Handle a mouse button release from outside the terminal viewport.
432    ///
433    /// Clears the pressed state and ends any active text selection without
434    /// sending an encoded event to the PTY.
435    pub fn release(&self) {
436        *self.pressed_button.borrow_mut() = None;
437        self.end_selection();
438    }
439
440    /// Handle a wheel event intelligently.
441    ///
442    /// The behavior depends on the terminal state:
443    /// - If viewing scrollback history: scrolls the scrollback buffer.
444    /// - If mouse tracking is enabled (e.g., vim, helix): sends wheel escape
445    ///   sequences to the PTY.
446    /// - If on the alternate screen without mouse tracking (e.g., gitui, less):
447    ///   sends arrow key sequences to the PTY (alternate scroll mode, like
448    ///   wezterm/kitty/alacritty).
449    /// - Otherwise (normal shell): scrolls the scrollback buffer.
450    pub fn wheel(&self, delta_y: f64, row: usize, col: usize) {
451        let scroll_delta = if delta_y > 0.0 { 3 } else { -3 };
452        let scroll_offset = self.buffer.borrow().scroll_offset;
453        let (mouse_mode, alt_screen, app_cursor) = {
454            let parser = self.parser.borrow();
455            let screen = parser.screen();
456            (
457                screen.mouse_protocol_mode(),
458                screen.alternate_screen(),
459                screen.application_cursor(),
460            )
461        };
462
463        if scroll_offset > 0 {
464            // User is viewing scrollback history
465            let delta = scroll_delta;
466            self.scroll(delta);
467        } else if mouse_mode != vt100::MouseProtocolMode::None {
468            // App has enabled mouse tracking (vim, helix, etc.)
469            self.send_wheel_to_pty(row, col, delta_y);
470        } else if alt_screen {
471            // Alternate screen without mouse tracking (gitui, less, etc.)
472            // Send arrow key presses, matching wezterm/kitty/alacritty behavior
473            let key = match (delta_y > 0.0, app_cursor) {
474                (true, true) => "\x1bOA",
475                (true, false) => "\x1b[A",
476                (false, true) => "\x1bOB",
477                (false, false) => "\x1b[B",
478            };
479            for _ in 0..Self::ALTERNATE_SCROLL_LINES {
480                let _ = self.write_raw(key.as_bytes());
481            }
482        } else {
483            // Normal screen, no mouse tracking — scroll scrollback
484            let delta = scroll_delta;
485            self.scroll(delta);
486        }
487    }
488
489    /// Read the current terminal buffer.
490    pub fn read_buffer(&'_ self) -> Ref<'_, TerminalBuffer> {
491        self.buffer.borrow()
492    }
493
494    /// Returns a future that completes when new output is received from the PTY.
495    ///
496    /// Can be called repeatedly in a loop to detect ongoing output activity.
497    pub fn output_received(&self) -> impl std::future::Future<Output = ()> + '_ {
498        self.output_notifier.notified()
499    }
500
501    /// Returns a future that completes when the window title changes via OSC 0 or OSC 2.
502    ///
503    /// Can be called repeatedly in a loop to react to title updates from the shell.
504    pub fn title_changed(&self) -> impl std::future::Future<Output = ()> + '_ {
505        self.title_notifier.notified()
506    }
507
508    pub fn last_write_elapsed(&self) -> std::time::Duration {
509        self.last_write_time.borrow().elapsed()
510    }
511
512    /// Returns a future that completes when the terminal/PTY closes.
513    ///
514    /// This can be used to detect when the shell process exits and update the UI accordingly.
515    ///
516    /// # Example
517    ///
518    /// ```rust,ignore
519    /// use_future(move || async move {
520    ///     terminal_handle.closed().await;
521    ///     // Terminal has exited, update UI state
522    /// });
523    /// ```
524    pub fn closed(&self) -> impl std::future::Future<Output = ()> + '_ {
525        self.closer_notifier.notified()
526    }
527
528    /// Returns the unique identifier for this terminal instance.
529    pub fn id(&self) -> TerminalId {
530        self.id
531    }
532
533    /// Track whether shift is currently pressed.
534    ///
535    /// This should be called from your key event handlers to track shift state
536    /// for shift+drag text selection.
537    pub fn shift_pressed(&self, pressed: bool) {
538        let mut mods = self.modifiers.borrow_mut();
539        if pressed {
540            mods.insert(Modifiers::SHIFT);
541        } else {
542            mods.remove(Modifiers::SHIFT);
543        }
544    }
545
546    /// Get the current text selection.
547    pub fn get_selection(&self) -> Option<TerminalSelection> {
548        self.buffer.borrow().selection.clone()
549    }
550
551    /// Set the text selection.
552    pub fn set_selection(&self, selection: Option<TerminalSelection>) {
553        self.buffer.borrow_mut().selection = selection;
554    }
555
556    pub fn start_selection(&self, row: usize, col: usize) {
557        let mut buffer = self.buffer.borrow_mut();
558        let scroll = buffer.scroll_offset;
559        buffer.selection = Some(TerminalSelection {
560            dragging: true,
561            start_row: row,
562            start_col: col,
563            start_scroll: scroll,
564            end_row: row,
565            end_col: col,
566            end_scroll: scroll,
567        });
568        Platform::get().send(UserEvent::RequestRedraw);
569    }
570
571    pub fn update_selection(&self, row: usize, col: usize) {
572        let mut buffer = self.buffer.borrow_mut();
573        let scroll = buffer.scroll_offset;
574        if let Some(selection) = &mut buffer.selection
575            && selection.dragging
576        {
577            selection.end_row = row;
578            selection.end_col = col;
579            selection.end_scroll = scroll;
580            Platform::get().send(UserEvent::RequestRedraw);
581        }
582    }
583
584    pub fn end_selection(&self) {
585        if let Some(selection) = &mut self.buffer.borrow_mut().selection {
586            selection.dragging = false;
587            Platform::get().send(UserEvent::RequestRedraw);
588        }
589    }
590
591    /// Clear the current selection.
592    pub fn clear_selection(&self) {
593        self.buffer.borrow_mut().selection = None;
594        Platform::get().send(UserEvent::RequestRedraw);
595    }
596
597    pub fn get_selected_text(&self) -> Option<String> {
598        let buffer = self.buffer.borrow();
599        let selection = buffer.selection.clone()?;
600        if selection.is_empty() {
601            return None;
602        }
603
604        let scroll = buffer.scroll_offset;
605        let (display_start, start_col, display_end, end_col) = selection.display_positions(scroll);
606
607        let mut parser = self.parser.borrow_mut();
608        let saved_scrollback = parser.screen().scrollback();
609        let (_rows, cols) = parser.screen().size();
610
611        let mut lines = Vec::new();
612
613        for d in display_start..=display_end {
614            let cp = d - scroll as i64;
615            let needed_scrollback = (-cp).max(0) as usize;
616            let viewport_row = cp.max(0) as u16;
617
618            parser.screen_mut().set_scrollback(needed_scrollback);
619
620            let row_cells: Vec<_> = (0..cols)
621                .filter_map(|c| parser.screen().cell(viewport_row, c).cloned())
622                .collect();
623
624            let is_single = display_start == display_end;
625            let is_first = d == display_start;
626            let is_last = d == display_end;
627
628            let cells = if is_single {
629                let s = start_col.min(row_cells.len());
630                let e = end_col.min(row_cells.len());
631                &row_cells[s..e]
632            } else if is_first {
633                let s = start_col.min(row_cells.len());
634                &row_cells[s..]
635            } else if is_last {
636                &row_cells[..end_col.min(row_cells.len())]
637            } else {
638                &row_cells
639            };
640
641            let line: String = cells
642                .iter()
643                .map(|cell| {
644                    if cell.has_contents() {
645                        cell.contents()
646                    } else {
647                        " "
648                    }
649                })
650                .collect::<String>();
651
652            lines.push(line);
653        }
654
655        parser.screen_mut().set_scrollback(saved_scrollback);
656
657        Some(lines.join("\n"))
658    }
659}