Skip to main content

freya_terminal/
pty.rs

1use std::{
2    cell::RefCell,
3    path::PathBuf,
4    rc::Rc,
5    time::Instant,
6};
7
8use freya_core::{
9    notify::ArcNotify,
10    prelude::{
11        Platform,
12        UserEvent,
13        spawn_forever,
14    },
15};
16use futures_lite::AsyncReadExt;
17use keyboard_types::Modifiers;
18use portable_pty::{
19    CommandBuilder,
20    MasterPty,
21    PtySize,
22    native_pty_system,
23};
24use termwiz::escape::{
25    Action,
26    CSI,
27    OperatingSystemCommand,
28    csi::{
29        Cursor,
30        Device,
31    },
32    parser::Parser as TermwizParser,
33};
34use vt100::Parser;
35
36use crate::{
37    buffer::TerminalBuffer,
38    handle::{
39        TerminalCleaner,
40        TerminalError,
41        TerminalHandle,
42        TerminalId,
43    },
44};
45
46/// Query the maximum scrollback available without disturbing the viewport.
47/// Saves current scrollback, queries max, and restores.
48pub(crate) fn query_max_scrollback(parser: &mut Parser) -> usize {
49    let saved = parser.screen().scrollback();
50    parser.screen_mut().set_scrollback(usize::MAX);
51    let max = parser.screen().scrollback();
52    parser.screen_mut().set_scrollback(saved);
53    max
54}
55
56/// Extract visible cells from the parser at the current scrollback position.
57pub(crate) fn extract_buffer(
58    parser: &Parser,
59    scroll_offset: usize,
60    total_scrollback: usize,
61) -> TerminalBuffer {
62    let (rows, cols) = parser.screen().size();
63    let rows_vec: Vec<Vec<vt100::Cell>> = (0..rows)
64        .map(|r| {
65            (0..cols)
66                .filter_map(|c| parser.screen().cell(r, c).cloned())
67                .collect()
68        })
69        .collect();
70    let (cur_r, cur_c) = parser.screen().cursor_position();
71    TerminalBuffer {
72        rows: rows_vec,
73        cursor_row: cur_r as usize,
74        cursor_col: cur_c as usize,
75        cols: cols as usize,
76        rows_count: rows as usize,
77        selection: None,
78        scroll_offset,
79        total_scrollback,
80        cursor_visible: !parser.screen().hide_cursor(),
81    }
82}
83
84/// Spawn a PTY and return a TerminalHandle.
85pub(crate) fn spawn_pty(
86    id: TerminalId,
87    command: CommandBuilder,
88    scrollback_size: usize,
89) -> Result<TerminalHandle, TerminalError> {
90    let (update_tx, mut update_rx) = futures_channel::mpsc::unbounded::<()>();
91
92    let buffer = Rc::new(RefCell::new(TerminalBuffer::default()));
93    let parser = Rc::new(RefCell::new(Parser::new(24, 80, scrollback_size)));
94    let writer = Rc::new(RefCell::new(None::<Box<dyn std::io::Write + Send>>));
95    let closer_notifier = ArcNotify::new();
96    let output_notifier = ArcNotify::new();
97    let title_notifier = ArcNotify::new();
98    let cwd: Rc<RefCell<Option<PathBuf>>> = Rc::new(RefCell::new(None));
99    let title: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
100    let clipboard_content: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
101    let clipboard_notifier = ArcNotify::new();
102
103    let pty_system = native_pty_system();
104    let pair = pty_system
105        .openpty(PtySize::default())
106        .map_err(|_| TerminalError::NotInitialized)?;
107    let master_writer = pair
108        .master
109        .take_writer()
110        .map_err(|_| TerminalError::NotInitialized)?;
111    *writer.borrow_mut() = Some(master_writer);
112
113    pair.slave
114        .spawn_command(command)
115        .map_err(|_| TerminalError::NotInitialized)?;
116    let reader = pair
117        .master
118        .try_clone_reader()
119        .map_err(|_| TerminalError::NotInitialized)?;
120    let mut reader = blocking::Unblock::new(reader);
121
122    let master: Rc<RefCell<Box<dyn MasterPty + Send>>> = Rc::new(RefCell::new(pair.master));
123
124    let platform = Platform::get();
125    let reader_task = spawn_forever({
126        let parser = parser.clone();
127        let buffer = buffer.clone();
128        let closer_notifier = closer_notifier.clone();
129        let writer = writer.clone();
130        async move {
131            use futures_lite::StreamExt;
132            while let Some(()) = update_rx.next().await {
133                let mut parser = parser.borrow_mut();
134                let total_scrollback = query_max_scrollback(&mut parser);
135
136                let mut buffer = buffer.borrow_mut();
137                let old_total_scrollback = buffer.total_scrollback;
138                let delta = total_scrollback.saturating_sub(old_total_scrollback);
139                parser.screen_mut().set_scrollback(buffer.scroll_offset);
140                let mut new_buffer =
141                    extract_buffer(&parser, buffer.scroll_offset, total_scrollback);
142                parser.screen_mut().set_scrollback(0);
143
144                new_buffer.selection = buffer.selection.take().map(|mut selection| {
145                    selection.start_scroll = selection.start_scroll.saturating_add(delta);
146                    selection.end_scroll = selection.end_scroll.saturating_add(delta);
147                    selection
148                });
149                *buffer = new_buffer;
150                platform.send(UserEvent::RequestRedraw);
151            }
152            // Channel closed — PTY exited
153            *writer.borrow_mut() = None;
154            closer_notifier.notify();
155            platform.send(UserEvent::RequestRedraw);
156        }
157    });
158
159    let pty_task = spawn_forever({
160        let writer = writer.clone();
161        let parser = parser.clone();
162        let output_notifier = output_notifier.clone();
163        let cwd = cwd.clone();
164        let title = title.clone();
165        let title_notifier = title_notifier.clone();
166        let clipboard_content = clipboard_content.clone();
167        let clipboard_notifier = clipboard_notifier.clone();
168        async move {
169            let mut tw_parser = TermwizParser::new();
170            loop {
171                let mut buf = [0u8; 4096];
172
173                match reader.read(&mut buf).await {
174                    Ok(0) => break,
175                    Ok(n) => {
176                        let data = &buf[..n];
177
178                        parser.borrow_mut().process(data);
179
180                        // Use termwiz to detect terminal queries and OSC sequences
181                        let actions = tw_parser.parse_as_vec(data);
182                        let mut responses: Vec<Vec<u8>> = Vec::new();
183
184                        for action in actions {
185                            match action {
186                                Action::CSI(CSI::Device(dev)) => match *dev {
187                                    Device::RequestPrimaryDeviceAttributes => {
188                                        responses.push(b"\x1b[?62;22c".to_vec());
189                                    }
190                                    Device::RequestSecondaryDeviceAttributes => {
191                                        responses.push(b"\x1b[>0;0;0c".to_vec());
192                                    }
193                                    Device::StatusReport => {
194                                        responses.push(b"\x1b[0n".to_vec());
195                                    }
196                                    _ => {}
197                                },
198                                Action::CSI(CSI::Cursor(Cursor::RequestActivePositionReport)) => {
199                                    let p = parser.borrow();
200                                    let (row, col) = p.screen().cursor_position();
201                                    let response = format!("\x1b[{};{}R", row + 1, col + 1);
202                                    responses.push(response.into_bytes());
203                                }
204                                Action::OperatingSystemCommand(osc) => match *osc {
205                                    OperatingSystemCommand::CurrentWorkingDirectory(url) => {
206                                        // Strip file:// prefix if present
207                                        let path =
208                                            if let Some(stripped) = url.strip_prefix("file://") {
209                                                // file:///path or file://hostname/path
210                                                if let Some(rest) = stripped.strip_prefix('/') {
211                                                    PathBuf::from(format!("/{rest}"))
212                                                } else if let Some((_host, path)) =
213                                                    stripped.split_once('/')
214                                                {
215                                                    PathBuf::from(format!("/{path}"))
216                                                } else {
217                                                    PathBuf::from(stripped)
218                                                }
219                                            } else {
220                                                PathBuf::from(url)
221                                            };
222                                        *cwd.borrow_mut() = Some(path);
223                                    }
224                                    OperatingSystemCommand::SetWindowTitle(t)
225                                    | OperatingSystemCommand::SetIconNameAndWindowTitle(t) => {
226                                        *title.borrow_mut() = Some(t);
227                                        title_notifier.notify();
228                                    }
229                                    OperatingSystemCommand::SetSelection(_sel, text) => {
230                                        *clipboard_content.borrow_mut() = Some(text);
231                                        clipboard_notifier.notify();
232                                    }
233                                    _ => {}
234                                },
235                                _ => {}
236                            }
237                        }
238
239                        if !responses.is_empty()
240                            && let Some(writer) = &mut *writer.borrow_mut()
241                        {
242                            for response in responses {
243                                let _ = writer.write_all(&response);
244                            }
245                            let _ = writer.flush();
246                        }
247
248                        let _ = update_tx.unbounded_send(());
249                        output_notifier.notify();
250                    }
251                    Err(_) => break,
252                }
253            }
254        }
255    });
256
257    Ok(TerminalHandle {
258        closer_notifier: closer_notifier.clone(),
259        cleaner: Rc::new(TerminalCleaner {
260            writer: writer.clone(),
261            reader_task,
262            pty_task,
263            closer_notifier,
264        }),
265        id,
266        buffer,
267        parser,
268        writer,
269        master,
270        cwd,
271        title,
272        title_notifier,
273        clipboard_content,
274        clipboard_notifier,
275        output_notifier,
276        last_write_time: Rc::new(RefCell::new(Instant::now())),
277        pressed_button: Rc::new(RefCell::new(None)),
278        modifiers: Rc::new(RefCell::new(Modifiers::empty())),
279    })
280}