fltk_term/
lib.rs

1#![doc = include_str!("../README.md")]
2#![allow(dead_code)]
3#![allow(clippy::single_match)]
4#![allow(clippy::needless_doctest_main)]
5
6use fltk::{enums::*, prelude::*, *};
7use portable_pty::MasterPty;
8use std::cell::Cell as StdCell;
9use std::{
10    io::{self, Write},
11    str,
12    sync::{
13        atomic::{AtomicBool, Ordering},
14        Arc, Mutex,
15    },
16};
17mod canvas;
18pub mod cell_performer;
19mod cells;
20mod pty;
21mod styles;
22
23pub use canvas::TermCanvas;
24pub use cells::{Cell, CellBuffer, Style};
25use styles::*;
26
27const UP: &[u8] = if cfg!(not(target_os = "windows")) {
28    b"\x10"
29} else {
30    b"\x1b[A"
31};
32const DOWN: &[u8] = if cfg!(not(target_os = "windows")) {
33    b"\x0E"
34} else {
35    b"\x1b[B"
36};
37
38pub fn menu_cb(m: &mut impl MenuExt) {
39    if let Ok(mpath) = m.item_pathname(None) {
40        match mpath.as_str() {
41            "/Copy\t" => {
42                let mut term: group::Group = app::widget_from_id("term").unwrap();
43                term.do_callback();
44            }
45            "/Paste\t" => {
46                let term: group::Group = app::widget_from_id("term").unwrap();
47                app::paste_text(&term);
48            }
49            _ => (),
50        }
51    }
52}
53
54pub fn init_menu(m: &mut (impl MenuExt + 'static)) {
55    m.add(
56        "Copy\t",
57        Shortcut::Ctrl | Shortcut::Shift | 'c',
58        menu::MenuFlag::Normal,
59        menu_cb,
60    );
61    m.add(
62        "Paste\t",
63        Shortcut::Ctrl | Shortcut::Shift | 'v',
64        menu::MenuFlag::Normal,
65        menu_cb,
66    );
67}
68
69/// Helper function to copy selection to clipboard
70fn copy_selection_to_clipboard(
71    sel_arc: &Arc<Mutex<crate::canvas::Selection>>,
72    buffer: &Arc<Mutex<CellBuffer>>,
73    widget_width: i32,
74) {
75    if let Ok(ssel) = sel_arc.lock() {
76        if let (Some(mut a), Some(mut b)) = (ssel.start, ssel.end) {
77            if b < a {
78                std::mem::swap(&mut a, &mut b);
79            }
80            if let Ok(buf) = buffer.lock() {
81                let pad_x = 6;
82                let char_w = ((draw::width("M") as f32).ceil() as i32).max(1);
83                let cols = ((widget_width - 2 * pad_x).max(1) / char_w).max(1) as usize;
84                let snap = buf.snapshot();
85                let mut visual: Vec<Vec<char>> = Vec::new();
86                for line in snap.iter() {
87                    let row: Vec<char> = line.iter().map(|c| c.ch).collect();
88                    if row.is_empty() {
89                        visual.push(Vec::new());
90                        continue;
91                    }
92                    for chunk in row.chunks(cols) {
93                        visual.push(chunk.to_vec());
94                    }
95                }
96                let mut out = String::new();
97                for v in a.0..=b.0 {
98                    if v >= visual.len() {
99                        break;
100                    }
101                    let line = &visual[v];
102                    let start = if v == a.0 { a.1.min(line.len()) } else { 0 };
103                    let end = if v == b.0 {
104                        (b.1 + 1).min(line.len())
105                    } else {
106                        line.len()
107                    };
108                    if start < end {
109                        for ch in &line[start..end] {
110                            out.push(*ch);
111                        }
112                    }
113                    if v != b.0 {
114                        out.push('\n');
115                    }
116                }
117                if !out.is_empty() {
118                    app::copy(&out);
119                    app::copy2(&out);
120                }
121            }
122        }
123    }
124}
125
126/// Helper function to scroll to bottom
127fn scroll_to_bottom(scroll: &mut group::Scroll, canvas: &group::Group) {
128    let view_h = scroll.h();
129    let max_y = (canvas.h() - view_h).max(0);
130    scroll.scroll_to(scroll.xposition(), max_y);
131}
132
133/// Helper function to paste text and scroll to bottom
134fn paste_and_scroll(widget: &group::Group, scroll: &mut group::Scroll, canvas: &group::Group) {
135    app::paste_text(widget);
136    scroll_to_bottom(scroll, canvas);
137}
138
139/// Helper function to check if copy shortcut is pressed
140fn is_copy_shortcut() -> bool {
141    let key = app::event_key();
142    app::event_state().contains(EventState::Ctrl)
143        && app::event_state().contains(EventState::Shift)
144        && key == Key::from_char('c')
145}
146
147/// Helper function to check if paste shortcut is pressed
148fn is_paste_shortcut() -> bool {
149    let key = app::event_key();
150    (app::event_state().contains(EventState::Ctrl)
151        && app::event_state().contains(EventState::Shift)
152        && key == Key::from_char('v'))
153        || (app::event_state().contains(EventState::Shift) && key == Key::Insert)
154}
155
156pub struct PPTerm {
157    scroll: group::Scroll,
158    canvas: TermCanvas,
159    pty: Option<pty::PtyHandles>,
160    shutdown_flag: Arc<AtomicBool>,
161    buffer: Arc<Mutex<CellBuffer>>,
162    cols: u16,
163    rows: u16,
164    auto_follow: Arc<Mutex<bool>>,
165    // Shared handles used by event/timer closures; populated once PTY starts
166    shared_writer: Arc<Mutex<Option<Arc<Mutex<Box<dyn Write + Send>>>>>>,
167    shared_master: Arc<Mutex<Option<Arc<Mutex<Box<dyn MasterPty + Send>>>>>>,
168}
169
170impl Default for PPTerm {
171    fn default() -> Self {
172        PPTerm::new(0, 0, 0, 0, None)
173    }
174}
175
176impl PPTerm {
177    /// Internal constructor that allows specifying initial terminal cols/rows.
178    #[allow(clippy::too_many_arguments)]
179    fn new_with_cols_rows_internal<L: Into<Option<&'static str>>>(
180        x: i32,
181        y: i32,
182        w: i32,
183        h: i32,
184        label: L,
185        init_cols: u16,
186        init_rows: u16,
187        max_lines: usize,
188        defer_start: bool,
189    ) -> Self {
190        let mut scroll =
191            group::Scroll::new(x, y, w, h, label).with_type(group::ScrollType::Vertical);
192        scroll.set_id("term_group");
193        let buffer = Arc::new(Mutex::new(CellBuffer::new(max_lines, BLACK, WHITE)));
194        let mut canvas = TermCanvas::new(scroll.x(), scroll.y(), w, h, None);
195        canvas.set_id("term");
196        canvas.set_buffer(buffer.clone());
197        canvas.set_scroll(scroll.clone());
198        canvas.start_blink(0.6);
199        canvas.end();
200        let mut m = menu::MenuButton::default()
201            .with_type(menu::MenuButtonType::Popup3)
202            .with_id("pop2");
203        init_menu(&mut m);
204        scroll.end();
205
206        // Compute approximate monospace metrics
207        draw::set_font(Font::Courier, 14);
208        let sample = "MMMMMMMMMM";
209        let (sw, sh) = draw::measure(sample, false);
210        let char_w = (sw as f32 / 10.0).ceil() as i32;
211        let line_h = sh.max(14);
212
213        // Calculate initial cols/rows for the PTY. If the widget hasn't been
214        // laid out yet (w/h == 0), fall back to the provided init cols/rows to
215        // avoid starting the shell with a tiny grid that can truncate/wrap the
216        // first printed prompt.
217        let pad_x = 6;
218        let pad_y = 4;
219        let actual_cols = if w > 0 {
220            ((w - 2 * pad_x).max(char_w) / char_w).max(10) as u16
221        } else {
222            init_cols.max(10)
223        };
224        let actual_rows = if h > 0 {
225            ((h - pad_y).max(line_h) / line_h).max(3) as u16
226        } else {
227            init_rows.max(3)
228        };
229
230        let shutdown_flag = Arc::new(AtomicBool::new(false));
231        // Shared handles used by event/timer closures; can be populated later via start()
232        let shared_writer: Arc<Mutex<Option<Arc<Mutex<Box<dyn Write + Send>>>>>> =
233            Arc::new(Mutex::new(None));
234        let shared_master: Arc<Mutex<Option<Arc<Mutex<Box<dyn MasterPty + Send>>>>>> =
235            Arc::new(Mutex::new(None));
236
237        // Optionally start immediately (default behavior) or defer until start() is called.
238        let handles = if !defer_start {
239            let h = pty::start(
240                buffer.clone(),
241                actual_cols,
242                actual_rows,
243                shutdown_flag.clone(),
244            );
245            if let Some(ref hh) = h {
246                *shared_writer.lock().unwrap() = Some(hh.writer.clone());
247                *shared_master.lock().unwrap() = Some(hh.master_pty.clone());
248            }
249            h
250        } else {
251            None
252        };
253
254        // React to outer widget resize: update canvas width and PTY cols/rows
255        scroll.resize_callback({
256            let mut canvas = canvas.clone();
257            let shared_master_cb = shared_master.clone();
258            let mut menu = m.clone();
259            move |_, x, y, w, h| {
260                // Important: don't call scroll.resize() here to avoid recursive callbacks.
261                // Only adjust child canvas width; height is managed by the periodic updater.
262                menu.resize(x, y, w, h);
263                canvas.set_size(w, canvas.h());
264                let pad_x = 6;
265                let pad_y = 4;
266                let cols = ((w - 2 * pad_x).max(char_w) / char_w).max(10) as u16;
267                let rows = ((h - pad_y).max(line_h) / line_h).max(3) as u16;
268                if let Some(ref pty) = *shared_master_cb.lock().unwrap() {
269                    let _ = pty::resize_pty(pty, cols, rows);
270                }
271            }
272        });
273
274        // Keyboard input -> PTY (works whether PTY is started now or deferred)
275        {
276            let selection_arc = canvas.selection_handle();
277            let buffer_for_copy = buffer.clone();
278            let mut scroll_for_input = scroll.clone();
279            let canvas_for_input = canvas.clone();
280            let shared_writer_ev = shared_writer.clone();
281            canvas.set_callback({
282                let sel_arc = selection_arc.clone();
283                let buf = buffer.clone();
284                move |g| {
285                    copy_selection_to_clipboard(&sel_arc, &buf, g.w());
286                }
287            });
288            canvas.handle({
289                move |t, ev| match ev {
290                    Event::Push => {
291                        if app::event_button() == 1 {
292                            t.take_focus().ok();
293                            let (mx, my) = (app::event_x(), app::event_y());
294                            // begin selection (account for scroll viewport + offset)
295                            let line_h = draw::height().max(14);
296                            let char_w = ((draw::width("M") as f32).ceil() as i32).max(1);
297                            let pad_x = 6;
298                            let view_top = scroll_for_input.y();
299                            let yoff = scroll_for_input.yposition();
300                            let row_view = ((my - view_top).max(0) / line_h) as usize;
301                            let row = row_view + (yoff / line_h).max(0) as usize;
302                            let col = ((mx - t.x() - pad_x).max(0) / char_w) as usize;
303                            if let Ok(mut sel) = selection_arc.lock() {
304                                sel.start = Some((row, col));
305                                sel.end = Some((row, col));
306                            }
307                            t.redraw();
308                            true
309                        } else {
310                            false
311                        }
312                    }
313                    Event::KeyDown => {
314                        if is_copy_shortcut() {
315                            copy_selection_to_clipboard(&selection_arc, &buffer_for_copy, t.w());
316                            return true;
317                        }
318                        if is_paste_shortcut() {
319                            paste_and_scroll(t, &mut scroll_for_input, &canvas_for_input);
320                            return true;
321                        }
322                        let key = app::event_key();
323                        let mods = app::event_state();
324                        let has_shift = mods.contains(EventState::Shift);
325                        let has_alt = mods.contains(EventState::Alt);
326                        let has_ctrl = mods.contains(EventState::Ctrl);
327                        let send = |bytes: &[u8]| {
328                            if let Some(ref wr_arc) = *shared_writer_ev.lock().unwrap() {
329                                if let Ok(mut w) = wr_arc.lock() {
330                                    if has_alt {
331                                        let _ = w.write_all(b"\x1b"); // Alt prefix
332                                    }
333                                    let _ = w.write_all(bytes);
334                                }
335                            }
336                        };
337                        let arrow_with_mods = |letter: u8| -> Vec<u8> {
338                            let mut v = Vec::new();
339                            let mut m = 1; // base
340                            if has_shift {
341                                m += 1;
342                            } // 2
343                            if has_alt {
344                                m += 2;
345                            } // 3
346                            if has_ctrl {
347                                m += 4;
348                            } // 5
349                            if m == 1 {
350                                v.extend_from_slice(&[0x1b, b'[', letter]);
351                            } else {
352                                v.extend_from_slice(b"\x1b[1;");
353                                v.extend_from_slice(m.to_string().as_bytes());
354                                v.push(letter);
355                            }
356                            v
357                        };
358                        match key {
359                            #[cfg(windows)]
360                            Key::BackSpace => {
361                                if let Some(ref wr_arc) = *shared_writer_ev.lock().unwrap() {
362                                    if let Ok(mut w) = wr_arc.lock() {
363                                        let _ = w.write_all(b"\x7f");
364                                    }
365                                }
366                            }
367                            Key::Up => {
368                                let seq = if has_shift || has_alt || has_ctrl {
369                                    arrow_with_mods(b'A')
370                                } else {
371                                    UP.to_vec()
372                                };
373                                send(&seq);
374                            }
375                            Key::Down => {
376                                let seq = if has_shift || has_alt || has_ctrl {
377                                    arrow_with_mods(b'B')
378                                } else {
379                                    DOWN.to_vec()
380                                };
381                                send(&seq);
382                            }
383                            Key::Left => {
384                                let seq = arrow_with_mods(b'D');
385                                send(&seq);
386                            }
387                            Key::Right => {
388                                let seq = arrow_with_mods(b'C');
389                                send(&seq);
390                            }
391                            // Prefer VT220-style for unmodified; many shells expect 1~/4~
392                            Key::Home => {
393                                if has_shift || has_alt || has_ctrl {
394                                    let seq = arrow_with_mods(b'H');
395                                    send(&seq);
396                                } else {
397                                    send(b"\x1b[1~");
398                                }
399                            }
400                            Key::End => {
401                                if has_shift || has_alt || has_ctrl {
402                                    let seq = arrow_with_mods(b'F');
403                                    send(&seq);
404                                } else {
405                                    send(b"\x1b[4~");
406                                }
407                            }
408                            Key::PageUp => {
409                                send(b"\x1b[5~");
410                            }
411                            Key::PageDown => {
412                                send(b"\x1b[6~");
413                            }
414                            Key::Insert => {
415                                send(b"\x1b[2~");
416                            }
417                            Key::Delete => {
418                                send(b"\x1b[3~");
419                            }
420                            Key::Enter => {
421                                send(b"\r");
422                            }
423                            Key::F1 => {
424                                send(b"\x1bOP");
425                            }
426                            Key::F2 => {
427                                send(b"\x1bOQ");
428                            }
429                            Key::F3 => {
430                                send(b"\x1bOR");
431                            }
432                            Key::F4 => {
433                                send(b"\x1bOS");
434                            }
435                            Key::F5 => {
436                                send(b"\x1b[15~");
437                            }
438                            Key::F6 => {
439                                send(b"\x1b[17~");
440                            }
441                            Key::F7 => {
442                                send(b"\x1b[18~");
443                            }
444                            Key::F8 => {
445                                send(b"\x1b[19~");
446                            }
447                            Key::F9 => {
448                                send(b"\x1b[20~");
449                            }
450                            Key::F10 => {
451                                send(b"\x1b[21~");
452                            }
453                            Key::F11 => {
454                                send(b"\x1b[23~");
455                            }
456                            Key::F12 => {
457                                send(b"\x1b[24~");
458                            }
459                            _ => {
460                                let txt = app::event_text();
461                                if !txt.is_empty() {
462                                    send(txt.as_bytes());
463                                }
464                            }
465                        }
466                        // Auto-scroll to bottom on any user key input
467                        scroll_to_bottom(&mut scroll_for_input, &canvas_for_input);
468                        true
469                    }
470                    Event::Drag => {
471                        let (mx, my) = (app::event_x(), app::event_y());
472                        // Update selection end (account for scroll viewport + offset)
473                        let line_h = draw::height().max(14);
474                        let char_w = ((draw::width("M") as f32).ceil() as i32).max(1);
475                        let pad_x = 6;
476                        let view_top = scroll_for_input.y();
477                        let yoff = scroll_for_input.yposition();
478                        let row_view = ((my - view_top).max(0) / line_h) as usize;
479                        let row = row_view + (yoff / line_h).max(0) as usize;
480                        let col = ((mx - t.x() - pad_x).max(0) / char_w) as usize;
481                        if let Ok(mut sel) = selection_arc.lock() {
482                            sel.end = Some((row, col));
483                        }
484                        // Auto-scroll during selection when dragging beyond the widget edges
485                        let edge = 14; // px threshold
486                                       // Monospace line height
487                                       // line_h computed above
488                        let mut new_y = scroll_for_input.yposition();
489                        // Use viewport edges to decide scroll
490                        let vt = scroll_for_input.y();
491                        let vb = vt + scroll_for_input.h();
492                        // Use a larger step so the pointer remains inside widget when dragging upward
493                        if my <= vt + edge {
494                            new_y = new_y.saturating_sub(line_h * 3);
495                        } else if my >= vb - edge {
496                            new_y = new_y.saturating_add(line_h * 2);
497                        }
498                        if new_y != scroll_for_input.yposition() {
499                            let max_y = (canvas_for_input.h() - scroll_for_input.h()).max(0);
500                            let new_y = new_y.clamp(0, max_y);
501                            scroll_for_input.scroll_to(scroll_for_input.xposition(), new_y);
502                        }
503                        t.redraw();
504                        true
505                    }
506                    Event::Released => {
507                        // Middle-click paste (X11-style) or right-click menu action
508                        if app::event_button() == 2 {
509                            app::paste_text(t);
510                        } else if app::event_button() == 3 {
511                            m.popup();
512                        }
513                        true
514                    }
515                    Event::Paste => {
516                        // Prefer event_clipboard() for portability; fallback to event_text()
517                        let mut pasted = String::new();
518                        if let Some(cb) = app::event_clipboard() {
519                            match cb {
520                                app::ClipboardEvent::Text(s) => pasted = s,
521                                app::ClipboardEvent::Image(_) => {}
522                            }
523                        }
524                        if pasted.is_empty() {
525                            pasted = app::event_text();
526                        }
527                        if !pasted.is_empty() {
528                            if let Some(ref wr_arc) = *shared_writer_ev.lock().unwrap() {
529                                if let Ok(mut w) = wr_arc.lock() {
530                                    let _ = w.write_all(pasted.as_bytes());
531                                }
532                            }
533                        }
534                        // Auto-scroll after paste
535                        scroll_to_bottom(&mut scroll_for_input, &canvas_for_input);
536                        true
537                    }
538                    Event::Focus => true,
539                    Event::Unfocus => true,
540                    _ => false,
541                }
542            });
543        }
544
545        // Periodic layout + resize updater
546        let mut canvas_clone = canvas.clone();
547        let buffer_clone = buffer.clone();
548        let shared_master_cl = shared_master.clone();
549        let mut scroll_clone = scroll.clone();
550        let auto_follow_flag = Arc::new(Mutex::new(true));
551        let last_vlines = Arc::new(Mutex::new(0usize));
552        let last_vlines_cl = last_vlines.clone();
553        let auto_follow_flag_cl = auto_follow_flag.clone();
554
555        // Track previous geometry to avoid redundant work
556        let prev_cols = StdCell::new(0u16);
557        let prev_rows = StdCell::new(0u16);
558        let prev_h = StdCell::new(0i32);
559
560        // Adaptive timer: fast on updates, slow when idle
561        app::add_timeout3(0.05, move |h| {
562            // If terminal group is not visible, back off
563            if !scroll_clone.visible() {
564                app::repeat_timeout3(0.5, h);
565                return;
566            }
567
568            // Compute viewport-based cols/rows first
569            let pad_x = 6;
570            let pad_y = 4;
571            let cols_vis = ((canvas_clone.w() - 12).max(char_w) / char_w).max(1) as usize;
572            let cols = ((scroll_clone.w() - 2 * pad_x).max(char_w) / char_w).max(10) as u16;
573            let rows = ((scroll_clone.h() - pad_y).max(line_h) / line_h).max(3) as u16;
574
575            // Decide whether we need to inspect buffer content in detail
576            let dims_changed = cols != prev_cols.get() || rows != prev_rows.get();
577
578            // Desired content height based on buffer lines (avoid full recompute when idle)
579            let mut had_dirty = false;
580            let line_count: usize = if let Ok(mut buf) = buffer_clone.lock() {
581                // Check if buffer reports any dirty areas; only then do expensive accounting
582                let dirty_areas = buf.take_dirty_areas();
583                had_dirty = !dirty_areas.is_empty();
584
585                if had_dirty || dims_changed || prev_h.get() == 0 {
586                    let snap = buf.snapshot();
587                    let mut vlines = 0usize;
588                    for line in snap.iter() {
589                        let len = line.len().max(1);
590                        vlines += len.div_ceil(cols_vis); // ceil div
591                    }
592                    vlines
593                } else {
594                    // No content change and no geometry change: reuse last vlines
595                    *last_vlines_cl.lock().unwrap()
596                }
597            } else {
598                0usize
599            };
600
601            let desired_h = (line_count as i32 * line_h + pad_y).max(scroll_clone.h());
602
603            // Resize canvas content to match buffer only if height changed
604            let mut changed = false;
605            if desired_h != prev_h.get() {
606                canvas_clone.set_size(scroll_clone.w(), desired_h);
607                prev_h.set(desired_h);
608                changed = true;
609            }
610
611            // Resize PTY only when grid changes
612            if cols != prev_cols.get() || rows != prev_rows.get() {
613                if let Some(ref pty) = *shared_master_cl.lock().unwrap() {
614                    let _ = pty::resize_pty(pty, cols, rows);
615                }
616                prev_cols.set(cols);
617                prev_rows.set(rows);
618                changed = true;
619            }
620
621            // Keep buffer aware of the terminal grid size for CUP/ED/EL semantics
622            if let Ok(mut buf) = buffer_clone.lock() {
623                buf.set_dimensions(cols as usize, rows as usize);
624            }
625
626            // Auto-follow policy: only on new output and if already at bottom
627            if *auto_follow_flag_cl.lock().unwrap() {
628                let mut new_output = false;
629                if let Ok(mut prev) = last_vlines_cl.lock() {
630                    if line_count > *prev {
631                        new_output = true;
632                    }
633                    *prev = line_count;
634                }
635                if new_output {
636                    let view_h = scroll_clone.h();
637                    let max_y = (canvas_clone.h() - view_h).max(0);
638                    scroll_clone.scroll_to(scroll_clone.xposition(), max_y);
639                }
640            }
641
642            // Redraw only on dirty output or geometry change
643            let next = if had_dirty || changed {
644                canvas_clone.redraw();
645                0.05
646            } else {
647                0.05
648            };
649            app::repeat_timeout3(next, h);
650        });
651
652        Self {
653            scroll,
654            canvas,
655            pty: handles,
656            shutdown_flag,
657            buffer,
658            cols: init_cols,
659            rows: init_rows,
660            auto_follow: auto_follow_flag,
661            shared_writer: shared_writer.clone(),
662            shared_master: shared_master.clone(),
663        }
664    }
665
666    /// Create a new canvas terminal with default dimensions (80x24).
667    pub fn new<L: Into<Option<&'static str>>>(x: i32, y: i32, w: i32, h: i32, label: L) -> Self {
668        Self::new_with_cols_rows_internal(x, y, w, h, label, 80, 24, 2000, false)
669    }
670
671    /// Create a new canvas terminal with explicit terminal dimensions (cols x rows).
672    pub fn new_with_dims<L: Into<Option<&'static str>>>(
673        x: i32,
674        y: i32,
675        w: i32,
676        h: i32,
677        label: L,
678        cols: u16,
679        rows: u16,
680    ) -> Self {
681        Self::new_with_cols_rows_internal(x, y, w, h, label, cols, rows, 2000, false)
682    }
683
684    /// Convenience: construct with only (cols, rows). Position/size can be
685    /// set later via standard FLTK `WidgetExt` methods like `size_of_parent()`.
686    pub fn with_dimensions(cols: u16, rows: u16) -> Self {
687        Self::new_with_cols_rows_internal(0, 0, 0, 0, None, cols, rows, 2000, false)
688    }
689
690    /// Convenience: construct with (cols, rows, scrollback_lines).
691    pub fn with_dimensions_and_scrollback(cols: u16, rows: u16, scrollback_lines: usize) -> Self {
692        Self::new_with_cols_rows_internal(0, 0, 0, 0, None, cols, rows, scrollback_lines, false)
693    }
694
695    /// Construct without starting the PTY; call `start()` after the window
696    /// has been shown and sized for correct initial geometry.
697    pub fn new_deferred<L: Into<Option<&'static str>>>(
698        x: i32,
699        y: i32,
700        w: i32,
701        h: i32,
702        label: L,
703    ) -> Self {
704        Self::new_with_cols_rows_internal(x, y, w, h, label, 80, 24, 2000, true)
705    }
706
707    /// Construct with explicit dimensions but defer PTY start until `start()`.
708    pub fn new_with_dims_deferred<L: Into<Option<&'static str>>>(
709        x: i32,
710        y: i32,
711        w: i32,
712        h: i32,
713        label: L,
714        cols: u16,
715        rows: u16,
716    ) -> Self {
717        Self::new_with_cols_rows_internal(x, y, w, h, label, cols, rows, 2000, true)
718    }
719
720    /// Convenience deferred constructors
721    pub fn with_dimensions_deferred(cols: u16, rows: u16) -> Self {
722        Self::new_with_cols_rows_internal(0, 0, 0, 0, None, cols, rows, 2000, true)
723    }
724
725    pub fn with_dimensions_and_scrollback_deferred(
726        cols: u16,
727        rows: u16,
728        scrollback_lines: usize,
729    ) -> Self {
730        Self::new_with_cols_rows_internal(0, 0, 0, 0, None, cols, rows, scrollback_lines, true)
731    }
732
733    /// Start the underlying PTY if it hasn't been started. Compute grid from
734    /// the current widget size so the shell starts at the correct width.
735    pub fn start(&mut self) {
736        if self.pty.is_some() {
737            return;
738        }
739
740        // Recompute monospace metrics
741        draw::set_font(Font::Courier, 14);
742        let sample = "MMMMMMMMMM";
743        let (sw, sh) = draw::measure(sample, false);
744        let char_w = (sw as f32 / 10.0).ceil() as i32;
745        let line_h = sh.max(14);
746        let pad_x = 6;
747        let pad_y = 4;
748        let w = self.scroll.w();
749        let h = self.scroll.h();
750        let cols = if w > 0 {
751            ((w - 2 * pad_x).max(char_w) / char_w).max(10) as u16
752        } else {
753            self.cols.max(10)
754        };
755        let rows = if h > 0 {
756            ((h - pad_y).max(line_h) / line_h).max(3) as u16
757        } else {
758            self.rows.max(3)
759        };
760
761        let handles = pty::start(
762            self.buffer.clone(),
763            cols,
764            rows,
765            self.shutdown_flag.clone(),
766        );
767        if let Some(ref h) = handles {
768            *self.shared_writer.lock().unwrap() = Some(h.writer.clone());
769            *self.shared_master.lock().unwrap() = Some(h.master_pty.clone());
770        }
771        self.pty = handles;
772    }
773
774    pub fn widget(&self) -> &group::Scroll {
775        &self.scroll
776    }
777    pub fn redraw(&mut self) {
778        self.canvas.redraw();
779    }
780    pub fn buffer(&self) -> Arc<Mutex<CellBuffer>> {
781        self.buffer.clone()
782    }
783    pub fn shutdown(&self) {
784        self.shutdown_flag.store(true, Ordering::Relaxed);
785    }
786    pub fn set_auto_follow(&mut self, enabled: bool) {
787        if let Ok(mut v) = self.auto_follow.lock() {
788            *v = enabled;
789        }
790    }
791    pub fn auto_follow_handle(&self) -> Arc<Mutex<bool>> {
792        self.auto_follow.clone()
793    }
794
795    /// Current column count reported by the widget.
796    pub fn cols(&self) -> u16 {
797        self.cols
798    }
799    /// Current row count reported by the widget.
800    pub fn rows(&self) -> u16 {
801        self.rows
802    }
803
804    /// Write raw bytes to the underlying PTY.
805    pub fn write_all(&self, s: &[u8]) -> Result<(), io::Error> {
806        if let Some(ref pty) = &self.pty {
807            match pty.writer.lock() {
808                Ok(mut w) => w.write_all(s),
809                Err(_) => Err(io::Error::new(
810                    io::ErrorKind::Other,
811                    "Failed to acquire writer lock",
812                )),
813            }
814        } else {
815            Err(io::Error::new(
816                io::ErrorKind::BrokenPipe,
817                "No writer available",
818            ))
819        }
820    }
821
822    /// Manually set terminal cell dimensions (cols x rows) and resize the PTY.
823    /// Note: the periodic layout updater will continue to sync PTY size to the
824    /// current widget size; this method is most useful for initial sizing or
825    /// when external code coordinates widget resizing alongside PTY resize.
826    pub fn set_dims(&mut self, cols: u16, rows: u16) -> Result<(), Box<dyn std::error::Error>> {
827        self.cols = cols;
828        self.rows = rows;
829        if let Some(ref p) = self.pty {
830            pty::resize_pty(&p.master_pty, cols, rows)
831        } else {
832            Ok(())
833        }
834    }
835}
836
837impl Drop for PPTerm {
838    fn drop(&mut self) {
839        self.shutdown();
840        if let Some(pty) = self.pty.take() {
841            // Use a timeout to avoid hanging on close
842            std::thread::spawn(move || {
843                let _ = pty.thread_handle.join();
844            });
845        }
846    }
847}
848
849fltk::widget_extends!(PPTerm, group::Scroll, scroll);
850
851fn canvas_sel_mouse_to_vpos(t: &group::Group, mx: i32, my: i32) -> (usize, usize) {
852    let line_h = draw::height().max(14);
853    let char_w = ((draw::width("M") as f32).ceil() as i32).max(1);
854    let pad_x = 6;
855    let col = ((mx - t.x() - pad_x).max(0) / char_w) as usize;
856    let row = ((my - t.y()).max(0) / line_h) as usize;
857    (row, col)
858}