Skip to main content

qframe/widgets/
page_transition.rs

1//! Animated changes between pages: a cross-fade, optionally with a short slide that follows the
2//! navigation direction.
3
4use std::time::Duration;
5
6use ratatui_core::buffer::Cell;
7use ratatui_core::style::Color;
8
9use crate::color::Rgb;
10use crate::geometry::{Rect, Size};
11use crate::motion::{Easing, steps};
12use crate::router::Navigation;
13use crate::text;
14use crate::widget::{Axis, Container, Flex, Length, MeasureCx, Node, PaintCx, Widget};
15
16/// The most cells a sliding page travels. Motion whispers: the slide hints at direction, it does
17/// not throw the page across the screen.
18const MAX_SLIDE: u16 = 6;
19
20/// Wraps the current page and animates whenever its key changes.
21///
22/// The outgoing screen is remembered cell by cell. When the key changes, each cell of the old
23/// page blends into the matching cell of the new one over the theme's `motion.page`: the old text
24/// fades into the background during the first half and the new text rises out of it during the
25/// second, while backgrounds blend continuously. With [`PageTransition::slide`] the new page also
26/// travels a few cells into place, stepping one cell at a time with its colours blended in
27/// proportion, from the right when going forward and from the left when going back.
28/// With reduced motion, or when the area changed size, the new page appears at once. The new
29/// page is live from the first frame: clicks and keys already reach it.
30///
31/// ```
32/// use qframe::prelude::*;
33/// use qframe::widgets::PageTransition;
34///
35/// # #[derive(Clone)] enum Msg {}
36/// # fn body(router: &Router<String>, ui: &mut View<'_, Msg>) {
37/// let page = router.current().clone();
38/// ui.add_with(PageTransition::new(page.clone()).slide(true).direction(router.direction()), |ui| {
39///     ui.page(page, |ui| {
40///         ui.add(Text::new("Deploys"));
41///     });
42/// })
43/// .fill();
44/// # }
45/// ```
46pub struct PageTransition<Msg> {
47    key: String,
48    slide: bool,
49    direction: Navigation,
50    content: Vec<Node<Msg>>,
51}
52
53#[derive(Default)]
54struct TransitionMemory {
55    key: Option<String>,
56    area: Rect,
57    /// The cells shown in the last frame.
58    shown: Grid,
59    /// The cells of the outgoing page while a transition runs.
60    from: Grid,
61    started: Option<Duration>,
62    direction: Navigation,
63}
64
65/// Cells copied from the part of the screen a transition can change. The two grids of a
66/// transition trade places instead of being rebuilt, so after the first frames copying allocates
67/// nothing.
68#[derive(Default)]
69struct Grid {
70    /// Where the cells came from: the visible part of the transition's area.
71    rect: Rect,
72    /// The cells of `rect`, row by row.
73    cells: Vec<Cell>,
74}
75
76impl Grid {
77    /// Copies the cells of `rect`, which lies on the buffer, reusing the grid's storage.
78    fn copy(&mut self, cx: &PaintCx<'_>, rect: Rect) {
79        self.rect = rect;
80        let len = usize::from(rect.width) * usize::from(rect.height);
81        self.cells.truncate(len);
82        let mut index = 0;
83        for y in rect.y..rect.bottom() {
84            for x in rect.x..rect.right() {
85                let Some(at) = buffer_cell(cx, x, y) else { continue };
86                match self.cells.get_mut(index) {
87                    Some(cell) => cell.clone_from(&cx.buf[at]),
88                    None => self.cells.push(cx.buf[at].clone()),
89                }
90                index += 1;
91            }
92        }
93    }
94
95    /// The copied cell at `x`, `y`, when it was copied.
96    fn get(&self, x: i32, y: i32) -> Option<&Cell> {
97        if !self.rect.contains(x, y) {
98            return None;
99        }
100        let index = (y - self.rect.y) * i32::from(self.rect.width) + (x - self.rect.x);
101        self.cells.get(usize::try_from(index).ok()?)
102    }
103
104    fn is_empty(&self) -> bool {
105        self.cells.is_empty()
106    }
107
108    fn clear(&mut self) {
109        self.cells.clear();
110        self.rect = Rect::default();
111    }
112}
113
114impl<Msg: 'static> PageTransition<Msg> {
115    /// A transition keyed by `key`, usually the router's current page. Cross-fades by default.
116    #[must_use]
117    pub fn new(key: impl Into<String>) -> Self {
118        Self {
119            key: key.into(),
120            slide: false,
121            direction: Navigation::Forward,
122            content: vec![Node::new(Flex::new(Axis::Column, Vec::new()), 0)],
123        }
124    }
125
126    /// Slides the incoming page a few cells into place as it fades in.
127    #[must_use]
128    pub fn slide(mut self, slide: bool) -> Self {
129        self.slide = slide;
130        self
131    }
132
133    /// The way the navigation went, e.g. [`Router::direction`](crate::router::Router::direction);
134    /// decides which side a sliding page comes from.
135    #[must_use]
136    pub fn direction(mut self, direction: Navigation) -> Self {
137        self.direction = direction;
138        self
139    }
140}
141
142impl<Msg: 'static> Container<Msg> for PageTransition<Msg> {
143    fn set_children(&mut self, children: Vec<Node<Msg>>) {
144        let mut column = Node::new(Flex::new(Axis::Column, children), 0);
145        column.layout.width = Length::Fill(1);
146        column.layout.height = Length::Fill(1);
147        self.content = vec![column];
148    }
149}
150
151impl<Msg: 'static> Widget<Msg> for PageTransition<Msg> {
152    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
153        self.content.first().map_or(Size::default(), |content| cx.measure_child(content, available))
154    }
155
156    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
157        let now = cx.now();
158        let mut memory = std::mem::take(cx.memory::<TransitionMemory>());
159        if memory.key.as_deref() != Some(self.key.as_str()) {
160            let can_blend = memory.key.is_some() && memory.area == area && !memory.shown.is_empty();
161            if can_blend && !cx.reduced_motion() {
162                std::mem::swap(&mut memory.from, &mut memory.shown);
163                memory.started = Some(now);
164                memory.direction = self.direction;
165            }
166            memory.key = Some(self.key.clone());
167        }
168        if memory.area != area {
169            memory.started = None;
170            memory.area = area;
171        }
172
173        if let Some(content) = self.content.first() {
174            cx.paint_child(content, area);
175        }
176
177        if let Some(started) = memory.started {
178            let duration = cx.env().theme().motion().page;
179            let progress = cx.progress_since(started, duration, Easing::EaseOut);
180            if progress >= 1.0 {
181                memory.started = None;
182                memory.from.clear();
183            } else {
184                let shift = if self.slide {
185                    let distance = (area.width / 8).min(MAX_SLIDE);
186                    let cells = i32::from(steps(1.0 - progress, distance));
187                    match memory.direction {
188                        Navigation::Forward => cells,
189                        Navigation::Back => -cells,
190                    }
191                } else {
192                    0
193                };
194                compose(cx, area, &memory.from, progress, shift);
195            }
196        }
197        // Only the visible cells can take part in the next transition.
198        memory.shown.copy(cx, visible(cx, area));
199        *cx.memory::<TransitionMemory>() = memory;
200    }
201
202    fn children(&self) -> &[Node<Msg>] {
203        &self.content
204    }
205
206    fn children_mut(&mut self) -> &mut [Node<Msg>] {
207        &mut self.content
208    }
209}
210
211/// The buffer position of the cell at `x`, `y`, when it lies on the buffer.
212fn buffer_cell(cx: &PaintCx<'_>, x: i32, y: i32) -> Option<(u16, u16)> {
213    let (x, y) = (u16::try_from(x).ok()?, u16::try_from(y).ok()?);
214    let buffer = cx.buf.area;
215    (x >= buffer.x && y >= buffer.y && x < buffer.x + buffer.width && y < buffer.y + buffer.height).then_some((x, y))
216}
217
218/// The part of `area` this frame can draw on: inside the clip and on the buffer.
219fn visible(cx: &PaintCx<'_>, area: Rect) -> Rect {
220    let buffer = cx.buf.area;
221    let buffer = Rect::new(i32::from(buffer.x), i32::from(buffer.y), buffer.width, buffer.height);
222    cx.clip().intersect(area).intersect(buffer)
223}
224
225fn rgb(color: Color) -> Option<Rgb> {
226    match color {
227        Color::Rgb(r, g, b) => Some(Rgb::new(r, g, b)),
228        _ => None,
229    }
230}
231
232/// Blends two terminal colours; colours that are not 24-bit switch halfway.
233fn blend(from: Color, to: Color, amount: f32) -> Color {
234    match (rgb(from), rgb(to)) {
235        (Some(a), Some(b)) => {
236            let mixed = a.mix(b, amount);
237            Color::Rgb(mixed.r, mixed.g, mixed.b)
238        }
239        _ if amount < 0.5 => from,
240        _ => to,
241    }
242}
243
244/// Draws the frame of a transition at `progress`: `from` blends into what was just painted,
245/// which is shifted `shift` cells to the right (negative: to the left).
246fn compose(cx: &mut PaintCx<'_>, area: Rect, from: &Grid, progress: f32, shift: i32) {
247    let width = i32::from(area.width);
248    let visible = visible(cx, area);
249    let blank = Cell::default();
250    for y in visible.y..visible.bottom() {
251        for step in 0..i32::from(visible.width) {
252            // The new page is read from the buffer while the frame is written into it. Walking each
253            // row against the shift reads every source cell before it is overwritten, so the page
254            // needs no copy.
255            let x = if shift > 0 { visible.right() - 1 - step } else { visible.x + step };
256            let column = x - area.x;
257            let (Some(old), Some(at)) = (from.get(x, y), buffer_cell(cx, x, y)) else { continue };
258            // The incoming page, displaced; the edge it uncovers repeats the nearest background.
259            let source = column - shift;
260            let new = buffer_cell(cx, area.x + source.clamp(0, width - 1), y).map_or(&blank, |at| &cx.buf[at]);
261            let background = blend(old.bg, new.bg, progress);
262            let mut cell = if progress < 0.5 {
263                let mut cell = old.clone();
264                cell.fg = blend(old.fg, background, progress * 2.0);
265                cell
266            } else {
267                let mut cell = new.clone();
268                if !(0..width).contains(&source) {
269                    cell.set_symbol(" ");
270                }
271                cell.fg = blend(background, new.fg, (progress - 0.5) * 2.0);
272                cell
273            };
274            cell.bg = background;
275            // Half of a wide glyph cut by the edge of the area becomes a space.
276            let symbol_width = text::width(cell.symbol());
277            if (symbol_width > 1 && column + 1 >= width) || (cell.symbol().is_empty() && column == 0) {
278                cell.set_symbol(" ");
279            }
280            cx.buf[at] = cell;
281        }
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::router::Router;
289    use crate::runtime::{App, Command, Harness};
290    use crate::widget::View;
291    use crate::widgets::Text;
292
293    struct Pages {
294        router: Router<String>,
295        slide: bool,
296    }
297
298    enum Msg {
299        Open(&'static str),
300        Back,
301    }
302
303    impl App for Pages {
304        type Msg = Msg;
305        fn update(&mut self, msg: Msg) -> Command<Msg> {
306            match msg {
307                Msg::Open(page) => self.router.push(page.to_owned()),
308                Msg::Back => {
309                    self.router.back();
310                }
311            }
312            Command::none()
313        }
314        fn view(&self, ui: &mut View<'_, Msg>) {
315            let page = self.router.current().clone();
316            let transition = PageTransition::new(page.clone()).slide(self.slide).direction(self.router.direction());
317            ui.add_with(transition, |ui| {
318                ui.page(page.clone(), |ui| {
319                    ui.add(Text::new(page.clone()));
320                });
321            })
322            .fill();
323        }
324    }
325
326    fn pages(slide: bool) -> Harness<Pages> {
327        Harness::new(Pages { router: Router::new("deploys".into()), slide }, 48, 2)
328    }
329
330    fn page_duration<A: App>(h: &Harness<A>) -> Duration {
331        h.env().theme().motion().page
332    }
333
334    #[test]
335    fn fades_through_the_background_and_settles() {
336        let mut h = pages(false);
337        assert_eq!(h.screen(), "deploys\n\n");
338        let text = h.fg(0, 0);
339        let canvas = h.bg(0, 0);
340        h.send(Msg::Open("releases"));
341        assert_eq!(h.screen(), "deploys\n\n", "the first frame still shows the old page");
342        assert_eq!(h.fg(0, 0), text);
343        // Just before the middle the old text has almost dissolved into the background.
344        let middle = page_duration(&h) / 6;
345        h.advance(middle);
346        assert_eq!(h.screen(), "deploys\n\n");
347        assert_ne!(h.fg(0, 0), text);
348        h.advance(page_duration(&h));
349        assert_eq!(h.screen(), "releases\n\n");
350        assert_eq!((h.fg(0, 0), h.bg(0, 0)), (text, canvas));
351    }
352
353    #[test]
354    fn slides_from_the_side_the_navigation_went() {
355        let mut h = pages(true);
356        // A quarter of the way in time the eased progress is past the middle: the new text shows,
357        // three of its six cells still to travel.
358        let quarter = page_duration(&h) / 4;
359        h.send(Msg::Open("releases")).advance(quarter);
360        assert_eq!(h.screen(), "   releases\n\n");
361        h.advance(page_duration(&h));
362        assert_eq!(h.screen(), "releases\n\n");
363        h.send(Msg::Back).advance(quarter);
364        assert_eq!(h.screen(), "loys\n\n");
365        h.advance(page_duration(&h));
366        assert_eq!(h.screen(), "deploys\n\n");
367    }
368
369    #[test]
370    fn a_clipped_transition_blends_only_what_shows() {
371        struct Clipped(Pages);
372        impl App for Clipped {
373            type Msg = Msg;
374            fn update(&mut self, msg: Msg) -> Command<Msg> {
375                self.0.update(msg)
376            }
377            fn view(&self, ui: &mut View<'_, Msg>) {
378                // The transition is taller than the scroll view showing it.
379                ui.add(Text::new("header"));
380                ui.add_with(crate::widgets::ScrollView::new(), |ui| {
381                    ui.column(|ui| self.0.view(ui)).height(crate::widget::Length::Cells(6)).fill_width();
382                })
383                .height(crate::widget::Length::Cells(2))
384                .fill_width();
385                ui.add(Text::new("footer"));
386            }
387        }
388        let mut h = Harness::new(Clipped(Pages { router: Router::new("deploys".into()), slide: true }), 20, 4);
389        assert_eq!(h.screen(), "header\ndeploys\n\nfooter\n");
390        let quarter = page_duration(&h) / 4;
391        h.send(Msg::Open("releases")).advance(quarter);
392        // Nineteen cells wide, the page travels two cells; one is still to go.
393        assert_eq!(h.screen(), "header\n releases\n\nfooter\n");
394        h.advance(page_duration(&h));
395        assert_eq!(h.screen(), "header\nreleases\n\nfooter\n");
396    }
397
398    #[test]
399    fn reduced_motion_changes_pages_at_once() {
400        let mut h = pages(true);
401        h.set_reduced_motion(true);
402        h.send(Msg::Open("releases"));
403        assert_eq!(h.screen(), "releases\n\n");
404    }
405}