Skip to main content

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