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}