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 text to the PTY as a paste operation.
200    ///
201    /// If bracketed paste mode is enabled, wraps the text with `\x1b[200~` ... `\x1b[201~`.
202    pub fn paste(&self, text: &str) -> Result<(), TerminalError> {
203        let bracketed = self.parser.borrow().screen().bracketed_paste();
204
205        let mut data = Vec::with_capacity(text.len() + 12);
206        if bracketed {
207            data.extend_from_slice(b"\x1b[200~");
208        }
209        data.extend_from_slice(text.as_bytes());
210        if bracketed {
211            data.extend_from_slice(b"\x1b[201~");
212        }
213
214        self.write(&data)
215    }
216
217    /// Write data to the PTY without resetting scroll or selection state.
218    fn write_raw(&self, data: &[u8]) -> Result<(), TerminalError> {
219        match &mut *self.writer.borrow_mut() {
220            Some(w) => {
221                w.write_all(data)
222                    .map_err(|e| TerminalError::WriteError(e.to_string()))?;
223                w.flush()
224                    .map_err(|e| TerminalError::WriteError(e.to_string()))?;
225                Ok(())
226            }
227            None => Err(TerminalError::NotInitialized),
228        }
229    }
230
231    /// Resize the terminal to the specified rows and columns.
232    ///
233    /// # Example
234    ///
235    /// ```rust,no_run
236    /// # use freya_terminal::prelude::*;
237    /// # let handle: TerminalHandle = unimplemented!();
238    /// handle.resize(24, 80);
239    /// ```
240    pub fn resize(&self, rows: u16, cols: u16) {
241        self.parser.borrow_mut().screen_mut().set_size(rows, cols);
242        self.refresh_buffer();
243        let _ = self.master.borrow().resize(PtySize {
244            rows,
245            cols,
246            pixel_width: 0,
247            pixel_height: 0,
248        });
249    }
250
251    /// Scroll the terminal by the specified delta.
252    ///
253    /// # Example
254    ///
255    /// ```rust,no_run
256    /// # use freya_terminal::prelude::*;
257    /// # let handle: TerminalHandle = unimplemented!();
258    /// handle.scroll(-3); // Scroll up 3 lines
259    /// handle.scroll(3); // Scroll down 3 lines
260    /// ```
261    pub fn scroll(&self, delta: i32) {
262        if self.parser.borrow().screen().alternate_screen() {
263            return;
264        }
265
266        {
267            let mut buffer = self.buffer.borrow_mut();
268            let new_offset = (buffer.scroll_offset as i64 + delta as i64).max(0) as usize;
269            buffer.scroll_offset = new_offset.min(buffer.total_scrollback);
270        }
271
272        self.refresh_buffer();
273        Platform::get().send(UserEvent::RequestRedraw);
274    }
275
276    /// Scroll the terminal to the bottom.
277    ///
278    /// # Example
279    ///
280    /// ```rust,no_run
281    /// # use freya_terminal::prelude::*;
282    /// # let handle: TerminalHandle = unimplemented!();
283    /// handle.scroll_to_bottom();
284    /// ```
285    pub fn scroll_to_bottom(&self) {
286        if self.parser.borrow().screen().alternate_screen() {
287            return;
288        }
289
290        self.buffer.borrow_mut().scroll_offset = 0;
291        self.refresh_buffer();
292        Platform::get().send(UserEvent::RequestRedraw);
293    }
294
295    /// Get the current scrollback position (scroll offset from buffer).
296    ///
297    /// # Example
298    ///
299    /// ```rust,no_run
300    /// # use freya_terminal::prelude::*;
301    /// # let handle: TerminalHandle = unimplemented!();
302    /// let position = handle.scrollback_position();
303    /// ```
304    pub fn scrollback_position(&self) -> usize {
305        self.buffer.borrow().scroll_offset
306    }
307
308    /// Get the current working directory reported by the shell via OSC 7.
309    ///
310    /// Returns `None` if the shell hasn't reported a CWD yet.
311    pub fn cwd(&self) -> Option<PathBuf> {
312        self.cwd.borrow().clone()
313    }
314
315    /// Get the window title reported by the shell via OSC 0 or OSC 2.
316    ///
317    /// Returns `None` if the shell hasn't reported a title yet.
318    pub fn title(&self) -> Option<String> {
319        self.title.borrow().clone()
320    }
321
322    /// Send a wheel event to the PTY.
323    ///
324    /// This sends mouse wheel events as escape sequences to the running process.
325    /// Uses the currently active mouse protocol encoding based on what
326    /// the application has requested via DECSET sequences.
327    pub fn send_wheel_to_pty(&self, row: usize, col: usize, delta_y: f64) {
328        let encoding = self.parser.borrow().screen().mouse_protocol_encoding();
329        let seq = encode_wheel_event(row, col, delta_y, encoding);
330        let _ = self.write_raw(seq.as_bytes());
331    }
332
333    /// Send a mouse move/drag event to the PTY based on the active mouse mode.
334    ///
335    /// - `AnyMotion` (DECSET 1003): sends motion events regardless of button state.
336    /// - `ButtonMotion` (DECSET 1002): sends motion events only while a button is held.
337    ///
338    /// When dragging, the held button is encoded in the motion event so TUI apps
339    /// can implement their own text selection.
340    ///
341    /// If shift is held and a button is pressed, updates the text selection instead
342    /// of sending events to the PTY.
343    pub fn mouse_move(&self, row: usize, col: usize) {
344        let is_dragging = self.pressed_button.borrow().is_some();
345
346        if self.modifiers.borrow().contains(Modifiers::SHIFT) && is_dragging {
347            // Shift+drag updates text selection (raw mode, bypasses PTY)
348            self.update_selection(row, col);
349            return;
350        }
351
352        let parser = self.parser.borrow();
353        let mouse_mode = parser.screen().mouse_protocol_mode();
354        let encoding = parser.screen().mouse_protocol_encoding();
355
356        let held = *self.pressed_button.borrow();
357
358        match mouse_mode {
359            vt100::MouseProtocolMode::AnyMotion => {
360                let seq = encode_mouse_move(row, col, held, encoding);
361                let _ = self.write_raw(seq.as_bytes());
362            }
363            vt100::MouseProtocolMode::ButtonMotion => {
364                if let Some(button) = held {
365                    let seq = encode_mouse_move(row, col, Some(button), encoding);
366                    let _ = self.write_raw(seq.as_bytes());
367                }
368            }
369            vt100::MouseProtocolMode::None => {
370                // No mouse tracking - do text selection if dragging
371                if is_dragging {
372                    self.update_selection(row, col);
373                }
374            }
375            _ => {}
376        }
377    }
378
379    /// Returns whether the running application has enabled mouse tracking.
380    fn is_mouse_tracking_enabled(&self) -> bool {
381        let parser = self.parser.borrow();
382        parser.screen().mouse_protocol_mode() != vt100::MouseProtocolMode::None
383    }
384
385    /// Handle a mouse button press event.
386    ///
387    /// When the running application has enabled mouse tracking (e.g. vim,
388    /// helix, htop), this sends the press escape sequence to the PTY.
389    /// Otherwise it starts a text selection.
390    ///
391    /// If shift is held, text selection is always performed regardless of
392    /// the application's mouse tracking state.
393    pub fn mouse_down(&self, row: usize, col: usize, button: TerminalMouseButton) {
394        *self.pressed_button.borrow_mut() = Some(button);
395
396        if self.modifiers.borrow().contains(Modifiers::SHIFT) {
397            // Shift+drag always does raw text selection
398            self.start_selection(row, col);
399        } else if self.is_mouse_tracking_enabled() {
400            let encoding = self.parser.borrow().screen().mouse_protocol_encoding();
401            let seq = encode_mouse_press(row, col, button, encoding);
402            let _ = self.write_raw(seq.as_bytes());
403        } else {
404            self.start_selection(row, col);
405        }
406    }
407
408    /// Handle a mouse button release event.
409    ///
410    /// When the running application has enabled mouse tracking, this sends the
411    /// release escape sequence to the PTY. Only `PressRelease`, `ButtonMotion`,
412    /// and `AnyMotion` modes receive release events — `Press` mode does not.
413    /// Otherwise it ends the current text selection.
414    ///
415    /// If shift is held, always ends the text selection instead of sending
416    /// events to the PTY.
417    pub fn mouse_up(&self, row: usize, col: usize, button: TerminalMouseButton) {
418        *self.pressed_button.borrow_mut() = None;
419
420        if self.modifiers.borrow().contains(Modifiers::SHIFT) {
421            // Shift+drag ends text selection
422            self.end_selection();
423            return;
424        }
425
426        let parser = self.parser.borrow();
427        let mouse_mode = parser.screen().mouse_protocol_mode();
428        let encoding = parser.screen().mouse_protocol_encoding();
429
430        match mouse_mode {
431            vt100::MouseProtocolMode::PressRelease
432            | vt100::MouseProtocolMode::ButtonMotion
433            | vt100::MouseProtocolMode::AnyMotion => {
434                let seq = encode_mouse_release(row, col, button, encoding);
435                let _ = self.write_raw(seq.as_bytes());
436            }
437            vt100::MouseProtocolMode::Press => {
438                // Press-only mode doesn't send release events
439            }
440            vt100::MouseProtocolMode::None => {
441                self.end_selection();
442            }
443        }
444    }
445
446    /// Number of arrow key presses to send per wheel tick in alternate scroll mode.
447    const ALTERNATE_SCROLL_LINES: usize = 3;
448
449    /// Handle a mouse button release from outside the terminal viewport.
450    ///
451    /// Clears the pressed state and ends any active text selection without
452    /// sending an encoded event to the PTY.
453    pub fn release(&self) {
454        *self.pressed_button.borrow_mut() = None;
455        self.end_selection();
456    }
457
458    /// Handle a wheel event intelligently.
459    ///
460    /// The behavior depends on the terminal state:
461    /// - If viewing scrollback history: scrolls the scrollback buffer.
462    /// - If mouse tracking is enabled (e.g., vim, helix): sends wheel escape
463    ///   sequences to the PTY.
464    /// - If on the alternate screen without mouse tracking (e.g., gitui, less):
465    ///   sends arrow key sequences to the PTY (alternate scroll mode, like
466    ///   wezterm/kitty/alacritty).
467    /// - Otherwise (normal shell): scrolls the scrollback buffer.
468    pub fn wheel(&self, delta_y: f64, row: usize, col: usize) {
469        let scroll_delta = if delta_y > 0.0 { 3 } else { -3 };
470        let scroll_offset = self.buffer.borrow().scroll_offset;
471        let (mouse_mode, alt_screen, app_cursor) = {
472            let parser = self.parser.borrow();
473            let screen = parser.screen();
474            (
475                screen.mouse_protocol_mode(),
476                screen.alternate_screen(),
477                screen.application_cursor(),
478            )
479        };
480
481        if scroll_offset > 0 {
482            // User is viewing scrollback history
483            let delta = scroll_delta;
484            self.scroll(delta);
485        } else if mouse_mode != vt100::MouseProtocolMode::None {
486            // App has enabled mouse tracking (vim, helix, etc.)
487            self.send_wheel_to_pty(row, col, delta_y);
488        } else if alt_screen {
489            // Alternate screen without mouse tracking (gitui, less, etc.)
490            // Send arrow key presses, matching wezterm/kitty/alacritty behavior
491            let key = match (delta_y > 0.0, app_cursor) {
492                (true, true) => "\x1bOA",
493                (true, false) => "\x1b[A",
494                (false, true) => "\x1bOB",
495                (false, false) => "\x1b[B",
496            };
497            for _ in 0..Self::ALTERNATE_SCROLL_LINES {
498                let _ = self.write_raw(key.as_bytes());
499            }
500        } else {
501            // Normal screen, no mouse tracking — scroll scrollback
502            let delta = scroll_delta;
503            self.scroll(delta);
504        }
505    }
506
507    /// Read the current terminal buffer.
508    pub fn read_buffer(&'_ self) -> Ref<'_, TerminalBuffer> {
509        self.buffer.borrow()
510    }
511
512    /// Returns a future that completes when new output is received from the PTY.
513    ///
514    /// Can be called repeatedly in a loop to detect ongoing output activity.
515    pub fn output_received(&self) -> impl std::future::Future<Output = ()> + '_ {
516        self.output_notifier.notified()
517    }
518
519    /// Returns a future that completes when the window title changes via OSC 0 or OSC 2.
520    ///
521    /// Can be called repeatedly in a loop to react to title updates from the shell.
522    pub fn title_changed(&self) -> impl std::future::Future<Output = ()> + '_ {
523        self.title_notifier.notified()
524    }
525
526    pub fn last_write_elapsed(&self) -> std::time::Duration {
527        self.last_write_time.borrow().elapsed()
528    }
529
530    /// Returns a future that completes when the terminal/PTY closes.
531    ///
532    /// This can be used to detect when the shell process exits and update the UI accordingly.
533    ///
534    /// # Example
535    ///
536    /// ```rust,ignore
537    /// use_future(move || async move {
538    ///     terminal_handle.closed().await;
539    ///     // Terminal has exited, update UI state
540    /// });
541    /// ```
542    pub fn closed(&self) -> impl std::future::Future<Output = ()> + '_ {
543        self.closer_notifier.notified()
544    }
545
546    /// Returns the unique identifier for this terminal instance.
547    pub fn id(&self) -> TerminalId {
548        self.id
549    }
550
551    /// Track whether shift is currently pressed.
552    ///
553    /// This should be called from your key event handlers to track shift state
554    /// for shift+drag text selection.
555    pub fn shift_pressed(&self, pressed: bool) {
556        let mut mods = self.modifiers.borrow_mut();
557        if pressed {
558            mods.insert(Modifiers::SHIFT);
559        } else {
560            mods.remove(Modifiers::SHIFT);
561        }
562    }
563
564    /// Get the current text selection.
565    pub fn get_selection(&self) -> Option<TerminalSelection> {
566        self.buffer.borrow().selection.clone()
567    }
568
569    /// Set the text selection.
570    pub fn set_selection(&self, selection: Option<TerminalSelection>) {
571        self.buffer.borrow_mut().selection = selection;
572    }
573
574    pub fn start_selection(&self, row: usize, col: usize) {
575        let mut buffer = self.buffer.borrow_mut();
576        let scroll = buffer.scroll_offset;
577        buffer.selection = Some(TerminalSelection {
578            dragging: true,
579            start_row: row,
580            start_col: col,
581            start_scroll: scroll,
582            end_row: row,
583            end_col: col,
584            end_scroll: scroll,
585        });
586        Platform::get().send(UserEvent::RequestRedraw);
587    }
588
589    pub fn update_selection(&self, row: usize, col: usize) {
590        let mut buffer = self.buffer.borrow_mut();
591        let scroll = buffer.scroll_offset;
592        if let Some(selection) = &mut buffer.selection
593            && selection.dragging
594        {
595            selection.end_row = row;
596            selection.end_col = col;
597            selection.end_scroll = scroll;
598            Platform::get().send(UserEvent::RequestRedraw);
599        }
600    }
601
602    pub fn end_selection(&self) {
603        if let Some(selection) = &mut self.buffer.borrow_mut().selection {
604            selection.dragging = false;
605            Platform::get().send(UserEvent::RequestRedraw);
606        }
607    }
608
609    /// Clear the current selection.
610    pub fn clear_selection(&self) {
611        self.buffer.borrow_mut().selection = None;
612        Platform::get().send(UserEvent::RequestRedraw);
613    }
614
615    pub fn get_selected_text(&self) -> Option<String> {
616        let buffer = self.buffer.borrow();
617        let selection = buffer.selection.clone()?;
618        if selection.is_empty() {
619            return None;
620        }
621
622        let scroll = buffer.scroll_offset;
623        let (display_start, start_col, display_end, end_col) = selection.display_positions(scroll);
624
625        let mut parser = self.parser.borrow_mut();
626        let saved_scrollback = parser.screen().scrollback();
627        let (_rows, cols) = parser.screen().size();
628
629        let mut lines = Vec::new();
630
631        for d in display_start..=display_end {
632            let cp = d - scroll as i64;
633            let needed_scrollback = (-cp).max(0) as usize;
634            let viewport_row = cp.max(0) as u16;
635
636            parser.screen_mut().set_scrollback(needed_scrollback);
637
638            let row_cells: Vec<_> = (0..cols)
639                .filter_map(|c| parser.screen().cell(viewport_row, c).cloned())
640                .collect();
641
642            let is_single = display_start == display_end;
643            let is_first = d == display_start;
644            let is_last = d == display_end;
645
646            let cells = if is_single {
647                let s = start_col.min(row_cells.len());
648                let e = end_col.min(row_cells.len());
649                &row_cells[s..e]
650            } else if is_first {
651                let s = start_col.min(row_cells.len());
652                &row_cells[s..]
653            } else if is_last {
654                &row_cells[..end_col.min(row_cells.len())]
655            } else {
656                &row_cells
657            };
658
659            let line: String = cells
660                .iter()
661                .map(|cell| {
662                    if cell.has_contents() {
663                        cell.contents()
664                    } else {
665                        " "
666                    }
667                })
668                .collect::<String>();
669
670            lines.push(line);
671        }
672
673        parser.screen_mut().set_scrollback(saved_scrollback);
674
675        Some(lines.join("\n"))
676    }
677}