Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -159,12 +159,30 @@ jobs:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# vhs-action's bundled ffmpeg installer is flaky on the GitHub runner
# (observed: "Failed to install ffmpeg"). Pre-install it from apt so the
# action finds it already present and skips its own download.
- name: Install ffmpeg for VHS
run: sudo apt-get update && sudo apt-get install -y ffmpeg
- uses: charmbracelet/vhs-action@v2
# `charmbracelet/vhs-action@v2` unconditionally runs its own bundled
# ffmpeg installer, which is persistently broken on the GitHub runner
# (observed across reruns: "Failed to install ffmpeg") and ignores an
# ffmpeg that is already present. Install the toolchain ourselves instead:
# - ffmpeg from apt (the package the action fails to fetch as a static
# build),
# - ttyd from its upstream release binary (not reliably in apt), and
# - vhs via `go install` (Go is preinstalled on ubuntu-latest).
# This drops the flaky action entirely while producing the same toolchain.
- name: Install VHS toolchain (ffmpeg, ttyd, vhs)
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y ffmpeg
sudo curl -fsSL https://github.com/tsl0922/ttyd/releases/latest/download/ttyd.x86_64 \
-o /usr/local/bin/ttyd
sudo chmod +x /usr/local/bin/ttyd
go install github.com/charmbracelet/vhs@latest
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
- name: Verify VHS toolchain
run: |
vhs --version
ttyd --version
ffmpeg -version | head -1
- name: Build release examples
run: cargo build --release --examples
- name: Render every tape
Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,32 @@
# Changelog

## [0.22.1] - 2026-06-22

