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());