Skip to main content

rusty_bubbletea/
program.rs

1//! Cleanroom Rust port of upstream Go source file: `tea.go` (Program runner)
2//! Upstream Target Tag / Version: `v2.0.8`
3//!
4//! <upstream-docs>
5//! Package tea provides a framework for building rich terminal user interfaces
6//! based on the paradigms of The Elm Architecture. It's well-suited for simple
7//! and complex terminal applications, either inline, full-window, or a mix of
8//! both. It's been battle-tested in several large projects and is
9//! production-ready.
10//!
11//! A tutorial is available at https://github.com/charmbracelet/bubbletea/tree/master/tutorials
12//!
13//! Example programs can be found at https://github.com/charmbracelet/bubbletea/tree/master/examples
14//! </upstream-docs>
15
16use std::fmt;
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::sync::mpsc::{channel, Receiver, Sender};
19
20/// Message channel used to feed the program's event loop.
21type MsgChannel = Sender<Box<dyn Msg>>;
22/// Message channel used to receive messages into the program's event loop.
23type MsgReceiver = Receiver<Box<dyn Msg>>;
24use std::sync::{Arc, Mutex};
25use std::thread;
26use std::time::Duration;
27
28use crate::commands::{
29    BatchMsg, InterruptMsg, QuitMsg, RequestWindowSizeMsg, ResumeMsg, SequenceMsg, SuspendMsg,
30};
31use crate::cursor::CursorPositionMsg;
32use crate::environ::EnvMsg;
33use crate::exec::ExecMsg;
34use crate::focus::{BlurMsg, FocusMsg};
35use crate::key::{KeyMod, KeyPressMsg, KeyReleaseMsg};
36use crate::keyboard::KeyboardEnhancementsMsg;
37use crate::model::{Model, Msg};
38use crate::mouse::{MouseClickMsg, MouseMotionMsg, MouseReleaseMsg, MouseWheelMsg};
39use crate::options::ProgramOptions;
40use crate::paste::PasteMsg;
41use crate::profile::ColorProfileMsg;
42use crate::renderer::{PrintLineMsg, Renderer};
43use crate::screen::{ClearScreenMsg, WindowSizeMsg};
44use crate::tty::{disable_raw_mode, enable_raw_mode};
45use crossterm::terminal::size as term_size;
46
47/// <upstream-comment>ErrProgramPanic is returned by [Program.Run] when the program recovers from a panic.</upstream-comment>
48pub const ERR_PROGRAM_PANIC: &str = "program experienced a panic";
49
50/// <upstream-comment>ErrProgramKilled is returned by [Program.Run] when the program gets killed.</upstream-comment>
51pub const ERR_PROGRAM_KILLED: &str = "program was killed";
52
53/// <upstream-comment>ErrInterrupted is returned by [Program.Run] when the program get a SIGINT
54/// signal, or when it receives a [InterruptMsg].</upstream-comment>
55pub const ERR_INTERRUPTED: &str = "program was interrupted";
56
57/// ProgramError is the error type returned by [Program::run].
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum ProgramError {
60    /// The program was killed (context cancellation or external kill).
61    Killed,
62    /// The program was interrupted (SIGINT or [InterruptMsg]).
63    Interrupted,
64    /// The program recovered from a panic.
65    Panic,
66}
67
68impl fmt::Display for ProgramError {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        match self {
71            ProgramError::Killed => write!(f, "{}", ERR_PROGRAM_KILLED),
72            ProgramError::Interrupted => write!(f, "{}", ERR_INTERRUPTED),
73            ProgramError::Panic => write!(f, "{}", ERR_PROGRAM_PANIC),
74        }
75    }
76}
77
78impl std::error::Error for ProgramError {}
79
80/// Program is the runner for a Bubble Tea v2.0.8 application.
81pub struct Program<M: Model> {
82    /// Buffered startup query sequences, flushed with the first render
83    /// (mirrors upstream `p.outputBuf` + `p.execute`).
84    startup_buf: Arc<Mutex<Option<Vec<u8>>>>,
85    model: M,
86    options: ProgramOptions<M>,
87    renderer: Arc<Mutex<Box<dyn Renderer>>>,
88    msg_tx: Option<MsgChannel>,
89    finished: Arc<AtomicBool>,
90}
91
92impl<M: Model> Program<M> {
93    /// Creates a new Program for the given model with default options.
94    pub fn new(model: M) -> Self {
95        let (w, h) = term_size().unwrap_or((80, 24));
96        let env: Vec<String> = std::env::vars().map(|(k, v)| format!("{k}={v}")).collect();
97        Self {
98            startup_buf: Arc::new(Mutex::new(None)),
99            model,
100            options: ProgramOptions::default(),
101            renderer: Arc::new(Mutex::new(Box::new(
102                crate::cursed_renderer::new_cursed_renderer(
103                    Box::new(std::io::stdout()),
104                    &env,
105                    w as usize,
106                    h as usize,
107                ),
108            ))),
109            msg_tx: None,
110            finished: Arc::new(AtomicBool::new(false)),
111        }
112    }
113
114    /// Sets custom program options.
115    pub fn with_options(mut self, options: ProgramOptions<M>) -> Self {
116        self.options = options;
117        self
118    }
119
120    /// <upstream-comment>Send sends a message to the main update function, effectively allowing
121    /// messages to be injected from outside the program for interoperability
122    /// purposes.</upstream-comment>
123    pub fn send(&self, msg: Box<dyn Msg>) {
124        if let Some(tx) = &self.msg_tx {
125            let _ = tx.send(msg);
126        }
127    }
128
129    /// <upstream-comment>Quit is a convenience function for quitting Bubble Tea programs. Use it
130    /// when you need to shut down a Bubble Tea program from the outside.</upstream-comment>
131    pub fn quit(&self) {
132        self.send(Box::new(QuitMsg));
133    }
134
135    /// <upstream-comment>Kill stops the program immediately and restores the former terminal state.
136    /// The final render that you would normally see when quitting will be skipped.
137    /// [Program.Run] returns a [ErrProgramKilled] error.</upstream-comment>
138    pub fn kill(&mut self) {
139        // Disable raw mode and mark the program finished; the run loop observes
140        // the finished flag and exits.
141        let _ = disable_raw_mode();
142        self.finished.store(true, Ordering::SeqCst);
143        if let Some(tx) = &self.msg_tx {
144            let _ = tx.send(Box::new(QuitMsg));
145        }
146    }
147
148    /// <upstream-comment>Wait waits/blocks until the underlying Program finished shutting down.</upstream-comment>
149    pub fn wait(&self) {
150        while !self.finished.load(Ordering::SeqCst) {
151            thread::sleep(Duration::from_millis(10));
152        }
153    }
154
155    /// <upstream-comment>Println prints above the Program. This output is unmanaged by the program
156    /// and will persist across renders by the Program.</upstream-comment>
157    pub fn println(&self, args: &str) {
158        self.send(Box::new(PrintLineMsg {
159            message_body: args.to_string(),
160        }));
161    }
162
163    /// <upstream-comment>Printf prints above the Program. It takes a format template followed by
164    /// values similar to fmt.Printf.</upstream-comment>
165    pub fn printf(&self, body: &str) {
166        self.send(Box::new(PrintLineMsg {
167            message_body: body.to_string(),
168        }));
169    }
170
171    /// Helper to process a message, execute terminal commands, and dispatch generated commands.
172    fn handle_msg(&mut self, msg: Box<dyn Msg>, tx: &MsgChannel) -> Result<bool, ProgramError> {
173        let processed_msg = if let Some(ref filter) = self.options.filter {
174            match filter(&self.model, msg) {
175                Some(m) => m,
176                None => return Ok(false),
177            }
178        } else {
179            msg
180        };
181
182        if processed_msg.as_ref().as_any().is::<QuitMsg>() {
183            return Ok(true);
184        }
185
186        if processed_msg.as_ref().as_any().is::<InterruptMsg>() {
187            return Err(ProgramError::Interrupted);
188        }
189
190        if processed_msg.as_ref().as_any().is::<SuspendMsg>() {
191            // Best-effort suspension: restore the terminal until a resume
192            // message arrives; the program continues afterwards.
193            let _ = disable_raw_mode();
194            self.send_resume_later(tx);
195        }
196
197        if processed_msg.as_ref().as_any().is::<ClearScreenMsg>() {
198            self.renderer.lock().unwrap().clear_screen();
199        } else if processed_msg
200            .as_ref()
201            .as_any()
202            .is::<crate::color::RequestBackgroundColorMsg>()
203        {
204            // Mirror upstream `p.execute(ansi.RequestBackgroundColor)`: the
205            // query is buffered and flushed with the first render.
206            if let Ok(mut buf) = self.startup_buf.lock() {
207                if let Some(b) = buf.as_mut() {
208                    b.extend_from_slice(
209                        rusty_x_ansi::background::REQUEST_BACKGROUND_COLOR.as_bytes(),
210                    );
211                }
212            }
213        } else if processed_msg
214            .as_ref()
215            .as_any()
216            .is::<crate::color::RequestForegroundColorMsg>()
217        {
218            if let Ok(mut buf) = self.startup_buf.lock() {
219                if let Some(b) = buf.as_mut() {
220                    b.extend_from_slice(
221                        rusty_x_ansi::background::REQUEST_FOREGROUND_COLOR.as_bytes(),
222                    );
223                }
224            }
225        } else if processed_msg
226            .as_ref()
227            .as_any()
228            .is::<crate::color::RequestCursorColorMsg>()
229        {
230            if let Ok(mut buf) = self.startup_buf.lock() {
231                if let Some(b) = buf.as_mut() {
232                    b.extend_from_slice(rusty_x_ansi::background::REQUEST_CURSOR_COLOR.as_bytes());
233                }
234            }
235        } else if let Some(cap) = processed_msg
236            .as_ref()
237            .as_any()
238            .downcast_ref::<crate::termcap::RequestCapabilityMsg>()
239        {
240            // Mirror upstream `p.execute(ansi.RequestTermcap(cap))`: write the
241            // XTGETTCAP query (DCS + q <Pt> ST) to the terminal so the terminal
242            // responds with a CapabilityMsg.
243            use std::io::Write as _;
244            let mut seq = String::from("\x1bP+q");
245            for b in cap.0.as_bytes() {
246                seq.push_str(&format!("{:02X}", b));
247            }
248            seq.push_str("\x1b\\");
249            let _ = std::io::stdout().write_all(seq.as_bytes());
250            return Ok(false);
251        } else if processed_msg
252            .as_ref()
253            .as_any()
254            .is::<crate::xterm::RequestTerminalVersionMsg>()
255        {
256            // Mirror upstream `p.execute(ansi.RequestNameVersion)`: query the
257            // terminal name and version (XTVERSION) so the terminal responds
258            // with a TerminalVersionMsg.
259            use std::io::Write as _;
260            let _ = std::io::stdout().write_all(b"\x1b[>q");
261            return Ok(false);
262        } else if processed_msg.as_ref().as_any().is::<RequestWindowSizeMsg>() {
263            if let Ok((w, h)) = term_size() {
264                let _ = tx.send(Box::new(WindowSizeMsg {
265                    width: w as usize,
266                    height: h as usize,
267                }));
268            }
269            // RequestWindowSizeMsg itself is internal — don't pass to model.update
270            return Ok(false);
271        } else if let Some(ws) = processed_msg
272            .as_ref()
273            .as_any()
274            .downcast_ref::<WindowSizeMsg>()
275        {
276            // Resize the renderer first, then fall through to model.update below
277            self.renderer.lock().unwrap().resize(ws.width, ws.height);
278        } else if let Some(exec_msg) = processed_msg.as_ref().as_any().downcast_ref::<ExecMsg>() {
279            let _ = disable_raw_mode();
280            let mut cmd = std::process::Command::new(&exec_msg.cmd);
281            cmd.args(&exec_msg.args);
282            let _ = cmd.status();
283            let _ = enable_raw_mode();
284        } else if let Some(print_msg) = processed_msg
285            .as_ref()
286            .as_any()
287            .downcast_ref::<PrintLineMsg>()
288        {
289            // Insert the line above the TUI without routing through model.update
290            let _ = self
291                .renderer
292                .lock()
293                .unwrap()
294                .insert_above(print_msg.message_body.clone());
295            // Re-render to flush queued lines
296            let view = self.model.view();
297            self.renderer.lock().unwrap().render(view);
298            return Ok(false);
299        } else if let Some(env) = processed_msg.as_ref().as_any().downcast_ref::<EnvMsg>() {
300            let _ = env;
301        } else if let Some(profile) = processed_msg
302            .as_ref()
303            .as_any()
304            .downcast_ref::<ColorProfileMsg>()
305        {
306            let p = match profile.profile {
307                crate::profile::ColorProfile::TrueColor => rusty_colorprofile::Profile::TrueColor,
308                crate::profile::ColorProfile::ANSI256 => rusty_colorprofile::Profile::Ansi256,
309                crate::profile::ColorProfile::ANSI => rusty_colorprofile::Profile::Ansi,
310                crate::profile::ColorProfile::Ascii => rusty_colorprofile::Profile::Ascii,
311            };
312            self.renderer.lock().unwrap().set_color_profile(p);
313        } else if let Some(_resume) = processed_msg.as_ref().as_any().downcast_ref::<ResumeMsg>() {
314            let _ = enable_raw_mode();
315        }
316
317        // Dispatch the commands carried by BatchMsg and SequenceMsg,
318        // mirroring the upstream handling of `tea.Batch` and `tea.Sequence`
319        // messages (`case BatchMsg: go p.execBatchMsg(msg); continue` and
320        // `case sequenceMsg: go p.execSequenceMsg(msg); continue`): the
321        // command trees are expanded on their own thread, recursively, so a
322        // QuitMsg produced by a sequence is only delivered after every
323        // preceding command (including nested batches and sequences) has
324        // completed.
325        if processed_msg.as_ref().as_any().is::<BatchMsg>() {
326            let any = processed_msg.into_any();
327            let batch = *any.downcast::<BatchMsg>().unwrap();
328            let tx_clone = tx.clone();
329            thread::spawn(move || exec_batch_msg(batch, &tx_clone));
330            return Ok(false);
331        }
332
333        // Mirror upstream `case MouseMsg:` in the event loop: route mouse
334        // messages to the renderer's on_mouse hook (used by composable view
335        // layers) and send any produced message back through the program.
336        // The message still falls through to the model's update below.
337        let mouse_msg = {
338            let any = processed_msg.as_ref().as_any();
339            if let Some(m) = any.downcast_ref::<crate::mouse::MouseClickMsg>() {
340                Some(crate::mouse::MouseMsg::Click(m.clone()))
341            } else if let Some(m) = any.downcast_ref::<crate::mouse::MouseMotionMsg>() {
342                Some(crate::mouse::MouseMsg::Motion(m.clone()))
343            } else if let Some(m) = any.downcast_ref::<crate::mouse::MouseReleaseMsg>() {
344                Some(crate::mouse::MouseMsg::Release(m.clone()))
345            } else {
346                any.downcast_ref::<crate::mouse::MouseWheelMsg>()
347                    .map(|m| crate::mouse::MouseMsg::Wheel(m.clone()))
348            }
349        };
350        if let Some(mouse_msg) = mouse_msg {
351            let cmd = self.renderer.lock().unwrap().on_mouse(mouse_msg);
352            if let Some(c) = cmd {
353                let tx_clone = tx.clone();
354                thread::spawn(move || {
355                    if let Some(new_msg) = c() {
356                        let _ = tx_clone.send(new_msg);
357                    }
358                });
359            }
360        }
361
362        if processed_msg.as_ref().as_any().is::<SequenceMsg>() {
363            let any = processed_msg.into_any();
364            let seq = *any.downcast::<SequenceMsg>().unwrap();
365            let tx_clone = tx.clone();
366            thread::spawn(move || exec_sequence_msg(seq, &tx_clone));
367            return Ok(false);
368        }
369
370        let cmd = self.model.update(&*processed_msg);
371        let view = self.model.view();
372        self.renderer.lock().unwrap().render(view);
373
374        if let Some(c) = cmd {
375            let tx_clone = tx.clone();
376            thread::spawn(move || {
377                if let Some(new_msg) = c() {
378                    let _ = tx_clone.send(new_msg);
379                }
380            });
381        }
382        Ok(false)
383    }
384
385    fn send_resume_later(&self, tx: &MsgChannel) {
386        let tx = tx.clone();
387        thread::spawn(move || {
388            let _ = tx.send(Box::new(ResumeMsg));
389        });
390    }
391
392    /// Runs the Bubble Tea v2.0.8 event loop until quit.
393    pub fn run(mut self) -> Result<M, Box<dyn std::error::Error>> {
394        let _ = enable_raw_mode();
395        self.renderer.lock().unwrap().start();
396
397        // Termios-based cursor movement optimizations, mirroring the
398        // upstream `initInput` -> `checkOptimizedMovements` flow.
399        // Detect the color profile from the environment and set it on the
400        // renderer (upstream `colorprofile.Detect` at startup); the
401        // ColorProfileMsg path may later upgrade it.
402        {
403            use std::os::fd::AsRawFd as _;
404            let env = rusty_ultraviolet::Environ(
405                std::env::vars().map(|(k, v)| format!("{k}={v}")).collect(),
406            );
407            let profile = rusty_ultraviolet::terminal_screen::detect_color_profile(
408                Some(std::io::stdout().as_raw_fd()),
409                &env,
410            );
411            self.renderer
412                .lock()
413                .unwrap()
414                .set_color_profile(match profile {
415                    rusty_ultraviolet::terminal_screen::ColorProfile::TrueColor => {
416                        rusty_colorprofile::Profile::TrueColor
417                    }
418                    rusty_ultraviolet::terminal_screen::ColorProfile::Ansi256 => {
419                        rusty_colorprofile::Profile::Ansi256
420                    }
421                    rusty_ultraviolet::terminal_screen::ColorProfile::Ansi => {
422                        rusty_colorprofile::Profile::Ansi
423                    }
424                    _ => rusty_colorprofile::Profile::NoTty,
425                });
426        }
427
428        let (hard_tabs, backspace) = check_optimized_movements();
429        // mapNl is false when the input is a real TTY (upstream:
430        // `runtime.GOOS != "windows" && p.ttyInput == nil`).
431        let map_nl = false;
432        self.renderer
433            .lock()
434            .unwrap()
435            .set_optimizations(hard_tabs, backspace, map_nl);
436
437        let (tx, rx): (MsgChannel, MsgReceiver) = channel();
438        self.msg_tx = Some(tx.clone());
439
440        let external_ctx = self.options.context.clone();
441
442        // Input thread: reads raw bytes from stdin and decodes them through
443        // the ultraviolet event decoder, mirroring the upstream
444        // `uv.NewTerminalReader` input path.
445        let input_tx = tx.clone();
446        thread::spawn(move || {
447            let reader: Box<dyn std::io::Read + Send> = Box::new(std::io::stdin());
448            let mut tr =
449                rusty_ultraviolet::terminal_reader::new_terminal_reader(reader, "xterm-256color");
450            tr.set_legacy(rusty_ultraviolet::LegacyKeyEncoding::default());
451            let (dec_tx, dec_rx) = std::sync::mpsc::channel::<rusty_ultraviolet::DecodedEvent>();
452            let streamer = std::thread::spawn(move || {
453                let _ = tr.stream_events(&dec_tx);
454            });
455            for ev in dec_rx {
456                if let Some(msg) = decoded_to_msg(ev) {
457                    if input_tx.send(msg).is_err() {
458                        break;
459                    }
460                }
461            }
462            let _ = streamer.join();
463        });
464
465        // Run initial command
466        if let Some(cmd) = self.model.init() {
467            let tx_clone = tx.clone();
468            thread::spawn(move || {
469                if let Some(msg) = cmd() {
470                    let _ = tx_clone.send(msg);
471                }
472            });
473        }
474
475        // Send initial window size query
476        if let Ok((w, h)) = term_size() {
477            let _ = tx.send(Box::new(WindowSizeMsg {
478                width: w as usize,
479                height: h as usize,
480            }));
481        }
482
483        // Send the environment variables used by the program.
484        let _ = tx.send(Box::new(EnvMsg::from_std()));
485
486        // Send the detected color profile to the program, mirroring the
487        // upstream `go p.Send(ColorProfileMsg{*p.profile})` at startup.
488        {
489            use std::os::fd::AsRawFd as _;
490            let env = rusty_ultraviolet::Environ(
491                std::env::vars().map(|(k, v)| format!("{k}={v}")).collect(),
492            );
493            let profile = rusty_ultraviolet::terminal_screen::detect_color_profile(
494                Some(std::io::stdout().as_raw_fd()),
495                &env,
496            );
497            let msg_profile = match profile {
498                rusty_ultraviolet::terminal_screen::ColorProfile::TrueColor => {
499                    crate::profile::ColorProfile::TrueColor
500                }
501                rusty_ultraviolet::terminal_screen::ColorProfile::Ansi256 => {
502                    crate::profile::ColorProfile::ANSI256
503                }
504                rusty_ultraviolet::terminal_screen::ColorProfile::Ansi => {
505                    crate::profile::ColorProfile::ANSI
506                }
507                _ => crate::profile::ColorProfile::Ascii,
508            };
509            let _ = tx.send(Box::new(crate::profile::ColorProfileMsg {
510                profile: msg_profile,
511            }));
512        }
513
514        // Query for synchronized updates support (mode 2026) and unicode core
515        // (mode 2027), mirroring the upstream `p.execute(...)` at startup:
516        // the queries are buffered and flushed together with the first
517        // render (ticker flush or the quit path's flush(true)).
518        let query_sync = should_query_synchronized_output();
519        self.startup_buf = Arc::new(Mutex::new(if query_sync {
520            Some(b"\x1b[?2026$p\x1b[?2027$p".to_vec())
521        } else {
522            None
523        }));
524        let startup_buf = self.startup_buf.clone();
525
526        // Render initial view frame. The frame is flushed by the render
527        // ticker (or the quit path's flush(true)), mirroring the upstream
528        // ticker-driven render loop.
529        let initial_view = self.model.view();
530        self.renderer.lock().unwrap().render(initial_view);
531
532        // Render ticker: flushes the pending view at the default framerate
533        // (60fps), like the upstream `startRenderer` goroutine.
534        let tick_renderer = self.renderer.clone();
535        let done = self.finished.clone();
536        let tick_buf = startup_buf.clone();
537        thread::spawn(move || {
538            let interval = Duration::from_millis(1000 / 60);
539            while !done.load(Ordering::SeqCst) {
540                thread::sleep(interval);
541                if let Some(buf) = tick_buf.lock().unwrap().take() {
542                    use std::io::Write as _;
543                    let _ = std::io::stdout().write_all(&buf);
544                }
545                let _ = tick_renderer.lock().unwrap().flush(false);
546            }
547        });
548
549        // Main event processing loop
550        let result = loop {
551            // Check for external context cancellation.
552            if let Some(ctx) = &external_ctx {
553                if ctx.done() {
554                    break Err(ProgramError::Killed);
555                }
556            }
557            match rx.recv_timeout(Duration::from_millis(50)) {
558                Ok(msg) => match self.handle_msg(msg, &tx) {
559                    Ok(true) => break Ok(()),
560                    Ok(false) => continue,
561                    Err(e) => break Err(e),
562                },
563                Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
564                Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break Ok(()),
565            }
566        };
567
568        self.finished.store(true, Ordering::SeqCst);
569        // Graceful shutdown: ensure we render the final state of the model
570        // (upstream `p.render(model)` after the event loop).
571        let final_view = self.model.view();
572        self.renderer.lock().unwrap().render(final_view);
573        // Flush the last frame with closing=true before closing, like the
574        // upstream `stopRenderer` path. Note: any startup queries still
575        // buffered are NOT written here — upstream flushes its output buffer
576        // only from the render ticker goroutine, so queries buffered but
577        // never flushed by the ticker are dropped (observed behavior).
578        let _fr = self.renderer.lock().unwrap().flush(true);
579        let _cr = self.renderer.lock().unwrap().close();
580        let _ = disable_raw_mode();
581
582        match result {
583            Ok(()) => Ok(self.model),
584            Err(e) => Err(Box::new(e)),
585        }
586    }
587}
588
589/// Execute the commands carried by a [BatchMsg], mirroring the upstream
590/// `execBatchMsg` handling (`tea.go`): every command runs concurrently on its
591/// own thread, and nested BatchMsg/SequenceMsg results are expanded inline
592/// (recursively) instead of being routed back through the event loop.
593/// Mirrors the upstream `wg.Wait()`: the batch is not complete until every
594/// command has finished, so a sequence containing this batch blocks until
595/// the whole tree completes.
596fn exec_batch_msg(batch: BatchMsg, tx: &MsgChannel) {
597    let handles: Vec<_> = batch
598        .0
599        .into_iter()
600        .flatten()
601        .map(|cmd| {
602            let tx = tx.clone();
603            thread::spawn(move || {
604                if let Some(msg) = cmd() {
605                    dispatch_msg(msg, &tx);
606                }
607            })
608        })
609        .collect();
610    for handle in handles {
611        let _ = handle.join();
612    }
613}
614
615/// Execute the commands carried by a [SequenceMsg], mirroring the upstream
616/// `execSequenceMsg` handling (`tea.go`): commands run one at a time in
617/// order, on the calling thread, and nested BatchMsg/SequenceMsg results are
618/// expanded inline (recursively) instead of being routed back through the
619/// event loop.
620fn exec_sequence_msg(seq: SequenceMsg, tx: &MsgChannel) {
621    for cmd in seq.0.into_iter().flatten() {
622        if let Some(msg) = cmd() {
623            dispatch_msg(msg, tx);
624        }
625    }
626}
627
628/// Dispatch a command result message: nested [BatchMsg]/[SequenceMsg]
629/// messages are expanded inline (recursively), while every other message is
630/// sent back through the event loop channel — mirroring upstream
631/// `execBatchMsg` / `execSequenceMsg` and `p.Send` for default messages.
632fn dispatch_msg(msg: Box<dyn Msg>, tx: &MsgChannel) {
633    if msg.as_ref().as_any().is::<BatchMsg>() {
634        let any = msg.into_any();
635        let batch = *any.downcast::<BatchMsg>().unwrap();
636        exec_batch_msg(batch, tx);
637    } else if msg.as_ref().as_any().is::<SequenceMsg>() {
638        let any = msg.into_any();
639        let seq = *any.downcast::<SequenceMsg>().unwrap();
640        exec_sequence_msg(seq, tx);
641    } else {
642        let _ = tx.send(msg);
643    }
644}
645
646/// ShouldQuerySynchronizedOutput returns whether the terminal is known to
647/// support synchronized output (mode 2026), mirroring the upstream gate in
648/// `tea.go`.
649fn should_query_synchronized_output() -> bool {
650    let term_type = std::env::var("TERM").unwrap_or_default();
651    let term_prog = std::env::var("TERM_PROGRAM").ok();
652    let ssh_tty = std::env::var("SSH_TTY").is_ok();
653    let wt_session = std::env::var("WT_SESSION").is_ok();
654
655    let ok_term_prog = term_prog.is_some();
656    wt_session
657        || term_type.contains("ghostty")
658        || term_type.contains("wezterm")
659        || (!ok_term_prog && !ssh_tty)
660        || (!ssh_tty && !term_prog.as_deref().unwrap_or("").contains("Apple"))
661        || term_type.contains("alacritty")
662        || term_type.contains("kitty")
663        || term_type.contains("rio")
664}
665
666/// Converts an ultraviolet [rusty_ultraviolet::DecodedEvent] into a
667/// Bubble Tea message.
668fn decoded_to_msg(ev: rusty_ultraviolet::DecodedEvent) -> Option<Box<dyn Msg>> {
669    use rusty_ultraviolet::DecodedEvent as D;
670    match ev {
671        D::KeyPress(k) => Some(Box::new(KeyPressMsg(uv_key_to_key(k)))),
672        D::KeyRelease(k) => Some(Box::new(KeyReleaseMsg(uv_key_to_key(k)))),
673        D::MouseClick(m) => Some(Box::new(MouseClickMsg(uv_mouse_to_mouse(m)))),
674        D::MouseRelease(m) => Some(Box::new(MouseReleaseMsg(uv_mouse_to_mouse(m)))),
675        D::MouseWheel(m) => Some(Box::new(MouseWheelMsg(uv_mouse_to_mouse(m)))),
676        D::MouseMotion(m) => Some(Box::new(MouseMotionMsg(uv_mouse_to_mouse(m)))),
677        D::WindowSize(s) => Some(Box::new(WindowSizeMsg {
678            width: s.width,
679            height: s.height,
680        })),
681        D::Paste(s) => Some(Box::new(PasteMsg { content: s })),
682        D::Focus => Some(Box::new(FocusMsg)),
683        D::Blur => Some(Box::new(BlurMsg)),
684        D::KeyboardEnhancements(flags) => Some(Box::new(KeyboardEnhancementsMsg { flags })),
685        D::CursorPosition { x, y } => Some(Box::new(CursorPositionMsg {
686            x: x.max(0) as usize,
687            y: y.max(0) as usize,
688        })),
689        // Terminal query responses, mirroring the upstream
690        // `uv.Event` -> `tea.Msg` translations (capability, name/version and
691        // color responses).
692        D::Capability(s) => Some(Box::new(crate::termcap::CapabilityMsg { content: s })),
693        D::TerminalVersion(s) => Some(Box::new(crate::xterm::TerminalVersionMsg { name: s })),
694        D::ForegroundColor(Some(c)) => Some(Box::new(crate::color::ForegroundColorMsg(c))),
695        D::BackgroundColor(Some(c)) => Some(Box::new(crate::color::BackgroundColorMsg(c))),
696        D::CursorColor(Some(c)) => Some(Box::new(crate::color::CursorColorMsg(c))),
697        _ => None,
698    }
699}
700
701/// Converts an ultraviolet key into the Bubble Tea key representation.
702fn uv_key_to_key(k: rusty_ultraviolet::Key) -> crate::key::Key {
703    use rusty_ultraviolet::key as uvk;
704    let code = match k.code {
705        uvk::KEY_UP => crate::key::KEY_UP,
706        uvk::KEY_DOWN => crate::key::KEY_DOWN,
707        uvk::KEY_LEFT => crate::key::KEY_LEFT,
708        uvk::KEY_RIGHT => crate::key::KEY_RIGHT,
709        uvk::KEY_PG_UP => crate::key::KEY_PG_UP,
710        uvk::KEY_PG_DOWN => crate::key::KEY_PG_DOWN,
711        uvk::KEY_HOME => crate::key::KEY_HOME,
712        uvk::KEY_END => crate::key::KEY_END,
713        uvk::KEY_ENTER => crate::key::KEY_ENTER,
714        uvk::KEY_TAB => crate::key::KEY_TAB,
715        uvk::KEY_BACKSPACE => crate::key::KEY_BACKSPACE,
716        uvk::KEY_ESCAPE => crate::key::KEY_ESCAPE,
717        uvk::KEY_SPACE => crate::key::KEY_SPACE,
718        // Unmapped special keys fall back to the unicode replacement
719        // character; the text field carries the printable representation.
720        _ => char::from_u32(k.code).unwrap_or('\0'),
721    };
722    crate::key::Key {
723        text: k.text.clone(),
724        mod_keys: KeyMod(k.mod_.0 as u8),
725        code,
726        shifted_code: char::from_u32(k.shifted_code),
727        base_code: char::from_u32(k.base_code),
728        is_repeat: k.is_repeat,
729    }
730}
731
732/// Converts an ultraviolet mouse event into the Bubble Tea representation.
733fn uv_mouse_to_mouse(m: rusty_ultraviolet::Mouse) -> crate::mouse::Mouse {
734    let button = match m.button.0 {
735        0 => crate::mouse::MouseButton::MouseNone,
736        1 => crate::mouse::MouseButton::MouseLeft,
737        2 => crate::mouse::MouseButton::MouseMiddle,
738        3 => crate::mouse::MouseButton::MouseRight,
739        4 => crate::mouse::MouseButton::MouseWheelUp,
740        5 => crate::mouse::MouseButton::MouseWheelDown,
741        6 => crate::mouse::MouseButton::MouseWheelLeft,
742        7 => crate::mouse::MouseButton::MouseWheelRight,
743        8 => crate::mouse::MouseButton::MouseBackward,
744        9 => crate::mouse::MouseButton::MouseForward,
745        10 => crate::mouse::MouseButton::MouseButton10,
746        _ => crate::mouse::MouseButton::MouseButton11,
747    };
748    crate::mouse::Mouse {
749        x: m.x.max(0) as usize,
750        y: m.y.max(0) as usize,
751        button,
752        mod_keys: KeyMod(m.mod_.0 as u8),
753    }
754}
755
756/// CheckOptimizedMovements reads the stdin termios and reports whether hard
757/// tabs (TABDLY==TAB0) and backspace (BSDLY==BS0) optimizations are enabled.
758fn check_optimized_movements() -> (bool, bool) {
759    use std::os::fd::AsRawFd;
760    #[cfg(unix)]
761    {
762        let fd = std::io::stdin().as_raw_fd();
763        let mut t: libc::termios = unsafe { std::mem::zeroed() };
764        if unsafe { libc::tcgetattr(fd, &mut t) } != 0 {
765            return (false, false);
766        }
767        let hard_tabs = t.c_oflag & libc::TABDLY == libc::TAB0;
768        #[cfg(target_os = "macos")]
769        let backspace = t.c_lflag & libc::BSDLY == libc::BS0;
770        #[cfg(not(target_os = "macos"))]
771        let backspace = false;
772        (hard_tabs, backspace)
773    }
774    #[cfg(not(unix))]
775    {
776        let _ = ();
777        (true, true)
778    }
779}