Patch release closing two community issues (#278, #279). Additive only — no
existing public items changed.

### Added

- **Navigate from inside `ui.screen(...)` closures** (#279) — new
`Context::push_screen(name)`, `Context::pop_screen()`, and
`Context::reset_screen()`. Each records a deferred navigation that is applied
to the active `ScreenState` the moment the closure returns. Previously the
only way to navigate was to mutate the `ScreenState` outside the closure:
calling `screens.push(...)` *inside* it does not compile, because
`screen(...)` holds a `&mut ScreenState` borrow for the closure's duration.
- **Public crossterm backend for custom render loops** (#278) — `Terminal`,
`InlineTerminal`, and `event::from_crossterm` are now `pub`, and the
`crossterm` crate is re-exported as `slt::crossterm`. External integrations
can drive SLT's render pipeline with their own event loop — the way ratatui
exposes `CrosstermBackend` — without reimplementing the backend or risking a
mismatched crossterm version.

### Fixed

- **`docs/COMPLETE_REFERENCE.md` §6.5** — the screen-navigation example no
longer shows the non-compiling `screens.push(...)`-inside-closure pattern; it
now uses `ui.push_screen(...)` / `ui.pop_screen()`.

## [0.22.0] - 2026-06-11

Rust modernization and optimization release. No new public widgets or features —
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ private_intra_doc_links = "warn"

[package]
name = "superlighttui"
version = "0.22.0"
version = "0.22.1"
edition = "2024"
description = "Super Light TUI - A lightweight, ergonomic terminal UI library"
license = "MIT"
Expand Down
4 changes: 2 additions & 2 deletions crates/slt-wasm/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "slt-wasm"
version = "0.22.0"
version = "0.22.1"
edition = "2024"
rust-version = "1.88"
description = "WASM/browser backend for SuperLightTUI"
Expand All @@ -10,7 +10,7 @@ homepage = "https://github.com/subinium/SuperLightTUI"
documentation = "https://docs.rs/slt-wasm"

[dependencies]
superlighttui = { version = "0.22.0", path = "../..", default-features = false }
superlighttui = { version = "0.22.1", path = "../..", default-features = false }
wasm-bindgen = "0.2"
web-sys = { version = "0.3", features = [
"CssStyleDeclaration",
Expand Down
15 changes: 13 additions & 2 deletions docs/COMPLETE_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ slt::RunConfig::default()

`Backend` trait (for non-terminal targets): implement `fn size(&self) -> (u32, u32)`, `fn buffer_mut(&mut self) -> &mut Buffer`, `fn flush(&mut self) -> io::Result<()>`. Drive with `slt::frame`.

Embedding with your own event loop (issue #278): the built-in crossterm backends `slt::Terminal` / `slt::InlineTerminal` are public — construct one, render into `buffer_mut()`, call `flush()`, and feed input you read yourself through `slt::event::from_crossterm`. The `crossterm` crate is re-exported as `slt::crossterm` so you never pin a mismatched version.

`AppState::new()` holds frame-to-frame state; reuse the same instance across frames. `AppState::tick()`, `::fps()`, `::set_debug(bool)`.

---
Expand Down Expand Up @@ -280,6 +282,7 @@ Legend: `Response = { clicked, hovered, changed, focused, rect }`. `&mut Self` m
| `ui.tooltip(text)` | `()` | Shown near cursor on hover. |
| `ui.group(name)` | `ContainerBuilder` | Named group for shared hover/focus styling. |
| `ui.screen(name, &mut screens, |ui|{...})` | `()` | Render only when `screens.current() == name`. Isolates hook state and focus per screen. |
| `ui.push_screen(name)` / `ui.pop_screen()` / `ui.reset_screen()` | `()` | Navigate from **inside** a `screen(...)` closure. Deferred and applied when the closure returns (issue #279). |
| `ui.form(&mut form_state, |ui|{...})` | `&mut Self` | Form container. |
| `ui.form_field(&mut field)` | `&mut Self` | One field (label + input + error). |
| `ui.form_submit(label)` | `Response` | Submit button. |
Expand Down Expand Up @@ -504,15 +507,23 @@ fn panel(ui: &mut Context, title: &str, f: impl FnOnce(&mut Context)) {
let mut screens = ScreenState::new("home");
slt::run(|ui| {
ui.screen("home", &mut screens, |ui| {
if ui.button("Settings").clicked { screens.push("settings"); }
if ui.button("Settings").clicked { ui.push_screen("settings"); }
});
ui.screen("settings", &mut screens, |ui| {
if ui.button("Back").clicked { screens.pop(); }
if ui.button("Back").clicked { ui.pop_screen(); }
});
});
```
Each `screen(...)` call isolates hook state and focus.

Navigate from **inside** a `screen(...)` closure with `ui.push_screen(name)`,
`ui.pop_screen()`, or `ui.reset_screen()`. These record a deferred navigation
that is applied to your `ScreenState` the moment the closure returns — calling
`screens.push(...)` directly inside the closure does **not** compile, because
`screen(...)` already holds a `&mut ScreenState` borrow for the duration of the
closure (issue #279). Outside any `screen(...)` closure you can still mutate the
`ScreenState` directly (`screens.push("x")`, `screens.pop()`).

### 6.6 Multi-mode app
```rust
let mut modes = ModeState::new("app", "home");
Expand Down
2 changes: 1 addition & 1 deletion src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use crate::widgets::{
ColorPickerState, CommandPaletteState, ContextItem, FilePickerState, FormField, FormState,
GaugeResponse, GridColumn, GutterResponse, HighlightRange, ListState, MultiSelectState,
NumberInputState, PaginatorState, PaginatorStyle, PickerMode, RadioState, SchedKind,
SchedulerSlot, SchedulerState, ScreenState, ScrollState, SelectState, SpinnerState,
SchedulerSlot, SchedulerState, ScreenNav, ScreenState, ScrollState, SelectState, SpinnerState,
SplitPaneResponse, SplitPaneState, StreamingTextState, TableState, TabsState, TextInputState,
TextareaState, ToastLevel, ToastState, ToolApprovalState, TreeState, ValidateTrigger,
color_hex_label, parse_hex_color,
Expand Down
15 changes: 15 additions & 0 deletions src/context/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ pub struct Context {
pub(crate) deferred_draws: Vec<Option<RawDrawCallback>>,
pub(crate) rollback: ContextRollbackState,
pub(crate) pending_tooltips: Vec<PendingTooltip>,
/// Issue #279: screen-navigation requests recorded by
/// [`Context::push_screen`] / [`Context::pop_screen`] /
/// [`Context::reset_screen`] from inside a [`Context::screen`] closure.
/// Drained and applied to the active [`crate::ScreenState`] right after the
/// closure returns. Deferring the mutation here lets app code navigate from
/// within the closure without a double mutable borrow of its `ScreenState`.
pub(crate) pending_screen_nav: Vec<ScreenNav>,
pub(crate) hovered_groups: std::collections::HashSet<std::sync::Arc<str>>,
/// Issue #273: version keys recorded by [`Context::cached`] regions on the
/// PREVIOUS frame, moved in from `FrameState::region_versions`. Indexed by
Expand Down Expand Up @@ -208,6 +215,10 @@ pub(super) struct ContextCheckpoint {
deferred_draws_len: usize,
context_stack_len: usize,
pending_tooltips_len: usize,
/// Issue #279: drop deferred screen-navigation requests recorded by a
/// panicking subtree inside an `error_boundary`, so a rolled-back screen
/// closure does not leave a phantom push/pop queued for its `ScreenState`.
pending_screen_nav_len: usize,
/// Issue #273: `cached` region keys recorded so far, so a panicking
/// `cached` region inside an `error_boundary` rolls back its key entry
/// (and any nested ones) — keeping the recorded keys consistent with the
Expand All @@ -224,6 +235,7 @@ impl ContextCheckpoint {
deferred_draws_len: ctx.deferred_draws.len(),
context_stack_len: ctx.context_stack.len(),
pending_tooltips_len: ctx.pending_tooltips.len(),
pending_screen_nav_len: ctx.pending_screen_nav.len(),
region_versions_cur_len: ctx.region_versions_cur.len(),
rollback: ctx.rollback.clone(),
}
Expand All @@ -238,6 +250,9 @@ impl ContextCheckpoint {
// Drop tooltips queued by the panicking widget but keep any that were
// already pending before the error boundary was entered.
ctx.pending_tooltips.truncate(self.pending_tooltips_len);
// Issue #279: drop screen-navigation requests queued by the panicking
// subtree but keep any recorded before the error boundary was entered.
ctx.pending_screen_nav.truncate(self.pending_screen_nav_len);
// Issue #273: drop `cached` keys recorded by the panicking subtree.
ctx.region_versions_cur
.truncate(self.region_versions_cur_len);
Expand Down
1 change: 1 addition & 0 deletions src/context/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ impl Context {
text_color_stack,
},
pending_tooltips,
pending_screen_nav: Vec::new(),
hovered_groups,
region_versions_prev,
region_versions_cur,
Expand Down
71 changes: 71 additions & 0 deletions src/context/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,77 @@ fn screen_helper_renders_only_current_screen() {
assert!(!rendered.contains("Home Screen"));
}

// === Issue #279: navigate from inside a `ui.screen` closure ===

#[test]
fn push_screen_inside_closure_navigates() {
let mut backend = TestBackend::new(24, 3);
let mut screens = ScreenState::new("home");

// `ui.push_screen` records a deferred nav that is applied once the active
// closure returns — so it does not double-borrow `screens` (issue #279).
backend.render(|ui| {
ui.screen("home", &mut screens, |ui| {
ui.push_screen("settings");
});
ui.screen("settings", &mut screens, |ui| {
ui.text("Settings Screen");
});
});

assert_eq!(screens.current(), "settings");
// The push is applied mid-frame, so the settings screen renders the same
// frame the navigation happened.
assert!(backend.to_string().contains("Settings Screen"));
}

#[test]
fn pop_screen_inside_closure_navigates() {
let mut backend = TestBackend::new(24, 3);
let mut screens = ScreenState::new("home");
screens.push("settings");

backend.render(|ui| {
ui.screen("settings", &mut screens, |ui| {
ui.pop_screen();
});
});

assert_eq!(screens.current(), "home");
}

#[test]
fn reset_screen_inside_closure_returns_to_root() {
let mut backend = TestBackend::new(24, 3);
let mut screens = ScreenState::new("home");
screens.push("settings");
screens.push("advanced");

backend.render(|ui| {
ui.screen("advanced", &mut screens, |ui| {
ui.reset_screen();
});
});

assert_eq!(screens.current(), "home");
assert_eq!(screens.depth(), 1);
}

#[test]
fn screen_nav_no_op_without_request() {
// A screen closure that records no navigation leaves the stack untouched.
let mut backend = TestBackend::new(24, 3);
let mut screens = ScreenState::new("home");

backend.render(|ui| {
ui.screen("home", &mut screens, |ui| {
ui.text("Home");
});
});

assert_eq!(screens.current(), "home");
}

// === Issue #54: mouse_drag / mouse_up convenience methods ===

#[test]
Expand Down
53 changes: 53 additions & 0 deletions src/context/widgets_display/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,18 @@ impl Context {

// Restore outer focus
self.focus_index = outer_focus_index;

// Issue #279: apply navigation requested from inside the closure
// now that the closure's `&mut Context` borrow has ended. We still
// hold `&mut screens` here, so there is no double mutable borrow —
// app code can call `ui.push_screen(...)` / `ui.pop_screen()` from
// within the closure without the borrow conflict from the issue.
if !self.pending_screen_nav.is_empty() {
let navs = std::mem::take(&mut self.pending_screen_nav);
for nav in navs {
screens.apply_nav(nav);
}
}
} else {
// Skip: advance hook cursor past the reserved segment
if seg_count > 0 && seg_start >= self.rollback.hook_cursor {
Expand All @@ -299,6 +311,47 @@ impl Context {
}
}

/// Request pushing a new screen onto the active [`ScreenState`] stack.
///
/// Call this from inside a [`Context::screen`] closure to navigate forward.
/// The push is deferred and applied to your `ScreenState` the moment the
/// closure returns, so it does not conflict with the `&mut ScreenState`
/// already borrowed by `screen(...)` (issue #279).
///
/// # Example
///
/// ```no_run
/// # let mut screens = slt::ScreenState::new("home");
/// # slt::run(|ui| {
/// ui.screen("home", &mut screens, |ui| {
/// if ui.button("Settings").clicked {
/// ui.push_screen("settings");
/// }
/// });
/// # });
/// ```
pub fn push_screen(&mut self, name: impl Into<String>) {
self.pending_screen_nav.push(ScreenNav::Push(name.into()));
}

/// Request popping the current screen off the active [`ScreenState`] stack
/// (the root screen is preserved).
///
/// Like [`Self::push_screen`], the pop is deferred and applied when the
/// enclosing [`Context::screen`] closure returns (issue #279).
pub fn pop_screen(&mut self) {
self.pending_screen_nav.push(ScreenNav::Pop);
}

/// Request resetting the active [`ScreenState`] stack to just its root
/// screen.
///
/// Deferred and applied when the enclosing [`Context::screen`] closure
/// returns (issue #279).
pub fn reset_screen(&mut self) {
self.pending_screen_nav.push(ScreenNav::Reset);
}

/// Create a vertical (column) container.
///
/// Children are stacked top-to-bottom. Returns a [`Response`] with
Expand Down
Loading
Loading