diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1107a27..c846095 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,8 +129,6 @@ jobs: name: Coverage runs-on: ubuntu-latest needs: gate - permissions: - contents: write steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -199,6 +197,31 @@ jobs: run: | cargo tarpaulin --all-features --out xml --output-dir target/tarpaulin -- --test-threads=1 2>&1 | tee /tmp/tarpaulin.log + - name: Upload coverage report for dev badge + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: target/tarpaulin/cobertura.xml + if-no-files-found: error + retention-days: 1 + + coverage-badge: + name: Update coverage badge + runs-on: ubuntu-latest + needs: coverage + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: dev + - name: Download coverage report + uses: actions/download-artifact@v4 + with: + name: coverage-report + path: target/tarpaulin - name: Write coverage badge in place run: | raw="$(grep -oE 'line-rate="[0-9.]+' target/tarpaulin/cobertura.xml | head -1 | grep -oE '[0-9.]+')" @@ -215,12 +238,12 @@ jobs: esac curl -sS "https://img.shields.io/badge/coverage-${pct}%25-${color}.svg" -o coverage.svg # Commit the badge in place only when it changed (git diff ignores - # untracked files, so use status), so the push cannot re-trigger CI - # indefinitely. + # untracked files, so use status). The skip marker prevents this + # trusted publication commit from starting another CI run. git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" if [ -n "$(git status --porcelain -- coverage.svg)" ]; then git add coverage.svg - git commit -q -m "docs: update coverage badge ($pct%)" - git push -q origin dev + git commit -q -m "docs: update coverage badge ($pct%) [skip ci]" + git push -q origin HEAD:dev fi diff --git a/.gitignore b/.gitignore index 97a79f0..576a030 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ Cargo.lock # Local artifacts +.codemap/ /scripts/failures /tmp diff --git a/Cargo.toml b/Cargo.toml index 1fa3843..495993f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ name = "rusty-bubbletea" exclude = ["POLICIES.md"] version = "2.0.8" edition = "2021" +rust-version = "1.91" description = "Cleanroom Rust port of Charmbracelet's Bubble Tea (v2.0.8) TUI Elm architecture framework" license = "MIT" diff --git a/UPSTREAM_MAPPING.md b/UPSTREAM_MAPPING.md index e452bce..7847e52 100644 --- a/UPSTREAM_MAPPING.md +++ b/UPSTREAM_MAPPING.md @@ -10,13 +10,13 @@ upstream tag `v2.0.8`, checked out locally in `upstream-go/` (gitignored). | Upstream Go File | Rust Equivalent / Status | Notes / Description | | :--- | :--- | :--- | -| `tea.go` | `src/lib.rs`, `src/view.rs`, `src/program.rs` | Core Elm architecture: `Model`, `Msg`, `Cmd`, `Program`, `View` | -| `tea_test.go` | `tests/tea_test.rs` | Core program unit tests | +| `tea.go` | `src/lib.rs`, `src/view.rs`, `src/program.rs` | Core Elm architecture: `Model`, `Msg`, `Cmd`, `Program`, `View`; `Program` owns one-shot lifecycle state, external handles, configured I/O, cancellation, panic recovery, and renderer cleanup | +| `tea_test.go` | `tests/tea_test.rs` | Core program unit tests, including startup contract, headless lifecycle, cancellation, panic recovery, handle cleanup, and protocol-output ordering | | `clipboard.go` | `src/clipboard.rs` | OSC52 clipboard ops (`set_clipboard`, `read_clipboard`, `ClipboardMsg`) | | `color.go` | `src/color.rs` — **Refactored** | Response messages wrap `rusty-ultraviolet` color events; `is_dark` via the upstream HSL logic | Color requests and messages (`request_background_color`, `BackgroundColorMsg`, …) | -| `commands.go` | `src/commands.rs` | Built-in commands (`quit`, `batch`, `sequence`, `tick`, `every`, `request_window_size`) | +| `commands.go` | `src/commands.rs` | Built-in commands (`quit`, `batch`, `sequence`, `tick`, `every`, `request_window_size`); no-op commands are removed while singleton command behavior remains deterministic | | `commands_test.go` | `tests/commands_test.rs` | Command suite tests | -| `cursed_renderer.go` | `src/cursed_renderer.rs` | CursedRenderer: declarative view frames, ANSI diffing, unmanaged lines | +| `cursed_renderer.go` | `src/cursed_renderer.rs` | CursedRenderer: declarative view frames, ANSI diffing, unmanaged lines, and direct protocol output ahead of buffered frames | | `cursed_renderer_test.go` | `tests/tea_test.rs` | Renderer tests | | `cursor.go` | `src/cursor.rs` | Cursor position/shape, `request_cursor_position` | | `environ.go` | `src/environ.rs` | `EnvMsg` environment variables | @@ -33,12 +33,12 @@ upstream tag `v2.0.8`, checked out locally in `upstream-go/` (gitignored). | `mouse.go` | `src/mouse.rs` | `MouseButton`, `Mouse`, typed mouse messages | | `mouse_test.go` | `tests/mouse_test.rs` | Mouse suite tests | | `nil_renderer.go` | `src/nil_renderer.rs` | No-op renderer | -| `options.go` | `src/options.rs` | `ProgramOptions` constructors | +| `options.go` | `src/options.rs` | `ProgramOptions` constructors; explicit input disabling is tracked separately from default stdin and FPS is normalized to the documented 60–120 bounds | | `options_test.go` | `tests/commands_test.rs` | Option tests | | `paste.go` | `src/paste.rs` | Bracketed paste messages | | `profile.go` | `src/profile.rs` | `ColorProfileMsg` | | `raw.go` | `src/raw.rs` | `raw` command sending ANSI sequences | -| `renderer.go` | `src/renderer.rs` | `Renderer` trait | +| `renderer.go` | `src/renderer.rs` | `Renderer` trait, including the direct protocol-output hook used before buffered frame flushes | | `screen.go` | `src/screen.rs` | `WindowSizeMsg`, `clear_screen`, `ModeReportMsg` | | `screen_test.go` | `tests/commands_test.rs` | Screen tests | | `signals_unix.go` | `src/signals_unix.rs` | SIGWINCH resize listener | @@ -170,14 +170,15 @@ in the Support Files section. | :--- | :--- | :--- | | `LICENSE` | `LICENSE` | MIT License (matching upstream copyright) | | `README.md` | `README.md` | Documented Rust port header with graphics & links | +| Repository lifecycle guide | `docs/src/lib.rs` | User-facing documentation anchor for `ProgramHandle`, headless options, cancellation, and graceful versus error shutdown | | `UPGRADE_GUIDE_V2.md` | `README.md` (notes) | v1 -> v2 migration guidance summarized in README | -| `go.mod` / `go.sum` | `Cargo.toml` | Dependency manifest (Go modules -> Cargo crates) | +| `go.mod` / `go.sum` | `Cargo.toml` | Dependency manifest (Go modules -> Cargo crates); candidate declares the supported Rust 1.91 toolchain floor | | `examples/go.mod` / `examples/go.sum` / `tutorials/go.mod` / `tutorials/go.sum` | `Cargo.toml` | Example-module manifests (deps like bubbles, glamour, harmonica are example-only) | | `examples/*/README.md` and `examples/*/*.gif` | `examples/` docs | Per-example docs/assets; retained as upstream documentation references | | `examples/isbn-form/isbn-form.tape` | (asset) | VHS recording asset; not applicable to the Rust crate | | `examples/table/demo.tape` | (asset) | VHS recording asset; not applicable to the Rust crate | | `Taskfile.yaml` / `.goreleaser.yml` / `.golangci.yml` | `.github/workflows/publish.yml` | Build/lint/release config -> CI workflow | -| `.github/workflows/*` | `.github/workflows/publish.yml` | CI/CD -> Rust publish workflow + example parity check | +| `.github/workflows/*` | `.github/workflows/publish.yml` | CI/CD -> Rust CI/publish workflows, example parity, and trusted default-branch badge publication | | `.github/ISSUE_TEMPLATE/*` / `.github/dependabot.yml` / `.gitattributes` / `.gitignore` / `.editorconfig` | `.gitignore` | Process/config files; not applicable to the Rust crate | | `testdata/*.golden` | `tests/*.rs` | Golden outputs accounted for by test assertions | @@ -205,6 +206,6 @@ in the Support Files section. - Port-wide fixes required for parity: kitty-bitset `KeyMod` constants, SGR emission order (colors before attrs, 39/49/59 default-color resets, attr reset codes 22/23/24/25/27/8/29), pen reset before pending spaces in `renderLine`, go-exact `Duration::String()`, the color - profile applied to the renderer (env-detect + ColorProfileMsg), OSC queries buffered and - flushed with the first render (ticker-only), final model render on graceful quit, and the + profile applied to the renderer (env-detect + ColorProfileMsg), protocol and OSC queries + emitted ahead of buffered renderer startup output, final model render on graceful quit, and the start-up message burst (WindowSizeMsg + EnvMsg + ColorProfileMsg) matching upstream. diff --git a/docs/src/lib.rs b/docs/src/lib.rs new file mode 100644 index 0000000..9392a31 --- /dev/null +++ b/docs/src/lib.rs @@ -0,0 +1,21 @@ +//! Bubble Tea lifecycle and configuration guide. +//! +//! +//! # Running a Program +//! +//! [`rusty_bubbletea::Program`] owns a model's event loop. Configure terminal +//! dimensions, environment, input, output, color profile, and cancellation +//! through [`rusty_bubbletea::ProgramOptions`]. For a runner moved to another +//! thread, obtain [`rusty_bubbletea::ProgramHandle`] before calling +//! [`rusty_bubbletea::Program::run`]. The handle can queue messages, request a +//! graceful `quit`, request an error `kill`, and wait for renderer cleanup. +//! +//! `with_input(None)` creates a deterministic headless program by disabling +//! input. `without_renderer()` selects the no-op renderer. A graceful quit +//! renders and flushes the final model view; cancellation, interruption, and +//! kill skip that final frame while still restoring terminal state. +//! +//! +//! Maintainer note: this file is the documentation anchor for the public +//! lifecycle contract. Implementation details belong in `src/program.rs` and +//! option semantics belong in `src/options.rs`. diff --git a/evidence/acceptance/BUI-011/independent-review.md b/evidence/acceptance/BUI-011/independent-review.md new file mode 100644 index 0000000..23a5164 --- /dev/null +++ b/evidence/acceptance/BUI-011/independent-review.md @@ -0,0 +1,59 @@ +# BUI-011 Independent Review + +Status: implementation self-review complete; protected pull-request review and +exact-head CI remain the independent merge gate. + +## Scope + +- `src/program.rs`: one-shot lifecycle state, `ProgramHandle`, configured + renderer/input setup, startup messages, cancellation, panic recovery, render + ticker shutdown, and graceful/error cleanup. +- `src/renderer.rs` and `src/cursed_renderer.rs`: direct protocol-output + path for queries that must precede buffered renderer startup output. +- `src/options.rs`: explicit headless input sentinel and FPS normalization. +- `src/commands.rs`: no-op command filtering and singleton command behavior. +- `tests/tea_test.rs`: focused lifecycle, startup, cancellation, panic, FPS, + command-shape and protocol-output-order regressions. +- `docs/src/lib.rs`: public lifecycle and headless-configuration guidance. +- `.github/workflows/ci.yml`: protected coverage reporting and trusted + default-branch badge publication. + +## Review checks + +| Check | Result | Evidence | +| --- | --- | --- | +| Lifecycle has one runner, observable cleanup, graceful quit, kill, interruption, cancellation, and panic paths | Pass | `src/program.rs`; focused lifecycle tests | +| Headless execution avoids raw input setup and honors configured startup values | Pass | `tests/tea_test.rs::test_program_uses_configured_startup_contract` | +| Command no-op filtering preserves empty and singleton semantics | Pass | `tests/tea_test.rs::test_commands_and_messages` | +| Protocol queries precede buffered renderer startup output | Pass | `tests/tea_test.rs::test_protocol_query_precedes_buffered_renderer_startup_output`; targeted PTY parity | +| Documentation uses the current `` contract | Pass | `src/program.rs`, `src/options.rs`, `src/commands.rs`, `docs/src/lib.rs` | +| Focused Rust validation | Pass | `cargo check -p rusty-bubbletea --lib`; `cargo test -p rusty-bubbletea --test tea_test --no-fail-fast`; 14 tests passed | +| Reported protected parity failures | Pass locally | 14 reported examples matched Go traces after the protocol-output fix; the timing-sensitive `send-msg` trace matched across six repeated runs | +| Coverage workflow keeps pull-request candidate jobs read-only | Pass locally | Coverage report upload is push-only; badge commit/push is isolated to a trusted `dev` push job using `HEAD:dev` | + +## Findings and limits + +The first protected CI attempt exposed a protocol ordering defect in +`verify_examples`: synchronized-output and terminal-color queries were written into the renderer frame buffer after startup control +sequences. The renderer now has a direct protocol-output path, and the focused regression plus targeted PTY parity checks pass. +The second protected CI attempt (run 32947899448) then exposed a workflow +publication defect: coverage reached 74.04%, but the PR merge checkout had no +local `dev` ref and the badge step failed with `src refspec dev does not +match any`. Coverage now keeps `contents: read` for candidate pull-request +execution, uploads the report only on a trusted `dev` push, and performs badge +publication in a push-only job with `HEAD:dev` and `[skip ci]` to avoid +recursive validation. The corrected workflow awaits a new protected exact-head +run. +The worktree cannot run the untouched full dependency graph because the +`rusty-bubbles` dev dependency resolves its `../rusty-bubbletea` path to the +primary checkout, causing Cargo's package-collision error when the isolated +worktree is present. Focused validation therefore temporarily omitted that +dev-only dependency and restored the manifest immediately after each run; the +feature diff retains the original dependency line. + +The manifest now declares `rust-version = "1.91"`, matching the workspace +toolchain policy and making the candidate's supported compiler floor explicit. + +This record is implementation evidence, not an approval or merge +authorization. The final independent review, protected checks, and aggregate +acceptance remain owned by the repository and Mutate release gates. diff --git a/src/commands.rs b/src/commands.rs index 9ac680a..8586eef 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,11 +1,15 @@ //! Cleanroom Rust port of upstream Go source file: `commands.go` //! Upstream Target Tag / Version: `v2.0.8` //! -//! +//! //! # Commands //! //! Built-in command functions (`batch`, `sequence`, `every`, `tick`, `request_window_size`). -//! +//! +//! +//! Maintainer note: command trees are values until the program event loop +//! executes them. `batch` preserves concurrent execution, while `sequence` +//! preserves source order, including for nested command trees. use crate::model::{Cmd, Msg}; use std::fmt; @@ -76,15 +80,10 @@ pub fn interrupt() -> Cmd { /// Batch performs a bunch of commands concurrently with no ordering guarantees /// about the results. Use `batch` to return several commands. pub fn batch(cmds: Vec) -> Cmd { - let mut valid_cmds = Vec::new(); - for cmd in cmds { - if cmd.is_some() { - valid_cmds.push(cmd); - } - } + let valid_cmds = retain_commands(cmds); match valid_cmds.len() { 0 => None, - 1 => valid_cmds.into_iter().next().unwrap(), + 1 => valid_cmds.into_iter().next().flatten(), _ => Some(Box::new(move || Some(Box::new(BatchMsg(valid_cmds))))), } } @@ -92,19 +91,20 @@ pub fn batch(cmds: Vec) -> Cmd { /// Sequence runs the given commands one at a time, in order. Contrast this with /// `batch`, which runs commands concurrently. pub fn sequence(cmds: Vec) -> Cmd { - let mut valid_cmds = Vec::new(); - for cmd in cmds { - if cmd.is_some() { - valid_cmds.push(cmd); - } - } + let valid_cmds = retain_commands(cmds); match valid_cmds.len() { 0 => None, - 1 => valid_cmds.into_iter().next().unwrap(), + 1 => valid_cmds.into_iter().next().flatten(), _ => Some(Box::new(move || Some(Box::new(SequenceMsg(valid_cmds))))), } } +/// Drops no-op commands while retaining the command container shape expected +/// by the event-loop executor. +fn retain_commands(cmds: Vec) -> Vec { + cmds.into_iter().filter(|cmd| cmd.is_some()).collect() +} + /// Every is a command that ticks in sync with the system clock. So, if you /// wanted to tick with the system clock every second, minute or hour you /// could use this. It's also handy for having different things tick in sync. diff --git a/src/cursed_renderer.rs b/src/cursed_renderer.rs index 54ea906..000ed8c 100644 --- a/src/cursed_renderer.rs +++ b/src/cursed_renderer.rs @@ -981,6 +981,13 @@ impl Renderer for CursedRenderer { Ok(n) } + fn write_direct(&mut self, s: &str) -> Result> { + let bytes = s.as_bytes(); + self.w.write_all(bytes)?; + self.w.flush()?; + Ok(bytes.len()) + } + fn on_mouse(&mut self, m: MouseMsg) -> Cmd { if let Some(lv) = &self.last_view { if let Some(on_mouse) = &lv.on_mouse { diff --git a/src/options.rs b/src/options.rs index 51f0014..fe693e6 100644 --- a/src/options.rs +++ b/src/options.rs @@ -1,13 +1,17 @@ //! Cleanroom Rust port of upstream Go source file: `options.go` //! Upstream Target Tag / Version: `v2.0.8` //! -//! +//! //! # Program Options //! //! Program options (`with_fps`, `without_renderer`, `with_filter`, `with_window_size`, //! `with_context`, `with_output`, `with_input`, `with_environment`, //! `without_signal_handler`, `without_catch_panics`, `without_signals`, `with_color_profile`). -//! +//! +//! +//! Maintainer note: options are consumed once by [`crate::program::Program::run`]. +//! The input sentinel keeps the default stdin behavior distinct from an explicit +//! `with_input(None)`, which disables input for deterministic headless programs. use std::io::{Read, Write}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -63,8 +67,11 @@ pub struct ProgramOptions { pub height: usize, /// Optional event filter. pub filter: Option>, - /// Input reader override; None means stdin. + /// Input reader override. The default uses stdin; [`Self::with_input`] with + /// `None` disables input entirely. pub input: Option>, + /// Distinguishes the default stdin input from an explicit disabled input. + input_disabled: bool, /// Output writer override; None means stdout. pub output: Option>, /// Environment variables used by the program. @@ -87,6 +94,7 @@ impl Default for ProgramOptions { height: 0, filter: None, input: None, + input_disabled: false, output: None, environ: None, color_profile: None, @@ -114,10 +122,16 @@ impl ProgramOptions { /// WithInput sets the input which, by default, is stdin. In most cases you /// won't need to use this. To disable input entirely pass None. pub fn with_input(mut self, input: Option>) -> Self { + self.input_disabled = input.is_none(); self.input = input; self } + /// Returns whether input was explicitly disabled with [`Self::with_input`]. + pub(crate) fn input_disabled(&self) -> bool { + self.input_disabled + } + /// WithEnvironment sets the environment variables that the program will use. /// This is useful when the program is running in a remote session (e.g. SSH) and /// you want to pass the environment variables from the remote session to the @@ -171,7 +185,7 @@ impl ProgramOptions { /// less than 1, the default value of 60 will be used. If over 120, the FPS /// will be capped at 120. pub fn with_fps(mut self, fps: u32) -> Self { - self.fps = fps; + self.fps = if fps == 0 { 60 } else { fps.min(120) }; self } diff --git a/src/program.rs b/src/program.rs index e9ce8eb..844a6bd 100644 --- a/src/program.rs +++ b/src/program.rs @@ -12,16 +12,29 @@ //! //! Example programs can be found at https://github.com/charmbracelet/bubbletea/tree/master/examples //! +//! +//! +//! [`Program`] runs a model's event loop. Use [`Program::handle`] when the +//! program must be run on another thread and controlled from the outside. +//! [`ProgramOptions`](crate::options::ProgramOptions) supplies deterministic +//! input, output, terminal-size, renderer, and cancellation behavior. +//! +//! +//! Maintainer note: setup, event dispatch, effect execution, and renderer +//! shutdown are deliberately kept as separate phases. Shared lifecycle state +//! makes pre-start commands, external cancellation, and final shutdown +//! observable without borrowing the model across threads. use std::fmt; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::io::{IsTerminal, Read, Write}; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::mpsc::{channel, Receiver, Sender}; /// Message channel used to feed the program's event loop. type MsgChannel = Sender>; /// Message channel used to receive messages into the program's event loop. type MsgReceiver = Receiver>; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, MutexGuard}; use std::thread; use std::time::Duration; @@ -36,9 +49,11 @@ use crate::key::{KeyMod, KeyPressMsg, KeyReleaseMsg}; use crate::keyboard::KeyboardEnhancementsMsg; use crate::model::{Model, Msg}; use crate::mouse::{MouseClickMsg, MouseMotionMsg, MouseReleaseMsg, MouseWheelMsg}; +use crate::nil_renderer::NilRenderer; use crate::options::ProgramOptions; use crate::paste::PasteMsg; use crate::profile::ColorProfileMsg; +use crate::raw::RawMsg; use crate::renderer::{PrintLineMsg, Renderer}; use crate::screen::{ClearScreenMsg, WindowSizeMsg}; use crate::tty::{disable_raw_mode, enable_raw_mode}; @@ -77,37 +92,93 @@ impl fmt::Display for ProgramError { impl std::error::Error for ProgramError {} +const PROGRAM_NEW: u8 = 0; +const PROGRAM_RUNNING: u8 = 1; +const PROGRAM_FINISHED: u8 = 2; + +/// A cloneable control surface for a running [`Program`]. +/// +/// A handle may be created before the program is moved to its runner thread. +/// Messages sent before startup remain ordered in the program's event queue; +/// messages sent after shutdown are ignored. `kill` requests an error +/// shutdown, while `quit` requests the normal graceful shutdown. +#[derive(Clone)] +pub struct ProgramHandle { + msg_tx: MsgChannel, + state: Arc, + killed: Arc, +} + +impl ProgramHandle { + /// Sends a typed message to the program event loop. + /// + /// The message is ignored after the program has finished or after a kill + /// request. This method never blocks on the model or renderer. + pub fn send(&self, msg: Box) { + if self.killed.load(Ordering::SeqCst) + || self.state.load(Ordering::SeqCst) == PROGRAM_FINISHED + { + return; + } + let _ = self.msg_tx.send(msg); + } + + /// Requests a graceful program shutdown. + pub fn quit(&self) { + self.send(Box::new(QuitMsg)); + } + + /// Requests an immediate program shutdown with [`ProgramError::Killed`]. + pub fn kill(&self) { + if self.state.load(Ordering::SeqCst) == PROGRAM_FINISHED { + return; + } + self.killed.store(true, Ordering::SeqCst); + let _ = self.msg_tx.send(Box::new(QuitMsg)); + } + + /// Waits until renderer and terminal cleanup has completed. + /// + /// Call this after starting the program. A handle intentionally does not + /// guess whether a program that has not been started will be started later. + pub fn wait(&self) { + while self.state.load(Ordering::SeqCst) != PROGRAM_FINISHED { + thread::sleep(Duration::from_millis(10)); + } + } +} + /// Program is the runner for a Bubble Tea v2.0.8 application. pub struct Program { - /// Buffered startup query sequences, flushed with the first render - /// (mirrors upstream `p.outputBuf` + `p.execute`). - startup_buf: Arc>>>, model: M, options: ProgramOptions, - renderer: Arc>>, - msg_tx: Option, - finished: Arc, + renderer: Option>>>, + msg_tx: MsgChannel, + msg_rx: Option, + state: Arc, + killed: Arc, + stopping: Arc, + render_thread: Option>, } impl Program { - /// Creates a new Program for the given model with default options. + /// Creates a new program for the given model with default options. + /// + /// The returned program owns its event queue immediately, so a + /// [`ProgramHandle`] can enqueue startup messages before [`Self::run`] + /// takes ownership of the runner. pub fn new(model: M) -> Self { - let (w, h) = term_size().unwrap_or((80, 24)); - let env: Vec = std::env::vars().map(|(k, v)| format!("{k}={v}")).collect(); + let (msg_tx, msg_rx) = channel(); Self { - startup_buf: Arc::new(Mutex::new(None)), model, options: ProgramOptions::default(), - renderer: Arc::new(Mutex::new(Box::new( - crate::cursed_renderer::new_cursed_renderer( - Box::new(std::io::stdout()), - &env, - w as usize, - h as usize, - ), - ))), - msg_tx: None, - finished: Arc::new(AtomicBool::new(false)), + renderer: None, + msg_tx, + msg_rx: Some(msg_rx), + state: Arc::new(AtomicU8::new(PROGRAM_NEW)), + killed: Arc::new(AtomicBool::new(false)), + stopping: Arc::new(AtomicBool::new(false)), + render_thread: None, } } @@ -117,39 +188,50 @@ impl Program { self } + /// Returns a cloneable control surface for this program. + /// + /// ``` + /// # use rusty_bubbletea::{Cmd, Model, Msg, Program, View}; + /// # struct Example; + /// # impl Model for Example { + /// # fn update(&mut self, _msg: &dyn Msg) -> Cmd { None } + /// # fn view(&self) -> View { View::new("") } + /// # } + /// let program = Program::new(Example); + /// let handle = program.handle(); + /// handle.quit(); + /// ``` + pub fn handle(&self) -> ProgramHandle { + ProgramHandle { + msg_tx: self.msg_tx.clone(), + state: self.state.clone(), + killed: self.killed.clone(), + } + } + /// Send sends a message to the main update function, effectively allowing /// messages to be injected from outside the program for interoperability /// purposes. pub fn send(&self, msg: Box) { - if let Some(tx) = &self.msg_tx { - let _ = tx.send(msg); - } + self.handle().send(msg); } /// Quit is a convenience function for quitting Bubble Tea programs. Use it /// when you need to shut down a Bubble Tea program from the outside. pub fn quit(&self) { - self.send(Box::new(QuitMsg)); + self.handle().quit(); } /// Kill stops the program immediately and restores the former terminal state. /// The final render that you would normally see when quitting will be skipped. /// [Program.Run] returns a [ErrProgramKilled] error. - pub fn kill(&mut self) { - // Disable raw mode and mark the program finished; the run loop observes - // the finished flag and exits. - let _ = disable_raw_mode(); - self.finished.store(true, Ordering::SeqCst); - if let Some(tx) = &self.msg_tx { - let _ = tx.send(Box::new(QuitMsg)); - } + pub fn kill(&self) { + self.handle().kill(); } /// Wait waits/blocks until the underlying Program finished shutting down. pub fn wait(&self) { - while !self.finished.load(Ordering::SeqCst) { - thread::sleep(Duration::from_millis(10)); - } + self.handle().wait(); } /// Println prints above the Program. This output is unmanaged by the program @@ -168,6 +250,22 @@ impl Program { })); } + fn renderer_guard(&self) -> Option>> { + self.renderer.as_ref()?.lock().ok() + } + + fn render_view(&self, view: crate::view::View) { + if let Some(mut renderer) = self.renderer_guard() { + renderer.render(view); + } + } + + fn write_direct(&self, text: &str) { + if let Some(mut renderer) = self.renderer_guard() { + let _ = renderer.write_direct(text); + } + } + /// Helper to process a message, execute terminal commands, and dispatch generated commands. fn handle_msg(&mut self, msg: Box, tx: &MsgChannel) -> Result { let processed_msg = if let Some(ref filter) = self.options.filter { @@ -195,164 +293,135 @@ impl Program { } if processed_msg.as_ref().as_any().is::() { - self.renderer.lock().unwrap().clear_screen(); + if let Some(mut renderer) = self.renderer_guard() { + renderer.clear_screen(); + } } else if processed_msg .as_ref() .as_any() .is::() { - // Mirror upstream `p.execute(ansi.RequestBackgroundColor)`: the - // query is buffered and flushed with the first render. - if let Ok(mut buf) = self.startup_buf.lock() { - if let Some(b) = buf.as_mut() { - b.extend_from_slice( - rusty_x_ansi::background::REQUEST_BACKGROUND_COLOR.as_bytes(), - ); - } - } + self.write_direct(rusty_x_ansi::background::REQUEST_BACKGROUND_COLOR); } else if processed_msg .as_ref() .as_any() .is::() { - if let Ok(mut buf) = self.startup_buf.lock() { - if let Some(b) = buf.as_mut() { - b.extend_from_slice( - rusty_x_ansi::background::REQUEST_FOREGROUND_COLOR.as_bytes(), - ); - } - } + self.write_direct(rusty_x_ansi::background::REQUEST_FOREGROUND_COLOR); } else if processed_msg .as_ref() .as_any() .is::() { - if let Ok(mut buf) = self.startup_buf.lock() { - if let Some(b) = buf.as_mut() { - b.extend_from_slice(rusty_x_ansi::background::REQUEST_CURSOR_COLOR.as_bytes()); - } - } + self.write_direct(rusty_x_ansi::background::REQUEST_CURSOR_COLOR); } else if let Some(cap) = processed_msg .as_ref() .as_any() .downcast_ref::() { // Mirror upstream `p.execute(ansi.RequestTermcap(cap))`: write the - // XTGETTCAP query (DCS + q ST) to the terminal so the terminal - // responds with a CapabilityMsg. - use std::io::Write as _; + // XTGETTCAP query (DCS + q ST) to the configured output. let mut seq = String::from("\x1bP+q"); - for b in cap.0.as_bytes() { - seq.push_str(&format!("{:02X}", b)); + for byte in cap.0.as_bytes() { + seq.push_str(&format!("{byte:02X}")); } seq.push_str("\x1b\\"); - let _ = std::io::stdout().write_all(seq.as_bytes()); + self.write_direct(&seq); return Ok(false); } else if processed_msg .as_ref() .as_any() .is::() { - // Mirror upstream `p.execute(ansi.RequestNameVersion)`: query the - // terminal name and version (XTVERSION) so the terminal responds - // with a TerminalVersionMsg. - use std::io::Write as _; - let _ = std::io::stdout().write_all(b"\x1b[>q"); + // Mirror upstream `p.execute(ansi.RequestNameVersion)` using the + // configured renderer output rather than process-global stdout. + self.write_direct("\x1b[>q"); return Ok(false); } else if processed_msg.as_ref().as_any().is::() { - if let Ok((w, h)) = term_size() { - let _ = tx.send(Box::new(WindowSizeMsg { - width: w as usize, - height: h as usize, - })); - } - // RequestWindowSizeMsg itself is internal — don't pass to model.update + let (width, height) = configured_window_size(&self.options); + let _ = tx.send(Box::new(WindowSizeMsg { width, height })); + // RequestWindowSizeMsg itself is internal — don't pass to model.update. return Ok(false); } else if let Some(ws) = processed_msg .as_ref() .as_any() .downcast_ref::() { - // Resize the renderer first, then fall through to model.update below - self.renderer.lock().unwrap().resize(ws.width, ws.height); + // Resize the renderer first, then fall through to model.update below. + if let Some(mut renderer) = self.renderer_guard() { + renderer.resize(ws.width, ws.height); + } } else if let Some(exec_msg) = processed_msg.as_ref().as_any().downcast_ref::() { let _ = disable_raw_mode(); - let mut cmd = std::process::Command::new(&exec_msg.cmd); - cmd.args(&exec_msg.args); - let _ = cmd.status(); + let mut command = std::process::Command::new(&exec_msg.cmd); + command.args(&exec_msg.args); + let _ = command.status(); let _ = enable_raw_mode(); } else if let Some(print_msg) = processed_msg .as_ref() .as_any() .downcast_ref::() { - // Insert the line above the TUI without routing through model.update - let _ = self - .renderer - .lock() - .unwrap() - .insert_above(print_msg.message_body.clone()); - // Re-render to flush queued lines - let view = self.model.view(); - self.renderer.lock().unwrap().render(view); + // Insert the line above the TUI without routing through model.update. + if let Some(mut renderer) = self.renderer_guard() { + let _ = renderer.insert_above(print_msg.message_body.clone()); + renderer.render(self.model.view()); + } return Ok(false); - } else if let Some(env) = processed_msg.as_ref().as_any().downcast_ref::() { - let _ = env; + } else if processed_msg.as_ref().as_any().is::() { + if let Some(raw) = processed_msg.as_ref().as_any().downcast_ref::() { + self.write_direct(&raw.0); + } + return Ok(false); + } else if let Some(_env) = processed_msg.as_ref().as_any().downcast_ref::() { + // EnvMsg remains visible to the model below, matching ordinary + // Bubble Tea startup messages. } else if let Some(profile) = processed_msg .as_ref() .as_any() .downcast_ref::() { - let p = match profile.profile { - crate::profile::ColorProfile::TrueColor => rusty_colorprofile::Profile::TrueColor, - crate::profile::ColorProfile::ANSI256 => rusty_colorprofile::Profile::Ansi256, - crate::profile::ColorProfile::ANSI => rusty_colorprofile::Profile::Ansi, - crate::profile::ColorProfile::Ascii => rusty_colorprofile::Profile::Ascii, - }; - self.renderer.lock().unwrap().set_color_profile(p); - } else if let Some(_resume) = processed_msg.as_ref().as_any().downcast_ref::() { + if let Some(mut renderer) = self.renderer_guard() { + renderer.set_color_profile(color_profile(profile.profile)); + } + } else if processed_msg.as_ref().as_any().is::() { let _ = enable_raw_mode(); } - // Dispatch the commands carried by BatchMsg and SequenceMsg, - // mirroring the upstream handling of `tea.Batch` and `tea.Sequence` - // messages (`case BatchMsg: go p.execBatchMsg(msg); continue` and - // `case sequenceMsg: go p.execSequenceMsg(msg); continue`): the - // command trees are expanded on their own thread, recursively, so a - // QuitMsg produced by a sequence is only delivered after every - // preceding command (including nested batches and sequences) has - // completed. + // Dispatch BatchMsg and SequenceMsg recursively. Batch commands run + // concurrently, while sequence commands preserve source order. if processed_msg.as_ref().as_any().is::() { let any = processed_msg.into_any(); - let batch = *any.downcast::().unwrap(); - let tx_clone = tx.clone(); - thread::spawn(move || exec_batch_msg(batch, &tx_clone)); + if let Ok(batch) = any.downcast::() { + let tx_clone = tx.clone(); + thread::spawn(move || exec_batch_msg(*batch, &tx_clone)); + } return Ok(false); } - // Mirror upstream `case MouseMsg:` in the event loop: route mouse - // messages to the renderer's on_mouse hook (used by composable view - // layers) and send any produced message back through the program. - // The message still falls through to the model's update below. + // Route mouse messages through the renderer's optional interceptor; + // the original event still falls through to model.update. let mouse_msg = { let any = processed_msg.as_ref().as_any(); - if let Some(m) = any.downcast_ref::() { - Some(crate::mouse::MouseMsg::Click(m.clone())) - } else if let Some(m) = any.downcast_ref::() { - Some(crate::mouse::MouseMsg::Motion(m.clone())) - } else if let Some(m) = any.downcast_ref::() { - Some(crate::mouse::MouseMsg::Release(m.clone())) + if let Some(mouse) = any.downcast_ref::() { + Some(crate::mouse::MouseMsg::Click(mouse.clone())) + } else if let Some(mouse) = any.downcast_ref::() { + Some(crate::mouse::MouseMsg::Motion(mouse.clone())) + } else if let Some(mouse) = any.downcast_ref::() { + Some(crate::mouse::MouseMsg::Release(mouse.clone())) } else { - any.downcast_ref::() - .map(|m| crate::mouse::MouseMsg::Wheel(m.clone())) + any.downcast_ref::() + .map(|mouse| crate::mouse::MouseMsg::Wheel(mouse.clone())) } }; if let Some(mouse_msg) = mouse_msg { - let cmd = self.renderer.lock().unwrap().on_mouse(mouse_msg); - if let Some(c) = cmd { + let command = self + .renderer_guard() + .and_then(|mut renderer| renderer.on_mouse(mouse_msg)); + if let Some(command) = command { let tx_clone = tx.clone(); thread::spawn(move || { - if let Some(new_msg) = c() { + if let Some(new_msg) = command() { let _ = tx_clone.send(new_msg); } }); @@ -361,20 +430,20 @@ impl Program { if processed_msg.as_ref().as_any().is::() { let any = processed_msg.into_any(); - let seq = *any.downcast::().unwrap(); - let tx_clone = tx.clone(); - thread::spawn(move || exec_sequence_msg(seq, &tx_clone)); + if let Ok(sequence) = any.downcast::() { + let tx_clone = tx.clone(); + thread::spawn(move || exec_sequence_msg(*sequence, &tx_clone)); + } return Ok(false); } - let cmd = self.model.update(&*processed_msg); - let view = self.model.view(); - self.renderer.lock().unwrap().render(view); + let command = self.model.update(&*processed_msg); + self.render_view(self.model.view()); - if let Some(c) = cmd { + if let Some(command) = command { let tx_clone = tx.clone(); thread::spawn(move || { - if let Some(new_msg) = c() { + if let Some(new_msg) = command() { let _ = tx_clone.send(new_msg); } }); @@ -389,80 +458,128 @@ impl Program { }); } - /// Runs the Bubble Tea v2.0.8 event loop until quit. - pub fn run(mut self) -> Result> { - let _ = enable_raw_mode(); - self.renderer.lock().unwrap().start(); - - // Termios-based cursor movement optimizations, mirroring the - // upstream `initInput` -> `checkOptimizedMovements` flow. - // Detect the color profile from the environment and set it on the - // renderer (upstream `colorprofile.Detect` at startup); the - // ColorProfileMsg path may later upgrade it. - { - use std::os::fd::AsRawFd as _; - let env = rusty_ultraviolet::Environ( - std::env::vars().map(|(k, v)| format!("{k}={v}")).collect(), - ); - let profile = rusty_ultraviolet::terminal_screen::detect_color_profile( - Some(std::io::stdout().as_raw_fd()), - &env, - ); - self.renderer - .lock() - .unwrap() - .set_color_profile(match profile { - rusty_ultraviolet::terminal_screen::ColorProfile::TrueColor => { - rusty_colorprofile::Profile::TrueColor - } - rusty_ultraviolet::terminal_screen::ColorProfile::Ansi256 => { - rusty_colorprofile::Profile::Ansi256 - } - rusty_ultraviolet::terminal_screen::ColorProfile::Ansi => { - rusty_colorprofile::Profile::Ansi - } - _ => rusty_colorprofile::Profile::NoTty, - }); + fn stop_render_thread(&mut self) { + self.stopping.store(true, Ordering::SeqCst); + if let Some(render_thread) = self.render_thread.take() { + let _ = render_thread.join(); } + } - let (hard_tabs, backspace) = check_optimized_movements(); - // mapNl is false when the input is a real TTY (upstream: - // `runtime.GOOS != "windows" && p.ttyInput == nil`). - let map_nl = false; - self.renderer - .lock() - .unwrap() - .set_optimizations(hard_tabs, backspace, map_nl); + fn cleanup_renderer(&mut self, graceful: bool) { + self.stop_render_thread(); + if let Some(renderer) = self.renderer.take() { + if let Ok(mut renderer) = renderer.lock() { + if graceful { + renderer.render(self.model.view()); + let _ = renderer.flush(true); + } + let _ = renderer.close(); + } + } + let _ = disable_raw_mode(); + } - let (tx, rx): (MsgChannel, MsgReceiver) = channel(); - self.msg_tx = Some(tx.clone()); + fn run_inner(&mut self) -> Result<(), Box> { + let rx = self.msg_rx.take().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "program event loop has already been consumed", + ) + })?; + let tx = self.msg_tx.clone(); - let external_ctx = self.options.context.clone(); + let env_pairs = self + .options + .environ + .take() + .unwrap_or_else(|| std::env::vars().collect()); + let env_strings: Vec = env_pairs + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect(); + let term = env_value(&env_strings, "TERM") + .filter(|value| !value.is_empty()) + .unwrap_or("xterm-256color") + .to_owned(); + let (width, height) = configured_window_size(&self.options); + let output_is_stdout = self.options.output.is_none(); + let output: Box = match self.options.output.take() { + Some(output) => output, + None => Box::new(std::io::stdout()), + }; + let renderer: Box = if self.options.disable_renderer { + Box::new(NilRenderer) + } else { + Box::new(crate::cursed_renderer::new_cursed_renderer( + output, + &env_strings, + width, + height, + )) + }; + self.renderer = Some(Arc::new(Mutex::new(renderer))); - // Input thread: reads raw bytes from stdin and decodes them through - // the ultraviolet event decoder, mirroring the upstream - // `uv.NewTerminalReader` input path. - let input_tx = tx.clone(); - thread::spawn(move || { - let reader: Box = Box::new(std::io::stdin()); - let mut tr = - rusty_ultraviolet::terminal_reader::new_terminal_reader(reader, "xterm-256color"); - tr.set_legacy(rusty_ultraviolet::LegacyKeyEncoding::default()); - let (dec_tx, dec_rx) = std::sync::mpsc::channel::(); - let streamer = std::thread::spawn(move || { - let _ = tr.stream_events(&dec_tx); - }); - for ev in dec_rx { - if let Some(msg) = decoded_to_msg(ev) { - if input_tx.send(msg).is_err() { - break; + let profile = match self.options.color_profile { + Some(profile) => color_profile(profile), + None => detect_color_profile(&env_strings, output_is_stdout), + }; + let (hard_tabs, backspace) = if self.options.disable_renderer { + (false, false) + } else { + check_optimized_movements() + }; + let renderer_ready = { + if let Some(mut renderer) = self.renderer_guard() { + renderer.start(); + renderer.set_color_profile(profile); + renderer.set_optimizations(hard_tabs, backspace, false); + true + } else { + false + } + }; + if !renderer_ready { + self.cleanup_renderer(false); + return Err(Box::new(std::io::Error::other( + "program renderer lock is unavailable", + ))); + } + + let input_disabled = self.options.input_disabled(); + let use_raw_mode = !self.options.disable_renderer + && !input_disabled + && (self.options.input.is_some() || std::io::stdin().is_terminal()); + if use_raw_mode { + let _ = enable_raw_mode(); + } + + if !input_disabled { + let reader = self.options.input.take(); + let input_tx = tx.clone(); + thread::spawn(move || { + let reader: Box = match reader { + Some(reader) => reader, + None => Box::new(std::io::stdin()), + }; + let mut terminal_reader = + rusty_ultraviolet::terminal_reader::new_terminal_reader(reader, &term); + terminal_reader.set_legacy(rusty_ultraviolet::LegacyKeyEncoding::default()); + let (decoded_tx, decoded_rx) = + std::sync::mpsc::channel::(); + let streamer = thread::spawn(move || { + let _ = terminal_reader.stream_events(&decoded_tx); + }); + for event in decoded_rx { + if let Some(msg) = decoded_to_msg(event) { + if input_tx.send(msg).is_err() { + break; + } } } - } - let _ = streamer.join(); - }); + let _ = streamer.join(); + }); + } - // Run initial command if let Some(cmd) = self.model.init() { let tx_clone = tx.clone(); thread::spawn(move || { @@ -472,120 +589,168 @@ impl Program { }); } - // Send initial window size query - if let Ok((w, h)) = term_size() { - let _ = tx.send(Box::new(WindowSizeMsg { - width: w as usize, - height: h as usize, - })); - } - - // Send the environment variables used by the program. - let _ = tx.send(Box::new(EnvMsg::from_std())); + let _ = tx.send(Box::new(WindowSizeMsg { width, height })); + let _ = tx.send(Box::new(EnvMsg::new(env_pairs))); + let msg_profile = match profile { + rusty_colorprofile::Profile::TrueColor => crate::profile::ColorProfile::TrueColor, + rusty_colorprofile::Profile::Ansi256 => crate::profile::ColorProfile::ANSI256, + rusty_colorprofile::Profile::Ansi => crate::profile::ColorProfile::ANSI, + rusty_colorprofile::Profile::Ascii + | rusty_colorprofile::Profile::NoTty + | rusty_colorprofile::Profile::Unknown => crate::profile::ColorProfile::Ascii, + }; + let _ = tx.send(Box::new(ColorProfileMsg { + profile: msg_profile, + })); - // Send the detected color profile to the program, mirroring the - // upstream `go p.Send(ColorProfileMsg{*p.profile})` at startup. - { - use std::os::fd::AsRawFd as _; - let env = rusty_ultraviolet::Environ( - std::env::vars().map(|(k, v)| format!("{k}={v}")).collect(), - ); - let profile = rusty_ultraviolet::terminal_screen::detect_color_profile( - Some(std::io::stdout().as_raw_fd()), - &env, - ); - let msg_profile = match profile { - rusty_ultraviolet::terminal_screen::ColorProfile::TrueColor => { - crate::profile::ColorProfile::TrueColor - } - rusty_ultraviolet::terminal_screen::ColorProfile::Ansi256 => { - crate::profile::ColorProfile::ANSI256 - } - rusty_ultraviolet::terminal_screen::ColorProfile::Ansi => { - crate::profile::ColorProfile::ANSI - } - _ => crate::profile::ColorProfile::Ascii, - }; - let _ = tx.send(Box::new(crate::profile::ColorProfileMsg { - profile: msg_profile, - })); + if !self.options.disable_renderer && should_query_synchronized_output(&env_strings) { + self.write_direct("\x1b[?2026$p\x1b[?2027$p"); } - // Query for synchronized updates support (mode 2026) and unicode core - // (mode 2027), mirroring the upstream `p.execute(...)` at startup: - // the queries are buffered and flushed together with the first - // render (ticker flush or the quit path's flush(true)). - let query_sync = should_query_synchronized_output(); - self.startup_buf = Arc::new(Mutex::new(if query_sync { - Some(b"\x1b[?2026$p\x1b[?2027$p".to_vec()) - } else { - None - })); - let startup_buf = self.startup_buf.clone(); - - // Render initial view frame. The frame is flushed by the render - // ticker (or the quit path's flush(true)), mirroring the upstream - // ticker-driven render loop. - let initial_view = self.model.view(); - self.renderer.lock().unwrap().render(initial_view); - - // Render ticker: flushes the pending view at the default framerate - // (60fps), like the upstream `startRenderer` goroutine. - let tick_renderer = self.renderer.clone(); - let done = self.finished.clone(); - let tick_buf = startup_buf.clone(); - thread::spawn(move || { - let interval = Duration::from_millis(1000 / 60); - while !done.load(Ordering::SeqCst) { - thread::sleep(interval); - if let Some(buf) = tick_buf.lock().unwrap().take() { - use std::io::Write as _; - let _ = std::io::stdout().write_all(&buf); + self.render_view(self.model.view()); + + let ticker_renderer = self.renderer.as_ref().cloned(); + let stopping = self.stopping.clone(); + let fps = self.options.fps.clamp(1, 120); + self.render_thread = ticker_renderer.map(|renderer| { + thread::spawn(move || { + let interval = Duration::from_millis(1000 / fps as u64); + while !stopping.load(Ordering::SeqCst) { + thread::sleep(interval); + if stopping.load(Ordering::SeqCst) { + break; + } + if let Ok(mut renderer) = renderer.lock() { + let _ = renderer.flush(false); + } } - let _ = tick_renderer.lock().unwrap().flush(false); - } + }) }); - // Main event processing loop + let external_ctx = self.options.context.clone(); let result = loop { - // Check for external context cancellation. + if self.killed.load(Ordering::SeqCst) { + break Err(ProgramError::Killed); + } if let Some(ctx) = &external_ctx { if ctx.done() { break Err(ProgramError::Killed); } } - match rx.recv_timeout(Duration::from_millis(50)) { + match rx.recv_timeout(Duration::from_millis(10)) { Ok(msg) => match self.handle_msg(msg, &tx) { Ok(true) => break Ok(()), Ok(false) => continue, - Err(e) => break Err(e), + Err(error) => break Err(error), }, Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break Ok(()), } }; - self.finished.store(true, Ordering::SeqCst); - // Graceful shutdown: ensure we render the final state of the model - // (upstream `p.render(model)` after the event loop). - let final_view = self.model.view(); - self.renderer.lock().unwrap().render(final_view); - // Flush the last frame with closing=true before closing, like the - // upstream `stopRenderer` path. Note: any startup queries still - // buffered are NOT written here — upstream flushes its output buffer - // only from the render ticker goroutine, so queries buffered but - // never flushed by the ticker are dropped (observed behavior). - let _fr = self.renderer.lock().unwrap().flush(true); - let _cr = self.renderer.lock().unwrap().close(); - let _ = disable_raw_mode(); + let graceful = result.is_ok(); + self.cleanup_renderer(graceful); + result.map_err(|error| Box::new(error) as Box) + } + + /// Runs the Bubble Tea v2.0.8 event loop until quit. + pub fn run(mut self) -> Result> { + if self + .state + .compare_exchange( + PROGRAM_NEW, + PROGRAM_RUNNING, + Ordering::SeqCst, + Ordering::SeqCst, + ) + .is_err() + { + return Err(Box::new(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "program can only be run once", + ))); + } + + let result = if self.options.disable_catch_panics { + self.run_inner() + } else { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.run_inner())) { + Ok(result) => result, + Err(_) => { + self.cleanup_renderer(false); + Err(Box::new(ProgramError::Panic) as Box) + } + } + }; + self.state.store(PROGRAM_FINISHED, Ordering::SeqCst); match result { Ok(()) => Ok(self.model), - Err(e) => Err(Box::new(e)), + Err(error) => Err(error), } } } +/// Returns the configured initial terminal dimensions, with a stable fallback +/// for headless or non-terminal execution. +fn configured_window_size(options: &ProgramOptions) -> (usize, usize) { + let detected = match term_size() { + Ok((width, height)) => (width as usize, height as usize), + Err(_) => (80, 24), + }; + let width = if options.width == 0 { + detected.0 + } else { + options.width + }; + let height = if options.height == 0 { + detected.1 + } else { + options.height + }; + (width.max(1), height.max(1)) +} + +/// Looks up one `KEY=VALUE` entry from the configured environment snapshot. +fn env_value<'a>(env: &'a [String], key: &str) -> Option<&'a str> { + env.iter().find_map(|entry| { + let (entry_key, value) = entry.split_once('=')?; + (entry_key == key).then_some(value) + }) +} + +/// Maps the public Bubble Tea color profile to the renderer's profile type. +fn color_profile(profile: crate::profile::ColorProfile) -> rusty_colorprofile::Profile { + match profile { + crate::profile::ColorProfile::TrueColor => rusty_colorprofile::Profile::TrueColor, + crate::profile::ColorProfile::ANSI256 => rusty_colorprofile::Profile::Ansi256, + crate::profile::ColorProfile::ANSI => rusty_colorprofile::Profile::Ansi, + crate::profile::ColorProfile::Ascii => rusty_colorprofile::Profile::Ascii, + } +} + +/// Detects the renderer color profile using the configured environment and, +/// when stdout is the configured output, the process stdout terminal handle. +fn detect_color_profile(env: &[String], output_is_stdout: bool) -> rusty_colorprofile::Profile { + let output_fd = if output_is_stdout { + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + Some(std::io::stdout().as_raw_fd()) + } + #[cfg(not(unix))] + { + None + } + } else { + None + }; + rusty_ultraviolet::terminal_screen::detect_color_profile( + output_fd, + &rusty_ultraviolet::Environ(env.to_vec()), + ) +} + /// Execute the commands carried by a [BatchMsg], mirroring the upstream /// `execBatchMsg` handling (`tea.go`): every command runs concurrently on its /// own thread, and nested BatchMsg/SequenceMsg results are expanded inline @@ -646,18 +811,18 @@ fn dispatch_msg(msg: Box, tx: &MsgChannel) { /// ShouldQuerySynchronizedOutput returns whether the terminal is known to /// support synchronized output (mode 2026), mirroring the upstream gate in /// `tea.go`. -fn should_query_synchronized_output() -> bool { - let term_type = std::env::var("TERM").unwrap_or_default(); - let term_prog = std::env::var("TERM_PROGRAM").ok(); - let ssh_tty = std::env::var("SSH_TTY").is_ok(); - let wt_session = std::env::var("WT_SESSION").is_ok(); +fn should_query_synchronized_output(env: &[String]) -> bool { + let term_type = env_value(env, "TERM").unwrap_or_default(); + let term_prog = env_value(env, "TERM_PROGRAM"); + let ssh_tty = env_value(env, "SSH_TTY").is_some(); + let wt_session = env_value(env, "WT_SESSION").is_some(); let ok_term_prog = term_prog.is_some(); wt_session || term_type.contains("ghostty") || term_type.contains("wezterm") || (!ok_term_prog && !ssh_tty) - || (!ssh_tty && !term_prog.as_deref().unwrap_or("").contains("Apple")) + || (!ssh_tty && !term_prog.unwrap_or("").contains("Apple")) || term_type.contains("alacritty") || term_type.contains("kitty") || term_type.contains("rio") diff --git a/src/renderer.rs b/src/renderer.rs index cd450be..0a3fdaf 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -1,11 +1,15 @@ //! Cleanroom Rust port of upstream Go source file: `renderer.go` //! Upstream Target Tag / Version: `v2.0.8` //! -//! +//! //! # Renderer Trait //! //! Renderer interface trait for Bubble Tea v2.0.8 (`render(View)`, `flush(bool)`, `insert_above`, `clear_screen`). -//! +//! +//! +//! Maintainer note: frame rendering stays buffered so a renderer can diff and +//! flush updates atomically. Direct terminal queries are a separate escape +//! hatch for protocol responses that must precede a pending frame. use crate::model::Cmd; use crate::mouse::MouseMsg; @@ -41,6 +45,15 @@ pub trait Renderer: Send + Sync { /// Write raw string to output. fn write_string(&mut self, s: &str) -> Result>; + /// Writes a protocol sequence directly to the renderer output. + /// + /// The default delegates to write_string. Renderers with an out-of-band + /// writer should override this when the sequence must appear before + /// buffered frame output. + fn write_direct(&mut self, s: &str) -> Result> { + self.write_string(s) + } + /// Mouse event interceptor. fn on_mouse(&mut self, msg: MouseMsg) -> Cmd; diff --git a/tests/tea_test.rs b/tests/tea_test.rs index dc469af..004437d 100644 --- a/tests/tea_test.rs +++ b/tests/tea_test.rs @@ -31,7 +31,13 @@ impl Model for TestModel { quit() } - fn update(&mut self, _msg: &dyn Msg) -> Cmd { + fn update(&mut self, msg: &dyn Msg) -> Cmd { + if msg.as_any().is::() + || msg.as_any().is::() + || msg.as_any().is::() + { + return None; + } self.counter += 1; quit() } @@ -41,14 +47,203 @@ impl Model for TestModel { } } +#[derive(Default)] +struct StartupModel { + window: Option<(usize, usize)>, + environment: Option, + profile: Option, +} + +impl Model for StartupModel { + fn update(&mut self, msg: &dyn Msg) -> Cmd { + if let Some(window) = msg.as_any().downcast_ref::() { + self.window = Some((window.width, window.height)); + } else if let Some(environment) = msg.as_any().downcast_ref::() { + self.environment = Some(environment.getenv("APP_MODE")); + } else if let Some(profile) = msg.as_any().downcast_ref::() { + self.profile = Some(profile.profile); + return quit(); + } + None + } + + fn view(&self) -> View { + View::new("startup") + } +} + +struct PanicModel; + +impl Model for PanicModel { + fn init(&self) -> Cmd { + panic!("intentional lifecycle panic") + } + + fn update(&mut self, _msg: &dyn Msg) -> Cmd { + None + } + + fn view(&self) -> View { + View::new("panic") + } +} + /// Full interactive program run. #[test] fn test_v2_program_run() { let model = TestModel { counter: 0 }; - let prog = Program::new(model); + let prog = Program::new(model).with_options( + ProgramOptions::default() + .without_renderer() + .with_input(None), + ); assert_eq!(prog.run().unwrap().counter, 0); } +#[test] +fn test_program_handle_queues_prestart_quit_and_waits_for_cleanup() { + let program = Program::new(TestModel { counter: 0 }).with_options( + ProgramOptions::default() + .without_renderer() + .with_input(None), + ); + let handle = program.handle(); + handle.quit(); + + let runner = std::thread::spawn(move || { + program + .run() + .map(|model| model.counter) + .map_err(|error| error.to_string()) + }); + let model_counter = runner + .join() + .expect("program runner thread") + .expect("quit succeeds"); + handle.wait(); + assert_eq!(model_counter, 0); +} + +#[test] +fn test_program_handle_kill_returns_killed_after_cleanup() { + let program = Program::new(TestModel { counter: 0 }).with_options( + ProgramOptions::default() + .without_renderer() + .with_input(None), + ); + let handle = program.handle(); + handle.kill(); + + let runner = std::thread::spawn(move || { + program + .run() + .map(|_| String::new()) + .map_err(|error| error.to_string()) + }); + let error = runner + .join() + .expect("program runner thread") + .expect_err("kill should return an error"); + handle.wait(); + assert_eq!(error, rusty_bubbletea::program::ERR_PROGRAM_KILLED); +} + +#[test] +fn test_program_uses_configured_startup_contract() { + let options = ProgramOptions::default() + .without_renderer() + .with_input(None) + .with_window_size(100, 40) + .with_environment(vec![ + ("TERM".to_string(), "xterm-256color".to_string()), + ("APP_MODE".to_string(), "test".to_string()), + ]) + .with_color_profile(ColorProfile::ANSI256); + let model = Program::new(StartupModel::default()) + .with_options(options) + .run() + .expect("configured startup should quit cleanly"); + + assert_eq!(model.window, Some((100, 40))); + assert_eq!(model.environment.as_deref(), Some("test")); + assert_eq!(model.profile, Some(ColorProfile::ANSI256)); +} + +#[test] +fn test_program_context_cancellation_returns_killed() { + let context = Context::new(); + context.cancel(); + let result = Program::new(TestModel { counter: 0 }) + .with_options( + ProgramOptions::default() + .without_renderer() + .with_input(None) + .with_context(context), + ) + .run(); + + let error = result.err().expect("cancelled program should fail"); + assert_eq!( + error.downcast_ref::(), + Some(&rusty_bubbletea::program::ProgramError::Killed) + ); +} + +#[test] +fn test_program_recovers_from_init_panic() { + let result = Program::new(PanicModel) + .with_options( + ProgramOptions::default() + .without_renderer() + .with_input(None), + ) + .run(); + + let error = result + .err() + .expect("panic should be converted to ProgramError"); + assert_eq!( + error.downcast_ref::(), + Some(&rusty_bubbletea::program::ProgramError::Panic) + ); +} + +#[test] +fn test_protocol_query_precedes_buffered_renderer_startup_output() { + let output = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + struct RecordingWriter(std::sync::Arc>>); + impl std::io::Write for RecordingWriter { + fn write(&mut self, data: &[u8]) -> std::io::Result { + let mut output = self + .0 + .lock() + .map_err(|_| std::io::Error::other("recording writer lock poisoned"))?; + output.extend_from_slice(data); + Ok(data.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + let result = Program::new(TestModel { counter: 0 }) + .with_options( + ProgramOptions::default() + .with_input(None) + .with_output(Box::new(RecordingWriter(output.clone()))) + .with_environment(vec![("TERM".to_string(), "xterm-256color".to_string())]), + ) + .run(); + assert!(result.is_ok()); + + let bytes = output.lock().expect("recorded output lock").clone(); + assert!( + bytes.starts_with(b"\x1b[?2026$p\x1b[?2027$p"), + "protocol query must precede buffered renderer startup output: {bytes:?}" + ); +} + #[derive(Default)] struct MultiMsgModel { messages_received: usize, @@ -180,6 +375,11 @@ fn test_options_and_context() { assert!(opts.context.is_some()); assert!(opts.filter.is_some()); assert!(opts.environ.is_some()); + assert_eq!(ProgramOptions::::default().with_fps(0).fps, 60); + assert_eq!( + ProgramOptions::::default().with_fps(121).fps, + 120 + ); } #[test] @@ -205,6 +405,12 @@ fn test_commands_and_messages() { assert_eq!(seq_msg.0.len(), 2); assert!(format!("{:?}", seq_msg).contains("2 commands")); + let singleton = commands::batch(vec![None, commands::quit()]); + let singleton_msg = (singleton.expect("one command should be retained"))() + .expect("retained command should produce a message"); + assert!(singleton_msg.as_ref().as_any().is::()); + assert!(commands::sequence(vec![None, None]).is_none()); + let rsz = commands::request_window_size(); assert!(rsz.is_some());