Skip to main content

tui_lipan/widgets/
managed_terminal.rs

1//! Managed terminal widget with built-in PTY management.
2//!
3//! This composite widget wraps the low-level [`Terminal`] widget with automatic
4//! PTY lifecycle management, providing a "batteries included" terminal that works
5//! out of the box while still allowing low-level control when needed.
6//!
7//! # Example
8//!
9//! ```rust,ignore
10//! use tui_lipan::prelude::*;
11//!
12//! // Simple usage - just works
13//! ManagedTerminal::new()
14//!     .config(TerminalPtyConfig::default().cwd("/home/user/projects"))
15//!     .on_status(ctx.link().callback(|status| Msg::Status(status)))
16//!
17//! // With custom scrollback
18//! ManagedTerminal::new()
19//!     .scrollback(5000)
20//!     .initial_size(120, 40)
21//! ```
22//!
23//! For advanced use cases (custom PTY handling, multiple terminals, etc.),
24//! use the low-level [`Terminal`] widget with [`TerminalPty`] directly.
25
26use std::sync::Arc;
27use std::time::Duration;
28
29use crate::Command;
30use crate::callback::{Callback, CommandLink};
31use crate::core::component::{Component, Context, Update};
32use crate::core::element::Element;
33use crate::style::Length;
34use crate::widgets::terminal::{
35    Terminal, TerminalInputEvent, TerminalPty, TerminalPtyConfig, TerminalPtyEvent,
36    TerminalRenderSnapshot, TerminalScreen, TerminalViewport,
37};
38use crate::widgets::{Text, VStack};
39
40/// Managed terminal component with built-in PTY lifecycle management.
41///
42/// This component handles PTY spawning, resizing, scrollback management, and
43/// all the internal wiring required for a functional terminal emulator.
44#[derive(Clone)]
45pub struct ManagedTerminal {
46    props: ManagedTerminalProps,
47}
48
49/// Properties for configuring a managed terminal.
50#[derive(Clone, PartialEq)]
51pub struct ManagedTerminalProps {
52    /// PTY configuration (shell, cwd, env vars, etc.)
53    pub config: TerminalPtyConfig,
54    /// Scrollback buffer size in lines.
55    /// Default: `2000`.
56    pub scrollback: usize,
57    /// Initial terminal size in columns.
58    /// Default: `120`.
59    pub initial_cols: u16,
60    /// Initial terminal size in rows.
61    /// Default: `24`.
62    pub initial_rows: u16,
63    /// Callback for status changes (connecting, ready, error, exited)
64    pub on_status: Option<Callback<ManagedTerminalStatus>>,
65    /// Whether to auto-start the PTY on component init.
66    /// Default: `true`.
67    pub auto_start: bool,
68    /// Placeholder to show before PTY is ready
69    pub placeholder: Option<Arc<str>>,
70    /// Enable mouse forwarding to PTY.
71    /// Default: `true`.
72    pub forward_mouse: bool,
73    /// Enable scroll wheel for scrollback.
74    /// Default: `true`.
75    pub scroll_wheel: bool,
76    /// Delay before applying a burst of terminal viewport resizes.
77    /// Default: `16ms`. Use [`std::time::Duration::ZERO`] to apply every resize immediately.
78    /// Interval used to coalesce bursts of PTY resize requests; zero applies each request.
79    pub resize_debounce: Duration,
80    /// Style for the terminal content
81    pub style: crate::style::Style,
82    /// Whether the terminal should be focusable
83    pub focusable: bool,
84    /// Whether the terminal participates in Tab / Shift+Tab traversal.
85    pub tab_stop: bool,
86    /// Callback fired when the terminal gains focus.
87    pub on_focus: Option<Callback<()>>,
88    /// Callback fired when the terminal loses focus.
89    pub on_blur: Option<Callback<()>>,
90    /// Custom width.
91    /// Default: `Length::Flex(1)`.
92    pub width: Length,
93    /// Custom height.
94    /// Default: `Length::Flex(1)`.
95    pub height: Length,
96}
97
98impl Default for ManagedTerminalProps {
99    fn default() -> Self {
100        Self {
101            config: TerminalPtyConfig::default(),
102            scrollback: 2000,
103            initial_cols: 120,
104            initial_rows: 24,
105            on_status: None,
106            auto_start: true,
107            placeholder: Some(Arc::from("Starting terminal...")),
108            forward_mouse: true,
109            scroll_wheel: true,
110            resize_debounce: Duration::from_millis(16),
111            style: crate::style::Style::default(),
112            focusable: true,
113            tab_stop: true,
114            on_focus: None,
115            on_blur: None,
116            width: Length::Flex(1),
117            height: Length::Flex(1),
118        }
119    }
120}
121
122/// Status events emitted by the managed terminal.
123#[derive(Clone, Debug, PartialEq, Eq)]
124pub enum ManagedTerminalStatus {
125    /// PTY is being initialized
126    Starting,
127    /// PTY is ready and accepting input
128    Ready,
129    /// Shell exited with status code
130    Exited(i32),
131    /// Error occurred (contains error message)
132    Error(Arc<str>),
133}
134
135impl ManagedTerminal {
136    /// Create a new managed terminal with default settings.
137    pub fn new() -> Self {
138        Self {
139            props: ManagedTerminalProps::default(),
140        }
141    }
142
143    /// Set the PTY configuration.
144    pub fn config(mut self, config: TerminalPtyConfig) -> Self {
145        self.props.config = config;
146        self
147    }
148
149    /// Set the scrollback buffer size in lines.
150    pub fn scrollback(mut self, lines: usize) -> Self {
151        self.props.scrollback = lines;
152        self
153    }
154
155    /// Set the initial terminal dimensions.
156    pub fn initial_size(mut self, cols: u16, rows: u16) -> Self {
157        self.props.initial_cols = cols.max(1);
158        self.props.initial_rows = rows.max(1);
159        self
160    }
161
162    /// Set callback for status changes.
163    pub fn on_status(mut self, callback: Callback<ManagedTerminalStatus>) -> Self {
164        self.props.on_status = Some(callback);
165        self
166    }
167
168    /// Set whether to auto-start the PTY on init.
169    /// Default: `true`.
170    pub fn auto_start(mut self, auto_start: bool) -> Self {
171        self.props.auto_start = auto_start;
172        self
173    }
174
175    /// Set placeholder text to show before PTY is ready.
176    pub fn placeholder(mut self, text: impl Into<Arc<str>>) -> Self {
177        self.props.placeholder = Some(text.into());
178        self
179    }
180
181    /// Set whether to forward mouse events to the PTY.
182    pub fn forward_mouse(mut self, forward: bool) -> Self {
183        self.props.forward_mouse = forward;
184        self
185    }
186
187    /// Set whether scroll wheel controls scrollback.
188    pub fn scroll_wheel(mut self, enabled: bool) -> Self {
189        self.props.scroll_wheel = enabled;
190        self
191    }
192
193    /// Set the window used to coalesce PTY and screen resizes.
194    ///
195    /// The first resize of a burst arms a single timer and the latest pending size
196    /// is applied when it fires, so a continuous drag keeps reflowing at a steady
197    /// cadence instead of stalling until the drag stops. A zero duration disables
198    /// coalescing and applies each resize immediately.
199    ///
200    /// Coalescing matters beyond saving `ioctl` calls: a column change forces the
201    /// screen to reflow, which drops every OSC 133 semantic mark, so an unthrottled
202    /// width drag destroys shell-integration history.
203    pub fn resize_debounce(mut self, delay: Duration) -> Self {
204        self.props.resize_debounce = delay;
205        self
206    }
207
208    /// Set the terminal content style.
209    pub fn style(mut self, style: crate::style::Style) -> Self {
210        self.props.style = style;
211        self
212    }
213
214    /// Set whether the terminal is focusable.
215    pub fn focusable(mut self, focusable: bool) -> Self {
216        self.props.focusable = focusable;
217        self
218    }
219
220    /// Set whether the terminal participates in Tab / Shift+Tab traversal.
221    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
222        self.props.tab_stop = tab_stop;
223        self
224    }
225
226    /// Set the callback fired when the terminal gains focus.
227    pub fn on_focus(mut self, callback: Callback<()>) -> Self {
228        self.props.on_focus = Some(callback);
229        self
230    }
231
232    /// Set the callback fired when the terminal loses focus.
233    pub fn on_blur(mut self, callback: Callback<()>) -> Self {
234        self.props.on_blur = Some(callback);
235        self
236    }
237
238    /// Set custom width.
239    pub fn width(mut self, width: Length) -> Self {
240        self.props.width = width;
241        self
242    }
243
244    /// Set custom height.
245    pub fn height(mut self, height: Length) -> Self {
246        self.props.height = height;
247        self
248    }
249}
250
251impl Default for ManagedTerminal {
252    fn default() -> Self {
253        Self::new()
254    }
255}
256
257impl From<ManagedTerminal> for Element {
258    fn from(terminal: ManagedTerminal) -> Self {
259        let props = terminal.props.clone();
260        crate::child(move || terminal.clone(), props)
261    }
262}
263
264// Internal messages for the component (exposed for Component trait implementation)
265#[derive(Clone)]
266pub enum ManagedTerminalMsg {
267    /// PTY is ready and connected
268    PtyReady(TerminalPty),
269    /// PTY event received (output, exited, error)
270    PtyEvent(TerminalPtyEvent),
271    /// Terminal input event from user
272    TerminalInput(TerminalInputEvent),
273    /// Mouse event bytes to forward to PTY
274    TerminalMouse(Vec<u8>),
275    /// Scroll to specific scrollback offset
276    TerminalScrollTo(usize),
277    /// Terminal resized
278    Resize { cols: u16, rows: u16 },
279    /// Apply the latest debounced terminal resize if this generation is current.
280    FlushResize { generation: u64 },
281    /// Start the PTY (manual mode only)
282    Start,
283}
284
285/// Internal state for the managed terminal component.
286pub struct ManagedTerminalState {
287    screen: TerminalScreen,
288    snapshot: TerminalRenderSnapshot,
289    pty: Option<TerminalPty>,
290    cols: u16,
291    rows: u16,
292    pending_resize: Option<(u16, u16)>,
293    resize_generation: u64,
294    #[cfg(test)]
295    resize_apply_count: usize,
296    status: ManagedTerminalStatus,
297}
298
299impl Component for ManagedTerminal {
300    type Message = ManagedTerminalMsg;
301    type Properties = ManagedTerminalProps;
302    type State = ManagedTerminalState;
303
304    fn create_state(&self, props: &Self::Properties) -> Self::State {
305        #[cfg_attr(not(feature = "terminal-images"), allow(unused_mut))]
306        let mut screen =
307            TerminalScreen::new(props.initial_rows, props.initial_cols, props.scrollback);
308        // Size images against the host's real cell, and tell the child the same thing through the
309        // PTY, so a picture the child sized for itself lands on the cells it reserved.
310        #[cfg(feature = "terminal-images")]
311        screen.set_cell_size(crate::host_cell_size());
312
313        ManagedTerminalState {
314            screen,
315            snapshot: TerminalRenderSnapshot::default(),
316            pty: None,
317            cols: props.initial_cols,
318            rows: props.initial_rows,
319            pending_resize: None,
320            resize_generation: 0,
321            #[cfg(test)]
322            resize_apply_count: 0,
323            status: ManagedTerminalStatus::Starting,
324        }
325    }
326
327    fn init(&mut self, ctx: &mut Context<Self>) -> Option<Command> {
328        // Emit initial status
329        if let Some(on_status) = &ctx.props.on_status {
330            on_status.emit(ManagedTerminalStatus::Starting);
331        }
332
333        if ctx.props.auto_start {
334            let config = ctx.props.config.clone();
335            Some(ctx.link().command(move |link| {
336                Self::spawn_pty(link, &config);
337            }))
338        } else {
339            None
340        }
341    }
342
343    fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
344        match msg {
345            ManagedTerminalMsg::PtyReady(pty) => {
346                // Resize PTY to match our current dimensions
347                let _ = pty.resize(ctx.state.cols, ctx.state.rows);
348                ctx.state.pty = Some(pty);
349                ctx.state.status = ManagedTerminalStatus::Ready;
350
351                if let Some(on_status) = &ctx.props.on_status {
352                    on_status.emit(ManagedTerminalStatus::Ready);
353                }
354                Update::full()
355            }
356            ManagedTerminalMsg::PtyEvent(event) => {
357                match event {
358                    TerminalPtyEvent::Output(bytes) => {
359                        ctx.state.screen.process_bytes(&bytes);
360                        // Forward any terminal responses (device queries, etc.) back to the PTY.
361                        // This is critical for TUI apps like fzf that query terminal capabilities.
362                        if let Some(pty) = &ctx.state.pty {
363                            for response in ctx.state.screen.drain_responses() {
364                                if let Err(err) = pty.write(&response) {
365                                    let msg = format!("pty response write failed: {err}");
366                                    ctx.state.status = ManagedTerminalStatus::Error(Arc::from(msg));
367                                    break;
368                                }
369                            }
370                        }
371                        ctx.state.snapshot = ctx.state.screen.render_snapshot();
372                    }
373                    TerminalPtyEvent::Exited(code) => {
374                        ctx.state.status = ManagedTerminalStatus::Exited(code);
375                        ctx.state.pty = None;
376
377                        if let Some(on_status) = &ctx.props.on_status {
378                            on_status.emit(ManagedTerminalStatus::Exited(code));
379                        }
380                    }
381                    TerminalPtyEvent::Error(message) => {
382                        ctx.state.status = ManagedTerminalStatus::Error(message.clone());
383
384                        if let Some(on_status) = &ctx.props.on_status {
385                            on_status.emit(ManagedTerminalStatus::Error(message));
386                        }
387                    }
388                }
389                Update::full()
390            }
391            ManagedTerminalMsg::TerminalInput(input) => {
392                if let Some(pty) = &ctx.state.pty {
393                    if let Err(err) = pty.write(&input.bytes) {
394                        let msg = format!("stdin write failed: {err}");
395                        ctx.state.status = ManagedTerminalStatus::Error(Arc::from(msg));
396                    }
397                    // Snap to live view when user types
398                    if ctx.state.screen.scrollback_offset() > 0 {
399                        ctx.state.screen.set_scrollback(0);
400                        ctx.state.snapshot = ctx.state.screen.render_snapshot();
401                        return Update::full();
402                    }
403                }
404                Update::none()
405            }
406            ManagedTerminalMsg::TerminalMouse(bytes) => {
407                if let Some(pty) = &ctx.state.pty
408                    && let Err(err) = pty.write(&bytes)
409                {
410                    let msg = format!("mouse write failed: {err}");
411                    ctx.state.status = ManagedTerminalStatus::Error(Arc::from(msg));
412                }
413                Update::none()
414            }
415            ManagedTerminalMsg::TerminalScrollTo(offset) => {
416                ctx.state.screen.set_scrollback(offset);
417                ctx.state.snapshot = ctx.state.screen.render_snapshot();
418                Update::full()
419            }
420            ManagedTerminalMsg::Resize { cols, rows } => {
421                let dimensions = (cols.max(1), rows.max(1));
422                if ctx.props.resize_debounce.is_zero() {
423                    ctx.state.pending_resize = None;
424                    ctx.state.resize_generation =
425                        ctx.state.resize_generation.wrapping_add(1).max(1);
426                    return Self::apply_resize(ctx, dimensions.0, dimensions.1);
427                }
428
429                let armed = ctx.state.pending_resize.is_some();
430                if !armed && dimensions == (ctx.state.cols, ctx.state.rows) {
431                    return Update::none();
432                }
433
434                ctx.state.pending_resize = Some(dimensions);
435                // Only the first resize of a burst arms a timer. Re-arming on every
436                // event would restart the window each frame of a drag, so the flush
437                // would never fire until the drag paused.
438                if armed {
439                    return Update::none();
440                }
441
442                ctx.state.resize_generation = ctx.state.resize_generation.wrapping_add(1).max(1);
443                let generation = ctx.state.resize_generation;
444                Update::command_only(Command::after(
445                    ctx.props.resize_debounce,
446                    move |link: CommandLink<ManagedTerminalMsg>| {
447                        link.send(ManagedTerminalMsg::FlushResize { generation });
448                    },
449                ))
450            }
451            ManagedTerminalMsg::FlushResize { generation } => {
452                if generation != ctx.state.resize_generation {
453                    return Update::none();
454                }
455                let Some((cols, rows)) = ctx.state.pending_resize.take() else {
456                    return Update::none();
457                };
458                Self::apply_resize(ctx, cols, rows)
459            }
460            ManagedTerminalMsg::Start => {
461                if ctx.state.pty.is_none() {
462                    let config = ctx.props.config.clone();
463                    return Update::with_command(ctx.link().command(move |link| {
464                        Self::spawn_pty(link, &config);
465                    }));
466                }
467                Update::none()
468            }
469        }
470    }
471
472    fn view(&self, ctx: &Context<Self>) -> Element {
473        // If no PTY is ready yet, show placeholder
474        if ctx.state.pty.is_none() && ctx.props.placeholder.is_some() {
475            let placeholder = ctx
476                .props
477                .placeholder
478                .clone()
479                .expect("placeholder.is_some() checked in enclosing if condition");
480            return VStack::new()
481                .width(ctx.props.width)
482                .height(ctx.props.height)
483                .child(Text::new(placeholder))
484                .into();
485        }
486
487        let mut terminal = Terminal::new()
488            .snapshot(ctx.state.snapshot.clone())
489            .style(ctx.props.style)
490            .focusable(ctx.props.focusable)
491            .tab_stop(ctx.props.tab_stop)
492            .width(ctx.props.width)
493            .height(ctx.props.height)
494            .scroll_wheel(ctx.props.scroll_wheel)
495            .on_input(ctx.link().callback(ManagedTerminalMsg::TerminalInput))
496            .on_resize(ctx.link().callback(|viewport: TerminalViewport| {
497                ManagedTerminalMsg::Resize {
498                    cols: viewport.cols,
499                    rows: viewport.rows,
500                }
501            }))
502            .on_scroll_to(ctx.link().callback(ManagedTerminalMsg::TerminalScrollTo));
503
504        if let Some(on_focus) = ctx.props.on_focus.clone() {
505            terminal = terminal.on_focus(on_focus);
506        }
507        if let Some(on_blur) = ctx.props.on_blur.clone() {
508            terminal = terminal.on_blur(on_blur);
509        }
510
511        if ctx.props.forward_mouse {
512            terminal =
513                terminal.on_mouse_forward(ctx.link().callback(ManagedTerminalMsg::TerminalMouse));
514        }
515
516        terminal.into()
517    }
518}
519
520impl ManagedTerminal {
521    fn apply_resize(ctx: &mut Context<Self>, cols: u16, rows: u16) -> Update {
522        if cols == ctx.state.cols && rows == ctx.state.rows {
523            return Update::none();
524        }
525
526        ctx.state.cols = cols;
527        ctx.state.rows = rows;
528        #[cfg(test)]
529        {
530            ctx.state.resize_apply_count += 1;
531        }
532
533        // Resize PTY first so the child process learns the new dimensions.
534        if let Some(pty) = &ctx.state.pty
535            && let Err(err) = pty.resize(cols, rows)
536        {
537            let msg = format!("pty resize failed: {err}");
538            ctx.state.status = ManagedTerminalStatus::Error(Arc::from(msg));
539            return Update::full();
540        }
541
542        ctx.state.screen.resize(rows, cols);
543        ctx.state.snapshot = ctx.state.screen.render_snapshot();
544        Update::full()
545    }
546}
547
548impl ManagedTerminal {
549    /// Spawn the PTY and set up event handling.
550    fn spawn_pty(link: CommandLink<ManagedTerminalMsg>, config: &TerminalPtyConfig) {
551        #[cfg_attr(not(feature = "terminal-images"), allow(unused_mut))]
552        let mut config = config.clone();
553        #[cfg(feature = "terminal-images")]
554        {
555            config = config.cell_size(crate::host_cell_size());
556        }
557        let event_link = link.clone();
558
559        match TerminalPty::spawn(config, move |event| {
560            event_link.send(ManagedTerminalMsg::PtyEvent(event));
561        }) {
562            Ok(pty) => link.send(ManagedTerminalMsg::PtyReady(pty)),
563            Err(err) => link.send(ManagedTerminalMsg::PtyEvent(TerminalPtyEvent::Error(
564                err.to_string().into(),
565            ))),
566        }
567    }
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573
574    #[test]
575    fn managed_terminal_props_default() {
576        let props = ManagedTerminalProps::default();
577        assert_eq!(props.scrollback, 2000);
578        assert_eq!(props.initial_cols, 120);
579        assert_eq!(props.initial_rows, 24);
580        assert!(props.auto_start);
581        assert!(props.forward_mouse);
582        assert!(props.scroll_wheel);
583        assert_eq!(props.resize_debounce, Duration::from_millis(16));
584        assert!(props.focusable);
585    }
586
587    #[test]
588    fn managed_terminal_builder() {
589        let terminal = ManagedTerminal::new()
590            .scrollback(5000)
591            .initial_size(80, 30)
592            .auto_start(false)
593            .forward_mouse(false)
594            .resize_debounce(Duration::ZERO);
595
596        assert_eq!(terminal.props.scrollback, 5000);
597        assert_eq!(terminal.props.initial_cols, 80);
598        assert_eq!(terminal.props.initial_rows, 30);
599        assert!(!terminal.props.auto_start);
600        assert!(!terminal.props.forward_mouse);
601        assert_eq!(terminal.props.resize_debounce, Duration::ZERO);
602    }
603
604    #[test]
605    fn rapid_resize_burst_avoids_intermediate_mark_wipes_and_applies_once() {
606        let props = ManagedTerminalProps {
607            auto_start: false,
608            resize_debounce: Duration::from_millis(32),
609            ..ManagedTerminalProps::default()
610        };
611        let mut backend =
612            crate::test_backend::TestBackend::new_with_props(ManagedTerminal::new(), props);
613        backend
614            .state_mut()
615            .screen
616            .process_bytes(b"\x1b]133;C\x1b\\output\r\n");
617        let marks = backend.state().screen.semantic_marks();
618        assert!(!marks.is_empty());
619
620        backend
621            .dispatch(ManagedTerminalMsg::Resize { cols: 10, rows: 24 })
622            .unwrap();
623        backend
624            .dispatch(ManagedTerminalMsg::Resize {
625                cols: 110,
626                rows: 24,
627            })
628            .unwrap();
629
630        // The burst arms exactly one timer: re-arming per event would restart the
631        // window every frame of a drag, so the flush would never fire until it ended.
632        let latest_generation = backend.state().resize_generation;
633        assert_eq!(latest_generation, 1);
634        backend
635            .dispatch(ManagedTerminalMsg::FlushResize {
636                generation: latest_generation.saturating_add(1),
637            })
638            .unwrap();
639
640        // Neither intermediate width was applied, so the semantic marks remain anchored.
641        assert_eq!(backend.state().cols, 120);
642        assert_eq!(backend.state().resize_apply_count, 0);
643        assert_eq!(backend.state().screen.semantic_marks(), marks);
644
645        std::thread::sleep(Duration::from_millis(64));
646        backend.pump().unwrap();
647
648        // Only the final, different width reaches the resize path.
649        assert_eq!(backend.state().cols, 110);
650        assert_eq!(backend.state().resize_apply_count, 1);
651        // A settled width reflow still invalidates absolute semantic indices;
652        // debounce prevents every transient width from doing this repeatedly.
653        assert!(backend.state().screen.semantic_marks().is_empty());
654    }
655
656    #[test]
657    fn a_resize_after_a_flush_arms_a_fresh_window() {
658        let props = ManagedTerminalProps {
659            auto_start: false,
660            resize_debounce: Duration::from_millis(16),
661            ..ManagedTerminalProps::default()
662        };
663        let mut backend =
664            crate::test_backend::TestBackend::new_with_props(ManagedTerminal::new(), props);
665
666        backend
667            .dispatch(ManagedTerminalMsg::Resize { cols: 90, rows: 24 })
668            .unwrap();
669        std::thread::sleep(Duration::from_millis(48));
670        backend.pump().unwrap();
671        assert_eq!(backend.state().cols, 90);
672        assert_eq!(backend.state().resize_apply_count, 1);
673
674        // A continuous drag keeps reflowing: the next burst is not swallowed by the
675        // generation guard left behind by the previous one.
676        backend
677            .dispatch(ManagedTerminalMsg::Resize { cols: 70, rows: 24 })
678            .unwrap();
679        std::thread::sleep(Duration::from_millis(48));
680        backend.pump().unwrap();
681        assert_eq!(backend.state().cols, 70);
682        assert_eq!(backend.state().resize_apply_count, 2);
683    }
684}