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    }
81}
82
83/// Spawn a PTY and return a TerminalHandle.
84pub(crate) fn spawn_pty(
85    id: TerminalId,
86    command: CommandBuilder,
87    scrollback_size: usize,
88) -> Result<TerminalHandle, TerminalError> {
89    let (update_tx, mut update_rx) = futures_channel::mpsc::unbounded::<()>();
90
91    let buffer = Rc::new(RefCell::new(TerminalBuffer::default()));
92    let parser = Rc::new(RefCell::new(Parser::new(24, 80, scrollback_size)));
93    let writer = Rc::new(RefCell::new(None::<Box<dyn std::io::Write + Send>>));
94    let closer_notifier = ArcNotify::new();
95    let output_notifier = ArcNotify::new();
96    let title_notifier = ArcNotify::new();
97    let cwd: Rc<RefCell<Option<PathBuf>>> = Rc::new(RefCell::new(None));
98    let title: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
99
100    let pty_system = native_pty_system();
101    let pair = pty_system
102        .openpty(PtySize::default())
103        .map_err(|_| TerminalError::NotInitialized)?;
104    let master_writer = pair
105        .master
106        .take_writer()
107        .map_err(|_| TerminalError::NotInitialized)?;
108    *writer.borrow_mut() = Some(master_writer);
109
110    pair.slave
111        .spawn_command(command)
112        .map_err(|_| TerminalError::NotInitialized)?;
113    let reader = pair
114        .master
115        .try_clone_reader()
116        .map_err(|_| TerminalError::NotInitialized)?;
117    let mut reader = blocking::Unblock::new(reader);
118
119    let master: Rc<RefCell<Box<dyn MasterPty + Send>>> = Rc::new(RefCell::new(pair.master));
120
121    let platform = Platform::get();
122    let reader_task = spawn_forever({
123        let parser = parser.clone();
124        let buffer = buffer.clone();
125        let closer_notifier = closer_notifier.clone();
126        let writer = writer.clone();
127        async move {
128            use futures_lite::StreamExt;
129            while let Some(()) = update_rx.next().await {
130                let mut parser = parser.borrow_mut();
131                let total_scrollback = query_max_scrollback(&mut parser);
132
133                let mut buffer = buffer.borrow_mut();
134                let old_total_scrollback = buffer.total_scrollback;
135                let delta = total_scrollback.saturating_sub(old_total_scrollback);
136                parser.screen_mut().set_scrollback(buffer.scroll_offset);
137                let mut new_buffer =
138                    extract_buffer(&parser, buffer.scroll_offset, total_scrollback);
139                parser.screen_mut().set_scrollback(0);
140
141                new_buffer.selection = buffer.selection.take().map(|mut selection| {
142                    selection.start_scroll = selection.start_scroll.saturating_add(delta);
143                    selection.end_scroll = selection.end_scroll.saturating_add(delta);
144                    selection
145                });
146                *buffer = new_buffer;
147                platform.send(UserEvent::RequestRedraw);
148            }
149            // Channel closed — PTY exited
150            *writer.borrow_mut() = None;
151            closer_notifier.notify();
152            platform.send(UserEvent::RequestRedraw);
153        }
154    });
155
156    let pty_task = spawn_forever({
157        let writer = writer.clone();
158        let parser = parser.clone();
159        let output_notifier = output_notifier.clone();
160        let cwd = cwd.clone();
161        let title = title.clone();
162        let title_notifier = title_notifier.clone();
163        async move {
164            let mut tw_parser = TermwizParser::new();
165            loop {
166                let mut buf = [0u8; 4096];
167
168                match reader.read(&mut buf).await {
169                    Ok(0) => break,
170                    Ok(n) => {
171                        let data = &buf[..n];
172
173                        parser.borrow_mut().process(data);
174
175                        // Use termwiz to detect terminal queries and OSC sequences
176                        let actions = tw_parser.parse_as_vec(data);
177                        let mut responses: Vec<Vec<u8>> = Vec::new();
178
179                        for action in actions {
180                            match action {
181                                Action::CSI(CSI::Device(dev)) => match *dev {
182                                    Device::RequestPrimaryDeviceAttributes => {
183                                        responses.push(b"\x1b[?62;22c".to_vec());
184                                    }
185                                    Device::RequestSecondaryDeviceAttributes => {
186                                        responses.push(b"\x1b[>0;0;0c".to_vec());
187                                    }
188                                    Device::StatusReport => {
189                                        responses.push(b"\x1b[0n".to_vec());
190                                    }
191                                    _ => {}
192                                },
193                                Action::CSI(CSI::Cursor(Cursor::RequestActivePositionReport)) => {
194                                    let p = parser.borrow();
195                                    let (row, col) = p.screen().cursor_position();
196                                    let response = format!("\x1b[{};{}R", row + 1, col + 1);
197                                    responses.push(response.into_bytes());
198                                }
199                                Action::OperatingSystemCommand(osc) => match *osc {
200                                    OperatingSystemCommand::CurrentWorkingDirectory(url) => {
201                                        // Strip file:// prefix if present
202                                        let path =
203                                            if let Some(stripped) = url.strip_prefix("file://") {
204                                                // file:///path or file://hostname/path
205                                                if let Some(rest) = stripped.strip_prefix('/') {
206                                                    PathBuf::from(format!("/{rest}"))
207                                                } else if let Some((_host, path)) =
208                                                    stripped.split_once('/')
209                                                {
210                                                    PathBuf::from(format!("/{path}"))
211                                                } else {
212                                                    PathBuf::from(stripped)
213                                                }
214                                            } else {
215                                                PathBuf::from(url)
216                                            };
217                                        *cwd.borrow_mut() = Some(path);
218                                    }
219                                    OperatingSystemCommand::SetWindowTitle(t)
220                                    | OperatingSystemCommand::SetIconNameAndWindowTitle(t) => {
221                                        *title.borrow_mut() = Some(t);
222                                        title_notifier.notify();
223                                    }
224                                    _ => {}
225                                },
226                                _ => {}
227                            }
228                        }
229
230                        if !responses.is_empty()
231                            && let Some(writer) = &mut *writer.borrow_mut()
232                        {
233                            for response in responses {
234                                let _ = writer.write_all(&response);
235                            }
236                            let _ = writer.flush();
237                        }
238
239                        let _ = update_tx.unbounded_send(());
240                        output_notifier.notify();
241                    }
242                    Err(_) => break,
243                }
244            }
245        }
246    });
247
248    Ok(TerminalHandle {
249        closer_notifier: closer_notifier.clone(),
250        cleaner: Rc::new(TerminalCleaner {
251            writer: writer.clone(),
252            reader_task,
253            pty_task,
254            closer_notifier,
255        }),
256        id,
257        buffer,
258        parser,
259        writer,
260        master,
261        cwd,
262        title,
263        title_notifier,
264        output_notifier,
265        last_write_time: Rc::new(RefCell::new(Instant::now())),
266        pressed_button: Rc::new(RefCell::new(None)),
267        modifiers: Rc::new(RefCell::new(Modifiers::empty())),
268    })
269}