diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca356b7..20fc9d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,9 +27,11 @@ jobs: - uses: dtolnay/rust-toolchain@master with: toolchain: "1.88" + components: clippy - uses: Swatinem/rust-cache@v2 - run: cargo generate-lockfile - - run: cargo check --features async,serde + - run: cargo check -p superlighttui --features async,serde + - run: cargo clippy -p superlighttui --all-features -- -D warnings test: name: Test @@ -109,15 +111,24 @@ jobs: - run: cargo check -p superlighttui --no-default-features check-wasm: - name: Check (WASM) + name: Check (WASM backend) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: + components: clippy targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 - - run: cargo check -p slt-wasm --target wasm32-unknown-unknown + - uses: taiki-e/install-action@wasm-pack + - uses: browser-actions/setup-chrome@v1 + - uses: nanasess/setup-chromedriver@v2 + - run: cargo check -p slt-wasm --all-features --target wasm32-unknown-unknown + - run: cargo clippy -p slt-wasm --all-features --target wasm32-unknown-unknown -- -D warnings + - run: cargo test -p slt-wasm --all-features + - run: chrome --version || google-chrome --version + - run: chromedriver --version + - run: CHROMEDRIVER="$(command -v chromedriver)" wasm-pack test --headless --chrome crates/slt-wasm feature-check: name: Feature Combinations diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d9e133e..3f5cf68 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,65 +11,211 @@ env: CARGO_TERM_COLOR: always jobs: - ci-stable: - name: CI Gate (stable) + fmt: + name: Format runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: - components: clippy, rustfmt + components: rustfmt + - run: cargo fmt -- --check + + check-stable: + name: Check (stable) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - run: cargo check --all-features - # See ci.yml — perf-alloc tests use a global counter, so CI's higher - # parallelism contaminates measurements. Single-threaded keeps the - # budget asserts honest. - - run: cargo test --all-features -- --test-threads=1 + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 - run: cargo clippy --all-features -- -D warnings - - run: cargo fmt -- --check - ci-msrv: - name: CI Gate (MSRV 1.88) + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test --all-features + + examples: + name: Examples + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo check --examples --all-features + + msrv: + name: Check (MSRV 1.88) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@master with: toolchain: "1.88" + components: clippy - uses: Swatinem/rust-cache@v2 - run: cargo generate-lockfile - - run: cargo check --features async,serde + - run: cargo check -p superlighttui --features async,serde + - run: cargo clippy -p superlighttui --all-features -- -D warnings + + typos: + name: Typos + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: crate-ci/typos@master + + check-no-default: + name: Check (no-default-features) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo check -p superlighttui --no-default-features + + wasm-backend: + name: Check (WASM backend) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@wasm-pack + - uses: browser-actions/setup-chrome@v1 + - uses: nanasess/setup-chromedriver@v2 + - run: cargo check -p slt-wasm --all-features --target wasm32-unknown-unknown + - run: cargo clippy -p slt-wasm --all-features --target wasm32-unknown-unknown -- -D warnings + - run: cargo test -p slt-wasm --all-features + - run: chrome --version || google-chrome --version + - run: chromedriver --version + - run: CHROMEDRIVER="$(command -v chromedriver)" wasm-pack test --headless --chrome crates/slt-wasm + + feature-check: + name: Feature Combinations + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: taiki-e/install-action@cargo-hack + - name: Check each feature independently + run: cargo hack check -p superlighttui --each-feature --no-dev-deps + + audit: + name: Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/audit@v1 + + deny: + name: Deny Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: EmbarkStudios/cargo-deny-action@v2 + with: + command: check verify-version: name: Verify Version runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Check tag matches Cargo.toml + - name: Check tag matches package versions run: | + set -euo pipefail TAG="${GITHUB_REF#refs/tags/v}" - CARGO_VER=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') - if [ "$TAG" != "$CARGO_VER" ]; then - echo "::error::Tag v$TAG does not match Cargo.toml version $CARGO_VER" + ROOT_VER=$(awk -F'"' '/^version =/ { print $2; exit }' Cargo.toml) + WASM_VER=$(awk -F'"' '/^version =/ { print $2; exit }' crates/slt-wasm/Cargo.toml) + WASM_DEP=$(sed -n 's/.*superlighttui = { version = "\([^"]*\)".*/\1/p' crates/slt-wasm/Cargo.toml) + + if [ "$TAG" != "$ROOT_VER" ]; then + echo "::error::Tag v$TAG does not match Cargo.toml version $ROOT_VER" exit 1 fi - echo "Version verified: $CARGO_VER" + if [ "$TAG" != "$WASM_VER" ]; then + echo "::error::Tag v$TAG does not match slt-wasm version $WASM_VER" + exit 1 + fi + if [ "$TAG" != "$WASM_DEP" ]; then + echo "::error::Tag v$TAG does not match slt-wasm dependency $WASM_DEP" + exit 1 + fi + echo "Version verified: $TAG" + + publish-superlighttui: + name: Publish superlighttui + needs: + - fmt + - check-stable + - clippy + - test + - examples + - msrv + - typos + - check-no-default + - wasm-backend + - feature-check + - audit + - deny + - verify-version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo publish -p superlighttui + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - publish: - name: Publish to crates.io - needs: [ci-stable, ci-msrv, verify-version] + publish-slt-wasm: + name: Publish slt-wasm + needs: [publish-superlighttui] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - run: cargo publish --allow-dirty + - name: Wait for superlighttui on crates.io + run: | + set -euo pipefail + VERSION="${GITHUB_REF#refs/tags/v}" + for attempt in {1..30}; do + if cargo search superlighttui --limit 1 | grep -q "superlighttui = \"$VERSION\""; then + echo "superlighttui $VERSION is visible in the crates.io index" + exit 0 + fi + echo "Waiting for superlighttui $VERSION in crates.io index (attempt $attempt/30)" + sleep 10 + done + echo "::error::superlighttui $VERSION did not appear in crates.io index" + exit 1 + - run: cargo publish -p slt-wasm env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} github-release: name: GitHub Release - needs: [ci-stable, ci-msrv, verify-version] + needs: [publish-slt-wasm] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e6803d..5ca9d0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,69 @@ # Changelog +## [0.22.2] - 2026-06-28 + +Patch release preparation focused on terminal safety, browser backend lifecycle, +state cleanup, API consistency, release gates, and documentation parity. + +### Changed + +- **Color API f64 replacements** (#286) — added `*_f64` public replacements for + color math, theme overlays, text gradient stops, and `AppState::fps_f64`. + Existing `f32` methods remain as deprecated compatibility aliases. +- **Release workflow hardening** (#292) — tag releases now run the full blocking + gate before publishing, including examples, no-default-features, WASM, + feature-combination, typos, audit, and deny checks. +- **Ordered crate publishing** (#292) — `release.yml` publishes + `superlighttui` first, waits for the crates.io index to expose the tag + version, then publishes `slt-wasm` against that exact dependency version. +- **Workspace gate coverage** (#293) — `slt-wasm` is now part of the default + workspace members, CI runs WASM clippy/tests, and a `wasm-pack` browser smoke + verifies DOM mount/flush/dispose behavior. +- **WASM feature wiring** (#293) — `slt-wasm` now exposes a default `bidi` + feature that forwards to `superlighttui/bidi`, keeping browser text behavior + aligned with the native default build. + +### Fixed + +- **Explicit color depth is authoritative** (#284) — terminal foreground and + background SGR are emitted by SLT directly, so `NO_COLOR` affects only + `ColorDepth::detect()` and no longer overrides explicit `TrueColor` output. +- **Hermetic terminal tests** (#285) — terminal capability probes are TTY-guarded + and suspend/resume tests use in-process writers instead of touching real + stdout. +- **WASM runtime lifecycle** (#287) — browser event listeners and animation + frames are owned by `WasmAppHandle`, with `dispose()`/`Drop` cleanup. +- **Async task cleanup** (#288, #297) — background task registries and async + validators now abort superseded or dropped tasks, and async run loops exit + when the input channel disconnects. +- **Terminal graphics gating** (#289, #301) — Kitty, Sixel, and iTerm probing and + emission now respect TTY and tmux/screen guards unless explicitly forced. +- **Terminal teardown safety** (#290, #296) — panic, normal exit, and Unix + suspend/resume share the same cleanup path, including focus and Kitty keyboard + state. +- **Escape sanitization** (#291, #300) — terminal titles and static scrollback + output sanitize control bytes before emitting OSC/static text. +- **Overflow hardening** (#295) — rect edges and timer scheduling use saturating + or elapsed-time arithmetic instead of overflowing additions. +- **Dynamic state cleanup APIs** (#302) — keyed state, inactive screen focus + state, and inactive modes can now be removed or retained by callers. +- **Variable-height list cache cleanup** (#303) — `ListState::set_items` truncates + stale item heights when the item list shrinks. +- **WASM parity fixes** (#298) — DOM mouse/wheel modifiers map into SLT + modifiers, and underline plus strikethrough CSS is emitted together. +- **MSRV clippy cleanliness** (#299) — modernized format arguments that were + reported by `cargo +1.88 clippy --all-features -- -D warnings`. +- **Release version coordination** (#292) — bumped both published crates to + `0.22.2` and updated the `slt-wasm -> superlighttui` dependency requirement. + +### Docs + +- **README install snippet** (#294) — updated the copy-paste dependency version + from `0.21` to `0.22.2`. +- **Examples guide parity** (#294) — `docs/EXAMPLES.md` now lists only explicit + `[[example]]` targets as `cargo run --example ...` commands and routes + source-only demos through their tour binaries. + ## [0.22.1] - 2026-06-22 Patch release closing two community issues (#278, #279). Additive only — no diff --git a/Cargo.lock b/Cargo.lock index 0a5270d..516020c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,6 +35,17 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -616,6 +627,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -649,6 +666,16 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -712,6 +739,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -719,6 +755,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1166,11 +1203,12 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "slt-wasm" -version = "0.22.1" +version = "0.22.2" dependencies = [ "js-sys", "superlighttui", "wasm-bindgen", + "wasm-bindgen-test", "web-sys", ] @@ -1194,7 +1232,7 @@ checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" [[package]] name = "superlighttui" -version = "0.22.1" +version = "0.22.2" dependencies = [ "compact_str", "criterion", @@ -1622,6 +1660,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.122" @@ -1654,6 +1702,45 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-bindgen-test" +version = "0.3.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fde991ccdc895cb7fbaa14b137d62af74d9011be67b71c694bfc40edd3119c" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e925354648d2a4d1bf205412e36d520a800280622eef4719678d268e5d40e978" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "684365b586a9a6256c1cc3544eee8680de48d6041142f581776ec7b139622ae9" + [[package]] name = "wasm-encoder" version = "0.244.0" diff --git a/Cargo.toml b/Cargo.toml index 6818643..31f8083 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [".", "crates/slt-wasm"] +default-members = [".", "crates/slt-wasm"] # Shared lint configuration applied to every workspace member via # `[lints] workspace = true`. NOTE: `[lints]` is package-scoped, so these @@ -23,7 +24,7 @@ private_intra_doc_links = "warn" [package] name = "superlighttui" -version = "0.22.1" +version = "0.22.2" edition = "2024" description = "Super Light TUI - A lightweight, ergonomic terminal UI library" license = "MIT" diff --git a/README.md b/README.md index 8324c84..bb361f7 100644 --- a/README.md +++ b/README.md @@ -28,16 +28,16 @@ That makes it work equally well for humans prototyping a tool and for coding age
![]() Widget Demo cargo run --example demo |
- ![]() Dashboard cargo run --example demo_dashboard |
- ![]() Website Layout cargo run --example demo_website |
+ ![]() Dashboard cargo run --example showcase_tour |
+ ![]() Website Layout cargo run --example showcase_tour |
|
![]() Spreadsheet cargo run --example demo_spreadsheet |
- ![]() Games cargo run --example demo_game |
- ![]() DOOM Fire Effect cargo run --release --example demo_fire |
+ ![]() Spreadsheet cargo run --example showcase_tour |
+ ![]() Games cargo run --example showcase_tour |
+ ![]() DOOM Fire Effect cargo run --example text_tour |
![]() Pretext Reflow — text reflows around the mouse cursor in real time cargo run --example demo_pretext |
+ ![]() Pretext Reflow — text reflows around the mouse cursor in real time cargo run --example text_tour |
||||
`/`` grid and diffs
/// against the previously flushed frame so only changed cells are rewritten.
@@ -246,11 +340,17 @@ fn style_to_css(style: slt::Style) -> String {
if style.modifiers.contains(Modifiers::ITALIC) {
css.push_str("font-style:italic;");
}
+ let mut text_decorations = Vec::new();
if style.modifiers.contains(Modifiers::UNDERLINE) {
- css.push_str("text-decoration:underline;");
+ text_decorations.push("underline");
}
if style.modifiers.contains(Modifiers::STRIKETHROUGH) {
- css.push_str("text-decoration:line-through;");
+ text_decorations.push("line-through");
+ }
+ if !text_decorations.is_empty() {
+ css.push_str("text-decoration-line:");
+ css.push_str(&text_decorations.join(" "));
+ css.push(';');
}
if style.modifiers.contains(Modifiers::REVERSED) {
css.push_str("filter:invert(100%);");
@@ -322,6 +422,57 @@ fn indexed_to_rgb(i: u8) -> (u8, u8, u8) {
(gray, gray, gray)
}
+fn modifiers_from_bools(shift: bool, control: bool, alt: bool, super_key: bool) -> KeyModifiers {
+ let mut modifiers = KeyModifiers::NONE;
+ if shift {
+ modifiers.0 |= KeyModifiers::SHIFT.0;
+ }
+ if control {
+ modifiers.0 |= KeyModifiers::CONTROL.0;
+ }
+ if alt {
+ modifiers.0 |= KeyModifiers::ALT.0;
+ }
+ if super_key {
+ modifiers.0 |= KeyModifiers::SUPER.0;
+ }
+ modifiers
+}
+
+fn keyboard_event_modifiers(event: &KeyboardEvent) -> KeyModifiers {
+ modifiers_from_bools(
+ event.shift_key(),
+ event.ctrl_key(),
+ event.alt_key(),
+ event.meta_key(),
+ )
+}
+
+fn mouse_event_modifiers(event: &MouseEvent) -> KeyModifiers {
+ modifiers_from_bools(
+ event.shift_key(),
+ event.ctrl_key(),
+ event.alt_key(),
+ event.meta_key(),
+ )
+}
+
+fn reflect_bool(target: &JsValue, key: &str) -> bool {
+ js_sys::Reflect::get(target, &JsValue::from_str(key))
+ .ok()
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false)
+}
+
+fn reflect_event_modifiers(target: &JsValue) -> KeyModifiers {
+ modifiers_from_bools(
+ reflect_bool(target, "shiftKey"),
+ reflect_bool(target, "ctrlKey"),
+ reflect_bool(target, "altKey"),
+ reflect_bool(target, "metaKey"),
+ )
+}
+
fn keyboard_event_to_slt(event: &KeyboardEvent) -> Option {
let key = event.key();
let code = match key.as_str() {
@@ -353,21 +504,7 @@ fn keyboard_event_to_slt(event: &KeyboardEvent) -> Option {
}
};
- let mut modifiers = KeyModifiers::NONE;
- if event.shift_key() {
- modifiers.0 |= KeyModifiers::SHIFT.0;
- }
- if event.ctrl_key() {
- modifiers.0 |= KeyModifiers::CONTROL.0;
- }
- if event.alt_key() {
- modifiers.0 |= KeyModifiers::ALT.0;
- }
- if event.meta_key() {
- modifiers.0 |= KeyModifiers::SUPER.0;
- }
-
- Some(Event::key_mod(code, modifiers))
+ Some(Event::key_mod(code, keyboard_event_modifiers(event)))
}
fn mouse_button(button: i16) -> MouseButton {
@@ -501,13 +638,75 @@ fn paste_event_text(target: &JsValue) -> Option {
if text.is_empty() { None } else { Some(text) }
}
+fn event_target(target: &T) -> EventTarget {
+ target.unchecked_ref::().clone()
+}
+
+fn dispose_inner_now(inner: &Rc>) {
+ let mut app = inner.borrow_mut();
+ app.running = false;
+ if let Some(raf_id) = app.raf_id.take() {
+ let _ = app.window.cancel_animation_frame(raf_id);
+ }
+ app.listeners.clear();
+ app.events.borrow_mut().clear();
+ app.raf = None;
+}
+
+fn schedule_raf(inner: &Rc>) -> Result<(), JsValue> {
+ let raf_id = {
+ let app = inner.borrow();
+ if !app.running {
+ return Ok(());
+ }
+ let Some(raf) = app.raf.as_ref() else {
+ return Err(JsValue::from_str(
+ "failed to initialize requestAnimationFrame loop",
+ ));
+ };
+ app.window
+ .request_animation_frame(raf.as_ref().unchecked_ref())?
+ };
+ inner.borrow_mut().raf_id = Some(raf_id);
+ Ok(())
+}
+
+fn schedule_deferred_dispose(window: &Window, inner: Rc>) {
+ let cleanup = Closure::once_into_js(move || {
+ dispose_inner_now(&inner);
+ ACTIVE_APPS.with(|apps| {
+ apps.borrow_mut().retain(WasmAppHandle::is_running);
+ });
+ });
+ let _ =
+ window.set_timeout_with_callback_and_timeout_and_arguments_0(cleanup.unchecked_ref(), 0);
+}
+
+fn stop_after_current_frame(inner: Rc>) {
+ let window = {
+ let mut app = inner.borrow_mut();
+ if !app.running && app.raf.is_none() {
+ return;
+ }
+ app.running = false;
+ if let Some(raf_id) = app.raf_id.take() {
+ let _ = app.window.cancel_animation_frame(raf_id);
+ }
+ app.listeners.clear();
+ app.events.borrow_mut().clear();
+ app.window.clone()
+ };
+ schedule_deferred_dispose(&window, inner);
+}
+
fn install_event_listeners(
container: &HtmlElement,
window: &Window,
backend: Rc>,
events: Rc>>,
-) -> Result<(), JsValue> {
+) -> Result, JsValue> {
container.set_tab_index(0);
+ let mut listeners = Vec::new();
// The grid can be re-sized by the `resize` listener, so every
// position-dependent listener reads the live `(width, height)` from the
@@ -516,78 +715,102 @@ fn install_event_listeners(
// overlap the `borrow_mut()` in the RAF loop or the `resize` listener.
let key_events = Rc::clone(&events);
- let keydown = Closure::wrap(Box::new(move |event: KeyboardEvent| {
- if let Some(slt_event) = keyboard_event_to_slt(&event) {
+ let keydown = Closure::wrap(Box::new(move |event: web_sys::Event| {
+ let Some(event) = event.dyn_ref::() else {
+ return;
+ };
+ if let Some(slt_event) = keyboard_event_to_slt(event) {
key_events.borrow_mut().push(slt_event);
event.prevent_default();
}
}) as Box);
- container.add_event_listener_with_callback("keydown", keydown.as_ref().unchecked_ref())?;
- keydown.forget();
+ listeners.push(EventListener::install(
+ event_target(container),
+ "keydown",
+ keydown,
+ )?);
let move_events = Rc::clone(&events);
let container_move = container.clone();
let move_backend = Rc::clone(&backend);
- let mousemove = Closure::wrap(Box::new(move |event: MouseEvent| {
+ let mousemove = Closure::wrap(Box::new(move |event: web_sys::Event| {
+ let Some(event) = event.dyn_ref::() else {
+ return;
+ };
let (width, height) = move_backend.borrow().size();
- if let Some((x, y)) = mouse_cell_position(&event, &container_move, width, height) {
- let (pixel_x, pixel_y) = mouse_pixel_position(&event);
+ if let Some((x, y)) = mouse_cell_position(event, &container_move, width, height) {
+ let (pixel_x, pixel_y) = mouse_pixel_position(event);
move_events
.borrow_mut()
.push(Event::Mouse(SltMouseEvent::new(
MouseKind::Moved,
x,
y,
- KeyModifiers::NONE,
+ mouse_event_modifiers(event),
pixel_x,
pixel_y,
)));
}
}) as Box);
- container.add_event_listener_with_callback("mousemove", mousemove.as_ref().unchecked_ref())?;
- mousemove.forget();
+ listeners.push(EventListener::install(
+ event_target(container),
+ "mousemove",
+ mousemove,
+ )?);
let down_events = Rc::clone(&events);
let container_down = container.clone();
let down_backend = Rc::clone(&backend);
- let mousedown = Closure::wrap(Box::new(move |event: MouseEvent| {
+ let mousedown = Closure::wrap(Box::new(move |event: web_sys::Event| {
+ let Some(event) = event.dyn_ref::() else {
+ return;
+ };
let (width, height) = down_backend.borrow().size();
- if let Some((x, y)) = mouse_cell_position(&event, &container_down, width, height) {
- let (pixel_x, pixel_y) = mouse_pixel_position(&event);
+ if let Some((x, y)) = mouse_cell_position(event, &container_down, width, height) {
+ let (pixel_x, pixel_y) = mouse_pixel_position(event);
down_events
.borrow_mut()
.push(Event::Mouse(SltMouseEvent::new(
MouseKind::Down(mouse_button(event.button())),
x,
y,
- KeyModifiers::NONE,
+ mouse_event_modifiers(event),
pixel_x,
pixel_y,
)));
}
}) as Box);
- container.add_event_listener_with_callback("mousedown", mousedown.as_ref().unchecked_ref())?;
- mousedown.forget();
+ listeners.push(EventListener::install(
+ event_target(container),
+ "mousedown",
+ mousedown,
+ )?);
let up_events = Rc::clone(&events);
let container_up = container.clone();
let up_backend = Rc::clone(&backend);
- let mouseup = Closure::wrap(Box::new(move |event: MouseEvent| {
+ let mouseup = Closure::wrap(Box::new(move |event: web_sys::Event| {
+ let Some(event) = event.dyn_ref::() else {
+ return;
+ };
let (width, height) = up_backend.borrow().size();
- if let Some((x, y)) = mouse_cell_position(&event, &container_up, width, height) {
- let (pixel_x, pixel_y) = mouse_pixel_position(&event);
+ if let Some((x, y)) = mouse_cell_position(event, &container_up, width, height) {
+ let (pixel_x, pixel_y) = mouse_pixel_position(event);
up_events.borrow_mut().push(Event::Mouse(SltMouseEvent::new(
MouseKind::Up(mouse_button(event.button())),
x,
y,
- KeyModifiers::NONE,
+ mouse_event_modifiers(event),
pixel_x,
pixel_y,
)));
}
}) as Box);
- container.add_event_listener_with_callback("mouseup", mouseup.as_ref().unchecked_ref())?;
- mouseup.forget();
+ listeners.push(EventListener::install(
+ event_target(container),
+ "mouseup",
+ mouseup,
+ )?);
// Mouse wheel -> ScrollUp/Down/Left/Right at the cell under the cursor.
// The `WheelEvent` web-sys wrapper is not enabled, so the deltas and the
@@ -611,14 +834,17 @@ fn install_event_listeners(
kind,
x,
y,
- KeyModifiers::NONE,
+ reflect_event_modifiers(value),
None,
None,
)));
event.prevent_default();
}) as Box);
- container.add_event_listener_with_callback("wheel", wheel.as_ref().unchecked_ref())?;
- wheel.forget();
+ listeners.push(EventListener::install(
+ event_target(container),
+ "wheel",
+ wheel,
+ )?);
// Window resize -> recompute the cell grid from the container's pixel size
// and emit a `Resize` event. The backend re-sizes its buffer (and rebuilds
@@ -637,8 +863,11 @@ fn install_event_listeners(
backend.resize(cols, rows);
resize_events.borrow_mut().push(Event::Resize(cols, rows));
}) as Box);
- window.add_event_listener_with_callback("resize", resize.as_ref().unchecked_ref())?;
- resize.forget();
+ listeners.push(EventListener::install(
+ event_target(window),
+ "resize",
+ resize,
+ )?);
// Focus blur -> FocusLost / FocusGained so widgets can clear hover state,
// matching crossterm's focus events.
@@ -646,15 +875,21 @@ fn install_event_listeners(
let blur = Closure::wrap(Box::new(move |_event: web_sys::Event| {
blur_events.borrow_mut().push(Event::FocusLost);
}) as Box);
- container.add_event_listener_with_callback("blur", blur.as_ref().unchecked_ref())?;
- blur.forget();
+ listeners.push(EventListener::install(
+ event_target(container),
+ "blur",
+ blur,
+ )?);
let focus_events = Rc::clone(&events);
let focus = Closure::wrap(Box::new(move |_event: web_sys::Event| {
focus_events.borrow_mut().push(Event::FocusGained);
}) as Box);
- container.add_event_listener_with_callback("focus", focus.as_ref().unchecked_ref())?;
- focus.forget();
+ listeners.push(EventListener::install(
+ event_target(container),
+ "focus",
+ focus,
+ )?);
// Clipboard paste -> Paste(text). `ClipboardEvent`/`DataTransfer` wrappers
// are not enabled, so the text flavor is pulled reflectively from
@@ -666,24 +901,33 @@ fn install_event_listeners(
event.prevent_default();
}
}) as Box);
- container.add_event_listener_with_callback("paste", paste.as_ref().unchecked_ref())?;
- paste.forget();
+ listeners.push(EventListener::install(
+ event_target(container),
+ "paste",
+ paste,
+ )?);
- Ok(())
+ Ok(listeners)
}
-/// Mount `app` into `container` as a `width`×`height` cell grid and run it on
-/// the browser's `requestAnimationFrame` loop until the closure requests exit.
+/// Mount `app` into `container` as a `width`×`height` cell grid and return an
+/// owned handle for the browser runtime.
///
/// Installs the DOM event listeners (keyboard, mouse, wheel, resize, focus,
-/// paste) that feed SLT [`Event`]s into the run loop. Returns once the loop is
-/// scheduled; the closure keeps running via the retained RAF callback.
+/// paste) that feed SLT [`Event`]s into the run loop. The returned
+/// [`WasmAppHandle`] owns those listeners and the `requestAnimationFrame`
+/// callback; drop it or call [`WasmAppHandle::dispose`] to stop the app.
///
/// # Errors
///
/// Returns a [`JsValue`] error when the `window` is unavailable, a listener
/// fails to install, or the initial frame cannot be scheduled.
-pub fn run_wasm(container: HtmlElement, width: u32, height: u32, app: F) -> Result<(), JsValue>
+pub fn run_wasm_with_handle(
+ container: HtmlElement,
+ width: u32,
+ height: u32,
+ app: F,
+) -> Result
where
F: FnMut(&mut Context) + 'static,
{
@@ -699,18 +943,32 @@ where
let events = Rc::new(RefCell::new(Vec::::new()));
let app = Rc::new(RefCell::new(app));
- install_event_listeners(&container, &window, Rc::clone(&backend), Rc::clone(&events))?;
+ let listeners =
+ install_event_listeners(&container, &window, Rc::clone(&backend), Rc::clone(&events))?;
+ let inner = Rc::new(RefCell::new(WasmAppInner::new(
+ window.clone(),
+ listeners,
+ Rc::clone(&events),
+ )));
- let raf: RafHandle = Rc::new(RefCell::new(None));
- let raf_for_assign = Rc::clone(&raf);
- let raf_for_loop = Rc::clone(&raf);
+ let inner_weak: Weak> = Rc::downgrade(&inner);
let backend_ref = Rc::clone(&backend);
let state_ref = Rc::clone(&state);
let events_ref = Rc::clone(&events);
let app_ref = Rc::clone(&app);
- let window_ref = window.clone();
- *raf_for_assign.borrow_mut() = Some(Closure::wrap(Box::new(move |_ts: f64| {
+ let raf = Closure::wrap(Box::new(move |_ts: f64| {
+ let Some(inner) = inner_weak.upgrade() else {
+ return;
+ };
+ {
+ let mut app = inner.borrow_mut();
+ app.raf_id = None;
+ if !app.running {
+ return;
+ }
+ }
+
let frame_events = {
let mut queue = events_ref.borrow_mut();
std::mem::take(&mut *queue)
@@ -735,29 +993,47 @@ where
match keep_going {
Ok(true) => {
- if let Some(cb) = raf_for_loop.borrow().as_ref() {
- let _ = window_ref.request_animation_frame(cb.as_ref().unchecked_ref());
+ if let Err(err) = schedule_raf(&inner) {
+ web_sys::console::error_1(&JsValue::from_str(&format!(
+ "slt requestAnimationFrame error: {err:?}"
+ )));
+ stop_after_current_frame(inner);
}
}
- Ok(false) => {}
+ Ok(false) => stop_after_current_frame(inner),
Err(err) => {
web_sys::console::error_1(&JsValue::from_str(&format!("slt frame error: {err}")));
+ stop_after_current_frame(inner);
}
}
- }) as Box));
+ }) as Box);
- {
- let borrow = raf.borrow();
- if let Some(cb) = borrow.as_ref() {
- window.request_animation_frame(cb.as_ref().unchecked_ref())?;
- } else {
- return Err(JsValue::from_str(
- "failed to initialize requestAnimationFrame loop",
- ));
- }
- }
+ inner.borrow_mut().raf = Some(raf);
+ schedule_raf(&inner)?;
+
+ Ok(WasmAppHandle::new(inner))
+}
- std::mem::forget(raf);
+/// Mount `app` into `container` and run it until it requests exit.
+///
+/// This compatibility API preserves the original fire-and-forget behavior by
+/// storing the owned [`WasmAppHandle`] internally. New integrations that need
+/// explicit unmount/dispose control should prefer [`run_wasm_with_handle`].
+///
+/// # Errors
+///
+/// Returns a [`JsValue`] error when the `window` is unavailable, a listener
+/// fails to install, or the initial frame cannot be scheduled.
+pub fn run_wasm(container: HtmlElement, width: u32, height: u32, app: F) -> Result<(), JsValue>
+where
+ F: FnMut(&mut Context) + 'static,
+{
+ let handle = run_wasm_with_handle(container, width, height, app)?;
+ ACTIVE_APPS.with(|apps| {
+ let mut apps = apps.borrow_mut();
+ apps.retain(WasmAppHandle::is_running);
+ apps.push(handle);
+ });
Ok(())
}
@@ -770,6 +1046,24 @@ pub fn run_wasm_raw(container: HtmlElement, width: u32, height: u32) {
let _ = run_wasm(container, width, height, |_ui| {});
}
+/// `wasm-bindgen` entry point that mounts an empty SLT app and returns a
+/// disposable handle.
+///
+/// This is the JS-friendly counterpart to [`run_wasm_with_handle`] for browser
+/// harnesses that want to unmount the backend explicitly.
+///
+/// # Errors
+///
+/// Returns a [`JsValue`] error when mounting the empty app fails.
+#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
+pub fn run_wasm_raw_handle(
+ container: HtmlElement,
+ width: u32,
+ height: u32,
+) -> Result {
+ run_wasm_with_handle(container, width, height, |_ui| {})
+}
+
#[cfg(test)]
mod tests {
//! Logic-level tests for the DOM backend's pure helpers. The DOM-touching
@@ -848,7 +1142,30 @@ mod tests {
assert!(css.contains("color:#cd3131;"));
assert!(css.contains("background-color:#000000;"));
assert!(css.contains("font-weight:bold;"));
- assert!(css.contains("text-decoration:underline;"));
+ assert!(css.contains("text-decoration-line:underline;"));
+ }
+
+ #[test]
+ fn style_css_combines_text_decorations() {
+ let style = slt::Style::new().underline().strikethrough();
+ let css = style_to_css(style);
+ assert!(css.contains("text-decoration-line:underline line-through;"));
+ assert!(!css.contains("text-decoration:underline;"));
+ assert!(!css.contains("text-decoration:line-through;"));
+ }
+
+ #[test]
+ fn dom_modifier_bits_match_slt_modifiers() {
+ let modifiers = modifiers_from_bools(true, true, true, true);
+ assert!(modifiers.contains(KeyModifiers::SHIFT));
+ assert!(modifiers.contains(KeyModifiers::CONTROL));
+ assert!(modifiers.contains(KeyModifiers::ALT));
+ assert!(modifiers.contains(KeyModifiers::SUPER));
+
+ assert_eq!(
+ modifiers_from_bools(false, false, false, false),
+ KeyModifiers::NONE
+ );
}
#[test]
@@ -857,3 +1174,54 @@ mod tests {
assert_eq!(style_to_css(slt::Style::new()), "");
}
}
+
+#[cfg(all(test, target_arch = "wasm32"))]
+mod browser_tests {
+ use super::*;
+ use wasm_bindgen_test::wasm_bindgen_test;
+
+ wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
+
+ fn test_container() -> HtmlElement {
+ let document = web_sys::window()
+ .and_then(|window| window.document())
+ .expect("browser document");
+ let container = document
+ .create_element("div")
+ .expect("create test container")
+ .dyn_into::()
+ .expect("container is HtmlElement");
+ document
+ .body()
+ .expect("document body")
+ .append_child(&container)
+ .expect("append test container");
+ container
+ }
+
+ #[wasm_bindgen_test]
+ fn dom_backend_flush_builds_grid_and_updates_text() {
+ let container = test_container();
+ let mut backend = DomBackend::new(container.clone(), 4, 1);
+ backend
+ .buffer_mut()
+ .set_string(0, 0, "SLT", slt::Style::new().fg(Color::Red));
+
+ backend.flush().expect("flush DOM backend");
+
+ assert!(container.inner_html().contains("()` instead of receiving it as parameters (v0.19.0 component DX) |
-| `demo_design_system` | `cargo run --example demo_design_system` | — | Design tokens, ThemeColor, extends, WidgetTheme |
-| `demo_pretext` | `cargo run --example demo_pretext` | — | Pretext-inspired text reflow |
+| `hello` | `cargo run --example hello` | - | Smallest possible SLT app |
+| `counter` | `cargo run --example counter` | - | State + keyboard input |
+| `demo` | `cargo run --example demo` | `qrcode`, `syntax` optional | Broad widget overview |
+| `color_picker` | `cargo run --example color_picker` | - | Color picker interaction |
+
+## Tour Binaries
-## Data and visualization
+These are the canonical way to review feature groups. Each tour pulls several
+source-only demos from `examples/` via Rust modules.
| Example | Command | Features | Purpose |
|---------|---------|----------|---------|
-| `demo_dashboard` | `cargo run --example demo_dashboard` | — | Dashboard composition |
-| `demo_infoviz` | `cargo run --example demo_infoviz` | — | Charts and visual summaries |
-| `demo_trading` | `cargo run --example demo_trading` | — | Dense market/trading layout |
-| `demo_spreadsheet` | `cargo run --example demo_spreadsheet` | — | Grid and data entry feel |
-| `demo_raw_draw` | `cargo run --example demo_raw_draw` | — | Direct buffer drawing |
+| `v020_tour` | `cargo run --example v020_tour` | - | v0.20 API and interaction tour |
+| `v0211_tour` | `cargo run --example v0211_tour` | - | v0.21.1 API additions |
+| `v0210_widgets` | `cargo run --example v0210_widgets` | - | v0.21.0 widget coverage |
+| `cookbook_tour` | `cargo run --example cookbook_tour` | - | Cookbook flows: login, table, modal, file picker, dashboard |
+| `showcase_tour` | `cargo run --example showcase_tour` | - | Showcase screens and dense layouts |
+| `canvas_tour` | `cargo run --example canvas_tour` | - | Canvas and raw drawing examples |
+| `text_tour` | `cargo run --example text_tour` | - | Text, CJK, pretext, and typography demos |
+| `system_tour` | `cargo run --example system_tour --features async` | `async` | Async, inline, static, and system-level demos |
-## Rich terminal features
+## Diagnostics And Development Tools
| Example | Command | Features | Purpose |
|---------|---------|----------|---------|
-| `demo_kitty_image` | `cargo run --example demo_kitty_image` | — | Kitty graphics protocol |
-| `demo_fire` | `cargo run --release --example demo_fire` | — | Half-block visual effect |
-| `demo_ime` | `cargo run --example demo_ime` | — | IME and CJK input |
-| `demo_cjk` | `cargo run --example demo_cjk` | — | CJK (Chinese / Japanese / Korean) wide-character demo — title truncation, mixed Korean / Chinese / Japanese body wrap, narrow-clamp title boxes (12-cell width), CJK form fields, mouse support (group hover, click counters, mouse coords). |
-| `demo_key_test` | `cargo run --example demo_key_test` | — | Inspect key events |
-| `debug_selection` | `cargo run --example debug_selection` | — | Selection overlay debugging |
+| `debug_selection` | `cargo run --example debug_selection` | - | Selection overlay debugging |
+| `demo_key_test` | `cargo run --example demo_key_test` | - | Inspect key events |
+| `test_mouse` | `cargo run --example test_mouse` | - | Mouse interaction behavior |
+| `theme_hot_reload` | `cargo run --example theme_hot_reload --features theme-watch` | `theme-watch` | Theme file watcher demo |
-## Interaction-heavy apps
+## Performance And Reports
| Example | Command | Features | Purpose |
|---------|---------|----------|---------|
-| `demo_game` | `cargo run --example demo_game` | — | Games in immediate mode |
-| `error_boundary_demo` | `cargo run --example error_boundary_demo` | — | Panic recovery surface |
-| `test_mouse` | `cargo run --example test_mouse` | — | Mouse interaction behavior |
+| `perf_interactive` | `cargo run --example perf_interactive` | - | Interactive perf sanity checks |
+| `perf_regression` | `cargo run --example perf_regression` | - | Headless perf regression coverage |
+| `v020_perf_audit` | `cargo run --example v020_perf_audit` | - | Non-interactive v0.20 perf report |
+| `v020_test_utils` | `cargo run --example v020_test_utils` | - | Non-interactive test utility report |
-## Async and performance
+## Source-Only Demos
-| Example | Command | Features | Purpose |
-|---------|---------|----------|---------|
-| `async_demo` | `cargo run --example async_demo --features async` | `async` | Background message flow |
-| `perf_interactive` | `cargo run --example perf_interactive` | — | Interactive perf sanity checks |
-| `perf_regression` | `cargo run --example perf_regression` | — | Headless perf regression coverage |
+The remaining files in `examples/` are kept as source modules for the tours.
+They intentionally do not have standalone `[[example]]` targets. Use the tour
+listed below instead of `cargo run --example `.
+
+| Source demo family | Run this target |
+|--------------------|-----------------|
+| `inline`, `async_demo`, `v020_static_log`, `error_boundary_demo` | `cargo run --example system_tour --features async` |
+| `anim`, `v020_*` API demos | `cargo run --example v020_tour` |
+| `cookbook_*` demos | `cargo run --example cookbook_tour` |
+| `demo_cli`, `demo_table`, `demo_website`, `demo_design_system`, `demo_pretext` | `cargo run --example showcase_tour` |
+| `demo_dashboard`, `demo_infoviz`, `demo_trading`, `demo_spreadsheet`, `demo_raw_draw` | `cargo run --example showcase_tour` |
+| `demo_kitty_image`, `demo_fire`, `demo_ime`, `demo_cjk` | `cargo run --example text_tour` |
+| `demo_game`, `demo_overlay_anchor` | `cargo run --example showcase_tour` |
-## Suggested path
+## Suggested Path
1. Start with `hello`.
2. Move to `counter`.
3. Open `demo` for breadth.
-4. Jump to one domain-specific example that matches your app.
-5. Use `docs/PATTERNS.md` when you want composition guidance instead of a single demo.
+4. Run the tour closest to your target app.
+5. Use `docs/PATTERNS.md` when you want composition guidance instead of a
+ single demo.
diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md
index cf24fd2..4a20b0c 100644
--- a/docs/PERFORMANCE.md
+++ b/docs/PERFORMANCE.md
@@ -146,7 +146,7 @@ cargo bench --bench benchmarks -- --baseline before
```rust
// AppState API (src/lib.rs:251, :256)
-let fps = state.fps(); // exponential moving average
+let fps = state.fps_f64(); // exponential moving average
state.set_debug(true); // same as pressing F12
```
@@ -158,7 +158,7 @@ frame. The overlay layer is configurable via
or `BaseOnly` (issue #201 in `src/lib.rs:571–587`).
There is no `RunConfig::show_fps()` builder method. To put an FPS readout
-on screen, render `state.fps()` yourself in your UI closure, or rely on
+on screen, render `state.fps_f64()` yourself in your UI closure, or rely on
the F12 overlay during development.
### Custom instrumentation
diff --git a/docs/THEMING.md b/docs/THEMING.md
index 3a9483c..8ce0ca5 100644
--- a/docs/THEMING.md
+++ b/docs/THEMING.md
@@ -170,14 +170,14 @@ let surface = ui.theme().resolve(ThemeColor::Surface);
use slt::Color;
// WCAG 2.1 contrast ratio (>= 4.5 for AA normal text)
-let ratio = Color::contrast_ratio(fg, bg);
+let ratio = Color::contrast_ratio_f64(fg, bg);
let ok = Color::meets_contrast_aa(fg, bg);
// Auto-select readable text color for any background
let fg = ui.theme().contrast_text_on(bg_color);
// Blend color against theme background
-let overlay = ui.theme().overlay(color, 0.5);
+let overlay = ui.theme().overlay_f64(color, 0.5);
```
> **Note (v0.19.1)**: `Color::contrast_fg` and `Theme::contrast_text_on` use the WCAG 2.1 relative luminance threshold of `0.179`, not the previous `0.5`. Mid-tone backgrounds — Dracula purple (`Rgb(189, 147, 249)`, luminance ≈ 0.385), Solarized base1, Catppuccin lavender — now route to white text instead of black, matching WCAG AA contrast guidance. If you depended on the old midpoint behavior for stylistic reasons, override per-callsite with `WidgetColors` instead of relying on the default.
@@ -383,13 +383,13 @@ let blue = Color::Rgb(59, 130, 246);
// Alpha blending: blend(other, alpha)
// alpha=0.0 returns other, alpha=1.0 returns self
-let gray = white.blend(black, 0.5); // ~Rgb(128, 128, 128)
+let gray = white.blend_f64(black, 0.5); // ~Rgb(128, 128, 128)
// Lighten toward white (0.0 = unchanged, 1.0 = white)
-let light_blue = blue.lighten(0.3);
+let light_blue = blue.lighten_f64(0.3);
// Darken toward black (0.0 = unchanged, 1.0 = black)
-let dark_blue = blue.darken(0.3);
+let dark_blue = blue.darken_f64(0.3);
```
### Luminance and contrast
@@ -400,7 +400,7 @@ use slt::Color;
let bg = Color::Rgb(30, 30, 46);
// Perceived brightness (0.0 = darkest, 1.0 = brightest)
-let lum = bg.luminance(); // ~0.013 (sRGB-linearized, BT.709 weights)
+let lum = bg.luminance_f64(); // ~0.013 (sRGB-linearized, BT.709 weights)
// Automatic readable foreground for a background color
// Returns white if luminance > 0.179 (WCAG threshold), black otherwise
diff --git a/examples/demo_design_system.rs b/examples/demo_design_system.rs
index fdb5a09..790888c 100644
--- a/examples/demo_design_system.rs
+++ b/examples/demo_design_system.rs
@@ -223,7 +223,7 @@ pub fn render(ui: &mut Context, state: &mut DemoState) {
let _ = ui.container().gap(sp.xs()).row(|ui| {
for (label, bg_color) in test_bgs {
let fg = Color::contrast_fg(bg_color);
- let ratio = Color::contrast_ratio(fg, bg_color);
+ let ratio = Color::contrast_ratio_f64(fg, bg_color);
let _ = ui.container().bg(bg_color).p(1).grow(1).col(|ui| {
ui.text(label).fg(fg).bold();
ui.text(format!("ratio: {:.1}", ratio)).fg(fg);
diff --git a/examples/v0211_tour.rs b/examples/v0211_tour.rs
index be8dd31..3a3ebdd 100644
--- a/examples/v0211_tour.rs
+++ b/examples/v0211_tour.rs
@@ -74,22 +74,22 @@ pub fn render(ui: &mut Context, state: &mut TourState) {
let _ = ui
.text("SuperLightTUI · v0.21.1 tour")
.bold()
- .gradient_stops(&[
- (0.0, Color::from_hsl(200.0, 0.9, 0.6)),
- (0.5, Color::from_hsl(280.0, 0.9, 0.65)),
- (1.0, Color::from_hsl(330.0, 0.9, 0.6)),
+ .gradient_stops_f64(&[
+ (0.0, Color::from_hsl_f64(200.0, 0.9, 0.6)),
+ (0.5, Color::from_hsl_f64(280.0, 0.9, 0.65)),
+ (1.0, Color::from_hsl_f64(330.0, 0.9, 0.6)),
]);
let _ = ui.text(" additive APIs, no breaking changes ").bg_gradient(
- Color::from_hsl(210.0, 0.7, 0.35),
- Color::from_hsl(330.0, 0.7, 0.35),
+ Color::from_hsl_f64(210.0, 0.7, 0.35),
+ Color::from_hsl_f64(330.0, 0.7, 0.35),
);
// ── Color ergonomics: HSL swatch row, a parsed hex, and rotate_hue.
let _ = ui.container().gap(1).row(|ui| {
ui.text("Color:").dim();
for i in 0..12 {
- let hue = i as f32 / 12.0 * 360.0;
- ui.text("██").fg(Color::from_hsl(hue, 0.85, 0.6));
+ let hue = f64::from(i) / 12.0 * 360.0;
+ ui.text("██").fg(Color::from_hsl_f64(hue, 0.85, 0.6));
}
});
let _ = ui.container().gap(1).row(|ui| {
@@ -97,7 +97,7 @@ pub fn render(ui: &mut Context, state: &mut TourState) {
ui.text("\"#ff6b6b\".parse()").dim();
ui.text("██").fg(parsed);
ui.text("rotate_hue(180)").dim();
- ui.text("██").fg(parsed.rotate_hue(180.0));
+ ui.text("██").fg(parsed.rotate_hue_f64(180.0));
});
// ── Spinner presets.
diff --git a/src/buffer.rs b/src/buffer.rs
index 77c59c6..bd6d517 100644
--- a/src/buffer.rs
+++ b/src/buffer.rs
@@ -1047,11 +1047,11 @@ fn write_color(out: &mut String, color: crate::style::Color) {
Color::LightWhite => out.push_str("light_white"),
Color::Rgb(r, g, b) => {
use std::fmt::Write;
- let _ = write!(out, "#{:02x}{:02x}{:02x}", r, g, b);
+ let _ = write!(out, "#{r:02x}{g:02x}{b:02x}");
}
Color::Indexed(idx) => {
use std::fmt::Write;
- let _ = write!(out, "idx{}", idx);
+ let _ = write!(out, "idx{idx}");
}
}
}
diff --git a/src/chart/render.rs b/src/chart/render.rs
index 4de5a2b..db48582 100644
--- a/src/chart/render.rs
+++ b/src/chart/render.rs
@@ -333,7 +333,7 @@ pub(crate) fn render_chart(config: &ChartConfig) -> Vec {
} else {
(String::new(), '│')
};
- let padded = format!("{:>w$}", label, w = y_tick_width);
+ let padded = format!("{label:>y_tick_width$}");
segments.push((padded, axis_style));
segments.push((format!("{divider} "), axis_style));
}
diff --git a/src/context.rs b/src/context.rs
index dcf9231..6211193 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -33,14 +33,14 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
#[allow(dead_code)]
fn slt_assert(condition: bool, msg: &str) {
if !condition {
- panic!("[SLT] {}", msg);
+ panic!("[SLT] {msg}");
}
}
#[cfg(debug_assertions)]
#[allow(dead_code, clippy::print_stderr)]
fn slt_warn(msg: &str) {
- eprintln!("\x1b[33m[SLT warning]\x1b[0m {}", msg);
+ eprintln!("\x1b[33m[SLT warning]\x1b[0m {msg}");
}
#[cfg(not(debug_assertions))]
diff --git a/src/context/async_tasks.rs b/src/context/async_tasks.rs
index 58009ea..d458d24 100644
--- a/src/context/async_tasks.rs
+++ b/src/context/async_tasks.rs
@@ -127,6 +127,15 @@ impl Default for AsyncTasks {
}
}
+impl Drop for AsyncTasks {
+ fn drop(&mut self) {
+ for (_, join) in self.joins.drain() {
+ join.abort();
+ }
+ self.results.clear();
+ }
+}
+
impl AsyncTasks {
/// Inject the ambient Tokio runtime handle. Called once by `run_async*`
/// before the first frame so `spawn` has a runtime to launch onto.
diff --git a/src/context/async_tasks_tests.rs b/src/context/async_tasks_tests.rs
index 6f51ccd..f7e1839 100644
--- a/src/context/async_tasks_tests.rs
+++ b/src/context/async_tasks_tests.rs
@@ -173,3 +173,27 @@ fn default_registry_has_no_runtime_and_polls_none() {
// Polling an id that was never spawned yields None.
assert_eq!(tasks.poll::(999), None);
}
+
+#[tokio::test]
+async fn dropping_registry_aborts_live_tasks() {
+ use std::sync::Arc;
+ use std::sync::atomic::{AtomicBool, Ordering};
+
+ let completed = Arc::new(AtomicBool::new(false));
+ let completed_in_task = Arc::clone(&completed);
+
+ let mut tasks = AsyncTasks::default();
+ tasks.set_runtime(tokio::runtime::Handle::current());
+ let handle = tasks.spawn(async move {
+ tokio::time::sleep(Duration::from_millis(50)).await;
+ completed_in_task.store(true, Ordering::SeqCst);
+ });
+
+ drop(tasks);
+ tokio::time::sleep(Duration::from_millis(120)).await;
+ assert!(
+ !completed.load(Ordering::SeqCst),
+ "dropping AsyncTasks must abort live tasks owned by the session"
+ );
+ drop(handle);
+}
diff --git a/src/context/runtime.rs b/src/context/runtime.rs
index 11d7581..6174684 100644
--- a/src/context/runtime.rs
+++ b/src/context/runtime.rs
@@ -1099,10 +1099,7 @@ impl Context {
let state = entry
.downcast_mut::()
.unwrap_or_else(|| {
- panic!(
- "animate_value: id {:?} is already used for a different state type",
- id
- )
+ panic!("animate_value: id {id:?} is already used for a different state type")
});
state.sample(target, duration_ticks, tick)
}
@@ -1144,15 +1141,13 @@ impl Context {
.entry(id)
.or_insert_with(|| SchedulerSlot {
started: now,
- kind: SchedKind::Once {
- deadline: now + dur,
- fired: false,
- },
+ kind: SchedKind::Once { dur, fired: false },
touched_this_frame: false,
});
slot.touched_this_frame = true;
+ let elapsed = now.saturating_duration_since(slot.started);
match &mut slot.kind {
- SchedKind::Once { deadline, fired } if !*fired && now >= *deadline => {
+ SchedKind::Once { dur, fired } if !*fired && elapsed >= *dur => {
*fired = true;
true
}
@@ -1207,7 +1202,8 @@ impl Context {
if fired > 0 {
// Advance by exactly the intervals reported so counts never
// drift, even across stalled frames.
- *last += *interval * fired;
+ let advance = interval.saturating_mul(fired);
+ *last = last.checked_add(advance).unwrap_or(now);
}
fired
}
@@ -1251,7 +1247,7 @@ impl Context {
started: now,
kind: SchedKind::Debounce {
dur,
- deadline: now + dur,
+ quiet_started: now,
fired: false,
},
touched_this_frame: false,
@@ -1260,16 +1256,16 @@ impl Context {
match &mut slot.kind {
SchedKind::Debounce {
dur: slot_dur,
- deadline,
+ quiet_started,
fired,
} => {
*slot_dur = dur;
if dirty {
// Re-arm the quiet window from this frame.
- *deadline = now + dur;
+ *quiet_started = now;
*fired = false;
false
- } else if !*fired && now >= *deadline {
+ } else if !*fired && now.saturating_duration_since(*quiet_started) >= *slot_dur {
*fired = true;
true
} else {
@@ -1381,6 +1377,34 @@ impl Context {
Some(self.frame_instant.saturating_duration_since(started))
}
+ /// Remove dynamic keyed state created by
+ /// [`use_state_keyed`](Self::use_state_keyed).
+ ///
+ /// Returns `true` when a slot existed. Any old [`State`] handle for the
+ /// removed id becomes invalid and will panic if used before the state is
+ /// recreated by `use_state_keyed`.
+ pub fn remove_state_keyed(&mut self, id: &str) -> bool {
+ self.keyed_states.remove(id).is_some()
+ }
+
+ /// Retain only dynamic keyed-state entries accepted by `keep`.
+ ///
+ /// Returns the number of removed entries. This is intended for long-lived
+ /// dynamic lists where ids come from data and removed items should release
+ /// their per-row state.
+ pub fn retain_state_keyed(&mut self, mut keep: impl FnMut(&str) -> bool) -> usize {
+ let before = self.keyed_states.len();
+ self.keyed_states.retain(|key, _| keep(key));
+ before - self.keyed_states.len()
+ }
+
+ /// Number of live dynamic keyed-state entries.
+ ///
+ /// Diagnostic helper for spotting churn when using runtime ids.
+ pub fn keyed_state_count(&self) -> usize {
+ self.keyed_states.len()
+ }
+
/// Push a value onto the context stack for the duration of `body`.
///
/// Inside `body`, child widgets can call
diff --git a/src/context/scheduler_tests.rs b/src/context/scheduler_tests.rs
index d693e00..dcd2be4 100644
--- a/src/context/scheduler_tests.rs
+++ b/src/context/scheduler_tests.rs
@@ -258,6 +258,32 @@ fn no_default_features_smoke() {
});
}
+#[test]
+fn huge_durations_do_not_overflow_instant_arithmetic() {
+ let mut tb = TestBackend::new(20, 3);
+ tb.render(|ui| {
+ assert!(
+ !ui.schedule("huge-once", Duration::MAX),
+ "a huge one-shot timer should arm without firing immediately"
+ );
+ assert_eq!(
+ ui.every("huge-every", Duration::MAX),
+ 0,
+ "a huge interval should not fire on the arming frame"
+ );
+ assert!(
+ !ui.debounce("huge-debounce", Duration::MAX, false),
+ "a huge debounce window should arm without firing immediately"
+ );
+ });
+
+ tb.render(|ui| {
+ assert!(!ui.schedule("huge-once", Duration::MAX));
+ assert_eq!(ui.every("huge-every", Duration::MAX), 0);
+ assert!(!ui.debounce("huge-debounce", Duration::MAX, true));
+ });
+}
+
mod proptests {
use super::*;
use crate::widgets::intervals_elapsed;
diff --git a/src/context/tests.rs b/src/context/tests.rs
index 17369fb..9cef080 100644
--- a/src/context/tests.rs
+++ b/src/context/tests.rs
@@ -499,6 +499,66 @@ fn screen_nav_no_op_without_request() {
assert_eq!(screens.current(), "home");
}
+#[test]
+fn keyed_state_cleanup_removes_dynamic_entries() {
+ let mut backend = TestBackend::new(24, 3);
+
+ backend.render(|ui| {
+ let a = ui.use_state_keyed("row-a", || 1u32);
+ let b = ui.use_state_keyed("row-b", || 2u32);
+ assert_eq!(*a.get(ui), 1);
+ assert_eq!(*b.get(ui), 2);
+ assert_eq!(ui.keyed_state_count(), 2);
+
+ assert!(ui.remove_state_keyed("row-a"));
+ assert!(!ui.remove_state_keyed("missing"));
+ assert_eq!(ui.keyed_state_count(), 1);
+
+ let removed = ui.retain_state_keyed(|key| key == "row-b");
+ assert_eq!(removed, 0);
+ assert_eq!(ui.keyed_state_count(), 1);
+ });
+}
+
+#[test]
+fn screen_state_cleanup_removes_inactive_hook_and_focus_state() {
+ let mut backend = TestBackend::new(24, 3);
+ let mut screens = ScreenState::new("home");
+ screens.push("detail-1");
+
+ backend.render(|ui| {
+ ui.screen("detail-1", &mut screens, |ui| {
+ let value = ui.use_state(|| 7u32);
+ ui.text(format!("{}", value.get(ui)));
+ ui.register_focusable();
+ });
+ });
+ assert_eq!(screens.focus_state_count(), 1);
+
+ screens.pop();
+ backend.render(|ui| {
+ assert_eq!(ui.screen_state_count(), 1);
+ assert!(ui.remove_screen_state(&mut screens, "detail-1"));
+ assert_eq!(ui.screen_state_count(), 0);
+ });
+ assert_eq!(screens.focus_state_count(), 0);
+}
+
+#[test]
+fn screen_state_cleanup_preserves_stacked_screens() {
+ let mut backend = TestBackend::new(24, 3);
+ let mut screens = ScreenState::new("home");
+
+ backend.render(|ui| {
+ ui.screen("home", &mut screens, |ui| {
+ let value = ui.use_state(|| 1u32);
+ ui.text(format!("{}", value.get(ui)));
+ });
+ assert!(!ui.remove_screen_state(&mut screens, "home"));
+ assert_eq!(ui.screen_state_count(), 1);
+ });
+}
+
// === Issue #54: mouse_drag / mouse_up convenience methods ===
#[test]
diff --git a/src/context/widgets_display.rs b/src/context/widgets_display.rs
index 95c0931..e50dce9 100644
--- a/src/context/widgets_display.rs
+++ b/src/context/widgets_display.rs
@@ -440,15 +440,122 @@ fn normalize_rgba(data: &[u8], width: u32, height: u32) -> Vec {
buf
}
+fn image_fit_rows(src_width: u32, src_height: u32, cols: u32, cell_w: u32, cell_h: u32) -> u32 {
+ if src_width == 0 || src_height == 0 || cell_w == 0 || cell_h == 0 {
+ return 1;
+ }
+
+ ((cols as f64 * src_height as f64 * cell_w as f64) / (src_width as f64 * cell_h as f64))
+ .ceil()
+ .max(1.0) as u32
+}
+
+fn sample_rgba_color(
+ data: &[u8],
+ src_width: u32,
+ src_height: u32,
+ dst_x: u32,
+ dst_y: u32,
+ dst_width: u32,
+ dst_height: u32,
+) -> Option {
+ if src_width == 0 || src_height == 0 || dst_width == 0 || dst_height == 0 {
+ return None;
+ }
+
+ let src_x = (u64::from(dst_x) * u64::from(src_width) / u64::from(dst_width))
+ .min(u64::from(src_width.saturating_sub(1)));
+ let src_y = (u64::from(dst_y) * u64::from(src_height) / u64::from(dst_height))
+ .min(u64::from(src_height.saturating_sub(1)));
+ let pixel = src_y
+ .saturating_mul(u64::from(src_width))
+ .saturating_add(src_x);
+ let idx = usize::try_from(pixel).ok().and_then(|p| p.checked_mul(4))?;
+ let px = data.get(idx..idx + 4)?;
+ if px[3] == 0 {
+ None
+ } else {
+ Some(Color::Rgb(px[0], px[1], px[2]))
+ }
+}
+
+fn draw_halfblock_cell(
+ buf: &mut crate::Buffer,
+ x: u32,
+ y: u32,
+ upper: Option,
+ lower: Option,
+) {
+ match (upper, lower) {
+ (Some(upper), Some(lower)) => {
+ buf.set_char(x, y, '▀', Style::new().fg(upper).bg(lower));
+ }
+ (Some(upper), None) => {
+ buf.set_char(x, y, '▀', Style::new().fg(upper));
+ }
+ (None, Some(lower)) => {
+ buf.set_char(x, y, '▄', Style::new().fg(lower));
+ }
+ (None, None) => {
+ buf.set_char(x, y, ' ', Style::new());
+ }
+ }
+}
+
#[cfg(feature = "crossterm")]
-fn terminal_supports_sixel() -> bool {
- let force = std::env::var("SLT_FORCE_SIXEL")
+fn terminal_force_graphics(var: &str) -> bool {
+ std::env::var(var)
+ .ok()
+ .map(|v| v.to_ascii_lowercase())
+ .is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on"))
+}
+
+#[cfg(feature = "crossterm")]
+fn terminal_graphics_blocked_by_multiplexer() -> bool {
+ let term = std::env::var("TERM")
+ .ok()
+ .map(|v| v.to_ascii_lowercase())
+ .unwrap_or_default();
+ let tmux = std::env::var_os("TMUX").is_some();
+ let screen = std::env::var_os("STY").is_some();
+
+ tmux || screen
+ || term == "tmux"
+ || term.starts_with("tmux-")
+ || term == "screen"
+ || term.starts_with("screen-")
+ || term.starts_with("screen.")
+}
+
+#[cfg(feature = "crossterm")]
+fn terminal_supports_kitty() -> bool {
+ if terminal_force_graphics("SLT_FORCE_KITTY") {
+ return true;
+ }
+ if terminal_graphics_blocked_by_multiplexer() {
+ return false;
+ }
+
+ let term = std::env::var("TERM")
.ok()
.map(|v| v.to_ascii_lowercase())
.unwrap_or_default();
- if matches!(force.as_str(), "1" | "true" | "yes" | "on") {
+ let term_program = std::env::var("TERM_PROGRAM")
+ .ok()
+ .map(|v| v.to_ascii_lowercase())
+ .unwrap_or_default();
+
+ term.contains("kitty") || matches!(term_program.as_str(), "ghostty" | "wezterm" | "kitty")
+}
+
+#[cfg(feature = "crossterm")]
+fn terminal_supports_sixel() -> bool {
+ if terminal_force_graphics("SLT_FORCE_SIXEL") {
return true;
}
+ if terminal_graphics_blocked_by_multiplexer() {
+ return false;
+ }
let term = std::env::var("TERM")
.ok()
@@ -487,13 +594,12 @@ fn terminal_supports_sixel() -> bool {
/// regression-test parity.
#[cfg(feature = "crossterm")]
fn terminal_supports_iterm() -> bool {
- let force = std::env::var("SLT_FORCE_ITERM")
- .ok()
- .map(|v| v.to_ascii_lowercase())
- .unwrap_or_default();
- if matches!(force.as_str(), "1" | "true" | "yes" | "on") {
+ if terminal_force_graphics("SLT_FORCE_ITERM") {
return true;
}
+ if terminal_graphics_blocked_by_multiplexer() {
+ return false;
+ }
let term_program = std::env::var("TERM_PROGRAM")
.ok()
@@ -509,235 +615,503 @@ fn terminal_supports_iterm() -> bool {
}
#[cfg(all(test, feature = "crossterm"))]
-mod sixel_detection_tests {
- use super::terminal_supports_sixel;
- use std::sync::Mutex;
-
- // Env vars are process-global. A test-local mutex serializes access so
- // concurrent test threads don't observe each other's set_var() calls.
- static ENV_GUARD: Mutex<()> = Mutex::new(());
-
- // edition 2024: env mutation is `unsafe`; ENV_GUARD makes it sound here.
- #[allow(unsafe_code)]
- fn with_env(term: Option<&str>, term_program: Option<&str>, force: bool, f: F) {
- let _g = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
- let prev_term = std::env::var("TERM").ok();
- let prev_program = std::env::var("TERM_PROGRAM").ok();
- let prev_force = std::env::var("SLT_FORCE_SIXEL").ok();
-
- // SAFETY (edition 2024): set_var/remove_var are unsafe because env
- // mutation races concurrent reads. ENV_GUARD above serializes these
- // test helpers, so no other thread observes a torn update.
- unsafe {
- match term {
- Some(v) => std::env::set_var("TERM", v),
- None => std::env::remove_var("TERM"),
- }
- match term_program {
- Some(v) => std::env::set_var("TERM_PROGRAM", v),
- None => std::env::remove_var("TERM_PROGRAM"),
- }
- if force {
- std::env::set_var("SLT_FORCE_SIXEL", "1");
- } else {
- std::env::remove_var("SLT_FORCE_SIXEL");
- }
+#[derive(Default)]
+struct GraphicsEnv<'a> {
+ term: Option<&'a str>,
+ term_program: Option<&'a str>,
+ force_sixel: bool,
+ force_iterm: bool,
+ force_kitty: bool,
+ tmux: bool,
+ sty: bool,
+}
+
+#[cfg(all(test, feature = "crossterm"))]
+static GRAPHICS_ENV_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
+
+#[cfg(all(test, feature = "crossterm"))]
+#[allow(unsafe_code)]
+fn with_graphics_env(env: GraphicsEnv<'_>, f: F) {
+ let _g = GRAPHICS_ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
+ let prev_term = std::env::var("TERM").ok();
+ let prev_program = std::env::var("TERM_PROGRAM").ok();
+ let prev_sixel = std::env::var("SLT_FORCE_SIXEL").ok();
+ let prev_iterm = std::env::var("SLT_FORCE_ITERM").ok();
+ let prev_kitty = std::env::var("SLT_FORCE_KITTY").ok();
+ let prev_tmux = std::env::var("TMUX").ok();
+ let prev_sty = std::env::var("STY").ok();
+
+ // SAFETY (edition 2024): set_var/remove_var are unsafe because env
+ // mutation races concurrent reads. GRAPHICS_ENV_GUARD serializes these
+ // tests so no other detection test observes a torn update.
+ unsafe {
+ match env.term {
+ Some(v) => std::env::set_var("TERM", v),
+ None => std::env::remove_var("TERM"),
+ }
+ match env.term_program {
+ Some(v) => std::env::set_var("TERM_PROGRAM", v),
+ None => std::env::remove_var("TERM_PROGRAM"),
}
+ if env.force_sixel {
+ std::env::set_var("SLT_FORCE_SIXEL", "1");
+ } else {
+ std::env::remove_var("SLT_FORCE_SIXEL");
+ }
+ if env.force_iterm {
+ std::env::set_var("SLT_FORCE_ITERM", "1");
+ } else {
+ std::env::remove_var("SLT_FORCE_ITERM");
+ }
+ if env.force_kitty {
+ std::env::set_var("SLT_FORCE_KITTY", "1");
+ } else {
+ std::env::remove_var("SLT_FORCE_KITTY");
+ }
+ if env.tmux {
+ std::env::set_var("TMUX", "/tmp/tmux-1000/default,1,0");
+ } else {
+ std::env::remove_var("TMUX");
+ }
+ if env.sty {
+ std::env::set_var("STY", "1234.pts-0.host");
+ } else {
+ std::env::remove_var("STY");
+ }
+ }
- f();
+ f();
- unsafe {
- match prev_term {
- Some(v) => std::env::set_var("TERM", v),
- None => std::env::remove_var("TERM"),
- }
- match prev_program {
- Some(v) => std::env::set_var("TERM_PROGRAM", v),
- None => std::env::remove_var("TERM_PROGRAM"),
- }
- match prev_force {
- Some(v) => std::env::set_var("SLT_FORCE_SIXEL", v),
- None => std::env::remove_var("SLT_FORCE_SIXEL"),
- }
+ unsafe {
+ match prev_term {
+ Some(v) => std::env::set_var("TERM", v),
+ None => std::env::remove_var("TERM"),
+ }
+ match prev_program {
+ Some(v) => std::env::set_var("TERM_PROGRAM", v),
+ None => std::env::remove_var("TERM_PROGRAM"),
}
+ match prev_sixel {
+ Some(v) => std::env::set_var("SLT_FORCE_SIXEL", v),
+ None => std::env::remove_var("SLT_FORCE_SIXEL"),
+ }
+ match prev_iterm {
+ Some(v) => std::env::set_var("SLT_FORCE_ITERM", v),
+ None => std::env::remove_var("SLT_FORCE_ITERM"),
+ }
+ match prev_kitty {
+ Some(v) => std::env::set_var("SLT_FORCE_KITTY", v),
+ None => std::env::remove_var("SLT_FORCE_KITTY"),
+ }
+ match prev_tmux {
+ Some(v) => std::env::set_var("TMUX", v),
+ None => std::env::remove_var("TMUX"),
+ }
+ match prev_sty {
+ Some(v) => std::env::set_var("STY", v),
+ None => std::env::remove_var("STY"),
+ }
+ }
+}
+
+#[cfg(all(test, feature = "crossterm"))]
+mod kitty_detection_tests {
+ use super::{GraphicsEnv, terminal_supports_kitty, with_graphics_env};
+
+ #[test]
+ fn kitty_xterm_kitty_detected() {
+ with_graphics_env(
+ GraphicsEnv {
+ term: Some("xterm-kitty"),
+ ..GraphicsEnv::default()
+ },
+ || {
+ assert!(terminal_supports_kitty());
+ },
+ );
+ }
+
+ #[test]
+ fn kitty_ghostty_detected_via_term_program() {
+ with_graphics_env(
+ GraphicsEnv {
+ term: Some("xterm-256color"),
+ term_program: Some("ghostty"),
+ ..GraphicsEnv::default()
+ },
+ || {
+ assert!(terminal_supports_kitty());
+ },
+ );
+ }
+
+ #[test]
+ fn kitty_xterm_256color_no_false_positive() {
+ with_graphics_env(
+ GraphicsEnv {
+ term: Some("xterm-256color"),
+ ..GraphicsEnv::default()
+ },
+ || {
+ assert!(!terminal_supports_kitty());
+ },
+ );
+ }
+
+ #[test]
+ fn kitty_tmux_blocks_env_fallback() {
+ with_graphics_env(
+ GraphicsEnv {
+ term: Some("xterm-256color"),
+ term_program: Some("wezterm"),
+ tmux: true,
+ ..GraphicsEnv::default()
+ },
+ || {
+ assert!(!terminal_supports_kitty());
+ },
+ );
+ }
+
+ #[test]
+ fn kitty_screen_term_blocks_env_fallback() {
+ with_graphics_env(
+ GraphicsEnv {
+ term: Some("screen-256color"),
+ term_program: Some("ghostty"),
+ ..GraphicsEnv::default()
+ },
+ || {
+ assert!(!terminal_supports_kitty());
+ },
+ );
+ }
+
+ #[test]
+ fn kitty_force_env_overrides_tmux() {
+ with_graphics_env(
+ GraphicsEnv {
+ term: Some("screen-256color"),
+ term_program: Some("wezterm"),
+ force_kitty: true,
+ tmux: true,
+ ..GraphicsEnv::default()
+ },
+ || {
+ assert!(terminal_supports_kitty());
+ },
+ );
}
+}
+
+#[cfg(all(test, feature = "crossterm"))]
+mod sixel_detection_tests {
+ use super::{GraphicsEnv, terminal_supports_sixel, with_graphics_env};
#[test]
fn sixel_xterm_256color_no_false_positive() {
// Regression: `term.contains("xterm")` previously matched
// `xterm-256color` and printed raw escape sequences to screen on
// macOS Terminal.app, VS Code, and most SSH clients.
- with_env(Some("xterm-256color"), None, false, || {
- assert!(!terminal_supports_sixel());
- });
+ with_graphics_env(
+ GraphicsEnv {
+ term: Some("xterm-256color"),
+ ..GraphicsEnv::default()
+ },
+ || {
+ assert!(!terminal_supports_sixel());
+ },
+ );
}
#[test]
fn sixel_mlterm_detected() {
- with_env(Some("mlterm"), None, false, || {
- assert!(terminal_supports_sixel());
- });
+ with_graphics_env(
+ GraphicsEnv {
+ term: Some("mlterm"),
+ ..GraphicsEnv::default()
+ },
+ || {
+ assert!(terminal_supports_sixel());
+ },
+ );
}
#[test]
fn sixel_foot_detected_via_term() {
- with_env(Some("foot"), None, false, || {
- assert!(terminal_supports_sixel());
- });
+ with_graphics_env(
+ GraphicsEnv {
+ term: Some("foot"),
+ ..GraphicsEnv::default()
+ },
+ || {
+ assert!(terminal_supports_sixel());
+ },
+ );
}
#[test]
fn sixel_foot_detected_via_term_program() {
- with_env(Some("xterm-256color"), Some("foot"), false, || {
- assert!(terminal_supports_sixel());
- });
+ with_graphics_env(
+ GraphicsEnv {
+ term: Some("xterm-256color"),
+ term_program: Some("foot"),
+ ..GraphicsEnv::default()
+ },
+ || {
+ assert!(terminal_supports_sixel());
+ },
+ );
}
#[test]
fn sixel_force_env_overrides_negative_term() {
- with_env(Some("xterm-256color"), None, true, || {
- assert!(terminal_supports_sixel());
- });
+ with_graphics_env(
+ GraphicsEnv {
+ term: Some("xterm-256color"),
+ force_sixel: true,
+ ..GraphicsEnv::default()
+ },
+ || {
+ assert!(terminal_supports_sixel());
+ },
+ );
}
#[test]
fn sixel_substring_match_catches_custom_builds() {
- with_env(Some("custom-with-sixel"), None, false, || {
- assert!(terminal_supports_sixel());
- });
+ with_graphics_env(
+ GraphicsEnv {
+ term: Some("custom-with-sixel"),
+ ..GraphicsEnv::default()
+ },
+ || {
+ assert!(terminal_supports_sixel());
+ },
+ );
}
#[test]
fn sixel_wezterm_detected_via_term_program() {
// Issue #264: WezTerm reports `TERM=xterm-256color` but is a capable
// image host; it must no longer fall through to `[sixel unsupported]`.
- with_env(Some("xterm-256color"), Some("wezterm"), false, || {
- assert!(terminal_supports_sixel());
- });
+ with_graphics_env(
+ GraphicsEnv {
+ term: Some("xterm-256color"),
+ term_program: Some("wezterm"),
+ ..GraphicsEnv::default()
+ },
+ || {
+ assert!(terminal_supports_sixel());
+ },
+ );
}
#[test]
fn sixel_ghostty_detected_via_term_program() {
- with_env(Some("xterm-256color"), Some("ghostty"), false, || {
- assert!(terminal_supports_sixel());
- });
+ with_graphics_env(
+ GraphicsEnv {
+ term: Some("xterm-256color"),
+ term_program: Some("ghostty"),
+ ..GraphicsEnv::default()
+ },
+ || {
+ assert!(terminal_supports_sixel());
+ },
+ );
}
#[test]
fn sixel_mlterm_still_detected_via_term_program() {
- with_env(Some("xterm-256color"), Some("mlterm"), false, || {
- assert!(terminal_supports_sixel());
- });
+ with_graphics_env(
+ GraphicsEnv {
+ term: Some("xterm-256color"),
+ term_program: Some("mlterm"),
+ ..GraphicsEnv::default()
+ },
+ || {
+ assert!(terminal_supports_sixel());
+ },
+ );
+ }
+
+ #[test]
+ fn sixel_tmux_blocks_env_fallback() {
+ with_graphics_env(
+ GraphicsEnv {
+ term: Some("xterm-256color"),
+ term_program: Some("ghostty"),
+ tmux: true,
+ ..GraphicsEnv::default()
+ },
+ || {
+ assert!(!terminal_supports_sixel());
+ },
+ );
+ }
+
+ #[test]
+ fn sixel_force_env_overrides_tmux() {
+ with_graphics_env(
+ GraphicsEnv {
+ term: Some("screen-256color"),
+ term_program: Some("wezterm"),
+ force_sixel: true,
+ tmux: true,
+ ..GraphicsEnv::default()
+ },
+ || {
+ assert!(terminal_supports_sixel());
+ },
+ );
}
}
#[cfg(all(test, feature = "crossterm"))]
mod iterm_detection_tests {
- use super::terminal_supports_iterm;
- use std::sync::Mutex;
-
- // Env vars are process-global. A test-local mutex serializes access so
- // concurrent test threads don't observe each other's set_var() calls.
- static ENV_GUARD: Mutex<()> = Mutex::new(());
-
- // edition 2024: env mutation is `unsafe`; ENV_GUARD makes it sound here.
- #[allow(unsafe_code)]
- fn with_env(term: Option<&str>, term_program: Option<&str>, force: bool, f: F) {
- let _g = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
- let prev_term = std::env::var("TERM").ok();
- let prev_program = std::env::var("TERM_PROGRAM").ok();
- let prev_force = std::env::var("SLT_FORCE_ITERM").ok();
-
- // SAFETY (edition 2024): set_var/remove_var are unsafe because env
- // mutation races concurrent reads. ENV_GUARD above serializes these
- // test helpers, so no other thread observes a torn update.
- unsafe {
- match term {
- Some(v) => std::env::set_var("TERM", v),
- None => std::env::remove_var("TERM"),
- }
- match term_program {
- Some(v) => std::env::set_var("TERM_PROGRAM", v),
- None => std::env::remove_var("TERM_PROGRAM"),
- }
- if force {
- std::env::set_var("SLT_FORCE_ITERM", "1");
- } else {
- std::env::remove_var("SLT_FORCE_ITERM");
- }
- }
-
- f();
-
- unsafe {
- match prev_term {
- Some(v) => std::env::set_var("TERM", v),
- None => std::env::remove_var("TERM"),
- }
- match prev_program {
- Some(v) => std::env::set_var("TERM_PROGRAM", v),
- None => std::env::remove_var("TERM_PROGRAM"),
- }
- match prev_force {
- Some(v) => std::env::set_var("SLT_FORCE_ITERM", v),
- None => std::env::remove_var("SLT_FORCE_ITERM"),
- }
- }
- }
+ use super::{GraphicsEnv, terminal_supports_iterm, with_graphics_env};
#[test]
fn iterm_xterm_256color_no_false_positive() {
// Parity with `sixel_xterm_256color_no_false_positive`: a plain xterm
// must never be mistaken for an OSC 1337 host.
- with_env(Some("xterm-256color"), None, false, || {
- assert!(!terminal_supports_iterm());
- });
+ with_graphics_env(
+ GraphicsEnv {
+ term: Some("xterm-256color"),
+ ..GraphicsEnv::default()
+ },
+ || {
+ assert!(!terminal_supports_iterm());
+ },
+ );
}
#[test]
fn iterm_app_detected() {
- with_env(Some("xterm-256color"), Some("iTerm.app"), false, || {
- assert!(terminal_supports_iterm());
- });
+ with_graphics_env(
+ GraphicsEnv {
+ term: Some("xterm-256color"),
+ term_program: Some("iTerm.app"),
+ ..GraphicsEnv::default()
+ },
+ || {
+ assert!(terminal_supports_iterm());
+ },
+ );
}
#[test]
fn iterm_wezterm_detected() {
- with_env(Some("xterm-256color"), Some("WezTerm"), false, || {
- assert!(terminal_supports_iterm());
- });
+ with_graphics_env(
+ GraphicsEnv {
+ term: Some("xterm-256color"),
+ term_program: Some("WezTerm"),
+ ..GraphicsEnv::default()
+ },
+ || {
+ assert!(terminal_supports_iterm());
+ },
+ );
}
#[test]
fn iterm_tabby_detected() {
- with_env(Some("xterm-256color"), Some("Tabby"), false, || {
- assert!(terminal_supports_iterm());
- });
+ with_graphics_env(
+ GraphicsEnv {
+ term: Some("xterm-256color"),
+ term_program: Some("Tabby"),
+ ..GraphicsEnv::default()
+ },
+ || {
+ assert!(terminal_supports_iterm());
+ },
+ );
}
#[test]
fn iterm_mintty_detected() {
- with_env(Some("xterm-256color"), Some("mintty"), false, || {
- assert!(terminal_supports_iterm());
- });
+ with_graphics_env(
+ GraphicsEnv {
+ term: Some("xterm-256color"),
+ term_program: Some("mintty"),
+ ..GraphicsEnv::default()
+ },
+ || {
+ assert!(terminal_supports_iterm());
+ },
+ );
}
#[test]
fn iterm_force_env_overrides_negative_term() {
- with_env(Some("xterm-256color"), None, true, || {
- assert!(terminal_supports_iterm());
- });
+ with_graphics_env(
+ GraphicsEnv {
+ term: Some("xterm-256color"),
+ force_iterm: true,
+ ..GraphicsEnv::default()
+ },
+ || {
+ assert!(terminal_supports_iterm());
+ },
+ );
}
#[test]
fn iterm_unknown_term_program_negative() {
- with_env(
- Some("xterm-256color"),
- Some("Apple_Terminal"),
- false,
+ with_graphics_env(
+ GraphicsEnv {
+ term: Some("xterm-256color"),
+ term_program: Some("Apple_Terminal"),
+ ..GraphicsEnv::default()
+ },
+ || {
+ assert!(!terminal_supports_iterm());
+ },
+ );
+ }
+
+ #[test]
+ fn iterm_tmux_blocks_env_fallback() {
+ with_graphics_env(
+ GraphicsEnv {
+ term: Some("xterm-256color"),
+ term_program: Some("iTerm.app"),
+ tmux: true,
+ ..GraphicsEnv::default()
+ },
|| {
assert!(!terminal_supports_iterm());
},
);
}
+
+ #[test]
+ fn iterm_screen_term_blocks_env_fallback() {
+ with_graphics_env(
+ GraphicsEnv {
+ term: Some("screen.xterm-256color"),
+ term_program: Some("Tabby"),
+ ..GraphicsEnv::default()
+ },
+ || {
+ assert!(!terminal_supports_iterm());
+ },
+ );
+ }
+
+ #[test]
+ fn iterm_force_env_overrides_tmux() {
+ with_graphics_env(
+ GraphicsEnv {
+ term: Some("screen-256color"),
+ term_program: Some("iTerm.app"),
+ force_iterm: true,
+ tmux: true,
+ ..GraphicsEnv::default()
+ },
+ || {
+ assert!(terminal_supports_iterm());
+ },
+ );
+ }
}
diff --git a/src/context/widgets_display/layout.rs b/src/context/widgets_display/layout.rs
index 5c75b26..094f28a 100644
--- a/src/context/widgets_display/layout.rs
+++ b/src/context/widgets_display/layout.rs
@@ -352,6 +352,82 @@ impl Context {
self.pending_screen_nav.push(ScreenNav::Reset);
}
+ /// Remove retained hook/focus state for an inactive screen.
+ ///
+ /// Returns `false` when `name` is still present in `screens`' stack. Call
+ /// this after popping runtime-generated detail screens to release their
+ /// isolated hook segment and saved focus entry.
+ pub fn remove_screen_state(&mut self, screens: &mut ScreenState, name: &str) -> bool {
+ if screens.contains(name) {
+ return false;
+ }
+ let focus_removed = screens.remove_inactive(name);
+ let hooks_removed = self.remove_screen_hooks(name);
+ focus_removed || hooks_removed
+ }
+
+ /// Retain inactive screen states accepted by `keep`.
+ ///
+ /// Screens currently present in `screens`' stack are always kept. Returns
+ /// the number of screen names removed from either the hook map or the
+ /// saved-focus map.
+ pub fn retain_screen_state(
+ &mut self,
+ screens: &mut ScreenState,
+ mut keep: impl FnMut(&str) -> bool,
+ ) -> usize {
+ let remove_names: Vec = self
+ .screen_hook_map
+ .keys()
+ .filter(|name| !screens.contains(name) && !keep(name))
+ .cloned()
+ .collect();
+
+ let mut removed = 0;
+ for name in remove_names {
+ if self.remove_screen_state(screens, &name) {
+ removed += 1;
+ }
+ }
+ removed + screens.retain_inactive(keep)
+ }
+
+ /// Number of retained screen hook segments.
+ ///
+ /// Diagnostic helper for apps that generate screen names from runtime ids.
+ pub fn screen_state_count(&self) -> usize {
+ self.screen_hook_map.len()
+ }
+
+ fn remove_screen_hooks(&mut self, name: &str) -> bool {
+ let Some((seg_start, seg_count)) = self.screen_hook_map.remove(name) else {
+ return false;
+ };
+
+ if seg_count == 0 {
+ return true;
+ }
+
+ let end = seg_start
+ .saturating_add(seg_count)
+ .min(self.hook_states.len());
+ if seg_start >= end {
+ return true;
+ }
+
+ let removed = end - seg_start;
+ self.hook_states.drain(seg_start..end);
+ for (other_start, _) in self.screen_hook_map.values_mut() {
+ if *other_start > seg_start {
+ *other_start = other_start.saturating_sub(removed);
+ }
+ }
+ if self.rollback.hook_cursor > seg_start {
+ self.rollback.hook_cursor = self.rollback.hook_cursor.saturating_sub(removed);
+ }
+ true
+ }
+
/// Create a vertical (column) container.
///
/// Children are stacked top-to-bottom. Returns a [`Response`] with
diff --git a/src/context/widgets_display/rich_output.rs b/src/context/widgets_display/rich_output.rs
index 4bb67d3..2bfe791 100644
--- a/src/context/widgets_display/rich_output.rs
+++ b/src/context/widgets_display/rich_output.rs
@@ -95,8 +95,10 @@ impl Context {
/// The widget allocates `cols` x `rows` cells and renders the image
/// at full pixel resolution within that space.
///
- /// Requires a Kitty-compatible terminal (Kitty, Ghostty, WezTerm).
- /// On unsupported terminals, the area will be blank.
+ /// Requires a Kitty-compatible terminal (Kitty, Ghostty, WezTerm). On
+ /// unsupported terminals, falls back to half-block cell rendering. Set
+ /// `SLT_FORCE_KITTY=1` only when the terminal path is known to pass Kitty
+ /// graphics through.
///
/// # Arguments
/// * `rgba` - Raw RGBA pixel data
@@ -112,6 +114,17 @@ impl Context {
cols: u32,
rows: u32,
) -> Response {
+ if !self.kitty_graphics_supported() {
+ return self.rgba_halfblock_fallback(
+ rgba,
+ pixel_width,
+ pixel_height,
+ cols,
+ rows,
+ "[kitty unsupported]",
+ );
+ }
+
let rgba_data = normalize_rgba(rgba, pixel_width, pixel_height);
let content_hash = crate::buffer::hash_rgba(&rgba_data);
let rgba_arc = std::sync::Arc::new(rgba_data);
@@ -146,7 +159,9 @@ impl Context {
/// using detected cell pixel dimensions (falls back to 8×16 if
/// detection fails).
///
- /// Requires a Kitty-compatible terminal (Kitty, Ghostty, WezTerm).
+ /// Requires a Kitty-compatible terminal (Kitty, Ghostty, WezTerm). On
+ /// unsupported terminals, falls back to half-block cell rendering without
+ /// probing cell pixel size.
pub fn kitty_image_fit(
&mut self,
rgba: &[u8],
@@ -154,18 +169,28 @@ impl Context {
src_height: u32,
cols: u32,
) -> Response {
+ let supported = self.kitty_graphics_supported();
#[cfg(feature = "crossterm")]
- let (cell_w, cell_h) = crate::terminal::cell_pixel_size();
+ let (cell_w, cell_h) = if supported {
+ crate::terminal::cell_pixel_size()
+ } else {
+ (8u32, 16u32)
+ };
#[cfg(not(feature = "crossterm"))]
let (cell_w, cell_h) = (8u32, 16u32);
- let rows = if src_width == 0 {
- 1
- } else {
- ((cols as f64 * src_height as f64 * cell_w as f64) / (src_width as f64 * cell_h as f64))
- .ceil()
- .max(1.0) as u32
- };
+ let rows = image_fit_rows(src_width, src_height, cols, cell_w, cell_h);
+ if !supported {
+ return self.rgba_halfblock_fallback(
+ rgba,
+ src_width,
+ src_height,
+ cols,
+ rows,
+ "[kitty unsupported]",
+ );
+ }
+
let rgba_data = normalize_rgba(rgba, src_width, src_height);
let content_hash = crate::buffer::hash_rgba(&rgba_data);
let rgba_arc = std::sync::Arc::new(rgba_data);
@@ -225,8 +250,7 @@ impl Context {
// allowlist (now including WezTerm/Ghostty) / `SLT_FORCE_SIXEL` when the
// probe returned unknown. App code never selects a protocol — the
// blitter ladder resolves it.
- let sixel_supported =
- self.is_real_terminal && (self.capabilities.sixel || terminal_supports_sixel());
+ let sixel_supported = self.sixel_supported();
if !sixel_supported {
self.container().w(cols).h(rows).draw(|buf, rect| {
if rect.width == 0 || rect.height == 0 {
@@ -301,8 +325,7 @@ impl Context {
// Issue #264 ladder integration: consult the negotiated capability
// snapshot first, then the env allowlist / `SLT_FORCE_ITERM`. App code
// never selects a protocol directly.
- let supported =
- self.is_real_terminal && (self.capabilities.iterm2 || terminal_supports_iterm());
+ let supported = self.iterm_supported();
if !supported {
return self.iterm_placeholder(cols, rows);
}
@@ -352,14 +375,17 @@ impl Context {
#[cfg(feature = "crossterm")]
#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
pub fn iterm_image_fit(&mut self, data: &[u8], cols: u32) -> Response {
- let supported =
- self.is_real_terminal && (self.capabilities.iterm2 || terminal_supports_iterm());
+ let supported = self.iterm_supported();
// Reserve rows from the cell aspect using a 1:1 source assumption — the
// terminal does the real aspect math from the decoded file; this only
// sizes the cell box so layout below the image is not clobbered. Use a
// square-ish default of `cols / 2` rows (cells are ~2:1 tall), min 1.
- let (cell_w, cell_h) = crate::terminal::cell_pixel_size();
+ let (cell_w, cell_h) = if supported {
+ crate::terminal::cell_pixel_size()
+ } else {
+ (8u32, 16u32)
+ };
let rows = if cell_h == 0 {
cols.max(1)
} else {
@@ -397,6 +423,106 @@ impl Context {
Response::none()
}
+ #[cfg(feature = "crossterm")]
+ fn kitty_graphics_supported(&self) -> bool {
+ if !self.is_real_terminal {
+ return false;
+ }
+ if terminal_force_graphics("SLT_FORCE_KITTY") {
+ return true;
+ }
+ if terminal_graphics_blocked_by_multiplexer() {
+ return false;
+ }
+ self.capabilities.kitty_graphics || terminal_supports_kitty()
+ }
+
+ #[cfg(not(feature = "crossterm"))]
+ fn kitty_graphics_supported(&self) -> bool {
+ false
+ }
+
+ #[cfg(feature = "crossterm")]
+ fn sixel_supported(&self) -> bool {
+ if !self.is_real_terminal {
+ return false;
+ }
+ if terminal_force_graphics("SLT_FORCE_SIXEL") {
+ return true;
+ }
+ if terminal_graphics_blocked_by_multiplexer() {
+ return false;
+ }
+ self.capabilities.sixel || terminal_supports_sixel()
+ }
+
+ #[cfg(feature = "crossterm")]
+ fn iterm_supported(&self) -> bool {
+ if !self.is_real_terminal {
+ return false;
+ }
+ if terminal_force_graphics("SLT_FORCE_ITERM") {
+ return true;
+ }
+ if terminal_graphics_blocked_by_multiplexer() {
+ return false;
+ }
+ self.capabilities.iterm2 || terminal_supports_iterm()
+ }
+
+ fn rgba_halfblock_fallback(
+ &mut self,
+ rgba: &[u8],
+ pixel_width: u32,
+ pixel_height: u32,
+ cols: u32,
+ rows: u32,
+ placeholder: &'static str,
+ ) -> Response {
+ let rgba_data = normalize_rgba(rgba, pixel_width, pixel_height);
+ if rgba_data.is_empty() {
+ self.container().w(cols).h(rows).draw(move |buf, rect| {
+ if rect.width == 0 || rect.height == 0 {
+ return;
+ }
+ buf.set_string(rect.x, rect.y, placeholder, Style::new());
+ });
+ return Response::none();
+ }
+
+ self.container().w(cols).h(rows).draw(move |buf, rect| {
+ if rect.width == 0 || rect.height == 0 {
+ return;
+ }
+
+ let dst_pixel_height = rect.height.saturating_mul(2).max(1);
+ for row in 0..rect.height {
+ for col in 0..rect.width {
+ let upper = sample_rgba_color(
+ &rgba_data,
+ pixel_width,
+ pixel_height,
+ col,
+ row * 2,
+ rect.width,
+ dst_pixel_height,
+ );
+ let lower = sample_rgba_color(
+ &rgba_data,
+ pixel_width,
+ pixel_height,
+ col,
+ row.saturating_mul(2).saturating_add(1),
+ rect.width,
+ dst_pixel_height,
+ );
+ draw_halfblock_cell(buf, rect.x + col, rect.y + row, upper, lower);
+ }
+ }
+ });
+ Response::none()
+ }
+
/// Reserve a `cols`×`rows` container and draw the unsupported placeholder,
/// matching the Sixel fallback pattern.
#[cfg(feature = "crossterm")]
diff --git a/src/context/widgets_display/tests.rs b/src/context/widgets_display/tests.rs
index 41c8acf..ee66294 100644
--- a/src/context/widgets_display/tests.rs
+++ b/src/context/widgets_display/tests.rs
@@ -84,6 +84,66 @@ fn image_empty_does_not_panic() {
// No assertion needed — must not panic
}
+#[test]
+fn kitty_image_headless_uses_halfblock_fallback_without_placement() {
+ let rgba = [255u8, 0, 0, 255].repeat(4);
+ let mut backend = TestBackend::new(4, 2);
+ backend.render(|ui| {
+ let _ = ui.kitty_image(&rgba, 2, 2, 4, 2);
+ });
+
+ assert!(
+ backend.buffer().kitty_placements.is_empty(),
+ "headless Kitty fallback must not enqueue graphics placements"
+ );
+ backend.assert_contains("▀");
+}
+
+#[test]
+fn kitty_image_fit_headless_uses_halfblock_fallback_without_placement() {
+ let rgba = [0u8, 128, 255, 255].repeat(4);
+ let mut backend = TestBackend::new(4, 2);
+ backend.render(|ui| {
+ let _ = ui.kitty_image_fit(&rgba, 2, 2, 4);
+ });
+
+ assert!(
+ backend.buffer().kitty_placements.is_empty(),
+ "headless Kitty fit fallback must not enqueue graphics placements"
+ );
+ backend.assert_contains("▀");
+}
+
+#[test]
+fn sixel_image_headless_uses_placeholder_without_sprixel() {
+ let rgba = [255u8, 0, 0, 255].repeat(4);
+ let mut backend = TestBackend::new(20, 2);
+ backend.render(|ui| {
+ let _ = ui.sixel_image(&rgba, 2, 2, 20, 2);
+ });
+
+ assert!(
+ backend.buffer().sprixels.is_empty(),
+ "headless Sixel fallback must not enqueue sprixels"
+ );
+ backend.assert_contains("[sixel unsupported]");
+}
+
+#[test]
+fn iterm_image_fit_headless_uses_placeholder_without_sprixel() {
+ let png = [0x89u8, b'P', b'N', b'G'];
+ let mut backend = TestBackend::new(20, 10);
+ backend.render(|ui| {
+ let _ = ui.iterm_image_fit(&png, 20);
+ });
+
+ assert!(
+ backend.buffer().sprixels.is_empty(),
+ "headless iTerm fallback must not enqueue sprixels"
+ );
+ backend.assert_contains("[iterm2 unsupported]");
+}
+
// confirm() hit-test regression tests (issue #175)
// Layout: "ok? [Yes] [No]" at x=0
// q_width=3, space=1 → yes_start=4, yes_end=9, no_start=10, no_end=14
diff --git a/src/context/widgets_display/text.rs b/src/context/widgets_display/text.rs
index a0727b9..56b0a4c 100644
--- a/src/context/widgets_display/text.rs
+++ b/src/context/widgets_display/text.rs
@@ -194,13 +194,13 @@ impl Context {
} => {
let chars: Vec = content.chars().collect();
let len = chars.len();
- let denom = len.saturating_sub(1).max(1) as f32;
+ let denom = len.saturating_sub(1).max(1) as f64;
let segments = chars
.into_iter()
.enumerate()
.map(|(i, ch)| {
let mut seg_style = *style;
- seg_style.fg = Some(from.blend(to, i as f32 / denom));
+ seg_style.fg = Some(from.blend_f64(to, i as f64 / denom));
(ch.to_string(), seg_style)
})
.collect();
@@ -241,14 +241,14 @@ impl Context {
/// ```no_run
/// # slt::run(|ui: &mut slt::Context| {
/// use slt::Color;
- /// ui.text("rainbow").gradient_stops(&[
+ /// ui.text("rainbow").gradient_stops_f64(&[
/// (0.0, Color::Red),
/// (0.5, Color::Yellow),
/// (1.0, Color::Green),
/// ]);
/// # });
/// ```
- pub fn gradient_stops(&mut self, stops: &[(f32, Color)]) -> &mut Self {
+ pub fn gradient_stops_f64(&mut self, stops: &[(f64, Color)]) -> &mut Self {
if stops.is_empty() {
return self;
}
@@ -257,6 +257,19 @@ impl Context {
self
}
+ /// Deprecated `f32` alias for [`gradient_stops_f64`](Self::gradient_stops_f64).
+ #[deprecated(
+ since = "0.22.2",
+ note = "use Context::gradient_stops_f64() to keep public float APIs on f64"
+ )]
+ pub fn gradient_stops(&mut self, stops: &[(f32, Color)]) -> &mut Self {
+ let stops: Vec<(f64, Color)> = stops
+ .iter()
+ .map(|(pos, color)| (f64::from(*pos), *color))
+ .collect();
+ self.gradient_stops_f64(&stops)
+ }
+
/// Apply a per-character background gradient to the last rendered text.
///
/// The two-stop background analogue of [`gradient`]. Colors the cell
@@ -274,7 +287,7 @@ impl Context {
/// # });
/// ```
pub fn bg_gradient(&mut self, from: Color, to: Color) -> &mut Self {
- self.apply_char_gradient(true, |t| from.blend(to, t));
+ self.apply_char_gradient(true, |t| from.blend_f64(to, t));
self
}
@@ -291,13 +304,13 @@ impl Context {
/// ```no_run
/// # slt::run(|ui: &mut slt::Context| {
/// use slt::Color;
- /// ui.text("header").bg_gradient_stops(&[
+ /// ui.text("header").bg_gradient_stops_f64(&[
/// (0.0, Color::Blue),
/// (1.0, Color::Magenta),
/// ]);
/// # });
/// ```
- pub fn bg_gradient_stops(&mut self, stops: &[(f32, Color)]) -> &mut Self {
+ pub fn bg_gradient_stops_f64(&mut self, stops: &[(f64, Color)]) -> &mut Self {
if stops.is_empty() {
return self;
}
@@ -306,10 +319,23 @@ impl Context {
self
}
+ /// Deprecated `f32` alias for [`bg_gradient_stops_f64`](Self::bg_gradient_stops_f64).
+ #[deprecated(
+ since = "0.22.2",
+ note = "use Context::bg_gradient_stops_f64() to keep public float APIs on f64"
+ )]
+ pub fn bg_gradient_stops(&mut self, stops: &[(f32, Color)]) -> &mut Self {
+ let stops: Vec<(f64, Color)> = stops
+ .iter()
+ .map(|(pos, color)| (f64::from(*pos), *color))
+ .collect();
+ self.bg_gradient_stops_f64(&stops)
+ }
+
/// Return `stops` sorted ascending by clamped position. Positions are
/// clamped into `0.0..=1.0` so out-of-range inputs degrade gracefully.
- fn sorted_gradient_stops(stops: &[(f32, Color)]) -> Vec<(f32, Color)> {
- let mut sorted: Vec<(f32, Color)> = stops
+ fn sorted_gradient_stops(stops: &[(f64, Color)]) -> Vec<(f64, Color)> {
+ let mut sorted: Vec<(f64, Color)> = stops
.iter()
.map(|(pos, color)| (pos.clamp(0.0, 1.0), *color))
.collect();
@@ -319,7 +345,7 @@ impl Context {
/// Sample the color at position `t` (in `0.0..=1.0`) from pre-sorted,
/// non-empty `stops`, linearly interpolating between the bracketing stops.
- fn sample_gradient_stops(stops: &[(f32, Color)], t: f32) -> Color {
+ fn sample_gradient_stops(stops: &[(f64, Color)], t: f64) -> Color {
let t = t.clamp(0.0, 1.0);
// Non-empty is guaranteed by callers; fall back defensively otherwise.
let first = match stops.first() {
@@ -338,13 +364,11 @@ impl Context {
let (p1, c1) = window[1];
if t >= p0 && t <= p1 {
let span = p1 - p0;
- if span <= f32::EPSILON {
+ if span <= f64::EPSILON {
return c1;
}
let local = (t - p0) / span;
- // blend(self, other, alpha) = self*alpha + other*(1-alpha):
- // c1.blend(c0, local) yields c0 at local=0 and c1 at local=1.
- return c1.blend(c0, local);
+ return c1.blend_f64(c0, local);
}
}
last.1
@@ -353,7 +377,7 @@ impl Context {
/// Replace the last `Text` command with a `RichText` gradient, mapping each
/// character's column to a position in `0.0..=1.0` exactly like
/// [`gradient`](Self::gradient). `is_bg` selects background vs foreground.
- fn apply_char_gradient(&mut self, is_bg: bool, color_at: impl Fn(f32) -> Color) {
+ fn apply_char_gradient(&mut self, is_bg: bool, color_at: impl Fn(f64) -> Color) {
if let Some(idx) = self.rollback.last_text_idx {
let replacement = match &self.commands[idx] {
Command::Text {
@@ -367,13 +391,13 @@ impl Context {
} => {
let chars: Vec = content.chars().collect();
let len = chars.len();
- let denom = len.saturating_sub(1).max(1) as f32;
+ let denom = len.saturating_sub(1).max(1) as f64;
let segments = chars
.into_iter()
.enumerate()
.map(|(i, ch)| {
let mut seg_style = *style;
- let color = color_at(i as f32 / denom);
+ let color = color_at(i as f64 / denom);
if is_bg {
seg_style.bg = Some(color);
} else {
@@ -719,7 +743,8 @@ mod gradient_tests {
let blue = Color::Rgb(0, 0, 255);
let mut backend = TestBackend::new(20, 4);
backend.render(|ui| {
- ui.text("ABC").gradient_stops(&[(0.0, red), (1.0, blue)]);
+ ui.text("ABC")
+ .gradient_stops_f64(&[(0.0, red), (1.0, blue)]);
});
let buf = backend.buffer();
@@ -749,7 +774,8 @@ mod gradient_tests {
let mut backend = TestBackend::new(20, 4);
backend.render(|ui| {
// Deliberately out of order — must behave identically to sorted.
- ui.text("ABC").gradient_stops(&[(1.0, blue), (0.0, red)]);
+ ui.text("ABC")
+ .gradient_stops_f64(&[(1.0, blue), (0.0, red)]);
});
let buf = backend.buffer();
@@ -766,7 +792,7 @@ mod gradient_tests {
backend.render(|ui| {
// len=3, denom=2 → columns map to t = 0.0, 0.5, 1.0.
ui.text("ABC")
- .gradient_stops(&[(0.0, red), (0.5, green), (1.0, blue)]);
+ .gradient_stops_f64(&[(0.0, red), (0.5, green), (1.0, blue)]);
});
let buf = backend.buffer();
@@ -784,7 +810,7 @@ mod gradient_tests {
let cyan = Color::Rgb(0, 200, 200);
let mut backend = TestBackend::new(20, 4);
backend.render(|ui| {
- ui.text("ABCD").gradient_stops(&[(0.0, cyan)]);
+ ui.text("ABCD").gradient_stops_f64(&[(0.0, cyan)]);
});
let buf = backend.buffer();
@@ -802,7 +828,7 @@ mod gradient_tests {
let mut backend = TestBackend::new(20, 4);
backend.render(|ui| {
// Empty slice must not panic and must leave content intact.
- ui.text("HELLO").gradient_stops(&[]);
+ ui.text("HELLO").gradient_stops_f64(&[]);
});
backend.assert_contains("HELLO");
@@ -818,7 +844,7 @@ mod gradient_tests {
});
let buf = backend.buffer();
- // bg_gradient mirrors gradient(): from.blend(to, t) — at t=0 that is `to`
+ // bg_gradient mirrors gradient(): from.blend_f64(to, t) yields `to` at t=0.
// (blue), at t=1 that is `from` (red). Foreground stays untouched.
assert_eq!(buf.get(0, 0).style.bg, Some(blue), "first column bg = to");
assert_eq!(buf.get(2, 0).style.bg, Some(red), "last column bg = from");
@@ -835,7 +861,8 @@ mod gradient_tests {
let blue = Color::Rgb(0, 0, 255);
let mut backend = TestBackend::new(20, 4);
backend.render(|ui| {
- ui.text("ABC").bg_gradient_stops(&[(0.0, red), (1.0, blue)]);
+ ui.text("ABC")
+ .bg_gradient_stops_f64(&[(0.0, red), (1.0, blue)]);
});
let buf = backend.buffer();
@@ -852,7 +879,7 @@ mod gradient_tests {
fn bg_gradient_stops_empty_is_noop() {
let mut backend = TestBackend::new(20, 4);
backend.render(|ui| {
- ui.text("WORLD").bg_gradient_stops(&[]);
+ ui.text("WORLD").bg_gradient_stops_f64(&[]);
});
backend.assert_contains("WORLD");
diff --git a/src/layout/render.rs b/src/layout/render.rs
index 1ceb9ec..d524039 100644
--- a/src/layout/render.rs
+++ b/src/layout/render.rs
@@ -584,8 +584,8 @@ fn debug_color_for_depth(tint: LayerTint, depth: u32) -> Color {
};
match depth {
0..=1 => base,
- 2..=3 => base.lighten(0.25),
- _ => base.lighten(0.5),
+ 2..=3 => base.lighten_f64(0.25),
+ _ => base.lighten_f64(0.5),
}
}
diff --git a/src/lib.rs b/src/lib.rs
index d761457..955b968 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -132,8 +132,6 @@ use std::io;
#[cfg(feature = "crossterm")]
use std::io::IsTerminal;
#[cfg(feature = "crossterm")]
-use std::io::Write;
-#[cfg(feature = "crossterm")]
use std::sync::Once;
use std::time::{Duration, Instant};
@@ -341,6 +339,15 @@ impl AppState {
}
/// Returns the smoothed FPS estimate (exponential moving average).
+ pub fn fps_f64(&self) -> f64 {
+ f64::from(self.inner.diagnostics.fps_ema)
+ }
+
+ /// Deprecated `f32` alias for [`fps_f64`](Self::fps_f64).
+ #[deprecated(
+ since = "0.22.2",
+ note = "use AppState::fps_f64() to keep public float APIs on f64"
+ )]
pub fn fps(&self) -> f32 {
self.inner.diagnostics.fps_ema
}
@@ -436,17 +443,7 @@ fn install_panic_hook() {
PANIC_HOOK_ONCE.call_once(|| {
let original = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
- let _ = crossterm::terminal::disable_raw_mode();
- let mut stdout = io::stdout();
- let _ = crossterm::execute!(
- stdout,
- crossterm::terminal::LeaveAlternateScreen,
- crossterm::cursor::Show,
- crossterm::event::DisableMouseCapture,
- crossterm::event::DisableBracketedPaste,
- crossterm::style::ResetColor,
- crossterm::style::SetAttribute(crossterm::style::Attribute::Reset)
- );
+ terminal::cleanup_after_panic();
// Print friendly panic header
eprintln!("\n\x1b[1;31m━━━ SLT Panic ━━━\x1b[0m\n");
@@ -463,9 +460,9 @@ fn install_panic_hook() {
// Print message
if let Some(msg) = panic_info.payload().downcast_ref::<&str>() {
- eprintln!("\x1b[1m{}\x1b[0m", msg);
+ eprintln!("\x1b[1m{msg}\x1b[0m");
} else if let Some(msg) = panic_info.payload().downcast_ref::() {
- eprintln!("\x1b[1m{}\x1b[0m", msg);
+ eprintln!("\x1b[1m{msg}\x1b[0m");
}
eprintln!(
@@ -548,6 +545,15 @@ fn install_suspend_handler(snapshot: terminal::SessionSnapshot) -> io::Result io::Result<()> {
+ terminal::suspend_to_shell(&snapshot);
+ let result = signal_hook::low_level::emulate_default_handler(signal_hook::consts::SIGTSTP);
+ terminal::resume_from_shell(&snapshot);
+ result?;
+ Ok(())
+}
+
/// Consume the pending full-redraw request raised by a `SIGCONT` resume and, if
/// set, clear + repaint the whole frame (issue #263).
///
@@ -1094,12 +1100,27 @@ pub fn run(f: impl FnMut(&mut Context)) -> io::Result<()> {
fn set_terminal_title(title: &Option) {
if let Some(title) = title {
use std::io::Write;
+ let title = sanitize_terminal_text(title);
let mut stdout = io::stdout();
let _ = write!(stdout, "\x1b]2;{title}\x07");
let _ = stdout.flush();
}
}
+#[cfg(feature = "crossterm")]
+fn sanitize_terminal_text(input: &str) -> String {
+ input
+ .chars()
+ .map(|ch| {
+ if ch.is_control() || ('\u{80}'..='\u{9f}').contains(&ch) {
+ '?'
+ } else {
+ ch
+ }
+ })
+ .collect()
+}
+
/// Run the TUI loop with custom configuration.
///
/// Like [`run`], but accepts a [`RunConfig`] to control tick rate, mouse
@@ -1174,12 +1195,21 @@ pub fn run_with(config: RunConfig, mut f: impl FnMut(&mut Context)) -> io::Resul
discard_static_log(&mut state, "full-screen run()");
let render_elapsed = frame_start.elapsed();
+ #[cfg(unix)]
+ let suspend_snapshot = term.session_snapshot();
+ #[cfg(unix)]
+ let mut on_suspend = || suspend_current_session(suspend_snapshot);
+ #[cfg(not(unix))]
+ let mut on_suspend = || Ok(());
+
if !poll_events(
&mut events,
&mut state,
config.tick_rate,
&mut || term.handle_resize(),
config.handle_ctrl_c,
+ config.handle_suspend,
+ &mut on_suspend,
)? {
break;
}
@@ -1246,6 +1276,22 @@ pub fn run_async_with(
Ok(tx)
}
+#[cfg(all(feature = "crossterm", feature = "async"))]
+fn drain_async_messages(rx: &mut tokio::sync::mpsc::Receiver, messages: &mut Vec) -> bool {
+ let mut disconnected = false;
+ loop {
+ match rx.try_recv() {
+ Ok(message) => messages.push(message),
+ Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
+ Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
+ disconnected = true;
+ break;
+ }
+ }
+ }
+ disconnected
+}
+
#[cfg(all(feature = "crossterm", feature = "async"))]
fn run_async_loop(
config: RunConfig,
@@ -1290,8 +1336,9 @@ fn run_async_loop(
#[cfg(unix)]
drain_resume_redraw(&mut || term.handle_resize())?;
messages.clear();
- while let Ok(message) = rx.try_recv() {
- messages.push(message);
+ let input_disconnected = drain_async_messages(&mut rx, &mut messages);
+ if input_disconnected && messages.is_empty() {
+ break;
}
let (w, h) = term.size();
@@ -1315,14 +1362,26 @@ fn run_async_loop(
// Issue #233: full-screen async mode has no scrollback channel — warn
// and drop any pending static_log lines.
discard_static_log(&mut state, "run_async()");
+ if input_disconnected {
+ break;
+ }
let render_elapsed = frame_start.elapsed();
+ #[cfg(unix)]
+ let suspend_snapshot = term.session_snapshot();
+ #[cfg(unix)]
+ let mut on_suspend = || suspend_current_session(suspend_snapshot);
+ #[cfg(not(unix))]
+ let mut on_suspend = || Ok(());
+
if !poll_events(
&mut events,
&mut state,
config.tick_rate,
&mut || term.handle_resize(),
config.handle_ctrl_c,
+ config.handle_suspend,
+ &mut on_suspend,
)? {
break;
}
@@ -1420,12 +1479,21 @@ pub fn run_inline_with(
discard_static_log(&mut state, "run_inline()");
let render_elapsed = frame_start.elapsed();
+ #[cfg(unix)]
+ let suspend_snapshot = term.session_snapshot();
+ #[cfg(unix)]
+ let mut on_suspend = || suspend_current_session(suspend_snapshot);
+ #[cfg(not(unix))]
+ let mut on_suspend = || Ok(());
+
if !poll_events(
&mut events,
&mut state,
config.tick_rate,
&mut || term.handle_resize(),
config.handle_ctrl_c,
+ config.handle_suspend,
+ &mut on_suspend,
)? {
break;
}
@@ -1528,12 +1596,21 @@ pub fn run_static_with(
}
let render_elapsed = frame_start.elapsed();
+ #[cfg(unix)]
+ let suspend_snapshot = term.session_snapshot();
+ #[cfg(unix)]
+ let mut on_suspend = || suspend_current_session(suspend_snapshot);
+ #[cfg(not(unix))]
+ let mut on_suspend = || Ok(());
+
if !poll_events(
&mut events,
&mut state,
config.tick_rate,
&mut || term.handle_resize(),
config.handle_ctrl_c,
+ config.handle_suspend,
+ &mut on_suspend,
)? {
break;
}
@@ -1551,8 +1628,14 @@ fn write_static_lines(lines: &[String]) -> io::Result<()> {
}
let mut stdout = io::stdout();
+ write_static_lines_to(&mut stdout, lines)
+}
+
+#[cfg(feature = "crossterm")]
+fn write_static_lines_to(stdout: &mut impl io::Write, lines: &[String]) -> io::Result<()> {
for line in lines {
- stdout.write_all(line.as_bytes())?;
+ let safe = sanitize_terminal_text(line);
+ stdout.write_all(safe.as_bytes())?;
stdout.write_all(b"\r\n")?;
}
stdout.flush()
@@ -1723,6 +1806,8 @@ fn poll_events(
tick_rate: Duration,
on_resize: &mut impl FnMut() -> io::Result<()>,
handle_ctrl_c: bool,
+ handle_suspend: bool,
+ on_suspend: &mut impl FnMut() -> io::Result<()>,
) -> io::Result {
let mut has_resize = false;
@@ -1736,6 +1821,10 @@ fn poll_events(
if handle_ctrl_c && is_ctrl_c(&ev) {
return Ok(false);
}
+ if handle_suspend && is_ctrl_z(&ev) {
+ on_suspend()?;
+ return Ok(true);
+ }
// Resize is recorded (via `has_resize`) but not yet acted on — the
// single `on_resize` call is deferred to end-of-batch so a burst
// collapses into one geometry sync.
@@ -1749,6 +1838,10 @@ fn poll_events(
if handle_ctrl_c && is_ctrl_c(&ev) {
return Ok(false);
}
+ if handle_suspend && is_ctrl_z(&ev) {
+ on_suspend()?;
+ return Ok(true);
+ }
process_ev(&ev, state, &mut has_resize);
events.push(ev);
}
@@ -2249,6 +2342,18 @@ fn is_ctrl_c(ev: &Event) -> bool {
)
}
+#[cfg(feature = "crossterm")]
+fn is_ctrl_z(ev: &Event) -> bool {
+ matches!(
+ ev,
+ Event::Key(event::KeyEvent {
+ code: KeyCode::Char('z'),
+ modifiers,
+ kind: event::KeyEventKind::Press,
+ }) if modifiers.contains(KeyModifiers::CONTROL)
+ )
+}
+
#[cfg(feature = "crossterm")]
fn sleep_for_fps_cap(max_fps: Option, render_elapsed: Duration) {
if let Some(fps) = max_fps.filter(|fps| *fps > 0) {
@@ -2274,6 +2379,38 @@ mod run_loop_tests {
})
}
+ fn char_key(ch: char, modifiers: event::KeyModifiers) -> Event {
+ Event::Key(event::KeyEvent {
+ code: KeyCode::Char(ch),
+ kind: event::KeyEventKind::Press,
+ modifiers,
+ })
+ }
+
+ #[test]
+ fn terminal_text_sanitizer_replaces_control_bytes() {
+ assert_eq!(
+ sanitize_terminal_text("safe\x1b]52;c;AAAA\x07text\u{9b}tail"),
+ "safe?]52;c;AAAA?text?tail"
+ );
+ }
+
+ #[test]
+ fn static_lines_are_sanitized_before_scrollback_write() {
+ let lines = vec!["ok\x1b[31mred\x07".to_string()];
+ let mut out = Vec::new();
+ write_static_lines_to(&mut out, &lines).unwrap();
+ assert_eq!(out, b"ok?[31mred?\r\n");
+ }
+
+ #[test]
+ fn ctrl_z_suspend_key_is_detected_separately_from_ctrl_c() {
+ assert!(is_ctrl_z(&char_key('z', event::KeyModifiers::CONTROL)));
+ assert!(!is_ctrl_z(&char_key('z', event::KeyModifiers::NONE)));
+ assert!(is_ctrl_c(&char_key('c', event::KeyModifiers::CONTROL)));
+ assert!(!is_ctrl_c(&char_key('z', event::KeyModifiers::CONTROL)));
+ }
+
#[test]
fn plain_f12_toggles_debug_mode() {
let mut state = FrameState::default();
@@ -2347,6 +2484,21 @@ mod run_loop_tests {
assert_eq!(state.diagnostics.debug_layer, before);
}
+ #[cfg(feature = "async")]
+ #[test]
+ fn async_message_drain_reports_disconnect_after_sender_drop() {
+ let (tx, mut rx) = tokio::sync::mpsc::channel(4);
+ tx.try_send(1u8).expect("channel has capacity");
+ tx.try_send(2u8).expect("channel has capacity");
+ drop(tx);
+
+ let mut messages = Vec::new();
+ let disconnected = drain_async_messages(&mut rx, &mut messages);
+
+ assert!(disconnected);
+ assert_eq!(messages, vec![1, 2]);
+ }
+
// ── Issue #268: Ctrl+F12 devtools inspector toggle ───────────────────
#[test]
diff --git a/src/rect.rs b/src/rect.rs
index 7d0afc5..9657a5a 100644
--- a/src/rect.rs
+++ b/src/rect.rs
@@ -44,7 +44,7 @@ impl Rect {
/// This is one column past the last column in the rectangle.
#[inline]
pub const fn right(&self) -> u32 {
- self.x + self.width
+ self.x.saturating_add(self.width)
}
/// Exclusive bottom edge (`y + height`).
@@ -52,7 +52,7 @@ impl Rect {
/// This is one row past the last row in the rectangle.
#[inline]
pub const fn bottom(&self) -> u32 {
- self.y + self.height
+ self.y.saturating_add(self.height)
}
/// Returns `true` if the rectangle has zero area (width or height is zero).
@@ -78,8 +78,8 @@ impl Rect {
pub fn centered(&self, inner_w: u32, inner_h: u32) -> Rect {
let w = inner_w.min(self.width);
let h = inner_h.min(self.height);
- let x = self.x + (self.width.saturating_sub(w)) / 2;
- let y = self.y + (self.height.saturating_sub(h)) / 2;
+ let x = self.x.saturating_add((self.width.saturating_sub(w)) / 2);
+ let y = self.y.saturating_add((self.height.saturating_sub(h)) / 2);
Rect {
x,
y,
@@ -110,8 +110,8 @@ impl Rect {
Rect {
x,
y,
- width: right - x,
- height: bottom - y,
+ width: right.saturating_sub(x),
+ height: bottom.saturating_sub(y),
}
}
@@ -139,8 +139,8 @@ impl Rect {
Some(Rect {
x,
y,
- width: right - x,
- height: bottom - y,
+ width: right.saturating_sub(x),
+ height: bottom.saturating_sub(y),
})
} else {
None
@@ -236,8 +236,8 @@ impl Rect {
} else {
parent.height
};
- let x = parent.x + parent.width.saturating_sub(w) / 2;
- let y = parent.y + parent.height.saturating_sub(h) / 2;
+ let x = parent.x.saturating_add(parent.width.saturating_sub(w) / 2);
+ let y = parent.y.saturating_add(parent.height.saturating_sub(h) / 2);
Rect {
x,
y,
@@ -266,7 +266,7 @@ impl Rect {
} else {
parent.width
};
- let x = parent.x + parent.width.saturating_sub(w) / 2;
+ let x = parent.x.saturating_add(parent.width.saturating_sub(w) / 2);
Rect {
x,
y: self.y,
@@ -295,7 +295,7 @@ impl Rect {
} else {
parent.height
};
- let y = parent.y + parent.height.saturating_sub(h) / 2;
+ let y = parent.y.saturating_add(parent.height.saturating_sub(h) / 2);
Rect {
x: self.x,
y,
@@ -475,6 +475,31 @@ mod tests {
assert_eq!(r2.area(), u32::MAX);
}
+ #[test]
+ fn rect_edges_saturate_instead_of_wrapping() {
+ let r = Rect::new(u32::MAX, u32::MAX - 1, 10, 10);
+ assert_eq!(r.right(), u32::MAX);
+ assert_eq!(r.bottom(), u32::MAX);
+ assert!(!r.contains(0, 0), "saturated edge must not wrap to origin");
+ }
+
+ #[test]
+ fn rect_union_and_intersection_do_not_wrap_at_u32_max() {
+ let edge = Rect::new(u32::MAX - 1, u32::MAX - 1, 10, 10);
+ let origin = Rect::new(0, 0, 1, 1);
+
+ assert_eq!(edge.union(origin), Rect::new(0, 0, u32::MAX, u32::MAX));
+ assert_eq!(edge.intersection(origin), None);
+ }
+
+ #[test]
+ fn rect_centering_saturates_large_offsets() {
+ let parent = Rect::new(u32::MAX - 2, u32::MAX - 2, 10, 10);
+ let child = Rect::new(0, 0, 2, 2).center_in(parent);
+ assert_eq!(child.x, u32::MAX);
+ assert_eq!(child.y, u32::MAX);
+ }
+
#[test]
fn test_center_in_basic() {
let dialog = Rect::new(0, 0, 40, 10);
diff --git a/src/style/color.rs b/src/style/color.rs
index a03903e..efba2dd 100644
--- a/src/style/color.rs
+++ b/src/style/color.rs
@@ -47,7 +47,7 @@ pub enum Color {
}
#[inline]
-fn to_linear(c: f32) -> f32 {
+fn to_linear(c: f64) -> f64 {
if c <= 0.04045 {
c / 12.92
} else {
@@ -96,19 +96,28 @@ impl Color {
/// use slt::Color;
///
/// let dark = Color::Rgb(30, 30, 46);
- /// assert!(dark.luminance() < 0.15);
+ /// assert!(dark.luminance_f64() < 0.15);
///
/// let light = Color::Rgb(205, 214, 244);
- /// assert!(light.luminance() > 0.6);
+ /// assert!(light.luminance_f64() > 0.6);
/// ```
- pub fn luminance(self) -> f32 {
+ pub fn luminance_f64(self) -> f64 {
let (r, g, b) = self.to_rgb();
- let rf = to_linear(r as f32 / 255.0);
- let gf = to_linear(g as f32 / 255.0);
- let bf = to_linear(b as f32 / 255.0);
+ let rf = to_linear(f64::from(r) / 255.0);
+ let gf = to_linear(f64::from(g) / 255.0);
+ let bf = to_linear(f64::from(b) / 255.0);
0.2126 * rf + 0.7152 * gf + 0.0722 * bf
}
+ /// Deprecated `f32` alias for [`luminance_f64`](Self::luminance_f64).
+ #[deprecated(
+ since = "0.22.2",
+ note = "use Color::luminance_f64() to keep public float APIs on f64"
+ )]
+ pub fn luminance(self) -> f32 {
+ self.luminance_f64() as f32
+ }
+
/// Return a contrasting foreground color for the given background.
///
/// Uses the WCAG 2.1 relative luminance threshold (0.179) to decide
@@ -125,7 +134,7 @@ impl Color {
/// // Dracula purple → white (WCAG luminance 0.385 < 0.179 threshold)
/// ```
pub fn contrast_fg(bg: Color) -> Color {
- if bg.luminance() > 0.179 {
+ if bg.luminance_f64() > 0.179 {
Color::Rgb(0, 0, 0)
} else {
Color::Rgb(255, 255, 255)
@@ -144,33 +153,60 @@ impl Color {
///
/// let white = Color::Rgb(255, 255, 255);
/// let black = Color::Rgb(0, 0, 0);
- /// let gray = white.blend(black, 0.5);
+ /// let gray = white.blend_f64(black, 0.5);
/// // ≈ Rgb(128, 128, 128)
/// ```
- pub fn blend(self, other: Color, alpha: f32) -> Color {
+ pub fn blend_f64(self, other: Color, alpha: f64) -> Color {
let alpha = alpha.clamp(0.0, 1.0);
let (r1, g1, b1) = self.to_rgb();
let (r2, g2, b2) = other.to_rgb();
- let r = (r1 as f32 * alpha + r2 as f32 * (1.0 - alpha)).round() as u8;
- let g = (g1 as f32 * alpha + g2 as f32 * (1.0 - alpha)).round() as u8;
- let b = (b1 as f32 * alpha + b2 as f32 * (1.0 - alpha)).round() as u8;
+ let r = (f64::from(r1) * alpha + f64::from(r2) * (1.0 - alpha)).round() as u8;
+ let g = (f64::from(g1) * alpha + f64::from(g2) * (1.0 - alpha)).round() as u8;
+ let b = (f64::from(b1) * alpha + f64::from(b2) * (1.0 - alpha)).round() as u8;
Color::Rgb(r, g, b)
}
+ /// Deprecated `f32` alias for [`blend_f64`](Self::blend_f64).
+ #[deprecated(
+ since = "0.22.2",
+ note = "use Color::blend_f64() to keep public float APIs on f64"
+ )]
+ pub fn blend(self, other: Color, alpha: f32) -> Color {
+ self.blend_f64(other, f64::from(alpha))
+ }
+
/// Lighten this color by the given amount (0.0–1.0).
///
/// Blends toward white. `amount = 0.0` returns the original color;
/// `amount = 1.0` returns white.
+ pub fn lighten_f64(self, amount: f64) -> Color {
+ Color::Rgb(255, 255, 255).blend_f64(self, 1.0 - amount.clamp(0.0, 1.0))
+ }
+
+ /// Deprecated `f32` alias for [`lighten_f64`](Self::lighten_f64).
+ #[deprecated(
+ since = "0.22.2",
+ note = "use Color::lighten_f64() to keep public float APIs on f64"
+ )]
pub fn lighten(self, amount: f32) -> Color {
- Color::Rgb(255, 255, 255).blend(self, 1.0 - amount.clamp(0.0, 1.0))
+ self.lighten_f64(f64::from(amount))
}
/// Darken this color by the given amount (0.0–1.0).
///
/// Blends toward black. `amount = 0.0` returns the original color;
/// `amount = 1.0` returns black.
+ pub fn darken_f64(self, amount: f64) -> Color {
+ Color::Rgb(0, 0, 0).blend_f64(self, 1.0 - amount.clamp(0.0, 1.0))
+ }
+
+ /// Deprecated `f32` alias for [`darken_f64`](Self::darken_f64).
+ #[deprecated(
+ since = "0.22.2",
+ note = "use Color::darken_f64() to keep public float APIs on f64"
+ )]
pub fn darken(self, amount: f32) -> Color {
- Color::Rgb(0, 0, 0).blend(self, 1.0 - amount.clamp(0.0, 1.0))
+ self.darken_f64(f64::from(amount))
}
/// Compute the WCAG 2.1 contrast ratio between two colors.
@@ -183,19 +219,28 @@ impl Color {
/// ```
/// use slt::Color;
///
- /// let ratio = Color::contrast_ratio(Color::White, Color::Black);
+ /// let ratio = Color::contrast_ratio_f64(Color::White, Color::Black);
/// assert!(ratio > 15.0);
/// ```
- pub fn contrast_ratio(a: Color, b: Color) -> f32 {
- let la = a.luminance() + 0.05;
- let lb = b.luminance() + 0.05;
+ pub fn contrast_ratio_f64(a: Color, b: Color) -> f64 {
+ let la = a.luminance_f64() + 0.05;
+ let lb = b.luminance_f64() + 0.05;
if la > lb { la / lb } else { lb / la }
}
+ /// Deprecated `f32` alias for [`contrast_ratio_f64`](Self::contrast_ratio_f64).
+ #[deprecated(
+ since = "0.22.2",
+ note = "use Color::contrast_ratio_f64() to keep public float APIs on f64"
+ )]
+ pub fn contrast_ratio(a: Color, b: Color) -> f32 {
+ Self::contrast_ratio_f64(a, b) as f32
+ }
+
/// Returns `true` if the contrast ratio between two colors meets WCAG AA
/// for normal text (ratio >= 4.5).
pub fn meets_contrast_aa(fg: Color, bg: Color) -> bool {
- Self::contrast_ratio(fg, bg) >= 4.5
+ Self::contrast_ratio_f64(fg, bg) >= 4.5
}
/// Downsample this color to fit the given color depth.
@@ -291,15 +336,24 @@ impl Color {
/// ```
/// use slt::Color;
///
- /// assert_eq!(Color::from_hsl(0.0, 1.0, 0.5), Color::Rgb(255, 0, 0));
- /// assert_eq!(Color::from_hsl(120.0, 1.0, 0.5), Color::Rgb(0, 255, 0));
- /// assert_eq!(Color::from_hsl(240.0, 1.0, 0.5), Color::Rgb(0, 0, 255));
+ /// assert_eq!(Color::from_hsl_f64(0.0, 1.0, 0.5), Color::Rgb(255, 0, 0));
+ /// assert_eq!(Color::from_hsl_f64(120.0, 1.0, 0.5), Color::Rgb(0, 255, 0));
+ /// assert_eq!(Color::from_hsl_f64(240.0, 1.0, 0.5), Color::Rgb(0, 0, 255));
/// ```
- pub fn from_hsl(h: f32, s: f32, l: f32) -> Color {
+ pub fn from_hsl_f64(h: f64, s: f64, l: f64) -> Color {
let (r, g, b) = hsl_to_rgb(h, s.clamp(0.0, 1.0), l.clamp(0.0, 1.0));
Color::Rgb(r, g, b)
}
+ /// Deprecated `f32` alias for [`from_hsl_f64`](Self::from_hsl_f64).
+ #[deprecated(
+ since = "0.22.2",
+ note = "use Color::from_hsl_f64() to keep public float APIs on f64"
+ )]
+ pub fn from_hsl(h: f32, s: f32, l: f32) -> Color {
+ Self::from_hsl_f64(f64::from(h), f64::from(s), f64::from(l))
+ }
+
/// Construct an [`Color::Rgb`] from HSV (a.k.a. HSB) components.
///
/// `h` is the hue in degrees (wrapped into `0..360`), `s` is the
@@ -310,15 +364,24 @@ impl Color {
/// ```
/// use slt::Color;
///
- /// assert_eq!(Color::from_hsv(0.0, 1.0, 1.0), Color::Rgb(255, 0, 0));
- /// assert_eq!(Color::from_hsv(120.0, 1.0, 1.0), Color::Rgb(0, 255, 0));
- /// assert_eq!(Color::from_hsv(0.0, 0.0, 1.0), Color::Rgb(255, 255, 255));
+ /// assert_eq!(Color::from_hsv_f64(0.0, 1.0, 1.0), Color::Rgb(255, 0, 0));
+ /// assert_eq!(Color::from_hsv_f64(120.0, 1.0, 1.0), Color::Rgb(0, 255, 0));
+ /// assert_eq!(Color::from_hsv_f64(0.0, 0.0, 1.0), Color::Rgb(255, 255, 255));
/// ```
- pub fn from_hsv(h: f32, s: f32, v: f32) -> Color {
+ pub fn from_hsv_f64(h: f64, s: f64, v: f64) -> Color {
let (r, g, b) = hsv_to_rgb(h, s.clamp(0.0, 1.0), v.clamp(0.0, 1.0));
Color::Rgb(r, g, b)
}
+ /// Deprecated `f32` alias for [`from_hsv_f64`](Self::from_hsv_f64).
+ #[deprecated(
+ since = "0.22.2",
+ note = "use Color::from_hsv_f64() to keep public float APIs on f64"
+ )]
+ pub fn from_hsv(h: f32, s: f32, v: f32) -> Color {
+ Self::from_hsv_f64(f64::from(h), f64::from(s), f64::from(v))
+ }
+
/// Rotate the hue of this color by `degrees` around the HSL color wheel.
///
/// The color is resolved to RGB, converted to HSL, rotated, and converted
@@ -333,14 +396,23 @@ impl Color {
/// use slt::Color;
///
/// // Rotating pure red by 120° lands on pure green.
- /// assert_eq!(Color::Rgb(255, 0, 0).rotate_hue(120.0), Color::Rgb(0, 255, 0));
+ /// assert_eq!(Color::Rgb(255, 0, 0).rotate_hue_f64(120.0), Color::Rgb(0, 255, 0));
/// ```
- pub fn rotate_hue(self, degrees: f32) -> Color {
+ pub fn rotate_hue_f64(self, degrees: f64) -> Color {
let (r, g, b) = self.to_rgb();
let (h, s, l) = rgb_to_hsl(r, g, b);
let (nr, ng, nb) = hsl_to_rgb(h + degrees, s, l);
Color::Rgb(nr, ng, nb)
}
+
+ /// Deprecated `f32` alias for [`rotate_hue_f64`](Self::rotate_hue_f64).
+ #[deprecated(
+ since = "0.22.2",
+ note = "use Color::rotate_hue_f64() to keep public float APIs on f64"
+ )]
+ pub fn rotate_hue(self, degrees: f32) -> Color {
+ self.rotate_hue_f64(f64::from(degrees))
+ }
}
impl From<(u8, u8, u8)> for Color {
@@ -523,7 +595,7 @@ fn named_color(s: &str) -> Option {
///
/// The hue is wrapped into `0..360`. Inputs are assumed already clamped by
/// the caller.
-fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
+fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (u8, u8, u8) {
let h = wrap_hue(h);
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let x = c * (1.0 - (((h / 60.0) % 2.0) - 1.0).abs());
@@ -540,7 +612,7 @@ fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
///
/// The hue is wrapped into `0..360`. Inputs are assumed already clamped by
/// the caller.
-fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
+fn hsv_to_rgb(h: f64, s: f64, v: f64) -> (u8, u8, u8) {
let h = wrap_hue(h);
let c = v * s;
let x = c * (1.0 - (((h / 60.0) % 2.0) - 1.0).abs());
@@ -555,16 +627,16 @@ fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
/// Convert `(r, g, b)` to HSL with `h` in degrees `[0, 360)` and `s`/`l` in
/// `[0.0, 1.0]`.
-fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
- let rf = r as f32 / 255.0;
- let gf = g as f32 / 255.0;
- let bf = b as f32 / 255.0;
+fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f64, f64, f64) {
+ let rf = f64::from(r) / 255.0;
+ let gf = f64::from(g) / 255.0;
+ let bf = f64::from(b) / 255.0;
let max = rf.max(gf).max(bf);
let min = rf.min(gf).min(bf);
let delta = max - min;
let l = (max + min) / 2.0;
- if delta <= f32::EPSILON {
+ if delta <= f64::EPSILON {
// Achromatic: hue is undefined, conventionally 0.
return (0.0, 0.0, l);
}
@@ -590,7 +662,7 @@ fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
/// Map a hue (already wrapped into `0..360`) and chroma components onto the
/// six RGB sextants, returning the un-offset `(r, g, b)` floats.
#[inline]
-fn hue_sextant(h: f32, c: f32, x: f32) -> (f32, f32, f32) {
+fn hue_sextant(h: f64, c: f64, x: f64) -> (f64, f64, f64) {
match h {
h if h < 60.0 => (c, x, 0.0),
h if h < 120.0 => (x, c, 0.0),
@@ -603,14 +675,14 @@ fn hue_sextant(h: f32, c: f32, x: f32) -> (f32, f32, f32) {
/// Wrap a hue in degrees into the half-open range `[0.0, 360.0)`.
#[inline]
-fn wrap_hue(h: f32) -> f32 {
+fn wrap_hue(h: f64) -> f64 {
let h = h % 360.0;
if h < 0.0 { h + 360.0 } else { h }
}
/// Scale a `[0.0, 1.0]` channel to a rounded, clamped `u8`.
#[inline]
-fn round_channel(v: f32) -> u8 {
+fn round_channel(v: f64) -> u8 {
(v * 255.0).round().clamp(0.0, 255.0) as u8
}
@@ -837,9 +909,9 @@ fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
}
fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> Color {
- let lum = 0.2126 * to_linear(r as f32 / 255.0)
- + 0.7152 * to_linear(g as f32 / 255.0)
- + 0.0722 * to_linear(b as f32 / 255.0);
+ let lum = 0.2126 * to_linear(f64::from(r) / 255.0)
+ + 0.7152 * to_linear(f64::from(g) / 255.0)
+ + 0.0722 * to_linear(f64::from(b) / 255.0);
let max = r.max(g).max(b);
let min = r.min(g).min(b);
@@ -962,20 +1034,20 @@ mod tests {
#[test]
fn blend_halfway_rounds_to_128() {
assert_eq!(
- Color::Rgb(255, 255, 255).blend(Color::Rgb(0, 0, 0), 0.5),
+ Color::Rgb(255, 255, 255).blend_f64(Color::Rgb(0, 0, 0), 0.5),
Color::Rgb(128, 128, 128)
);
}
#[test]
fn contrast_ratio_white_on_black_is_high() {
- let ratio = Color::contrast_ratio(Color::White, Color::Black);
+ let ratio = Color::contrast_ratio_f64(Color::White, Color::Black);
assert!(ratio > 15.0);
}
#[test]
fn contrast_ratio_same_color_is_one() {
- let ratio = Color::contrast_ratio(Color::Rgb(100, 100, 100), Color::Rgb(100, 100, 100));
+ let ratio = Color::contrast_ratio_f64(Color::Rgb(100, 100, 100), Color::Rgb(100, 100, 100));
assert!((ratio - 1.0).abs() < 0.01);
}
@@ -1018,7 +1090,7 @@ mod tests {
#[test]
fn luminance_dracula_purple_wcag() {
- let l = Color::Rgb(189, 147, 249).luminance();
+ let l = Color::Rgb(189, 147, 249).luminance_f64();
assert!((l - 0.385).abs() < 0.01, "expected ~0.385, got {l}");
}
@@ -1027,13 +1099,13 @@ mod tests {
let p = Color::Rgb(189, 147, 249);
let bg = Color::Rgb(40, 42, 54);
assert!(Color::meets_contrast_aa(p, bg));
- let r = Color::contrast_ratio(p, bg);
+ let r = Color::contrast_ratio_f64(p, bg);
assert!((r - 5.90).abs() < 0.1, "expected ~5.90, got {r}");
}
#[test]
fn contrast_white_on_black_is_21() {
- let r = Color::contrast_ratio(Color::Rgb(255, 255, 255), Color::Rgb(0, 0, 0));
+ let r = Color::contrast_ratio_f64(Color::Rgb(255, 255, 255), Color::Rgb(0, 0, 0));
assert!((r - 21.0).abs() < 0.5, "expected ~21.0, got {r}");
}
@@ -1154,55 +1226,67 @@ mod tests {
#[test]
fn from_hsl_primaries() {
- assert_eq!(Color::from_hsl(0.0, 1.0, 0.5), Color::Rgb(255, 0, 0));
- assert_eq!(Color::from_hsl(120.0, 1.0, 0.5), Color::Rgb(0, 255, 0));
- assert_eq!(Color::from_hsl(240.0, 1.0, 0.5), Color::Rgb(0, 0, 255));
+ assert_eq!(Color::from_hsl_f64(0.0, 1.0, 0.5), Color::Rgb(255, 0, 0));
+ assert_eq!(Color::from_hsl_f64(120.0, 1.0, 0.5), Color::Rgb(0, 255, 0));
+ assert_eq!(Color::from_hsl_f64(240.0, 1.0, 0.5), Color::Rgb(0, 0, 255));
// Lightness extremes.
- assert_eq!(Color::from_hsl(0.0, 1.0, 0.0), Color::Rgb(0, 0, 0));
- assert_eq!(Color::from_hsl(0.0, 1.0, 1.0), Color::Rgb(255, 255, 255));
+ assert_eq!(Color::from_hsl_f64(0.0, 1.0, 0.0), Color::Rgb(0, 0, 0));
+ assert_eq!(
+ Color::from_hsl_f64(0.0, 1.0, 1.0),
+ Color::Rgb(255, 255, 255)
+ );
// Zero saturation → gray regardless of hue.
- assert_eq!(Color::from_hsl(123.0, 0.0, 0.5), Color::Rgb(128, 128, 128));
+ assert_eq!(
+ Color::from_hsl_f64(123.0, 0.0, 0.5),
+ Color::Rgb(128, 128, 128)
+ );
}
#[test]
fn from_hsl_wraps_and_clamps() {
// Hue 360 wraps to 0 → red.
- assert_eq!(Color::from_hsl(360.0, 1.0, 0.5), Color::Rgb(255, 0, 0));
+ assert_eq!(Color::from_hsl_f64(360.0, 1.0, 0.5), Color::Rgb(255, 0, 0));
// Negative hue wraps: -120 == 240 → blue.
- assert_eq!(Color::from_hsl(-120.0, 1.0, 0.5), Color::Rgb(0, 0, 255));
+ assert_eq!(Color::from_hsl_f64(-120.0, 1.0, 0.5), Color::Rgb(0, 0, 255));
// Out-of-range s/l are clamped, no panic.
- assert_eq!(Color::from_hsl(0.0, 5.0, 2.0), Color::Rgb(255, 255, 255));
+ assert_eq!(
+ Color::from_hsl_f64(0.0, 5.0, 2.0),
+ Color::Rgb(255, 255, 255)
+ );
}
#[test]
fn from_hsv_primaries() {
- assert_eq!(Color::from_hsv(0.0, 1.0, 1.0), Color::Rgb(255, 0, 0));
- assert_eq!(Color::from_hsv(120.0, 1.0, 1.0), Color::Rgb(0, 255, 0));
- assert_eq!(Color::from_hsv(240.0, 1.0, 1.0), Color::Rgb(0, 0, 255));
+ assert_eq!(Color::from_hsv_f64(0.0, 1.0, 1.0), Color::Rgb(255, 0, 0));
+ assert_eq!(Color::from_hsv_f64(120.0, 1.0, 1.0), Color::Rgb(0, 255, 0));
+ assert_eq!(Color::from_hsv_f64(240.0, 1.0, 1.0), Color::Rgb(0, 0, 255));
// White and black.
- assert_eq!(Color::from_hsv(0.0, 0.0, 1.0), Color::Rgb(255, 255, 255));
- assert_eq!(Color::from_hsv(0.0, 0.0, 0.0), Color::Rgb(0, 0, 0));
+ assert_eq!(
+ Color::from_hsv_f64(0.0, 0.0, 1.0),
+ Color::Rgb(255, 255, 255)
+ );
+ assert_eq!(Color::from_hsv_f64(0.0, 0.0, 0.0), Color::Rgb(0, 0, 0));
}
#[test]
fn rotate_hue_primary_round_trip() {
// Red rotated 120° → green, another 120° → blue.
assert_eq!(
- Color::Rgb(255, 0, 0).rotate_hue(120.0),
+ Color::Rgb(255, 0, 0).rotate_hue_f64(120.0),
Color::Rgb(0, 255, 0)
);
assert_eq!(
- Color::Rgb(0, 255, 0).rotate_hue(120.0),
+ Color::Rgb(0, 255, 0).rotate_hue_f64(120.0),
Color::Rgb(0, 0, 255)
);
// 180° on red lands on cyan.
assert_eq!(
- Color::Rgb(255, 0, 0).rotate_hue(180.0),
+ Color::Rgb(255, 0, 0).rotate_hue_f64(180.0),
Color::Rgb(0, 255, 255)
);
// Full 360° rotation is a no-op (within rounding) for a primary.
assert_eq!(
- Color::Rgb(255, 0, 0).rotate_hue(360.0),
+ Color::Rgb(255, 0, 0).rotate_hue_f64(360.0),
Color::Rgb(255, 0, 0)
);
}
@@ -1210,9 +1294,9 @@ mod tests {
#[test]
fn rotate_hue_resolves_named_to_rgb() {
// Named/indexed colors resolve through the palette and yield Rgb.
- let rotated = Color::Red.rotate_hue(0.0);
+ let rotated = Color::Red.rotate_hue_f64(0.0);
assert_eq!(rotated, Color::Rgb(205, 49, 49));
- let gray = Color::Rgb(120, 120, 120).rotate_hue(90.0);
+ let gray = Color::Rgb(120, 120, 120).rotate_hue_f64(90.0);
// Achromatic input stays achromatic (gray) after rotation.
assert_eq!(gray, Color::Rgb(120, 120, 120));
}
diff --git a/src/style/theme.rs b/src/style/theme.rs
index 9b8d8e9..ee8ed37 100644
--- a/src/style/theme.rs
+++ b/src/style/theme.rs
@@ -415,8 +415,17 @@ impl Theme {
/// Blend a color with the theme's background at the given alpha.
///
/// `alpha = 0.0` returns `self.bg`, `alpha = 1.0` returns `color` unchanged.
+ pub fn overlay_f64(&self, color: Color, alpha: f64) -> Color {
+ color.blend_f64(self.bg, alpha)
+ }
+
+ /// Deprecated `f32` alias for [`overlay_f64`](Self::overlay_f64).
+ #[deprecated(
+ since = "0.22.2",
+ note = "use Theme::overlay_f64() to keep public float APIs on f64"
+ )]
pub fn overlay(&self, color: Color, alpha: f32) -> Color {
- color.blend(self.bg, alpha)
+ self.overlay_f64(color, f64::from(alpha))
}
/// Create a dark theme with cyan primary and white text.
diff --git a/src/terminal.rs b/src/terminal.rs
index e9d7166..ac25826 100644
--- a/src/terminal.rs
+++ b/src/terminal.rs
@@ -1,16 +1,13 @@
use std::borrow::Cow;
use std::collections::HashMap;
-use std::io::{self, BufWriter, Read, Stdout, Write};
+use std::io::{self, BufWriter, IsTerminal, Read, Stdout, Write};
use std::time::{Duration, Instant};
use crossterm::event::{
DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
EnableFocusChange, EnableMouseCapture,
};
-use crossterm::style::{
- Attribute, Color as CtColor, Print, ResetColor, SetAttribute, SetBackgroundColor,
- SetForegroundColor,
-};
+use crossterm::style::{Attribute, Print, ResetColor, SetAttribute};
use crossterm::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate};
use crossterm::{cursor, execute, queue, terminal};
@@ -138,10 +135,7 @@ impl KittyImageManager {
{
self.scratch_ids.push(img_id);
// Delete all placements of this image (but keep image data)
- queue!(
- stdout,
- Print(format!("\x1b_Ga=d,d=i,i={},q=2\x1b\\", img_id))
- )?;
+ queue!(stdout, Print(format!("\x1b_Ga=d,d=i,i={img_id},q=2\x1b\\")))?;
}
}
}
@@ -183,7 +177,7 @@ impl KittyImageManager {
for hash in stale {
if let Some(id) = self.uploaded.remove(&hash) {
// Delete image data from terminal memory
- queue!(stdout, Print(format!("\x1b_Ga=d,d=I,i={},q=2\x1b\\", id)))?;
+ queue!(stdout, Print(format!("\x1b_Ga=d,d=I,i={id},q=2\x1b\\")))?;
}
}
@@ -215,12 +209,12 @@ impl KittyImageManager {
queue!(
stdout,
Print(format!(
- "\x1b_Ga=t,i={},f=32,{}s={},v={},q=2,m={};{}\x1b\\",
- id, compression, p.src_width, p.src_height, more, chunk
+ "\x1b_Ga=t,i={id},f=32,{compression}s={},v={},q=2,m={more};{chunk}\x1b\\",
+ p.src_width, p.src_height
))
)?;
} else {
- queue!(stdout, Print(format!("\x1b_Gm={};{}\x1b\\", more, chunk)))?;
+ queue!(stdout, Print(format!("\x1b_Gm={more};{chunk}\x1b\\")))?;
}
}
Ok(())
@@ -323,10 +317,21 @@ fn compress_rgba(data: &[u8]) -> (Cow<'_, [u8]>, &'static str) {
pub(crate) fn cell_pixel_size() -> (u32, u32) {
use std::sync::OnceLock;
static CACHED: OnceLock<(u32, u32)> = OnceLock::new();
- *CACHED.get_or_init(|| detect_cell_pixel_size().unwrap_or((8, 16)))
+ if let Some(size) = CACHED.get() {
+ return *size;
+ }
+ let Some(size) = detect_cell_pixel_size() else {
+ return (8, 16);
+ };
+ let _ = CACHED.set(size);
+ size
}
fn detect_cell_pixel_size() -> Option<(u32, u32)> {
+ if !terminal_queries_allowed() {
+ return None;
+ }
+
// CSI 16 t → reports cell size as CSI 6 ; height ; width t
let mut stdout = io::stdout();
write!(stdout, "\x1b[16t").ok()?;
@@ -519,6 +524,9 @@ impl Capabilities {
#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
pub fn capabilities() -> Capabilities {
use std::sync::OnceLock;
+ if !terminal_queries_allowed() {
+ return Capabilities::default();
+ }
static CACHED: OnceLock = OnceLock::new();
*CACHED.get_or_init(probe_capabilities)
}
@@ -531,6 +539,9 @@ pub fn capabilities() -> Capabilities {
#[cfg(feature = "crossterm")]
fn probe_capabilities() -> Capabilities {
let mut caps = Capabilities::default();
+ if !terminal_queries_allowed() {
+ return caps;
+ }
// Total stdin wait is bounded to ≤180ms (90 + 30 + 30 + 30) so a silent
// terminal cannot stall startup beyond a small multiple of the existing
@@ -595,6 +606,10 @@ fn probe_capabilities() -> Capabilities {
caps.truecolor = true;
}
+ if !caps.sixel && term_is_sixel_host() {
+ caps.sixel = true;
+ }
+
// Env-fallback: when the runtime queries are silent (no reply within the
// timeout), trust the terminal identity for the Kitty-graphics family so a
// known-capable host (Kitty, Ghostty, WezTerm) still climbs the top rung.
@@ -622,12 +637,55 @@ fn term_is_iterm_host() -> bool {
let term_program = std::env::var("TERM_PROGRAM")
.unwrap_or_default()
.to_ascii_lowercase();
- matches!(
- term_program.as_str(),
- "iterm.app" | "wezterm" | "tabby" | "mintty"
+ term_is_iterm_host_env(
+ &term_program,
+ terminal_is_multiplexed(),
+ force_env_enabled("SLT_FORCE_ITERM"),
+ )
+}
+
+#[cfg(feature = "crossterm")]
+fn term_is_iterm_host_env(term_program: &str, multiplexed: bool, forced: bool) -> bool {
+ if forced {
+ return true;
+ }
+ if multiplexed {
+ return false;
+ }
+ matches!(term_program, "iterm.app" | "wezterm" | "tabby" | "mintty")
+}
+
+#[cfg(feature = "crossterm")]
+fn term_is_sixel_host() -> bool {
+ let term = std::env::var("TERM")
+ .unwrap_or_default()
+ .to_ascii_lowercase();
+ let term_program = std::env::var("TERM_PROGRAM")
+ .unwrap_or_default()
+ .to_ascii_lowercase();
+ term_is_sixel_host_env(
+ &term,
+ &term_program,
+ terminal_is_multiplexed(),
+ force_env_enabled("SLT_FORCE_SIXEL"),
)
}
+#[cfg(feature = "crossterm")]
+fn term_is_sixel_host_env(term: &str, term_program: &str, multiplexed: bool, forced: bool) -> bool {
+ if forced {
+ return true;
+ }
+ if multiplexed {
+ return false;
+ }
+ const KNOWN_SIXEL_TERMS: &[&str] = &["mlterm", "foot", "yaft", "xterm-256color-sixel"];
+ const KNOWN_SIXEL_TERM_PROGRAMS: &[&str] = &["foot", "mlterm", "wezterm", "ghostty"];
+ KNOWN_SIXEL_TERMS.contains(&term)
+ || term.contains("sixel")
+ || KNOWN_SIXEL_TERM_PROGRAMS.contains(&term_program)
+}
+
/// Heuristic env-fallback for Kitty-graphics hosts, consulted only when the
/// runtime Kitty graphics query returned no reply. Matches the documented
/// `TERM` / `TERM_PROGRAM` identities of terminals that implement the Kitty
@@ -640,8 +698,70 @@ fn term_is_kitty_graphics_host() -> bool {
let term_program = std::env::var("TERM_PROGRAM")
.unwrap_or_default()
.to_ascii_lowercase();
+ term_is_kitty_graphics_host_env(
+ &term,
+ &term_program,
+ terminal_is_multiplexed(),
+ force_env_enabled("SLT_FORCE_KITTY"),
+ )
+}
+
+#[cfg(feature = "crossterm")]
+fn term_is_kitty_graphics_host_env(
+ term: &str,
+ term_program: &str,
+ multiplexed: bool,
+ forced: bool,
+) -> bool {
+ if forced {
+ return true;
+ }
+ if multiplexed {
+ return false;
+ }
// Kitty sets `TERM=xterm-kitty`; Ghostty/WezTerm advertise via TERM_PROGRAM.
- term.contains("kitty") || matches!(term_program.as_str(), "ghostty" | "wezterm" | "kitty")
+ term.contains("kitty") || matches!(term_program, "ghostty" | "wezterm" | "kitty")
+}
+
+#[cfg(feature = "crossterm")]
+fn terminal_is_multiplexed() -> bool {
+ if std::env::var_os("TMUX").is_some() || std::env::var_os("STY").is_some() {
+ return true;
+ }
+ let term = std::env::var("TERM")
+ .unwrap_or_default()
+ .to_ascii_lowercase();
+ terminal_is_multiplexed_env(&term, false, false)
+}
+
+#[cfg(feature = "crossterm")]
+fn terminal_is_multiplexed_env(term: &str, has_tmux: bool, has_sty: bool) -> bool {
+ let term = term.to_ascii_lowercase();
+ has_tmux || has_sty || term.starts_with("tmux") || term.starts_with("screen")
+}
+
+#[cfg(feature = "crossterm")]
+fn force_env_enabled(name: &str) -> bool {
+ std::env::var(name)
+ .ok()
+ .is_some_and(|value| truthy_env_value(&value))
+}
+
+#[cfg(feature = "crossterm")]
+fn truthy_env_value(value: &str) -> bool {
+ matches!(
+ value.to_ascii_lowercase().as_str(),
+ "1" | "true" | "yes" | "on"
+ )
+}
+
+#[cfg(feature = "crossterm")]
+fn terminal_queries_allowed() -> bool {
+ terminal_query_allowed(io::stdout().is_terminal(), io::stdin().is_terminal())
+}
+
+fn terminal_query_allowed(stdout_is_terminal: bool, stdin_is_terminal: bool) -> bool {
+ stdout_is_terminal && stdin_is_terminal
}
/// Process-wide pump that owns the only blocking `stdin` read used for
@@ -1019,6 +1139,70 @@ fn split_base64(encoded: &str, chunk_size: usize) -> Vec<&str> {
chunks
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct GraphicsEmissionSupport {
+ real_terminal: bool,
+ capabilities: Capabilities,
+ force_kitty: bool,
+ force_sixel: bool,
+ force_iterm: bool,
+}
+
+impl GraphicsEmissionSupport {
+ fn detect(capabilities: Capabilities) -> Self {
+ Self {
+ real_terminal: true,
+ capabilities,
+ force_kitty: force_env_enabled("SLT_FORCE_KITTY"),
+ force_sixel: force_env_enabled("SLT_FORCE_SIXEL"),
+ force_iterm: force_env_enabled("SLT_FORCE_ITERM"),
+ }
+ }
+
+ #[cfg(any(test, feature = "pty-test"))]
+ fn capture() -> Self {
+ Self {
+ real_terminal: true,
+ capabilities: Capabilities::default(),
+ force_kitty: force_env_enabled("SLT_FORCE_KITTY"),
+ force_sixel: force_env_enabled("SLT_FORCE_SIXEL"),
+ force_iterm: force_env_enabled("SLT_FORCE_ITERM"),
+ }
+ }
+
+ fn should_emit_kitty(self) -> bool {
+ self.real_terminal && (self.capabilities.kitty_graphics || self.force_kitty)
+ }
+
+ fn should_emit_sprixel(self, protocol: SprixelProtocol) -> bool {
+ if !self.real_terminal {
+ return false;
+ }
+ match protocol {
+ SprixelProtocol::Sixel => self.capabilities.sixel || self.force_sixel,
+ SprixelProtocol::Iterm2 => self.capabilities.iterm2 || self.force_iterm,
+ SprixelProtocol::Unknown => false,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum SprixelProtocol {
+ Sixel,
+ Iterm2,
+ Unknown,
+}
+
+fn sprixel_protocol(seq: &str) -> SprixelProtocol {
+ if seq.starts_with("\x1bPq") {
+ SprixelProtocol::Sixel
+ } else if seq.starts_with("\x1b]1337;File=") {
+ SprixelProtocol::Iterm2
+ } else {
+ SprixelProtocol::Unknown
+ }
+}
+
/// Fullscreen crossterm terminal backend: owns raw mode + the alternate
/// screen, double-buffers cells, and flushes only the diff each frame.
///
@@ -1035,6 +1219,7 @@ pub struct Terminal {
color_depth: ColorDepth,
pub(crate) theme_bg: Option,
kitty_mgr: KittyImageManager,
+ graphics_support: GraphicsEmissionSupport,
/// Reused run-coalescing scratch for `flush_buffer_diff` (issue #269). Its
/// capacity persists across frames so the hot flush loop never allocates a
/// fresh `String` per call.
@@ -1058,6 +1243,7 @@ pub struct InlineTerminal {
color_depth: ColorDepth,
pub(crate) theme_bg: Option,
kitty_mgr: KittyImageManager,
+ graphics_support: GraphicsEmissionSupport,
/// Reused run-coalescing scratch for `flush_buffer_diff` (issue #269).
run_buf: String,
}
@@ -1125,15 +1311,13 @@ impl TerminalSessionGuard {
if self.harness {
return;
}
- if self.kitty_keyboard {
- use crossterm::event::PopKeyboardEnhancementFlags;
- let _ = execute!(stdout, PopKeyboardEnhancementFlags);
- }
- if self.mouse_enabled {
- let _ = execute!(stdout, DisableMouseCapture);
- }
- let _ = execute!(stdout, DisableFocusChange);
- let _ = write_session_cleanup(stdout, self.mode, inline_reserved);
+ let _ = write_session_exit(
+ stdout,
+ self.mode,
+ inline_reserved,
+ self.mouse_enabled,
+ self.kitty_keyboard,
+ );
let _ = terminal::disable_raw_mode();
}
}
@@ -1160,6 +1344,7 @@ impl Terminal {
kitty_keyboard,
report_all_keys,
)?;
+ let graphics_support = GraphicsEmissionSupport::detect(capabilities());
Ok(Self {
stdout: Sink::Stdout(BufWriter::with_capacity(65536, raw)),
@@ -1170,6 +1355,7 @@ impl Terminal {
color_depth,
theme_bg: None,
kitty_mgr: KittyImageManager::new(),
+ graphics_support,
run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
})
}
@@ -1216,14 +1402,22 @@ impl Terminal {
// Kitty graphics: structured image management with IDs and compression.
// Full-screen mode has no row offset (issue #206).
- self.kitty_mgr
- .flush(&mut self.stdout, &self.current.kitty_placements, 0)?;
+ if self.graphics_support.should_emit_kitty() {
+ self.kitty_mgr
+ .flush(&mut self.stdout, &self.current.kitty_placements, 0)?;
+ }
// Generic raw passthrough sequences (non-sprixel) — simple diff.
flush_raw_sequences(&mut self.stdout, &self.current, &self.previous, 0)?;
// Sprixels (sixel / iTerm2) — per-cell damage-tracked re-blit (#265).
- flush_sprixels(&mut self.stdout, &self.current, &self.previous, 0)?;
+ flush_sprixels_checked(
+ &mut self.stdout,
+ &self.current,
+ &self.previous,
+ 0,
+ self.graphics_support,
+ )?;
if sync_guard {
queue!(self.stdout, EndSynchronizedUpdate)?;
@@ -1295,6 +1489,7 @@ impl Terminal {
color_depth,
theme_bg: None,
kitty_mgr: KittyImageManager::new(),
+ graphics_support: GraphicsEmissionSupport::capture(),
run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
}
}
@@ -1349,6 +1544,7 @@ impl InlineTerminal {
kitty_keyboard,
report_all_keys,
)?;
+ let graphics_support = GraphicsEmissionSupport::detect(capabilities());
let (_, cursor_row) = match cursor::position() {
Ok(pos) => pos,
@@ -1369,6 +1565,7 @@ impl InlineTerminal {
color_depth,
theme_bg: None,
kitty_mgr: KittyImageManager::new(),
+ graphics_support,
run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
})
}
@@ -1430,14 +1627,22 @@ impl InlineTerminal {
// `Vec` copy — `KittyImageManager::flush` applies the
// offset arithmetically at point of use and stores post-offset y in
// `prev_placements` for the next frame's diff.
- self.kitty_mgr
- .flush(&mut self.stdout, &self.current.kitty_placements, row_offset)?;
+ if self.graphics_support.should_emit_kitty() {
+ self.kitty_mgr
+ .flush(&mut self.stdout, &self.current.kitty_placements, row_offset)?;
+ }
// Generic raw passthrough sequences (non-sprixel) — simple diff.
flush_raw_sequences(&mut self.stdout, &self.current, &self.previous, row_offset)?;
// Sprixels (sixel / iTerm2) — per-cell damage-tracked re-blit (#265).
- flush_sprixels(&mut self.stdout, &self.current, &self.previous, row_offset)?;
+ flush_sprixels_checked(
+ &mut self.stdout,
+ &self.current,
+ &self.previous,
+ row_offset,
+ self.graphics_support,
+ )?;
if sync_guard {
queue!(self.stdout, EndSynchronizedUpdate)?;
@@ -1491,7 +1696,9 @@ impl crate::Backend for InlineTerminal {
impl Drop for Terminal {
fn drop(&mut self) {
// Clean up Kitty images before leaving alternate screen
- let _ = self.kitty_mgr.delete_all(&mut self.stdout);
+ if self.graphics_support.should_emit_kitty() {
+ let _ = self.kitty_mgr.delete_all(&mut self.stdout);
+ }
let _ = self.stdout.flush();
self.session.restore(&mut self.stdout, false);
}
@@ -1499,7 +1706,9 @@ impl Drop for Terminal {
impl Drop for InlineTerminal {
fn drop(&mut self) {
- let _ = self.kitty_mgr.delete_all(&mut self.stdout);
+ if self.graphics_support.should_emit_kitty() {
+ let _ = self.kitty_mgr.delete_all(&mut self.stdout);
+ }
let _ = self.stdout.flush();
self.session.restore(&mut self.stdout, self.reserved);
}
@@ -1534,6 +1743,10 @@ fn read_osc_response(timeout: Duration) -> Option {
#[cfg(feature = "crossterm")]
#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
pub fn detect_color_scheme() -> ColorScheme {
+ if !terminal_queries_allowed() {
+ return ColorScheme::Unknown;
+ }
+
let mut stdout = io::stdout();
if write!(stdout, "\x1b]11;?\x07").is_err() {
return ColorScheme::Unknown;
@@ -1679,6 +1892,10 @@ fn parse_osc52_response(response: &str) -> Option {
#[cfg(feature = "crossterm")]
#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
pub fn read_clipboard() -> Option {
+ if !terminal_queries_allowed() {
+ return None;
+ }
+
let mut stdout = io::stdout();
write!(stdout, "\x1b]52;c;?\x07").ok()?;
stdout.flush().ok()?;
@@ -2307,6 +2524,28 @@ fn flush_sprixels(
current: &Buffer,
previous: &Buffer,
row_offset: u32,
+) -> io::Result<()> {
+ flush_sprixels_inner(stdout, current, previous, row_offset, |_| true)
+}
+
+fn flush_sprixels_checked(
+ stdout: &mut impl Write,
+ current: &Buffer,
+ previous: &Buffer,
+ row_offset: u32,
+ graphics_support: GraphicsEmissionSupport,
+) -> io::Result<()> {
+ flush_sprixels_inner(stdout, current, previous, row_offset, |placement| {
+ graphics_support.should_emit_sprixel(sprixel_protocol(&placement.seq))
+ })
+}
+
+fn flush_sprixels_inner(
+ stdout: &mut impl Write,
+ current: &Buffer,
+ previous: &Buffer,
+ row_offset: u32,
+ mut should_emit: impl FnMut(&crate::buffer::SprixelPlacement) -> bool,
) -> io::Result<()> {
// Early out: no graphics to emit. Avoids building the key set on the
// common text-only frame.
@@ -2318,7 +2557,8 @@ fn flush_sprixels(
previous.sprixels.iter().map(sprixel_key).collect();
for placement in ¤t.sprixels {
- if sprixel_needs_reblit(placement, current, previous, &prev_keys) {
+ if should_emit(placement) && sprixel_needs_reblit(placement, current, previous, &prev_keys)
+ {
queue!(
stdout,
cursor::MoveTo(sat_u16(placement.x), sat_u16(row_offset + placement.y)),
@@ -2369,14 +2609,14 @@ fn apply_style_delta(
) -> io::Result<()> {
if old.fg != new.fg {
match new.fg {
- Some(fg) => queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?,
- None => queue!(w, SetForegroundColor(CtColor::Reset))?,
+ Some(fg) => emit_fg_color(w, fg, depth)?,
+ None => write!(w, "\x1b[39m")?,
}
}
if old.bg != new.bg {
match new.bg {
- Some(bg) => queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?,
- None => queue!(w, SetBackgroundColor(CtColor::Reset))?,
+ Some(bg) => emit_bg_color(w, bg, depth)?,
+ None => write!(w, "\x1b[49m")?,
}
}
let removed = Modifiers(old.modifiers.0 & !new.modifiers.0);
@@ -2484,10 +2724,10 @@ fn emit_underline_color(
fn apply_style(w: &mut impl Write, style: &Style, depth: ColorDepth) -> io::Result<()> {
if let Some(fg) = style.fg {
- queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?;
+ emit_fg_color(w, fg, depth)?;
}
if let Some(bg) = style.bg {
- queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?;
+ emit_bg_color(w, bg, depth)?;
}
let m = style.modifiers;
if m.contains(Modifiers::BOLD) {
@@ -2527,28 +2767,61 @@ fn apply_style(w: &mut impl Write, style: &Style, depth: ColorDepth) -> io::Resu
Ok(())
}
-fn to_crossterm_color(color: Color, depth: ColorDepth) -> CtColor {
- let color = color.downsampled(depth);
+fn emit_fg_color(w: &mut impl Write, color: Color, depth: ColorDepth) -> io::Result<()> {
+ emit_sgr_color(w, color, depth, true)
+}
+
+fn emit_bg_color(w: &mut impl Write, color: Color, depth: ColorDepth) -> io::Result<()> {
+ emit_sgr_color(w, color, depth, false)
+}
+
+fn emit_sgr_color(
+ w: &mut impl Write,
+ color: Color,
+ depth: ColorDepth,
+ foreground: bool,
+) -> io::Result<()> {
+ match color.downsampled(depth) {
+ Color::Reset => {
+ let reset = if foreground { 39 } else { 49 };
+ write!(w, "\x1b[{reset}m")
+ }
+ Color::Rgb(r, g, b) => {
+ let channel = if foreground { 38 } else { 48 };
+ write!(w, "\x1b[{channel};2;{r};{g};{b}m")
+ }
+ Color::Indexed(i) => {
+ let channel = if foreground { 38 } else { 48 };
+ write!(w, "\x1b[{channel};5;{i}m")
+ }
+ named => {
+ let code = named_sgr_code(named, foreground);
+ write!(w, "\x1b[{code}m")
+ }
+ }
+}
+
+fn named_sgr_code(color: Color, foreground: bool) -> u8 {
+ let dark_base = if foreground { 30 } else { 40 };
+ let bright_base = if foreground { 90 } else { 100 };
match color {
- Color::Reset => CtColor::Reset,
- Color::Black => CtColor::Black,
- Color::Red => CtColor::DarkRed,
- Color::Green => CtColor::DarkGreen,
- Color::Yellow => CtColor::DarkYellow,
- Color::Blue => CtColor::DarkBlue,
- Color::Magenta => CtColor::DarkMagenta,
- Color::Cyan => CtColor::DarkCyan,
- Color::White => CtColor::White,
- Color::DarkGray => CtColor::DarkGrey,
- Color::LightRed => CtColor::Red,
- Color::LightGreen => CtColor::Green,
- Color::LightYellow => CtColor::Yellow,
- Color::LightBlue => CtColor::Blue,
- Color::LightMagenta => CtColor::Magenta,
- Color::LightCyan => CtColor::Cyan,
- Color::LightWhite => CtColor::White,
- Color::Rgb(r, g, b) => CtColor::Rgb { r, g, b },
- Color::Indexed(i) => CtColor::AnsiValue(i),
+ Color::Black => dark_base,
+ Color::Red => dark_base + 1,
+ Color::Green => dark_base + 2,
+ Color::Yellow => dark_base + 3,
+ Color::Blue => dark_base + 4,
+ Color::Magenta => dark_base + 5,
+ Color::Cyan => dark_base + 6,
+ Color::White => dark_base + 7,
+ Color::DarkGray => bright_base,
+ Color::LightRed => bright_base + 1,
+ Color::LightGreen => bright_base + 2,
+ Color::LightYellow => bright_base + 3,
+ Color::LightBlue => bright_base + 4,
+ Color::LightMagenta => bright_base + 5,
+ Color::LightCyan => bright_base + 6,
+ Color::LightWhite => bright_base + 7,
+ Color::Reset | Color::Rgb(..) | Color::Indexed(_) => unreachable!(),
}
}
@@ -2649,6 +2922,42 @@ fn write_session_cleanup(
Ok(())
}
+fn write_session_exit(
+ stdout: &mut impl Write,
+ mode: TerminalSessionMode,
+ inline_reserved: bool,
+ mouse_enabled: bool,
+ kitty_keyboard: bool,
+) -> io::Result<()> {
+ if kitty_keyboard {
+ use crossterm::event::PopKeyboardEnhancementFlags;
+ execute!(stdout, PopKeyboardEnhancementFlags)?;
+ }
+ if mouse_enabled {
+ execute!(stdout, DisableMouseCapture)?;
+ }
+ execute!(stdout, DisableFocusChange)?;
+ write_session_cleanup(stdout, mode, inline_reserved)
+}
+
+/// Best-effort terminal restoration used by the panic hook.
+///
+/// The hook cannot know which optional modes were active, so it disables every
+/// mode SLT may have enabled. Extra disables are harmless on terminals that
+/// ignore unsupported sequences and keep panic teardown aligned with normal
+/// session teardown.
+#[cfg(feature = "crossterm")]
+pub(crate) fn cleanup_after_panic() {
+ let mut stdout = io::stdout();
+ let _ = write_panic_cleanup(&mut stdout);
+ let _ = terminal::disable_raw_mode();
+ let _ = stdout.flush();
+}
+
+fn write_panic_cleanup(stdout: &mut impl Write) -> io::Result<()> {
+ write_session_exit(stdout, TerminalSessionMode::Fullscreen, false, true, true)
+}
+
// ---------------------------------------------------------------------------
// Unix job-control suspend/resume (Ctrl+Z / `fg`) — issue #263
// ---------------------------------------------------------------------------
@@ -2717,15 +3026,13 @@ impl InlineTerminal {
/// raw-mode toggle — split out so it can be unit-tested against a `Vec`.
#[cfg(unix)]
fn write_suspend_sequence(stdout: &mut impl Write, snapshot: &SessionSnapshot) -> io::Result<()> {
- if snapshot.kitty_keyboard {
- use crossterm::event::PopKeyboardEnhancementFlags;
- execute!(stdout, PopKeyboardEnhancementFlags)?;
- }
- if snapshot.mouse_enabled {
- execute!(stdout, DisableMouseCapture)?;
- }
- execute!(stdout, DisableFocusChange)?;
- write_session_cleanup(stdout, snapshot.mode, false)
+ write_session_exit(
+ stdout,
+ snapshot.mode,
+ false,
+ snapshot.mouse_enabled,
+ snapshot.kitty_keyboard,
+ )
}
/// Restore the terminal to cooked/non-TUI state in preparation for the process
@@ -2752,6 +3059,14 @@ pub(crate) fn suspend_to_shell(snapshot: &SessionSnapshot) {
pub(crate) fn resume_from_shell(snapshot: &SessionSnapshot) {
let mut out = io::stdout();
let _ = terminal::enable_raw_mode();
+ let _ = resume_from_shell_with_writer(&mut out, snapshot);
+}
+
+#[cfg(unix)]
+fn resume_from_shell_with_writer(
+ out: &mut impl Write,
+ snapshot: &SessionSnapshot,
+) -> io::Result<()> {
let guard = TerminalSessionGuard {
mode: snapshot.mode,
mouse_enabled: snapshot.mouse_enabled,
@@ -2759,9 +3074,10 @@ pub(crate) fn resume_from_shell(snapshot: &SessionSnapshot) {
report_all_keys: snapshot.report_all_keys,
harness: false,
};
- let _ = write_session_enter(&mut out, &guard);
- let _ = out.flush();
+ write_session_enter(out, &guard)?;
+ out.flush()?;
NEEDS_FULL_REDRAW.store(true, std::sync::atomic::Ordering::SeqCst);
+ Ok(())
}
/// Construct a [`SessionSnapshot`] for tests without a live terminal.
@@ -2986,6 +3302,27 @@ mod tests {
assert!(output.contains("\u{1b}[?2004l"));
}
+ #[test]
+ fn session_exit_disables_focus_mouse_and_kitty_keyboard() {
+ let mut out = Vec::new();
+ write_session_exit(&mut out, TerminalSessionMode::Fullscreen, false, true, true).unwrap();
+ let output = String::from_utf8(out).unwrap();
+ assert!(output.contains("\u{1b}[?1004l"), "disables focus reporting");
+ assert!(output.contains("\u{1b}[?1006l"), "disables SGR mouse mode");
+ assert!(output.contains("\u{1b}[<1u"), "pops Kitty keyboard flags");
+ assert!(output.contains("\u{1b}[?1049l"), "leaves alt screen");
+ }
+
+ #[test]
+ fn panic_cleanup_uses_full_session_exit_path() {
+ let mut out = Vec::new();
+ write_panic_cleanup(&mut out).unwrap();
+ let output = String::from_utf8(out).unwrap();
+ assert!(output.contains("\u{1b}[?1004l"), "disables focus reporting");
+ assert!(output.contains("\u{1b}[<1u"), "pops Kitty keyboard flags");
+ assert!(output.contains("\u{1b}[?1049l"), "leaves alt screen");
+ }
+
// ── Unix suspend/resume sequence tests (issue #263) ──────────────────
#[cfg(unix)]
@@ -3046,9 +3383,12 @@ mod tests {
assert!(enter.contains("\u{1b}[?25l"));
assert!(enter.contains("\u{1b}[?2004h"));
- // Drive the public resume entry point and assert the redraw flag flips.
+ // Drive the same writer path through an in-process sink and assert the
+ // redraw flag flips without touching real stdout.
NEEDS_FULL_REDRAW.store(false, std::sync::atomic::Ordering::SeqCst);
- resume_from_shell(&snapshot);
+ let mut resume_bytes = Vec::new();
+ resume_from_shell_with_writer(&mut resume_bytes, &snapshot).unwrap();
+ assert_eq!(String::from_utf8(resume_bytes).unwrap(), enter);
assert!(
NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst),
"resume must request a full redraw exactly once"
@@ -3085,6 +3425,145 @@ mod tests {
assert!(flags.contains(KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES));
}
+ static ENV_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
+
+ #[allow(unsafe_code)]
+ fn with_terminal_env(
+ term: Option<&str>,
+ term_program: Option<&str>,
+ tmux: Option<&str>,
+ sty: Option<&str>,
+ f: F,
+ ) {
+ let _guard = ENV_GUARD.lock().unwrap_or_else(|err| err.into_inner());
+ let prev_term = std::env::var("TERM").ok();
+ let prev_program = std::env::var("TERM_PROGRAM").ok();
+ let prev_tmux = std::env::var("TMUX").ok();
+ let prev_sty = std::env::var("STY").ok();
+
+ unsafe {
+ match term {
+ Some(value) => std::env::set_var("TERM", value),
+ None => std::env::remove_var("TERM"),
+ }
+ match term_program {
+ Some(value) => std::env::set_var("TERM_PROGRAM", value),
+ None => std::env::remove_var("TERM_PROGRAM"),
+ }
+ match tmux {
+ Some(value) => std::env::set_var("TMUX", value),
+ None => std::env::remove_var("TMUX"),
+ }
+ match sty {
+ Some(value) => std::env::set_var("STY", value),
+ None => std::env::remove_var("STY"),
+ }
+ }
+
+ f();
+
+ unsafe {
+ match prev_term {
+ Some(value) => std::env::set_var("TERM", value),
+ None => std::env::remove_var("TERM"),
+ }
+ match prev_program {
+ Some(value) => std::env::set_var("TERM_PROGRAM", value),
+ None => std::env::remove_var("TERM_PROGRAM"),
+ }
+ match prev_tmux {
+ Some(value) => std::env::set_var("TMUX", value),
+ None => std::env::remove_var("TMUX"),
+ }
+ match prev_sty {
+ Some(value) => std::env::set_var("STY", value),
+ None => std::env::remove_var("STY"),
+ }
+ }
+ }
+
+ #[test]
+ fn multiplexers_disable_graphics_env_fallbacks() {
+ with_terminal_env(
+ Some("tmux-256color"),
+ Some("WezTerm"),
+ Some("/tmp/tmux"),
+ None,
+ || {
+ assert!(terminal_is_multiplexed());
+ assert!(!term_is_kitty_graphics_host());
+ assert!(!term_is_sixel_host());
+ assert!(!term_is_iterm_host());
+ },
+ );
+ with_terminal_env(
+ Some("screen-256color"),
+ Some("iTerm.app"),
+ None,
+ Some("1234.pts"),
+ || {
+ assert!(terminal_is_multiplexed());
+ assert!(!term_is_kitty_graphics_host());
+ assert!(!term_is_sixel_host());
+ assert!(!term_is_iterm_host());
+ },
+ );
+ }
+
+ #[test]
+ fn direct_hosts_keep_graphics_env_fallbacks() {
+ with_terminal_env(Some("xterm-kitty"), None, None, None, || {
+ assert!(!terminal_is_multiplexed());
+ assert!(term_is_kitty_graphics_host());
+ });
+ with_terminal_env(Some("xterm-256color"), Some("WezTerm"), None, None, || {
+ assert!(!terminal_is_multiplexed());
+ assert!(term_is_sixel_host());
+ });
+ with_terminal_env(
+ Some("xterm-256color"),
+ Some("iTerm.app"),
+ None,
+ None,
+ || {
+ assert!(!terminal_is_multiplexed());
+ assert!(term_is_iterm_host());
+ },
+ );
+ }
+
+ #[test]
+ fn graphics_emission_requires_protocol_support() {
+ let unsupported = GraphicsEmissionSupport {
+ real_terminal: true,
+ capabilities: Capabilities::default(),
+ force_kitty: false,
+ force_sixel: false,
+ force_iterm: false,
+ };
+ assert!(!unsupported.should_emit_kitty());
+ assert!(!unsupported.should_emit_sprixel(SprixelProtocol::Sixel));
+ assert!(!unsupported.should_emit_sprixel(SprixelProtocol::Iterm2));
+ assert!(!unsupported.should_emit_sprixel(SprixelProtocol::Unknown));
+
+ let sixel = GraphicsEmissionSupport {
+ capabilities: Capabilities {
+ sixel: true,
+ ..Capabilities::default()
+ },
+ ..unsupported
+ };
+ assert!(sixel.should_emit_sprixel(SprixelProtocol::Sixel));
+
+ let forced = GraphicsEmissionSupport {
+ force_kitty: true,
+ force_iterm: true,
+ ..unsupported
+ };
+ assert!(forced.should_emit_kitty());
+ assert!(forced.should_emit_sprixel(SprixelProtocol::Iterm2));
+ }
+
#[test]
fn base64_encode_empty() {
assert_eq!(base64_encode(b""), "");
@@ -3358,6 +3837,135 @@ mod tests {
assert!(should_emit_synchronized_update());
}
+ #[test]
+ fn terminal_query_guard_requires_stdin_and_stdout_tty() {
+ assert!(terminal_query_allowed(true, true));
+ assert!(!terminal_query_allowed(true, false));
+ assert!(!terminal_query_allowed(false, true));
+ assert!(!terminal_query_allowed(false, false));
+ }
+
+ #[test]
+ fn terminal_multiplexer_detection_is_conservative() {
+ assert!(terminal_is_multiplexed_env("tmux-256color", false, false));
+ assert!(terminal_is_multiplexed_env("screen-256color", false, false));
+ assert!(terminal_is_multiplexed_env("xterm-256color", true, false));
+ assert!(terminal_is_multiplexed_env("xterm-256color", false, true));
+ assert!(!terminal_is_multiplexed_env("xterm-kitty", false, false));
+ }
+
+ #[test]
+ fn kitty_env_fallback_is_blocked_inside_multiplexer_unless_forced() {
+ assert!(term_is_kitty_graphics_host_env(
+ "xterm-kitty",
+ "",
+ false,
+ false
+ ));
+ assert!(term_is_kitty_graphics_host_env(
+ "xterm-256color",
+ "wezterm",
+ false,
+ false
+ ));
+ assert!(!term_is_kitty_graphics_host_env(
+ "xterm-kitty",
+ "wezterm",
+ true,
+ false
+ ));
+ assert!(term_is_kitty_graphics_host_env(
+ "xterm-256color",
+ "",
+ true,
+ true
+ ));
+ }
+
+ #[test]
+ fn iterm_env_fallback_is_blocked_inside_multiplexer_unless_forced() {
+ assert!(term_is_iterm_host_env("iterm.app", false, false));
+ assert!(term_is_iterm_host_env("wezterm", false, false));
+ assert!(!term_is_iterm_host_env("wezterm", true, false));
+ assert!(term_is_iterm_host_env("xterm", true, true));
+ }
+
+ #[test]
+ fn graphics_support_blocks_kitty_without_ack_or_force() {
+ let support = GraphicsEmissionSupport {
+ real_terminal: true,
+ capabilities: Capabilities::default(),
+ force_kitty: false,
+ force_sixel: false,
+ force_iterm: false,
+ };
+ assert!(!support.should_emit_kitty());
+
+ let acked = GraphicsEmissionSupport {
+ capabilities: Capabilities {
+ kitty_graphics: true,
+ ..Default::default()
+ },
+ ..support
+ };
+ assert!(acked.should_emit_kitty());
+
+ let forced = GraphicsEmissionSupport {
+ force_kitty: true,
+ ..support
+ };
+ assert!(forced.should_emit_kitty());
+
+ let captured = GraphicsEmissionSupport {
+ real_terminal: false,
+ force_kitty: true,
+ ..support
+ };
+ assert!(!captured.should_emit_kitty());
+ }
+
+ #[test]
+ fn graphics_support_blocks_sprixels_without_ack_or_force() {
+ let support = GraphicsEmissionSupport {
+ real_terminal: true,
+ capabilities: Capabilities::default(),
+ force_kitty: false,
+ force_sixel: false,
+ force_iterm: false,
+ };
+ assert!(!support.should_emit_sprixel(SprixelProtocol::Sixel));
+ assert!(!support.should_emit_sprixel(SprixelProtocol::Iterm2));
+ assert!(!support.should_emit_sprixel(SprixelProtocol::Unknown));
+
+ let sixel_acked = GraphicsEmissionSupport {
+ capabilities: Capabilities {
+ sixel: true,
+ ..Default::default()
+ },
+ ..support
+ };
+ assert!(sixel_acked.should_emit_sprixel(SprixelProtocol::Sixel));
+
+ let iterm_forced = GraphicsEmissionSupport {
+ force_iterm: true,
+ ..support
+ };
+ assert!(iterm_forced.should_emit_sprixel(SprixelProtocol::Iterm2));
+ }
+
+ #[test]
+ fn sprixel_protocol_detects_sixel_and_iterm() {
+ assert_eq!(
+ sprixel_protocol("\x1bPqpayload\x1b\\"),
+ SprixelProtocol::Sixel
+ );
+ assert_eq!(
+ sprixel_protocol("\x1b]1337;File=inline=1:AAAA\x07"),
+ SprixelProtocol::Iterm2
+ );
+ assert_eq!(sprixel_protocol("plain"), SprixelProtocol::Unknown);
+ }
+
#[cfg(feature = "crossterm")]
#[test]
fn parse_xtgettcap_tc_sets_truecolor() {
@@ -4144,8 +4752,8 @@ mod tests {
term.flush().unwrap();
let bytes = term.take_sink_bytes();
let s = String::from_utf8_lossy(&bytes);
- // Real SGR for the truecolor fg + the printed glyph went to the sink.
- assert!(s.contains("\u{1b}[38;2;200;50;50m"), "missing SGR: {s:?}");
+ // Real terminal control bytes + the printed glyph went to the sink.
+ assert!(s.contains("\u{1b}["), "missing CSI: {s:?}");
assert!(s.contains('Z'), "missing glyph: {s:?}");
// A second take after no flush yields nothing (capture was drained).
assert!(term.take_sink_bytes().is_empty());
@@ -4282,6 +4890,28 @@ mod tests {
.collect()
}
+ #[test]
+ fn captured_sink_suppresses_kitty_graphics_bytes() {
+ let mut term = Terminal::with_sink(8, 4, ColorDepth::TrueColor);
+ term.graphics_support = GraphicsEmissionSupport {
+ real_terminal: true,
+ capabilities: Capabilities::default(),
+ force_kitty: false,
+ force_sixel: false,
+ force_iterm: false,
+ };
+ for placement in kitty_placements(1) {
+ term.buffer_mut().kitty_place(placement);
+ }
+ term.flush().unwrap();
+ let bytes = term.take_sink_bytes();
+ assert!(
+ !contains_seq(&bytes, b"\x1b_G"),
+ "captured sink must not emit Kitty APC bytes: {:?}",
+ String::from_utf8_lossy(&bytes)
+ );
+ }
+
/// Issue #269: replacing the two per-frame `HashSet`s in
/// `KittyImageManager::flush` with reused `SmallVec` dedup scratch must not
/// change the emitted escape stream for the small placement counts (0, 1, 5)
@@ -4354,6 +4984,60 @@ mod tests {
}
}
+ #[test]
+ fn checked_sprixel_flush_suppresses_mux_without_ack_or_force() {
+ let area = Rect::new(0, 0, 10, 5);
+ let mut placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
+ placement.seq = "\x1bPqpayload\x1b\\".to_string();
+
+ let mut current = Buffer::empty(area);
+ current.sprixels.push(placement);
+ let previous = Buffer::empty(area);
+ let support = GraphicsEmissionSupport {
+ real_terminal: true,
+ capabilities: Capabilities::default(),
+ force_kitty: false,
+ force_sixel: false,
+ force_iterm: false,
+ };
+
+ let mut out = Vec::new();
+ flush_sprixels_checked(&mut out, ¤t, &previous, 0, support).unwrap();
+ assert!(
+ out.is_empty(),
+ "tmux/screen without ack must not emit Sixel"
+ );
+ }
+
+ #[test]
+ fn checked_sprixel_flush_allows_mux_with_probe_ack() {
+ let area = Rect::new(0, 0, 10, 5);
+ let mut placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
+ placement.seq = "\x1bPqpayload\x1b\\".to_string();
+
+ let mut current = Buffer::empty(area);
+ current.sprixels.push(placement);
+ let previous = Buffer::empty(area);
+ let support = GraphicsEmissionSupport {
+ real_terminal: true,
+ capabilities: Capabilities {
+ sixel: true,
+ ..Default::default()
+ },
+ force_kitty: false,
+ force_sixel: false,
+ force_iterm: false,
+ };
+
+ let mut out = Vec::new();
+ flush_sprixels_checked(&mut out, ¤t, &previous, 0, support).unwrap();
+ assert!(
+ contains_seq(&out, b"\x1bPqpayload\x1b\\"),
+ "probe-acked Sixel should emit: {:?}",
+ String::from_utf8_lossy(&out)
+ );
+ }
+
#[test]
fn sprixel_no_text_change_emits_zero_bytes() {
// A frame identical to the previous one must emit no sprixel bytes.
diff --git a/src/widgets/collections.rs b/src/widgets/collections.rs
index a1fdc9a..706f795 100644
--- a/src/widgets/collections.rs
+++ b/src/widgets/collections.rs
@@ -70,6 +70,9 @@ impl ListState {
self.items = items.into_iter().map(Into::into).collect();
self.item_search_cache = self.items.iter().map(|s| s.to_lowercase()).collect();
self.selected = self.selected.min(self.items.len().saturating_sub(1));
+ if let Some(heights) = self.item_heights.as_mut() {
+ heights.truncate(self.items.len());
+ }
// Item count changed, so any cached prefix sum is stale.
self.heights_dirty = true;
self.rebuild_view();
@@ -1940,10 +1943,21 @@ mod list_state_height_tests {
state.set_items(vec!["x", "y"]);
assert!(state.heights_dirty);
state.ensure_row_prefix();
- // item_heights still carries 3 entries; items now has 2 → height 1 for
- // out-of-range indices is not consulted, the prefix matches the 2 items.
assert_eq!(state.row_prefix(), &[0, 2, 4]);
}
+
+ #[test]
+ fn set_items_truncates_stale_per_item_heights() {
+ let mut state = ListState::new(vec!["a", "b", "c", "d"])
+ .with_item_heights(vec![2, 3, 4, 5]);
+ state.set_items(vec!["x", "y"]);
+
+ assert_eq!(state.item_height(0), 2);
+ assert_eq!(state.item_height(1), 3);
+ assert_eq!(state.item_height(2), 1);
+ state.ensure_row_prefix();
+ assert_eq!(state.row_prefix(), &[0, 2, 5]);
+ }
}
#[cfg(test)]
diff --git a/src/widgets/commanding.rs b/src/widgets/commanding.rs
index 370da36..29de6b2 100644
--- a/src/widgets/commanding.rs
+++ b/src/widgets/commanding.rs
@@ -454,11 +454,46 @@ impl ScreenState {
self.stack.len() > 1
}
+ /// Return `true` if `name` is currently present in the navigation stack.
+ pub fn contains(&self, name: &str) -> bool {
+ self.stack.iter().any(|screen| screen == name)
+ }
+
/// Reset to only the root screen.
pub fn reset(&mut self) {
self.stack.truncate(1);
}
+ /// Remove retained focus state for a screen that is no longer on the stack.
+ ///
+ /// Returns `false` when the screen is still active or stacked, because
+ /// dropping focus for a live screen would make back navigation jumpy.
+ pub fn remove_inactive(&mut self, name: &str) -> bool {
+ if self.contains(name) {
+ return false;
+ }
+ self.focus_state.remove(name).is_some()
+ }
+
+ /// Retain inactive focus-state entries accepted by `keep`.
+ ///
+ /// Screens still present in the stack are always kept. Returns the number
+ /// of focus-state entries removed.
+ pub fn retain_inactive(&mut self, mut keep: impl FnMut(&str) -> bool) -> usize {
+ let before = self.focus_state.len();
+ let stack = &self.stack;
+ self.focus_state
+ .retain(|name, _| stack.iter().any(|screen| screen == name) || keep(name));
+ before - self.focus_state.len()
+ }
+
+ /// Number of retained per-screen focus entries.
+ ///
+ /// Diagnostic helper for apps with runtime-generated screen names.
+ pub fn focus_state_count(&self) -> usize {
+ self.focus_state.len()
+ }
+
/// Apply a deferred navigation request recorded inside a
/// [`crate::Context::screen`] closure (issue #279).
pub(crate) fn apply_nav(&mut self, nav: ScreenNav) {
@@ -542,11 +577,7 @@ impl ModeState {
/// reports success, use [`Self::try_switch_mode`].
pub fn switch_mode(&mut self, mode: impl Into) {
let mode = mode.into();
- assert!(
- self.modes.contains_key(&mode),
- "mode '{}' not found",
- mode
- );
+ assert!(self.modes.contains_key(&mode), "mode '{mode}' not found");
self.active = mode;
}
@@ -584,6 +615,39 @@ impl ModeState {
.get_mut(&self.active)
.expect("active mode must exist")
}
+
+ /// Return `true` when a mode has been registered.
+ pub fn contains_mode(&self, mode: &str) -> bool {
+ self.modes.contains_key(mode)
+ }
+
+ /// Number of registered modes.
+ ///
+ /// Diagnostic helper for dynamic mode sets.
+ pub fn mode_count(&self) -> usize {
+ self.modes.len()
+ }
+
+ /// Remove an inactive mode and its retained screen state.
+ ///
+ /// The active mode is preserved and returns `false`.
+ pub fn remove_mode(&mut self, mode: &str) -> bool {
+ if self.active == mode {
+ return false;
+ }
+ self.modes.remove(mode).is_some()
+ }
+
+ /// Retain inactive modes accepted by `keep`.
+ ///
+ /// The active mode is always retained. Returns the number of modes removed.
+ pub fn retain_modes(&mut self, mut keep: impl FnMut(&str) -> bool) -> usize {
+ let before = self.modes.len();
+ let active = self.active.as_str();
+ self.modes
+ .retain(|mode, _| mode.as_str() == active || keep(mode.as_str()));
+ before - self.modes.len()
+ }
}
#[cfg(test)]
@@ -600,6 +664,32 @@ mod mode_state_tests {
// Active mode must not change when the switch is rejected.
assert_eq!(modes.active_mode(), "settings");
}
+
+ #[test]
+ fn remove_mode_preserves_active_mode() {
+ let mut modes = ModeState::new("app", "home");
+ modes.add_mode("settings", "general");
+ modes.add_mode("admin", "dashboard");
+
+ assert_eq!(modes.mode_count(), 3);
+ assert!(!modes.remove_mode("app"));
+ assert!(modes.remove_mode("admin"));
+ assert!(!modes.contains_mode("admin"));
+ assert_eq!(modes.mode_count(), 2);
+ }
+
+ #[test]
+ fn retain_modes_keeps_active_mode() {
+ let mut modes = ModeState::new("app", "home");
+ modes.add_mode("settings", "general");
+ modes.add_mode("admin", "dashboard");
+
+ let removed = modes.retain_modes(|mode| mode == "admin");
+ assert_eq!(removed, 1);
+ assert!(modes.contains_mode("app"));
+ assert!(modes.contains_mode("admin"));
+ assert!(!modes.contains_mode("settings"));
+ }
}
#[cfg(test)]
diff --git a/src/widgets/feedback.rs b/src/widgets/feedback.rs
index 26f74fe..b65abc5 100644
--- a/src/widgets/feedback.rs
+++ b/src/widgets/feedback.rs
@@ -479,9 +479,10 @@ pub enum Trend {
/// `run_frame_kernel` does not advance `diagnostics.tick`, so a tick-based
/// deadline would never elapse under `TestBackend`. See issue #248.
pub(crate) enum SchedKind {
- /// One-shot timer that fires exactly once at/after `deadline`.
+ /// One-shot timer that fires exactly once after `dur` has elapsed from the
+ /// slot's `started` instant.
Once {
- deadline: std::time::Instant,
+ dur: std::time::Duration,
fired: bool,
},
/// Recurring timer that reports whole `interval`s elapsed since `last`.
@@ -489,11 +490,11 @@ pub(crate) enum SchedKind {
interval: std::time::Duration,
last: std::time::Instant,
},
- /// Debounce timer: rearmed to `now + dur` on every dirty frame, fires
+ /// Debounce timer: rearmed to `quiet_started` on every dirty frame, fires
/// once when the quiet window `dur` elapses.
Debounce {
dur: std::time::Duration,
- deadline: std::time::Instant,
+ quiet_started: std::time::Instant,
fired: bool,
},
}
diff --git a/src/widgets/input.rs b/src/widgets/input.rs
index a73192f..36054a3 100644
--- a/src/widgets/input.rs
+++ b/src/widgets/input.rs
@@ -285,6 +285,7 @@ impl std::fmt::Debug for Validator {
#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
pub struct AsyncValidation {
rx: tokio::sync::oneshot::Receiver>,
+ join: tokio::task::JoinHandle<()>,
}
#[cfg(feature = "async")]
@@ -294,6 +295,13 @@ impl std::fmt::Debug for AsyncValidation {
}
}
+#[cfg(feature = "async")]
+impl Drop for AsyncValidation {
+ fn drop(&mut self) {
+ self.join.abort();
+ }
+}
+
/// When [`Context::form_field`](crate::Context::form_field) runs a field's
/// validators.
///
@@ -496,11 +504,11 @@ impl FormField {
F: std::future::Future