freya_terminal/
handle.rs

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