#![warn(missing_docs)]
#![deny(unsafe_code)]
use std::borrow::Cow;
use std::time::{Duration, Instant};
use ratatui_core::buffer::Buffer;
use ratatui_core::layout::Rect;
use ratatui_core::style::Style;
use ratatui_core::widgets::StatefulWidget;
const DEFAULT_MIN_WIDTH: u16 = 4;
const EDGE_GLYPH: &str = "│";
#[derive(Debug, Clone)]
pub struct FlipState {
showing_back: bool,
started: Option<Instant>,
duration: Duration,
}
impl FlipState {
#[must_use]
pub fn new(duration: Duration) -> Self {
Self {
showing_back: false,
started: None,
duration,
}
}
pub fn flip(&mut self) -> bool {
if self.is_animating() {
return false;
}
self.started = Some(Instant::now());
true
}
#[must_use]
pub fn is_animating(&self) -> bool {
self.started.is_some()
}
#[must_use]
pub fn is_in_visual_window(&self) -> bool {
match self.started {
Some(t) => t.elapsed() < self.duration,
None => false,
}
}
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn progress(&self) -> f32 {
let Some(t) = self.started else {
return 0.0;
};
let elapsed = t.elapsed().as_micros() as f32;
let total = self.duration.as_micros() as f32;
(elapsed / total).clamp(0.0, 1.0)
}
#[must_use]
pub fn next_tick_in(&self) -> Option<Duration> {
let started = self.started?;
let elapsed = started.elapsed();
if elapsed >= self.duration {
return Some(Duration::ZERO);
}
let frame = Duration::from_millis(33); let remaining = self.duration.saturating_sub(elapsed);
Some(frame.min(remaining))
}
pub fn tick(&mut self) -> bool {
if let Some(t) = self.started {
if t.elapsed() >= self.duration {
self.started = None;
self.showing_back = !self.showing_back;
return true;
}
}
false
}
#[must_use]
pub fn width_ratio(&self) -> f32 {
width_ratio_for_progress(self.progress(), self.is_animating())
}
#[must_use]
pub fn showing_back(&self) -> bool {
if self.is_animating() && self.progress() >= 0.5 {
!self.showing_back
} else {
self.showing_back
}
}
pub fn set_showing_back(&mut self, b: bool) {
self.showing_back = b;
self.started = None;
}
}
impl Default for FlipState {
fn default() -> Self {
Self::new(Duration::from_millis(300))
}
}
pub struct FlipPanel<F, B> {
front: F,
back: B,
min_width: u16,
edge_style: Style,
edge_glyph: Cow<'static, str>,
}
impl<F, B> FlipPanel<F, B>
where
F: Fn(Rect, &mut Buffer),
B: Fn(Rect, &mut Buffer),
{
#[must_use]
pub fn new(front: F, back: B) -> Self {
Self {
front,
back,
min_width: DEFAULT_MIN_WIDTH,
edge_style: Style::default(),
edge_glyph: Cow::Borrowed(EDGE_GLYPH),
}
}
#[must_use]
pub fn min_width(mut self, w: u16) -> Self {
self.min_width = w;
self
}
#[must_use]
pub fn edge_style(mut self, style: Style) -> Self {
self.edge_style = style;
self
}
#[must_use]
pub fn edge_char(mut self, glyph: impl Into<Cow<'static, str>>) -> Self {
self.edge_glyph = glyph.into();
self
}
fn render_inner(&self, area: Rect, buf: &mut Buffer, state: &mut FlipState) {
state.tick();
let ratio = width_ratio_for_progress(state.progress(), state.is_animating());
let narrow = narrowed_rect(area, ratio);
let face_is_back = state.showing_back();
if narrow.width >= self.min_width {
if face_is_back {
(self.back)(narrow, buf);
} else {
(self.front)(narrow, buf);
}
} else {
let mid_x = narrow.x + narrow.width / 2;
let glyph = self.edge_glyph.as_ref();
for y in narrow.y..narrow.y.saturating_add(narrow.height) {
if let Some(cell) = buf.cell_mut((mid_x, y)) {
cell.set_symbol(glyph).set_style(self.edge_style);
}
}
}
}
}
impl<F, B> StatefulWidget for FlipPanel<F, B>
where
F: Fn(Rect, &mut Buffer),
B: Fn(Rect, &mut Buffer),
{
type State = FlipState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
self.render_inner(area, buf, state);
}
}
impl<F, B> StatefulWidget for &FlipPanel<F, B>
where
F: Fn(Rect, &mut Buffer),
B: Fn(Rect, &mut Buffer),
{
type State = FlipState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
self.render_inner(area, buf, state);
}
}
fn width_ratio_for_progress(progress: f32, is_animating: bool) -> f32 {
if !is_animating {
return 1.0;
}
if progress < 0.5 {
1.0 - (progress * 2.0)
} else {
(progress - 0.5) * 2.0
}
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn narrowed_rect(area: Rect, width_ratio: f32) -> Rect {
if area.width == 0 || area.height == 0 {
return area;
}
let mut new_width = (f32::from(area.width) * width_ratio).max(1.0) as u16;
if new_width > area.width {
new_width = area.width;
}
let x_offset = (area.width.saturating_sub(new_width)) / 2;
Rect::new(area.x + x_offset, area.y, new_width, area.height)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn state_starts_on_front() {
let s = FlipState::default();
assert!(!s.showing_back());
assert!(!s.is_animating());
}
#[test]
fn flip_starts_animation_and_swaps_after_duration() {
let mut s = FlipState::new(Duration::from_millis(1));
assert!(s.flip());
std::thread::sleep(Duration::from_millis(5));
assert!(s.tick());
assert!(s.showing_back());
assert!(!s.is_animating());
}
#[test]
fn flip_during_animation_is_noop() {
let mut s = FlipState::new(Duration::from_secs(10));
assert!(s.flip());
assert!(!s.flip(), "second flip during animation must be a no-op");
}
#[test]
fn set_showing_back_skips_animation() {
let mut s = FlipState::default();
s.set_showing_back(true);
assert!(s.showing_back());
assert!(!s.is_animating());
}
#[test]
fn width_ratio_collapses_then_expands() {
assert!((width_ratio_for_progress(0.0, true) - 1.0).abs() < 1e-6);
assert!((width_ratio_for_progress(0.5, true) - 0.0).abs() < 1e-6);
assert!((width_ratio_for_progress(1.0, true) - 1.0).abs() < 1e-6);
}
#[test]
fn width_ratio_is_one_when_not_animating() {
assert!((width_ratio_for_progress(0.42, false) - 1.0).abs() < 1e-6);
}
#[test]
fn narrowed_rect_centres_within_original() {
let area = Rect::new(0, 0, 100, 10);
let narrow = narrowed_rect(area, 0.5);
assert_eq!(narrow.width, 50);
assert_eq!(narrow.x, 25);
assert_eq!(narrow.height, 10);
assert_eq!(narrow.y, 0);
}
#[test]
fn narrowed_rect_clamps_to_at_least_one_column() {
let area = Rect::new(0, 0, 100, 10);
let narrow = narrowed_rect(area, 0.0);
assert_eq!(narrow.width, 1);
}
}