Skip to main content

ravenrs_widgets/lists/
paged.rs

1//! The pages of a document in a column.
2
3use std::cell::Cell;
4use std::fmt;
5use std::rc::Rc;
6
7use ravenrs_core::{Border, ImageData, Insets, Key, Role, Size};
8use ravenrs_layout::{Align, Axis, LayoutStyle};
9use ravenrs_reactive::{Binding, State};
10use ravenrs_style::{Semantic, TextStyle};
11
12use super::virtual_list::{ListProbe, ScrollCommand, VirtualList};
13use crate::ViewExt;
14use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
15use crate::imaging::{ZoomControl, ZoomView, zoom_stop};
16use crate::view::View;
17use crate::{ButtonSize, ContentMode, HStack, IconButton, IconName, Image, Skeleton, Spacer, Text};
18
19/// Room around the pages.
20const MARGIN: f32 = 16.0;
21/// Gap between pages.
22const PAGE_GAP: f32 = 12.0;
23const LEAST_ZOOM: f32 = 0.1;
24const MOST_ZOOM: f32 = 8.0;
25
26/// A bar with the page, the total and the zoom, over a column of pages of which only those
27/// that show are built, so the pictures of the others need not exist. Pictures come from whoever
28/// uses it, page by page: until one is ready, the page shows a placeholder of the right size.
29/// The fit follows the width of the view. Page Up, Page Down, Home and End move between pages,
30/// and `+`, `-` and `0` zoom.
31///
32/// ```
33/// use ravenrs_core::Size;
34/// use ravenrs_reactive::State;
35/// use ravenrs_widgets::{PagedDocumentView, Ui};
36///
37/// let page = State::new(0_usize);
38/// let document = PagedDocumentView::new(120, |_| Size::new(595.0, 842.0), |_| None).page(page.binding());
39/// let mut ui = Ui::new(document, ravenrs_style::Theme::raven_dark());
40/// ui.update();
41/// ```
42#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
43pub struct PagedDocumentView {
44    count: usize,
45    page_size: Rc<dyn Fn(usize) -> Size>,
46    picture: Rc<dyn Fn(usize) -> Option<ImageData>>,
47    page: Option<Binding<usize>>,
48    zoom: Option<Binding<ZoomView>>,
49    height: Option<f32>,
50    label: Option<String>,
51}
52
53impl fmt::Debug for PagedDocumentView {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        f.debug_struct("PagedDocumentView").field("count", &self.count).finish_non_exhaustive()
56    }
57}
58
59#[cfg_attr(feature = "live", ravenrs_live::expose)]
60impl PagedDocumentView {
61    /// A document of `count` pages, each of the size given by `page_size` at 100%, with the
62    /// picture given by `picture` when it shows. Asked only for what shows, on each build, so it
63    /// should hand over what is ready and start on what is not.
64    pub fn new(
65        count: usize,
66        page_size: impl Fn(usize) -> Size + 'static,
67        picture: impl Fn(usize) -> Option<ImageData> + 'static,
68    ) -> Self {
69        Self {
70            count,
71            page_size: Rc::new(page_size),
72            picture: Rc::new(picture),
73            page: None,
74            zoom: None,
75            height: None,
76            label: None,
77        }
78    }
79
80    /// The page in view, from 0. It follows the scroll, and setting it goes there.
81    pub fn page(mut self, page: Binding<usize>) -> Self {
82        self.page = Some(page);
83        self
84    }
85
86    /// The zoom, to share with other controls. Its `center` and `viewport` are not used.
87    pub fn zoom(mut self, zoom: Binding<ZoomView>) -> Self {
88        self.zoom = Some(zoom);
89        self
90    }
91
92    /// A fixed height. It takes the height its parent gives it by default.
93    pub fn height(mut self, height: f32) -> Self {
94        self.height = Some(height);
95        self
96    }
97
98    /// What screen readers call it, instead of the name in [`Labels`](crate::Labels).
99    pub fn label(mut self, label: impl Into<String>) -> Self {
100        self.label = Some(label.into());
101        self
102    }
103}
104
105/// A sheet with its picture, or the promise of one.
106struct Sheet {
107    width: f32,
108    height: f32,
109    picture: Option<ImageData>,
110    name: String,
111}
112
113impl View for Sheet {
114    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
115        let theme = *cx.theme();
116        let child = match &self.picture {
117            Some(data) => cx.child(
118                &Image::new(data.clone())
119                    .size(self.width, self.height)
120                    .content_mode(ContentMode::Fill)
121                    .alt(self.name.clone()),
122            ),
123            None => cx.child(&Skeleton::block(self.width, self.height)),
124        };
125        let node = cx.container(
126            LayoutStyle {
127                width: Some(self.width),
128                height: Some(self.height),
129                overflow_hidden: true,
130                ..LayoutStyle::stack(Axis::Vertical)
131            },
132            &[child],
133            Paint::Quad(QuadPaint {
134                border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
135                ..QuadPaint::new(theme.color(Semantic::Surface))
136            }),
137        );
138        cx.semantics(node, Semantics::new(Role::Image).name(self.name.clone()));
139        node
140    }
141}
142
143impl View for PagedDocumentView {
144    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
145        let labels = cx.labels();
146        let local_page: State<usize> = cx.use_state(|| 0);
147        let page = self.page.clone().unwrap_or_else(|| local_page.binding());
148        let local_zoom: State<ZoomView> = cx.use_state(ZoomView::fit);
149        let zoom = self.zoom.clone().unwrap_or_else(|| local_zoom.binding());
150        let commands: State<Option<ScrollCommand>> = cx.use_state(|| None);
151        let probe = cx.use_state(|| Rc::new(Cell::new(ListProbe::default()))).peek().clone();
152        // The page this view last went to or reported, and the offset that went with it.
153        let known = cx.use_state(|| Rc::new(Cell::new((0_usize, 0.0_f32)))).peek().clone();
154
155        let count = self.count;
156        let widest = (0..count).map(|i| (self.page_size)(i).width).fold(0.0_f32, f32::max).max(1.0);
157        // Its own frame: the list inside is built after this, and would be one step behind.
158        let room = cx.scroll_viewport().map_or(cx.viewport().width, |size| size.width);
159        let current = zoom.get();
160        let scale = if current.fit {
161            let fit = ((room - 2.0 * MARGIN) / widest).clamp(LEAST_ZOOM, MOST_ZOOM);
162            if (current.scale - fit).abs() > 0.0005 {
163                zoom.set(ZoomView { scale: fit, ..current });
164            }
165            fit
166        } else {
167            current.scale.clamp(LEAST_ZOOM, MOST_ZOOM)
168        };
169
170        let wanted = page.get().min(count.saturating_sub(1));
171        let (known_page, _) = known.get();
172        let jumped = wanted != known_page;
173        if jumped {
174            commands.set(Some(ScrollCommand::ToIndex(wanted)));
175        }
176
177        let (back_to, on_to) = (page.clone(), page.clone());
178        let last_page = count.saturating_sub(1);
179        let bar = HStack::new((
180            IconButton::new(IconName::ChevronUp)
181                .label(labels.previous_page.clone())
182                .size(ButtonSize::Small)
183                .disabled(wanted == 0)
184                .on_click(move || back_to.set(wanted.saturating_sub(1))),
185            IconButton::new(IconName::ChevronDown)
186                .label(labels.next_page.clone())
187                .size(ButtonSize::Small)
188                .disabled(wanted >= last_page)
189                .on_click(move || on_to.set((wanted + 1).min(last_page))),
190            Text::new(labels.page_name(wanted + 1, count)).style(TextStyle::Callout),
191            Spacer::new(),
192            ZoomControl::new(zoom.clone()).range(LEAST_ZOOM, MOST_ZOOM),
193        ))
194        .spacing(cx.theme().spacing.s3)
195        .align(Align::Center)
196        .padding(Insets::symmetric(cx.theme().spacing.s2, cx.theme().spacing.s4))
197        .background(Semantic::Surface);
198        let bar = cx.child(&bar);
199
200        let (size_of, heights, picture, names) =
201            (self.page_size.clone(), self.page_size.clone(), self.picture.clone(), labels.clone());
202        let list = VirtualList::new(count, move |index| {
203            let size = size_of(index);
204            Sheet {
205                width: size.width * scale,
206                height: size.height * scale,
207                picture: picture(index),
208                name: names.page_name(index + 1, count),
209            }
210        })
211        .row_heights(move |index| heights(index).height * scale)
212        .spacing(PAGE_GAP)
213        .padding(MARGIN)
214        .bare()
215        .commands(commands.clone())
216        .probe(probe.clone())
217        .scroll_across(widest * scale + 2.0 * MARGIN)
218        .row_align(Align::Center);
219        let column = cx.child(&list);
220        let column = cx.container(
221            LayoutStyle {
222                grow: 1.0,
223                min_height: Some(0.0),
224                align: Align::Stretch,
225                overflow_hidden: true,
226                ..LayoutStyle::stack(Axis::Vertical)
227            },
228            &[column],
229            Paint::Quad(QuadPaint::new(cx.theme().color(Semantic::Canvas))),
230        );
231
232        // Only a scroll speaks for the page: right after a jump, the view is where it was sent.
233        let seen = probe.get();
234        if jumped {
235            known.set((wanted, seen.offset));
236        } else if (seen.offset - known.get().1).abs() > 0.5 {
237            known.set((seen.current, seen.offset));
238            if seen.current != wanted {
239                page.set(seen.current);
240            }
241        }
242
243        let root = cx.container(
244            LayoutStyle {
245                height: self.height,
246                grow: if self.height.is_some() { 0.0 } else { 1.0 },
247                min_height: Some(0.0),
248                min_width: Some(0.0),
249                // Otherwise the column would make it as tall as all its pages.
250                overflow_hidden: true,
251                align: Align::Stretch,
252                align_self: Some(Align::Stretch),
253                ..LayoutStyle::stack(Axis::Vertical)
254            },
255            &[bar, column],
256            Paint::None,
257        );
258        cx.report_frame(root);
259        let name = self.label.clone().unwrap_or_else(|| labels.document_name.clone());
260        cx.semantics(root, Semantics::new(Role::Group).name(name));
261        cx.focusable(root, cx.theme().radii.sm);
262        let pinched = zoom.clone();
263        cx.on_pinch(
264            column,
265            Rc::new(move |delta| {
266                pinched.update(|v| {
267                    v.scale = (v.scale * (1.0 + delta)).clamp(LEAST_ZOOM, MOST_ZOOM);
268                    v.fit = false;
269                });
270            }),
271        );
272        cx.on_key(
273            root,
274            Rc::new(move |key, _| {
275                let last = count.saturating_sub(1);
276                let change = |up: bool| {
277                    zoom.update(|v| {
278                        v.scale = zoom_stop(v.scale, up).clamp(LEAST_ZOOM, MOST_ZOOM);
279                        v.fit = false;
280                    });
281                };
282                match key {
283                    Key::PageUp => page.set(wanted.saturating_sub(1)),
284                    Key::PageDown => page.set((wanted + 1).min(last)),
285                    Key::Home => page.set(0),
286                    Key::End => page.set(last),
287                    Key::Character(c) if c == "+" || c == "=" => change(true),
288                    Key::Character(c) if c == "-" => change(false),
289                    Key::Character(c) if c == "0" => zoom.update(|v| v.fit = true),
290                    _ => return false,
291                }
292                true
293            }),
294        );
295        root
296    }
297}