From 38fe2b0b22c88c9fc96bd27cd8be2c1f254adc57 Mon Sep 17 00:00:00 2001 From: Subin An Date: Mon, 22 Jun 2026 10:19:24 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20v0.22.1=20=E2=80=94=20screen=20nav?= =?UTF-8?q?=20ergonomics=20(#279)=20+=20public=20crossterm=20backend=20(#2?= =?UTF-8?q?78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch release closing two community issues. Additive only — no existing public items changed. #279 — Navigate from inside `ui.screen(...)` closures: - New `Context::push_screen`/`pop_screen`/`reset_screen` record a deferred navigation applied to the active `ScreenState` the moment the closure returns. `screen(...)` holds a `&mut ScreenState` borrow for the closure's duration, so calling `screens.push(...)` directly inside it never compiled (the only documented example was wrong). Deferring through the Context mirrors SLT's existing command-recording model and dodges the double borrow. - `docs/COMPLETE_REFERENCE.md` §6.5 updated to the working pattern. #278 — Public crossterm backend for custom render loops: - `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. Note: adding public items is semver-minor; cargo-semver-checks (a soft, non-blocking CI gate) will flag the patch bump accordingly. Closes #278, #279. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 27 ++++++++++ Cargo.lock | 4 +- Cargo.toml | 2 +- crates/slt-wasm/Cargo.toml | 4 +- docs/COMPLETE_REFERENCE.md | 15 +++++- src/context.rs | 2 +- src/context/core.rs | 15 ++++++ src/context/runtime.rs | 1 + src/context/tests.rs | 71 +++++++++++++++++++++++++++ src/context/widgets_display/layout.rs | 53 ++++++++++++++++++++ src/event.rs | 24 +++++++-- src/lib.rs | 13 ++++- src/terminal.rs | 36 +++++++++----- src/widgets/commanding.rs | 23 +++++++++ 14 files changed, 266 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8541bb..0e6803d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 — diff --git a/Cargo.lock b/Cargo.lock index 2b41b97..0a5270d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1166,7 +1166,7 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "slt-wasm" -version = "0.22.0" +version = "0.22.1" dependencies = [ "js-sys", "superlighttui", @@ -1194,7 +1194,7 @@ checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" [[package]] name = "superlighttui" -version = "0.22.0" +version = "0.22.1" dependencies = [ "compact_str", "criterion", diff --git a/Cargo.toml b/Cargo.toml index ae786b9..6818643 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/slt-wasm/Cargo.toml b/crates/slt-wasm/Cargo.toml index cd6b63c..b162ef7 100644 --- a/crates/slt-wasm/Cargo.toml +++ b/crates/slt-wasm/Cargo.toml @@ -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" @@ -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", diff --git a/docs/COMPLETE_REFERENCE.md b/docs/COMPLETE_REFERENCE.md index 0c7b8a6..b561ac2 100644 --- a/docs/COMPLETE_REFERENCE.md +++ b/docs/COMPLETE_REFERENCE.md @@ -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)`. --- @@ -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. | @@ -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"); diff --git a/src/context.rs b/src/context.rs index a0d0845..dcf9231 100644 --- a/src/context.rs +++ b/src/context.rs @@ -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, diff --git a/src/context/core.rs b/src/context/core.rs index a0ee4bc..5b50988 100644 --- a/src/context/core.rs +++ b/src/context/core.rs @@ -89,6 +89,13 @@ pub struct Context { pub(crate) deferred_draws: Vec>, pub(crate) rollback: ContextRollbackState, pub(crate) pending_tooltips: Vec, + /// 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, pub(crate) hovered_groups: std::collections::HashSet>, /// Issue #273: version keys recorded by [`Context::cached`] regions on the /// PREVIOUS frame, moved in from `FrameState::region_versions`. Indexed by @@ -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 @@ -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(), } @@ -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); diff --git a/src/context/runtime.rs b/src/context/runtime.rs index 1aac3a8..11d7581 100644 --- a/src/context/runtime.rs +++ b/src/context/runtime.rs @@ -226,6 +226,7 @@ impl Context { text_color_stack, }, pending_tooltips, + pending_screen_nav: Vec::new(), hovered_groups, region_versions_prev, region_versions_cur, diff --git a/src/context/tests.rs b/src/context/tests.rs index 15a369e..17369fb 100644 --- a/src/context/tests.rs +++ b/src/context/tests.rs @@ -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] diff --git a/src/context/widgets_display/layout.rs b/src/context/widgets_display/layout.rs index 93e4afd..5c75b26 100644 --- a/src/context/widgets_display/layout.rs +++ b/src/context/widgets_display/layout.rs @@ -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 { @@ -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) { + 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 diff --git a/src/event.rs b/src/event.rs index 1085cd6..46dba02 100644 --- a/src/event.rs +++ b/src/event.rs @@ -564,10 +564,16 @@ fn convert_button(button: crossterm_event::MouseButton) -> MouseButton { // ── crossterm conversions ──────────────────────────────────────────── -/// Convert a raw crossterm event into our lightweight [`Event`]. -/// Returns `None` for event kinds we don't handle. +/// Convert a raw crossterm event into SLT's lightweight [`Event`]. +/// +/// Returns `None` for event kinds SLT does not model. Public so external +/// integrations (custom event loops, embeddings that bring their own +/// crossterm reader — see issue #278) can reuse SLT's conversion instead of +/// reimplementing it. The `crossterm` crate is re-exported at the crate root +/// as [`crate::crossterm`] so callers can name the input type without pinning +/// their own (potentially mismatched) crossterm version. #[cfg(feature = "crossterm")] -pub(crate) fn from_crossterm(raw: crossterm_event::Event) -> Option { +pub fn from_crossterm(raw: crossterm_event::Event) -> Option { match raw { crossterm_event::Event::Key(k) => { let code = match k.code { @@ -660,6 +666,18 @@ pub(crate) fn from_crossterm(raw: crossterm_event::Event) -> Option { mod event_constructor_tests { use super::*; + // Issue #278: compile-time proof that the crossterm backend types and the + // event conversion are publicly reachable from the crate root, so external + // integrations can drive SLT's render pipeline with their own event loop. + #[cfg(feature = "crossterm")] + #[test] + fn issue_278_backend_and_conversion_are_public() { + fn _accepts_terminal(_t: Option) {} + fn _accepts_inline(_t: Option) {} + let _convert: fn(crate::crossterm::event::Event) -> Option = + crate::event::from_crossterm; + } + #[test] fn test_key_char() { let e = Event::key_char('q'); diff --git a/src/lib.rs b/src/lib.rs index 46bc9f2..d761457 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -137,6 +137,12 @@ use std::io::Write; use std::sync::Once; use std::time::{Duration, Instant}; +/// Re-export of the [`crossterm`] crate (issue #278) so callers can name the +/// input type accepted by [`event::from_crossterm`] without depending on — and +/// risking a version mismatch against — crossterm directly. +#[cfg(feature = "crossterm")] +#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))] +pub use crossterm; #[doc(hidden)] pub use layout::__bench_dim_buffer_around; #[doc(hidden)] @@ -168,8 +174,13 @@ pub use terminal::{Blitter, BlitterSupport, Capabilities, capabilities}; #[cfg(feature = "crossterm")] #[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))] pub use terminal::{ColorScheme, detect_color_scheme, read_clipboard}; +/// Concrete crossterm terminal backends, exposed (issue #278) so external +/// integrations can drive SLT's render pipeline with their own event loop — +/// pair with [`event::from_crossterm`]. Most apps should use [`run`] / +/// [`run_inline`], which build and drive these internally. #[cfg(feature = "crossterm")] -use terminal::{InlineTerminal, Terminal}; +#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))] +pub use terminal::{InlineTerminal, Terminal}; pub use crate::test_utils::{EventBuilder, FrameRecord, TestBackend, TestSequence}; /// PTY/sink test harness for end-to-end escape-byte assertions (issue #274). diff --git a/src/terminal.rs b/src/terminal.rs index 617c4c1..e9d7166 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -1019,7 +1019,14 @@ fn split_base64(encoded: &str, chunk_size: usize) -> Vec<&str> { chunks } -pub(crate) struct Terminal { +/// Fullscreen crossterm terminal backend: owns raw mode + the alternate +/// screen, double-buffers cells, and flushes only the diff each frame. +/// +/// Exposed (issue #278) so external integrations can drive SLT's rendering +/// with their own event loop instead of reimplementing the backend. Pair with +/// [`crate::event::from_crossterm`] to translate input. The built-in +/// [`crate::run`] entry point uses this same type internally. +pub struct Terminal { stdout: Sink, current: Buffer, previous: Buffer, @@ -1034,7 +1041,12 @@ pub(crate) struct Terminal { run_buf: String, } -pub(crate) struct InlineTerminal { +/// Inline crossterm terminal backend: renders into a fixed-height region +/// below the cursor instead of taking over the whole screen. +/// +/// Like [`Terminal`], exposed (issue #278) for custom integrations. Backs the +/// [`crate::run_inline`] entry point. +pub struct InlineTerminal { stdout: Sink, current: Buffer, previous: Buffer, @@ -1131,7 +1143,7 @@ impl Terminal { /// alternate screen and optionally enables mouse capture and the /// kitty keyboard protocol. When `report_all_keys` is set (and /// `kitty_keyboard` is too), bare modifier presses are reported. - pub(crate) fn new( + pub fn new( mouse: bool, kitty_keyboard: bool, report_all_keys: bool, @@ -1163,19 +1175,19 @@ impl Terminal { } /// Return the fullscreen terminal's current `(cols, rows)`. - pub(crate) fn size(&self) -> (u32, u32) { + pub fn size(&self) -> (u32, u32) { (self.current.area.width, self.current.area.height) } /// Mutable access to the back buffer used by the next render pass. - pub(crate) fn buffer_mut(&mut self) -> &mut Buffer { + pub fn buffer_mut(&mut self) -> &mut Buffer { &mut self.current } /// Diff the back buffer against the front buffer, write the changed /// cells to stdout under a synchronized-output guard, then swap /// front and back buffers. - pub(crate) fn flush(&mut self) -> io::Result<()> { + pub fn flush(&mut self) -> io::Result<()> { if self.current.area.width < self.previous.area.width { execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?; } @@ -1237,7 +1249,7 @@ impl Terminal { /// Re-query the terminal size and resize the front and back buffers /// to match. Called from the SIGWINCH handler. - pub(crate) fn handle_resize(&mut self) -> io::Result<()> { + pub fn handle_resize(&mut self) -> io::Result<()> { let (cols, rows) = terminal::size()?; let area = Rect::new(0, 0, cols as u32, rows as u32); self.current.resize(area); @@ -1319,7 +1331,7 @@ impl InlineTerminal { /// Optionally enables mouse capture and the kitty keyboard protocol. /// When `report_all_keys` is set (and `kitty_keyboard` is too), bare /// modifier presses are reported. - pub(crate) fn new( + pub fn new( height: u32, mouse: bool, kitty_keyboard: bool, @@ -1362,19 +1374,19 @@ impl InlineTerminal { } /// Return the inline terminal's current `(cols, rows)`. - pub(crate) fn size(&self) -> (u32, u32) { + pub fn size(&self) -> (u32, u32) { (self.current.area.width, self.current.area.height) } /// Mutable access to the back buffer used by the next render pass. - pub(crate) fn buffer_mut(&mut self) -> &mut Buffer { + pub fn buffer_mut(&mut self) -> &mut Buffer { &mut self.current } /// Diff the back buffer against the front buffer, write changed /// cells to stdout under a synchronized-output guard at the /// inline rows reserved below the cursor, then swap buffers. - pub(crate) fn flush(&mut self) -> io::Result<()> { + pub fn flush(&mut self) -> io::Result<()> { if self.current.area.width < self.previous.area.width { execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?; } @@ -1448,7 +1460,7 @@ impl InlineTerminal { /// Re-query the terminal size and resize the inline buffers to match /// the new column count, preserving the inline row height. - pub(crate) fn handle_resize(&mut self) -> io::Result<()> { + pub fn handle_resize(&mut self) -> io::Result<()> { let (cols, _) = terminal::size()?; let area = Rect::new(0, 0, cols as u32, self.height); self.current.resize(area); diff --git a/src/widgets/commanding.rs b/src/widgets/commanding.rs index 946b1c1..370da36 100644 --- a/src/widgets/commanding.rs +++ b/src/widgets/commanding.rs @@ -459,6 +459,16 @@ impl ScreenState { self.stack.truncate(1); } + /// Apply a deferred navigation request recorded inside a + /// [`crate::Context::screen`] closure (issue #279). + pub(crate) fn apply_nav(&mut self, nav: ScreenNav) { + match nav { + ScreenNav::Push(name) => self.push(name), + ScreenNav::Pop => self.pop(), + ScreenNav::Reset => self.reset(), + } + } + pub(crate) fn save_focus(&mut self, name: &str, focus_index: usize, focus_count: usize) { self.focus_state .insert(name.to_string(), (focus_index, focus_count)); @@ -469,6 +479,19 @@ impl ScreenState { } } +/// A deferred screen-navigation request recorded inside a +/// [`crate::Context::screen`] closure and applied to the active +/// [`ScreenState`] after the closure returns (issue #279). +#[derive(Debug, Clone)] +pub(crate) enum ScreenNav { + /// Push a new screen onto the stack. + Push(String), + /// Pop the current screen, preserving the root. + Pop, + /// Reset to only the root screen. + Reset, +} + /// Named mode system with independent screen stacks. /// /// Each mode contains its own [`ScreenState`]. Switching modes preserves From e1c0b71db9716476796ade1017031c9a4caba239 Mon Sep 17 00:00:00 2001 From: Subin An Date: Mon, 22 Jun 2026 11:25:09 +0900 Subject: [PATCH 2/2] ci: replace broken vhs-action ffmpeg installer with manual toolchain install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `charmbracelet/vhs-action@v2` unconditionally runs its own bundled ffmpeg installer, which is persistently broken on the GitHub runner ("Failed to install ffmpeg", reproduced across reruns) and ignores a pre-installed ffmpeg. Drop the action and install the toolchain directly: ffmpeg from apt, ttyd from its upstream release binary, and vhs via `go install` (Go ships on ubuntu-latest). The VHS Gallery job already carries `continue-on-error: true`, so this only turns a perpetual red X green — it never gated merge. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25aba2d..ca356b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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