1use 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#[derive(Clone)]
45pub struct ManagedTerminal {
46 props: ManagedTerminalProps,
47}
48
49#[derive(Clone, PartialEq)]
51pub struct ManagedTerminalProps {
52 pub config: TerminalPtyConfig,
54 pub scrollback: usize,
57 pub initial_cols: u16,
60 pub initial_rows: u16,
63 pub on_status: Option<Callback<ManagedTerminalStatus>>,
65 pub auto_start: bool,
68 pub placeholder: Option<Arc<str>>,
70 pub forward_mouse: bool,
73 pub scroll_wheel: bool,
76 pub resize_debounce: Duration,
80 pub style: crate::style::Style,
82 pub focusable: bool,
84 pub tab_stop: bool,
86 pub on_focus: Option<Callback<()>>,
88 pub on_blur: Option<Callback<()>>,
90 pub width: Length,
93 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#[derive(Clone, Debug, PartialEq, Eq)]
124pub enum ManagedTerminalStatus {
125 Starting,
127 Ready,
129 Exited(i32),
131 Error(Arc<str>),
133}
134
135impl ManagedTerminal {
136 pub fn new() -> Self {
138 Self {
139 props: ManagedTerminalProps::default(),
140 }
141 }
142
143 pub fn config(mut self, config: TerminalPtyConfig) -> Self {
145 self.props.config = config;
146 self
147 }
148
149 pub fn scrollback(mut self, lines: usize) -> Self {
151 self.props.scrollback = lines;
152 self
153 }
154
155 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 pub fn on_status(mut self, callback: Callback<ManagedTerminalStatus>) -> Self {
164 self.props.on_status = Some(callback);
165 self
166 }
167
168 pub fn auto_start(mut self, auto_start: bool) -> Self {
171 self.props.auto_start = auto_start;
172 self
173 }
174
175 pub fn placeholder(mut self, text: impl Into<Arc<str>>) -> Self {
177 self.props.placeholder = Some(text.into());
178 self
179 }
180
181 pub fn forward_mouse(mut self, forward: bool) -> Self {
183 self.props.forward_mouse = forward;
184 self
185 }
186
187 pub fn scroll_wheel(mut self, enabled: bool) -> Self {
189 self.props.scroll_wheel = enabled;
190 self
191 }
192
193 pub fn resize_debounce(mut self, delay: Duration) -> Self {
204 self.props.resize_debounce = delay;
205 self
206 }
207
208 pub fn style(mut self, style: crate::style::Style) -> Self {
210 self.props.style = style;
211 self
212 }
213
214 pub fn focusable(mut self, focusable: bool) -> Self {
216 self.props.focusable = focusable;
217 self
218 }
219
220 pub fn tab_stop(mut self, tab_stop: bool) -> Self {
222 self.props.tab_stop = tab_stop;
223 self
224 }
225
226 pub fn on_focus(mut self, callback: Callback<()>) -> Self {
228 self.props.on_focus = Some(callback);
229 self
230 }
231
232 pub fn on_blur(mut self, callback: Callback<()>) -> Self {
234 self.props.on_blur = Some(callback);
235 self
236 }
237
238 pub fn width(mut self, width: Length) -> Self {
240 self.props.width = width;
241 self
242 }
243
244 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#[derive(Clone)]
266pub enum ManagedTerminalMsg {
267 PtyReady(TerminalPty),
269 PtyEvent(TerminalPtyEvent),
271 TerminalInput(TerminalInputEvent),
273 TerminalMouse(Vec<u8>),
275 TerminalScrollTo(usize),
277 Resize { cols: u16, rows: u16 },
279 FlushResize { generation: u64 },
281 Start,
283}
284
285pub 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 #[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 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 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 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 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 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 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 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 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 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 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 assert_eq!(backend.state().cols, 110);
650 assert_eq!(backend.state().resize_apply_count, 1);
651 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 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}