Skip to main content

ravenrs_report_viewer/
fan.rs

1//! [`PageFan`]: real pages of rendered reports, spread like a hand of cards.
2
3use std::cell::RefCell;
4use std::rc::Rc;
5
6use ravenrs_core::{Color, CursorIcon, Effect, Point, Quad, Rect, Role, Scene, Shadow};
7use ravenrs_layout::{Axis, LayoutStyle};
8use ravenrs_report::{Document, Page, paint_page};
9use ravenrs_style::Semantic;
10use ravenrs_text::TextSystem;
11use ravenrs_widgets::{
12    Badge, BuildCx, Motion, NodeId, Paint, ScrollLink, ScrollLinked, ScrollRange, Semantics, View,
13};
14
15/// One page of the fan: what it shows, what it is called and where a click goes.
16#[derive(Clone, Debug)]
17struct FanPage {
18    page: Rc<Page>,
19    label: String,
20    url: String,
21}
22
23/// Pages of rendered reports spread like a hand of cards. They open as the fan
24/// scrolls into view; the page under the pointer (or keyboard focus) comes to the front
25/// and lifts with an accent ring while the others step back, and a click opens its
26/// address through [`ravenrs_widgets::Links`]. Each page is painted once at its size and
27/// reused, so scrolling and hovering repaint nothing but positions.
28///
29/// ```
30/// use ravenrs_report::{ReportEngine, samples};
31/// use ravenrs_report_viewer::PageFan;
32///
33/// let invoice = ReportEngine::new().render(&samples::invoice(), &samples::invoice_data());
34/// let fan = PageFan::new().page(&invoice, 0, "Invoice", "designer/");
35/// ```
36#[derive(Clone, Debug)]
37pub struct PageFan {
38    pages: Vec<FanPage>,
39    width: f32,
40    spread: f32,
41    hint: String,
42}
43
44impl Default for PageFan {
45    fn default() -> Self {
46        Self::new()
47    }
48}
49
50impl PageFan {
51    /// An empty fan of pages 260 pixels wide.
52    pub fn new() -> Self {
53        Self { pages: Vec::new(), width: 260.0, spread: 62.0, hint: "Open in the designer".to_owned() }
54    }
55
56    /// Adds page `index` of `document`, called `label`, opening `url`. A page that does
57    /// not exist is skipped. Pages are stacked back to front in the order added.
58    pub fn page(
59        mut self,
60        document: &Document,
61        index: usize,
62        label: impl Into<String>,
63        url: impl Into<String>,
64    ) -> Self {
65        if let Some(page) = document.pages.get(index) {
66            self.pages.push(FanPage { page: Rc::new(page.clone()), label: label.into(), url: url.into() });
67        }
68        self
69    }
70
71    /// The width of each page (260 by default); the height follows its paper.
72    pub fn width(mut self, width: f32) -> Self {
73        self.width = width.max(40.0);
74        self
75    }
76
77    /// How far apart neighboring pages open (62 by default).
78    pub fn spread(mut self, spread: f32) -> Self {
79        self.spread = spread.max(0.0);
80        self
81    }
82
83    /// The words shown under a page in front, after its name.
84    pub fn hint(mut self, hint: impl Into<String>) -> Self {
85        self.hint = hint.into();
86        self
87    }
88
89    fn height(&self) -> f32 {
90        self.pages.iter().map(|p| p.page.height / p.page.width.max(1.0) * self.width).fold(0.0, f32::max)
91    }
92}
93
94impl View for PageFan {
95    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
96        let count = self.pages.len();
97        // The page in front, and whether keyboard focus (not the pointer) put it there.
98        let front = cx.use_state(|| None::<(usize, bool)>);
99        let lifted = match front.get() {
100            Some((i, true)) => Some(i),
101            // Leaving the fan with the pointer lets every page go back.
102            Some((i, false)) if cx.is_hover_within() => Some(i),
103            _ => None,
104        };
105        let cache = cx.use_state(|| Rc::new(RefCell::new(Pictures::default()))).get();
106        let (width, height) = (self.width, self.height());
107        let span = self.spread * count.saturating_sub(1) as f32;
108        let total = Rect::new(0.0, 0.0, width + span, height + 64.0);
109        let mut order: Vec<usize> = (0..count).filter(|i| Some(*i) != lifted).collect();
110        order.extend(lifted);
111        let mut children = Vec::with_capacity(count);
112        for i in order {
113            let page = &self.pages[i];
114            let off = i as f32 - (count as f32 - 1.0) / 2.0;
115            let card = Card {
116                page: page.clone(),
117                index: i,
118                width,
119                height: page.page.height / page.page.width.max(1.0) * width,
120                lifted: lifted == Some(i),
121                dimmed: lifted.is_some_and(|l| l != i),
122                front: front.clone(),
123                hint: self.hint.clone(),
124                cache: cache.clone(),
125            };
126            // Closed, the pages sit in a pile in the middle; they open as the fan comes in.
127            let closed = Effect::moved(-off * self.spread, -3.0 * i as f32);
128            let link =
129                ScrollLink::keyframes([(0.0, closed), (1.0, Effect::IDENTITY)]).range(ScrollRange::Enter);
130            let placed = Placed {
131                x: span / 2.0 + off * self.spread,
132                y: 24.0 + (off.abs() * 6.0),
133                content: ScrollLinked::new(card, link),
134            };
135            children.push(cx.keyed_child(i as u64, &placed));
136        }
137        cx.container(
138            LayoutStyle {
139                width: Some(total.width()),
140                height: Some(total.height()),
141                ..LayoutStyle::stack(Axis::Vertical)
142            },
143            &children,
144            Paint::None,
145        )
146    }
147}
148
149/// A child placed at `(x, y)` inside its parent, out of the flow.
150struct Placed<C> {
151    x: f32,
152    y: f32,
153    content: C,
154}
155
156impl<C: View> View for Placed<C> {
157    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
158        let child = cx.child(&self.content);
159        cx.container(
160            LayoutStyle { absolute: Some((self.x, self.y)), ..LayoutStyle::stack(Axis::Vertical) },
161            &[child],
162            Paint::None,
163        )
164    }
165}
166
167/// Page pictures painted once, by page and width.
168#[derive(Default)]
169struct Pictures {
170    text: Option<TextSystem>,
171    scenes: Vec<(usize, u32, Rc<Scene>)>,
172}
173
174impl Pictures {
175    fn get(&mut self, index: usize, page: &Page, width: f32) -> Rc<Scene> {
176        let key = width.to_bits();
177        if let Some((_, _, scene)) = self.scenes.iter().find(|(i, w, _)| *i == index && *w == key) {
178            return scene.clone();
179        }
180        let mut scene = Scene::new();
181        paint_page(
182            page,
183            self.text.get_or_insert_with(TextSystem::new),
184            &mut scene,
185            Point::ZERO,
186            width / page.width.max(1.0),
187        );
188        let scene = Rc::new(scene);
189        self.scenes.push((index, key, scene.clone()));
190        scene
191    }
192}
193
194struct Card {
195    page: FanPage,
196    index: usize,
197    width: f32,
198    height: f32,
199    lifted: bool,
200    dimmed: bool,
201    front: ravenrs_reactive::State<Option<(usize, bool)>>,
202    hint: String,
203    cache: Rc<RefCell<Pictures>>,
204}
205
206impl View for Card {
207    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
208        let theme = *cx.theme();
209        let picture = self.cache.borrow_mut().get(self.index, &self.page.page, self.width);
210        let (lifted, dimmed) = (self.lifted, self.dimmed);
211        let ring = theme.color(Semantic::Accent);
212        let paint = Paint::Custom(Rc::new(move |frame: Rect, scene: &mut Scene| {
213            let start = scene.len();
214            scene.extend_from_slice(picture.primitives());
215            scene.apply_effect(start..scene.len(), Effect::moved(frame.x(), frame.y()), Point::ZERO);
216            if dimmed {
217                scene.push_quad(Quad::new(frame, Color::rgba(0.04, 0.05, 0.07, 0.38)));
218            }
219            if lifted {
220                let shadowed = Quad::new(frame, Color::TRANSPARENT).border(2.0, ring).shadow(Shadow {
221                    offset: Point::new(0.0, 16.0),
222                    blur: 36.0,
223                    color: Color::rgba(0.0, 0.0, 0.0, 0.45),
224                });
225                scene.push_quad(shadowed);
226            }
227        }));
228        let page = cx.container(
229            LayoutStyle {
230                width: Some(self.width),
231                height: Some(self.height),
232                ..LayoutStyle::stack(Axis::Vertical)
233            },
234            &[],
235            paint,
236        );
237        let mut parts = vec![page];
238        if lifted {
239            let label = format!("{} · {}", self.page.label, self.hint);
240            parts.push(cx.child(&Badge::new(label)));
241        }
242        let node = cx.container(
243            LayoutStyle {
244                spacing: 8.0,
245                align: ravenrs_layout::Align::Center,
246                ..LayoutStyle::stack(Axis::Vertical)
247            },
248            &parts,
249            Paint::None,
250        );
251        let lift = if lifted { Effect::moved(0.0, -22.0).with_scale(1.06) } else { Effect::IDENTITY };
252        cx.motion(node, Motion::new().effect(lift, theme.motion.snappy));
253        let (links, url) = (cx.links(), self.page.url.clone());
254        cx.on_click(node, Rc::new(move || links.open(&url)));
255        let (front, index) = (self.front.clone(), self.index);
256        cx.on_pointer_move(
257            node,
258            Rc::new(move |_, _| {
259                if front.get() != Some((index, false)) {
260                    front.set(Some((index, false)));
261                }
262            }),
263        );
264        let by_focus = Some((self.index, true));
265        if cx.is_focused() && self.front.get() != by_focus {
266            self.front.set(by_focus);
267        } else if !cx.is_focused() && self.front.get() == by_focus {
268            self.front.set(None);
269        }
270        cx.semantics(node, Semantics::new(Role::Link).name(format!("{}. {}", self.page.label, self.hint)));
271        cx.focusable(node, 10.0);
272        cx.cursor(node, CursorIcon::Pointer);
273        node
274    }
275}