Skip to main content

ratatui_flip_panel/
lib.rs

1//! A flippable two-faced panel widget for ratatui.
2//!
3//! Pack twice as much content into one panel: a *front* face and a
4//! *back* face. Toggle between them with a smooth horizontal-squish
5//! animation that approximates a 3-D card flip on terminals that can't
6//! actually rotate text.
7//!
8//! # Quick start
9//!
10//! ```no_run
11//! use std::time::Duration;
12//! use ratatui::{widgets::Paragraph, Frame};
13//! use ratatui_flip_panel::{FlipPanel, FlipState};
14//!
15//! // Long-lived; survives across render calls.
16//! let mut state = FlipState::new(Duration::from_millis(300));
17//!
18//! // On the user pressing `f`:
19//! state.flip();
20//!
21//! // Each frame:
22//! fn draw(frame: &mut Frame, state: &mut FlipState) {
23//!     let widget = FlipPanel::new(
24//!         |area, buf| Paragraph::new("Front face").render(area, buf),
25//!         |area, buf| Paragraph::new("Back face").render(area, buf),
26//!     );
27//!     frame.render_stateful_widget(widget, frame.area(), state);
28//! }
29//! # use ratatui::widgets::Widget;
30//! ```
31//!
32//! The widget uses the [`StatefulWidget`] trait so animation progress
33//! lives in your app state, not in the widget instance.
34//!
35//! # When to use this vs tabs
36//!
37//! Reach for tabs (or [`tui-tabs`]) when:
38//!
39//! - You have **three or more** logically distinct views.
40//! - You need the navigation strip to be visible at all times.
41//! - Your terminal renders are static (no per-frame tick loop).
42//!
43//! Reach for `FlipPanel` when:
44//!
45//! - You have **exactly two** related views of the same data (a "front"
46//!   summary and a "back" detail, for example).
47//! - Your app already runs a per-frame render loop (≥ ~20 Hz). The
48//!   animation needs frequent re-renders during its 200-400 ms window
49//!   to look smooth.
50//! - You want the cuteness of a flip to reinforce the "two sides of
51//!   the same thing" mental model.
52//!
53//! [`tui-tabs`]: https://crates.io/crates/tui-tabs
54
55#![warn(missing_docs)]
56#![deny(unsafe_code)]
57
58use std::borrow::Cow;
59use std::time::{Duration, Instant};
60
61use ratatui_core::buffer::Buffer;
62use ratatui_core::layout::Rect;
63use ratatui_core::style::Style;
64use ratatui_core::widgets::StatefulWidget;
65
66/// Default minimum width at which the inner face renders. Below this
67/// the widget draws a single vertical bar (the panel "edge-on").
68const DEFAULT_MIN_WIDTH: u16 = 4;
69
70/// Glyph drawn when the animation has narrowed the panel below
71/// [`FlipPanel::min_width`].
72const EDGE_GLYPH: &str = "│";
73
74/// Long-lived animation state for a [`FlipPanel`].
75///
76/// Holds the current face (front or back) and the start time of an
77/// in-progress flip, if any. The widget reads `progress()` and
78/// `showing_back_after_swap()` from this each render; the application
79/// calls `flip()` when the user requests a side change.
80///
81/// Persist this across frames — typically as a field on your
82/// application state struct alongside other UI state.
83#[derive(Debug, Clone)]
84pub struct FlipState {
85    /// Side that's stable when no animation is running.
86    showing_back: bool,
87    /// When the in-flight animation started, if any.
88    started: Option<Instant>,
89    /// How long the flip animation should take from start to settle.
90    duration: Duration,
91}
92
93impl FlipState {
94    /// Construct a state that starts on the front face with the given
95    /// animation `duration`.
96    ///
97    /// Typical durations are 200-400 ms. Below ~150 ms the swap can
98    /// look like a glitch; above ~500 ms it feels sluggish.
99    #[must_use]
100    pub fn new(duration: Duration) -> Self {
101        Self {
102            showing_back: false,
103            started: None,
104            duration,
105        }
106    }
107
108    /// Begin (or reverse) a flip. If a flip is already in flight this
109    /// is a no-op — wait for it to complete, then call again.
110    ///
111    /// Returns `true` if a new flip was started, `false` if one was
112    /// already in flight.
113    pub fn flip(&mut self) -> bool {
114        if self.is_animating() {
115            return false;
116        }
117        self.started = Some(Instant::now());
118        true
119    }
120
121    /// True when an animation is in flight or just finished waiting
122    /// for a `tick()` to finalise the side swap. Stays true from the
123    /// call to `flip()` until `tick()` clears the in-flight marker —
124    /// host loops should keep ticking while this returns true.
125    #[must_use]
126    pub fn is_animating(&self) -> bool {
127        self.started.is_some()
128    }
129
130    /// True when the wall-clock animation window is still in progress
131    /// (`elapsed < duration`). Use this if you specifically need to
132    /// distinguish "visually mid-flip" from "finished but awaiting
133    /// finalisation by `tick()`". Most callers want
134    /// [`Self::is_animating`] instead.
135    #[must_use]
136    pub fn is_in_visual_window(&self) -> bool {
137        match self.started {
138            Some(t) => t.elapsed() < self.duration,
139            None => false,
140        }
141    }
142
143    /// Linear progress 0.0 → 1.0 across the configured duration.
144    /// Returns 0.0 when no animation is running, 1.0 when one just
145    /// finished and `tick()` has not yet observed it.
146    #[must_use]
147    #[allow(clippy::cast_precision_loss)]
148    pub fn progress(&self) -> f32 {
149        let Some(t) = self.started else {
150            return 0.0;
151        };
152        let elapsed = t.elapsed().as_micros() as f32;
153        let total = self.duration.as_micros() as f32;
154        (elapsed / total).clamp(0.0, 1.0)
155    }
156
157    /// Suggested delay until the next animation frame should render,
158    /// or `None` when no animation is in flight.
159    ///
160    /// Use this as the event-poll timeout in event-driven hosts to
161    /// keep the animation smooth without busy-looping or hard-coding
162    /// a 30 Hz render loop. Returns `Some(~33 ms)` (~30 fps) during
163    /// the animation, then `Some(0)` for one tick to drive
164    /// finalisation, then `None`.
165    ///
166    /// # Example
167    ///
168    /// ```no_run
169    /// # use std::time::Duration;
170    /// # use ratatui_flip_panel::FlipState;
171    /// # fn handle_input() {}
172    /// # fn redraw() {}
173    /// # let mut state = FlipState::default();
174    /// // In your main loop:
175    /// let timeout = state.next_tick_in().unwrap_or(Duration::from_secs(60));
176    /// // crossterm: `event::poll(timeout)?` — wakes either on a real
177    /// // event or when the animation needs its next frame.
178    /// handle_input();
179    /// redraw(); // calls `frame.render_stateful_widget(widget, area, &mut state)`
180    /// ```
181    #[must_use]
182    pub fn next_tick_in(&self) -> Option<Duration> {
183        let started = self.started?;
184        let elapsed = started.elapsed();
185        if elapsed >= self.duration {
186            // Animation window is over; ask for an immediate redraw so
187            // `tick()` can finalise the side swap.
188            return Some(Duration::ZERO);
189        }
190        let frame = Duration::from_millis(33); // ~30 fps target
191        let remaining = self.duration.saturating_sub(elapsed);
192        Some(frame.min(remaining))
193    }
194
195    /// Drive the state machine. Call once per render. When an
196    /// animation completes, finalises the side swap and clears the
197    /// in-flight marker. Returns `true` iff a completion happened
198    /// this tick (useful for triggering a one-shot redraw).
199    pub fn tick(&mut self) -> bool {
200        if let Some(t) = self.started {
201            if t.elapsed() >= self.duration {
202                self.started = None;
203                self.showing_back = !self.showing_back;
204                return true;
205            }
206        }
207        false
208    }
209
210    /// Triangle-wave width fraction for the current animation frame:
211    /// 1.0 → 0.0 → 1.0 over progress 0.0 → 1.0. Returns 1.0 when not
212    /// animating. Useful when consuming code wants to drive its own
213    /// rendering path (e.g. multi-panel coordination at the app
214    /// layer) instead of using [`FlipPanel`] directly.
215    #[must_use]
216    pub fn width_ratio(&self) -> f32 {
217        width_ratio_for_progress(self.progress(), self.is_animating())
218    }
219
220    /// True when the back face is the resting / post-animation face.
221    /// During an in-flight animation, this is still the *target* face
222    /// only after the midpoint — the widget renders the visible face
223    /// based on this together with `progress()`.
224    #[must_use]
225    pub fn showing_back(&self) -> bool {
226        // Effective side: pre-midpoint shows old; post-midpoint shows new.
227        if self.is_animating() && self.progress() >= 0.5 {
228            !self.showing_back
229        } else {
230            self.showing_back
231        }
232    }
233
234    /// Set the visible face explicitly without playing the animation.
235    /// Use to restore state on app startup or after a reset.
236    pub fn set_showing_back(&mut self, b: bool) {
237        self.showing_back = b;
238        self.started = None;
239    }
240}
241
242impl Default for FlipState {
243    /// Default state: front face, 300 ms flip duration.
244    fn default() -> Self {
245        Self::new(Duration::from_millis(300))
246    }
247}
248
249/// A two-faced panel that swaps front ↔ back with a horizontal-squish
250/// animation.
251///
252/// The animation collapses the panel's width to zero then expands it
253/// back. The visible face swaps at the midpoint, giving the optical
254/// illusion of a card flipping. On terminals that can't rotate text,
255/// this is the closest approximation to a 3-D card flip.
256///
257/// `F` and `B` are render callbacks invoked at each frame. They get a
258/// (possibly narrowed) `Rect` and a `Buffer` to draw into. Closing
259/// over application state by reference is the typical pattern.
260///
261/// # Example
262///
263/// ```no_run
264/// # use std::time::Duration;
265/// # use ratatui::{Frame, widgets::{Paragraph, Widget}};
266/// # use ratatui_flip_panel::{FlipPanel, FlipState};
267/// # fn draw(frame: &mut Frame, state: &mut FlipState) {
268/// let widget = FlipPanel::new(
269///     |area, buf| Paragraph::new("front").render(area, buf),
270///     |area, buf| Paragraph::new("back").render(area, buf),
271/// );
272/// frame.render_stateful_widget(widget, frame.area(), state);
273/// # }
274/// ```
275pub struct FlipPanel<F, B> {
276    front: F,
277    back: B,
278    min_width: u16,
279    edge_style: Style,
280    edge_glyph: Cow<'static, str>,
281}
282
283impl<F, B> FlipPanel<F, B>
284where
285    F: Fn(Rect, &mut Buffer),
286    B: Fn(Rect, &mut Buffer),
287{
288    /// Construct a panel from two render callbacks. `front` runs when
289    /// the front face is visible, `back` when the back face is.
290    #[must_use]
291    pub fn new(front: F, back: B) -> Self {
292        Self {
293            front,
294            back,
295            min_width: DEFAULT_MIN_WIDTH,
296            edge_style: Style::default(),
297            edge_glyph: Cow::Borrowed(EDGE_GLYPH),
298        }
299    }
300
301    /// Minimum width at which the inner face renders. Below this the
302    /// panel draws a single vertical bar — the visual "edge-on" state
303    /// at the animation midpoint. Default: 4.
304    #[must_use]
305    pub fn min_width(mut self, w: u16) -> Self {
306        self.min_width = w;
307        self
308    }
309
310    /// Style for the edge glyph drawn at the animation midpoint.
311    /// Default: terminal default.
312    #[must_use]
313    pub fn edge_style(mut self, style: Style) -> Self {
314        self.edge_style = style;
315        self
316    }
317
318    /// Glyph drawn at the animation midpoint (the "edge-on" view).
319    /// Default: `│`. Typically a single grapheme — multi-cell strings
320    /// will be clipped by ratatui's centred paragraph rendering. Pass
321    /// any `&'static str`, `String`, or `Cow<'static, str>`.
322    #[must_use]
323    pub fn edge_char(mut self, glyph: impl Into<Cow<'static, str>>) -> Self {
324        self.edge_glyph = glyph.into();
325        self
326    }
327
328    /// Shared render path used by both the by-value and by-reference
329    /// `StatefulWidget` impls. Takes `&self` so the closures can be
330    /// invoked through either ownership form (they're `Fn`, not
331    /// `FnOnce`, so this works).
332    fn render_inner(&self, area: Rect, buf: &mut Buffer, state: &mut FlipState) {
333        state.tick();
334
335        let ratio = width_ratio_for_progress(state.progress(), state.is_animating());
336        let narrow = narrowed_rect(area, ratio);
337        let face_is_back = state.showing_back();
338
339        if narrow.width >= self.min_width {
340            if face_is_back {
341                (self.back)(narrow, buf);
342            } else {
343                (self.front)(narrow, buf);
344            }
345        } else {
346            let mid_x = narrow.x + narrow.width / 2;
347            let glyph = self.edge_glyph.as_ref();
348            for y in narrow.y..narrow.y.saturating_add(narrow.height) {
349                if let Some(cell) = buf.cell_mut((mid_x, y)) {
350                    cell.set_symbol(glyph).set_style(self.edge_style);
351                }
352            }
353        }
354    }
355}
356
357impl<F, B> StatefulWidget for FlipPanel<F, B>
358where
359    F: Fn(Rect, &mut Buffer),
360    B: Fn(Rect, &mut Buffer),
361{
362    type State = FlipState;
363
364    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
365        self.render_inner(area, buf, state);
366    }
367}
368
369/// Reference impl so callers can render through `&FlipPanel` without
370/// consuming the widget. Useful when the panel is a long-lived field
371/// on an app struct.
372impl<F, B> StatefulWidget for &FlipPanel<F, B>
373where
374    F: Fn(Rect, &mut Buffer),
375    B: Fn(Rect, &mut Buffer),
376{
377    type State = FlipState;
378
379    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
380        self.render_inner(area, buf, state);
381    }
382}
383
384/// Compute the panel's width fraction for a given animation progress.
385/// 1.0 at progress=0 and progress=1, collapsing to 0.0 at progress=0.5.
386/// When not animating, returns 1.0.
387fn width_ratio_for_progress(progress: f32, is_animating: bool) -> f32 {
388    if !is_animating {
389        return 1.0;
390    }
391    if progress < 0.5 {
392        1.0 - (progress * 2.0)
393    } else {
394        (progress - 0.5) * 2.0
395    }
396}
397
398/// Centre-shrink `area` to `width_ratio` of its width, preserving y/height.
399#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
400fn narrowed_rect(area: Rect, width_ratio: f32) -> Rect {
401    if area.width == 0 || area.height == 0 {
402        return area;
403    }
404    let mut new_width = (f32::from(area.width) * width_ratio).max(1.0) as u16;
405    if new_width > area.width {
406        new_width = area.width;
407    }
408    let x_offset = (area.width.saturating_sub(new_width)) / 2;
409    Rect::new(area.x + x_offset, area.y, new_width, area.height)
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    #[test]
417    fn state_starts_on_front() {
418        let s = FlipState::default();
419        assert!(!s.showing_back());
420        assert!(!s.is_animating());
421    }
422
423    #[test]
424    fn flip_starts_animation_and_swaps_after_duration() {
425        let mut s = FlipState::new(Duration::from_millis(1));
426        assert!(s.flip());
427        std::thread::sleep(Duration::from_millis(5));
428        assert!(s.tick());
429        assert!(s.showing_back());
430        assert!(!s.is_animating());
431    }
432
433    #[test]
434    fn flip_during_animation_is_noop() {
435        let mut s = FlipState::new(Duration::from_secs(10));
436        assert!(s.flip());
437        assert!(!s.flip(), "second flip during animation must be a no-op");
438    }
439
440    #[test]
441    fn set_showing_back_skips_animation() {
442        let mut s = FlipState::default();
443        s.set_showing_back(true);
444        assert!(s.showing_back());
445        assert!(!s.is_animating());
446    }
447
448    #[test]
449    fn width_ratio_collapses_then_expands() {
450        assert!((width_ratio_for_progress(0.0, true) - 1.0).abs() < 1e-6);
451        assert!((width_ratio_for_progress(0.5, true) - 0.0).abs() < 1e-6);
452        assert!((width_ratio_for_progress(1.0, true) - 1.0).abs() < 1e-6);
453    }
454
455    #[test]
456    fn width_ratio_is_one_when_not_animating() {
457        assert!((width_ratio_for_progress(0.42, false) - 1.0).abs() < 1e-6);
458    }
459
460    #[test]
461    fn narrowed_rect_centres_within_original() {
462        let area = Rect::new(0, 0, 100, 10);
463        let narrow = narrowed_rect(area, 0.5);
464        assert_eq!(narrow.width, 50);
465        assert_eq!(narrow.x, 25);
466        assert_eq!(narrow.height, 10);
467        assert_eq!(narrow.y, 0);
468    }
469
470    #[test]
471    fn narrowed_rect_clamps_to_at_least_one_column() {
472        let area = Rect::new(0, 0, 100, 10);
473        let narrow = narrowed_rect(area, 0.0);
474        assert_eq!(narrow.width, 1);
475    }
476}