1use 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
16const MAX_SLIDE: u16 = 6;
19
20pub 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 shown: Grid,
59 from: Grid,
61 started: Option<Duration>,
62 direction: Navigation,
63}
64
65#[derive(Default)]
69struct Grid {
70 rect: Rect,
72 cells: Vec<Cell>,
74}
75
76impl Grid {
77 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 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 #[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 #[must_use]
128 pub fn slide(mut self, slide: bool) -> Self {
129 self.slide = slide;
130 self
131 }
132
133 #[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 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
211fn 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
218fn 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
232fn 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
244fn 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 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 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 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 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 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 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 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}