Skip to main content

ravenrs_report_viewer/
lib.rs

1//! Document viewer for RavenRS Reporting.
2//!
3//! * [`DocumentViewer`] shows a rendered [`Document`]: pages, zoom, thumbnails
4//!   and text search that finds words regardless of case and accents.
5//! * [`ParameterPanel`] asks for a report's parameters and applies them together.
6//! * [`ViewerState`] holds what the user has chosen, so it survives rebuilds and
7//!   can be shared with the rest of an app.
8//!
9//! ```
10//! use std::rc::Rc;
11//! use ravenrs_report::{Band, Control, Report, ReportEngine};
12//! use ravenrs_report_viewer::{DocumentViewer, ViewerState};
13//! use serde_json::json;
14//!
15//! let report = Report::new("Sales").detail(Band::new(18.0).control(Control::field("[Name]").at(0.0, 0.0, 200.0, 18.0)));
16//! let document = Rc::new(ReportEngine::new().render(&report, &json!([{ "Name": "Ana" }])));
17//! let state = ViewerState::new();
18//! let viewer = DocumentViewer::new(document, &state);
19//! # let _ = viewer;
20//! ```
21
22mod fan;
23mod parameters;
24mod print;
25mod search;
26
27use std::cell::RefCell;
28use std::collections::HashMap;
29use std::rc::Rc;
30
31use ravenrs_app_i18n::{Language, Texts};
32use ravenrs_core::{Color, CursorIcon, Insets, Key, Point, Primitive, Quad, Rect, Role, Scene};
33use ravenrs_layout::{Align, Axis, LayoutStyle};
34use ravenrs_reactive::{Binding, State};
35use ravenrs_report::{Document, Item, TextBlock, paint_page};
36use ravenrs_style::{SPACING, Semantic, TextStyle, Weight};
37use ravenrs_text::{TextAttrs, TextSystem};
38use ravenrs_widgets::{
39    Alert, AnyView, BuildCx, Button, ButtonSize, ButtonStyle, Canvas, Divider, HStack, IconButton, IconName,
40    NodeId, Pagination, Paint, QuadPaint, ScrollView, Semantics, Spacer, Text, TextField, Tone, VStack, View,
41    ViewExt,
42};
43
44pub use fan::PageFan;
45pub use parameters::{ParameterLabels, ParameterPanel, ParameterValues};
46pub use print::print;
47pub use search::{Match, find_matches};
48
49const ZOOM_STEP: f32 = 0.25;
50const ZOOM_MIN: f32 = 0.5;
51const ZOOM_MAX: f32 = 3.0;
52const THUMBNAIL_ZOOM: f32 = 0.18;
53/// Thumbnails rendered at once; a long document shows those around the current page.
54const THUMBNAILS_MAX: usize = 12;
55/// Page scenes kept; each is cheap to redraw and costly to build.
56const SCENES_MAX: usize = 96;
57/// Room left out when working out how many thumbnails fit.
58const CHROME_HEIGHT: f32 = 200.0;
59/// Problems in the report shown above the page; the rest are summed up.
60const ERRORS_SHOWN: usize = 3;
61
62/// The pages, centered, in a column that can get wider than its scroll view.
63struct PagesColumn {
64    pages: Vec<AnyView>,
65    min_width: f32,
66}
67
68impl View for PagesColumn {
69    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
70        let pages = cx.children(&self.pages);
71        cx.container(
72            LayoutStyle {
73                spacing: SPACING.s3,
74                padding: SPACING.s6.into(),
75                min_width: Some(self.min_width),
76                align: Align::Center,
77                ..LayoutStyle::stack(Axis::Vertical)
78            },
79            &pages,
80            Paint::None,
81        )
82    }
83}
84
85/// The text a viewer shows.
86#[derive(Clone, Debug)]
87#[non_exhaustive]
88pub struct ViewerLabels {
89    /// "Previous" in the page navigation.
90    pub previous: String,
91    /// "Next" in the page navigation.
92    pub next: String,
93    /// "Page" in the page navigation.
94    pub page: String,
95    /// The search field's hint.
96    pub search: String,
97    /// The previous search result.
98    pub previous_match: String,
99    /// The next search result.
100    pub next_match: String,
101    /// Shown when a search finds nothing.
102    pub no_matches: String,
103    /// Between a result's position and the total.
104    pub of: String,
105    /// Shows or hides the thumbnails.
106    pub thumbnails: String,
107    /// Before the number in a thumbnail's name, for screen readers.
108    pub thumbnail_of_page: String,
109    /// Zooms out.
110    pub zoom_out: String,
111    /// Zooms in.
112    pub zoom_in: String,
113    /// Fits the page to the window's width.
114    pub fit_width: String,
115    /// Prints the document.
116    pub print: String,
117    /// The title of a problem in the report.
118    pub report_error: String,
119}
120
121impl Default for ViewerLabels {
122    fn default() -> Self {
123        Self::from_texts(&Texts::new(Language::English))
124    }
125}
126
127impl ViewerLabels {
128    /// The labels for a language and region such as `pt-BR`.
129    pub fn for_culture(culture: &str) -> Self {
130        if culture.starts_with("pt") { Self::pt_br() } else { Self::default() }
131    }
132
133    /// The texts of a catalog, in its language. What it does not have comes from English.
134    pub fn from_texts(texts: &Texts) -> Self {
135        Self {
136            previous: texts.get("viewer.previous"),
137            next: texts.get("viewer.next"),
138            page: texts.get("viewer.page"),
139            search: texts.get("viewer.search"),
140            previous_match: texts.get("viewer.previous_match"),
141            next_match: texts.get("viewer.next_match"),
142            no_matches: texts.get("viewer.no_matches"),
143            of: texts.get("viewer.of"),
144            thumbnails: texts.get("viewer.thumbnails"),
145            thumbnail_of_page: texts.get("viewer.thumbnail_of_page"),
146            zoom_out: texts.get("viewer.zoom_out"),
147            zoom_in: texts.get("viewer.zoom_in"),
148            fit_width: texts.get("viewer.fit_width"),
149            print: texts.get("viewer.print"),
150            report_error: texts.get("viewer.report_error"),
151        }
152    }
153
154    /// The labels in Brazilian Portuguese.
155    pub fn pt_br() -> Self {
156        Self::from_texts(&Texts::new(Language::BrazilianPortuguese))
157    }
158}
159
160/// What a viewer remembers. Clones share the same state.
161#[derive(Clone)]
162pub struct ViewerState {
163    page: State<usize>,
164    zoom: State<f32>,
165    query: State<String>,
166    current_match: State<usize>,
167    thumbnails: State<bool>,
168    cache: Rc<RefCell<Cache>>,
169}
170
171impl std::fmt::Debug for ViewerState {
172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        f.debug_struct("ViewerState")
174            .field("page", &self.page.get())
175            .field("zoom", &self.zoom.get())
176            .finish_non_exhaustive()
177    }
178}
179
180impl Default for ViewerState {
181    fn default() -> Self {
182        Self::new()
183    }
184}
185
186impl ViewerState {
187    /// A viewer on the first page at 100%.
188    pub fn new() -> Self {
189        Self::sharing(State::new(0), State::new(1.0))
190    }
191
192    /// A viewer whose page and zoom are states the app also uses.
193    pub fn sharing(page: State<usize>, zoom: State<f32>) -> Self {
194        Self {
195            page,
196            zoom,
197            query: State::new(String::new()),
198            current_match: State::new(0),
199            thumbnails: State::new(false),
200            cache: Rc::new(RefCell::new(Cache::default())),
201        }
202    }
203
204    /// The current page, counted from 0.
205    pub fn page(&self) -> &State<usize> {
206        &self.page
207    }
208
209    /// The zoom: 1.0 is one logical pixel per point.
210    pub fn zoom(&self) -> &State<f32> {
211        &self.zoom
212    }
213
214    /// The text being searched for.
215    pub fn query(&self) -> &State<String> {
216        &self.query
217    }
218
219    /// Whether the thumbnails are shown.
220    pub fn thumbnails(&self) -> &State<bool> {
221        &self.thumbnails
222    }
223}
224
225/// What is worth keeping between builds, for one document.
226#[derive(Default)]
227struct Cache {
228    /// Identifies the document; everything here is cleared when it changes.
229    document: usize,
230    scenes: HashMap<(usize, u32), Rc<Scene>>,
231    query: String,
232    matches: Rc<Vec<Match>>,
233    text: Option<TextSystem>,
234}
235
236impl Cache {
237    fn for_document(&mut self, document: &Rc<Document>) {
238        let id = Rc::as_ptr(document) as usize;
239        if self.document != id {
240            self.document = id;
241            self.scenes.clear();
242            self.query.clear();
243            self.matches = Rc::new(Vec::new());
244        }
245    }
246
247    fn scene(&mut self, document: &Document, page: usize, zoom: f32) -> Rc<Scene> {
248        let key = (page, zoom.to_bits());
249        if let Some(scene) = self.scenes.get(&key) {
250            return scene.clone();
251        }
252        let mut scene = Scene::new();
253        if let Some(sheet) = document.pages.get(page) {
254            paint_page(sheet, self.text.get_or_insert_with(TextSystem::new), &mut scene, Point::ZERO, zoom);
255        }
256        if self.scenes.len() >= SCENES_MAX {
257            self.scenes.clear();
258        }
259        let scene = Rc::new(scene);
260        self.scenes.insert(key, scene.clone());
261        scene
262    }
263
264    fn matches(&mut self, document: &Document, query: &str) -> Rc<Vec<Match>> {
265        if self.query != query {
266            self.query = query.to_owned();
267            self.matches = Rc::new(find_matches(document, query));
268        }
269        self.matches.clone()
270    }
271
272    /// Where a match is on its page, in points.
273    fn match_rect(&mut self, document: &Document, found: &Match) -> Option<Rect> {
274        let Item::Text(block) = document.pages.get(found.page)?.items.get(found.item)? else { return None };
275        let line = block.lines.get(found.line)?;
276        let text = self.text.get_or_insert_with(TextSystem::new);
277        let start = prefix_width(text, block, line.text.get(..found.range.start)?);
278        let end = prefix_width(text, block, line.text.get(..found.range.end)?);
279        Some(Rect::new(line.x + start, line.top, (end - start).max(1.0), block.line_height))
280    }
281}
282
283fn prefix_width(text: &mut TextSystem, block: &TextBlock, prefix: &str) -> f32 {
284    let attrs = TextAttrs {
285        size: block.size,
286        line_height: block.line_height,
287        weight: if block.bold { 600.0 } else { 400.0 },
288        ..TextAttrs::default()
289    };
290    text.layout(prefix, &attrs).measure(None).width
291}
292
293/// Copies a cached page into the frame being painted, moved to `origin`.
294fn draw_at(source: &Scene, origin: Point, scene: &mut Scene) {
295    let shift = |r: Rect| Rect::from_origin_size(Point::new(r.x() + origin.x, r.y() + origin.y), r.size);
296    for primitive in source.primitives() {
297        match primitive {
298            Primitive::Quad(q) => scene.push_quad(Quad { rect: shift(q.rect), ..*q }),
299            Primitive::Glyphs(run) => {
300                let mut run = run.clone();
301                for glyph in &mut run.glyphs {
302                    glyph.x += origin.x;
303                    glyph.y += origin.y;
304                }
305                scene.push_glyphs(run);
306            }
307            Primitive::Image(image) => {
308                scene.push_image(ravenrs_core::ImageQuad { rect: shift(image.rect), ..image.clone() });
309            }
310            Primitive::PushClip(r) => scene.push_clip(shift(*r)),
311            Primitive::PopClip => scene.pop_clip(),
312            _ => {}
313        }
314    }
315}
316
317/// Builds the extra controls of the toolbar.
318type ToolbarItems = Rc<dyn Fn() -> Vec<AnyView>>;
319
320/// Shows a rendered document.
321pub struct DocumentViewer {
322    document: Rc<Document>,
323    state: ViewerState,
324    labels: Option<ViewerLabels>,
325    leading: Option<ToolbarItems>,
326    trailing: Option<ToolbarItems>,
327    print_button: bool,
328}
329
330impl std::fmt::Debug for DocumentViewer {
331    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
332        f.debug_struct("DocumentViewer").field("title", &self.document.title).finish_non_exhaustive()
333    }
334}
335
336impl DocumentViewer {
337    /// A viewer of `document`.
338    pub fn new(document: Rc<Document>, state: &ViewerState) -> Self {
339        Self {
340            document,
341            state: state.clone(),
342            labels: None,
343            leading: None,
344            trailing: None,
345            print_button: true,
346        }
347    }
348
349    /// Sets the text the viewer shows.
350    pub fn labels(mut self, labels: ViewerLabels) -> Self {
351        self.labels = Some(labels);
352        self
353    }
354
355    /// Whether the toolbar offers to print (on by default).
356    pub fn print_button(mut self, show: bool) -> Self {
357        self.print_button = show;
358        self
359    }
360
361    /// Adds controls at the start of the toolbar.
362    pub fn toolbar_leading(mut self, items: impl Fn() -> Vec<AnyView> + 'static) -> Self {
363        self.leading = Some(Rc::new(items));
364        self
365    }
366
367    /// Adds controls at the end of the toolbar, such as print and export buttons.
368    pub fn toolbar_trailing(mut self, items: impl Fn() -> Vec<AnyView> + 'static) -> Self {
369        self.trailing = Some(Rc::new(items));
370        self
371    }
372
373    fn search_bar(&self, labels: &ViewerLabels, matches: &[Match], current: usize) -> impl View + use<> {
374        let (read, write) = (self.state.clone(), self.state.clone());
375        let document = self.document.clone();
376        let field = TextField::new(Binding::new(
377            move || read.query.get(),
378            move |query: String| {
379                let found = write.cache.borrow_mut().matches(&document, &query);
380                write.query.set(query);
381                write.current_match.set(0);
382                if let Some(first) = found.first() {
383                    write.page.set(first.page);
384                }
385            },
386        ))
387        .placeholder(labels.search.clone())
388        .icon(IconName::Search)
389        .compact()
390        .width(180.0)
391        .on_submit({
392            let step = self.stepper(1);
393            move |_| step()
394        });
395        let mut items: Vec<AnyView> = vec![Box::new(field)];
396        if !self.state.query.with(|q| q.trim().is_empty()) {
397            let status = if matches.is_empty() {
398                labels.no_matches.clone()
399            } else {
400                format!("{} {} {}", current + 1, labels.of, matches.len())
401            };
402            items.push(Box::new(Text::new(status).style(TextStyle::Callout).foreground(Semantic::InkMuted)));
403            if !matches.is_empty() {
404                let (back, forward) = (self.stepper(-1), self.stepper(1));
405                items.push(Box::new(
406                    IconButton::new(IconName::ChevronUp)
407                        .label(labels.previous_match.clone())
408                        .size(ButtonSize::Small)
409                        .on_click(back),
410                ));
411                items.push(Box::new(
412                    IconButton::new(IconName::ChevronDown)
413                        .label(labels.next_match.clone())
414                        .size(ButtonSize::Small)
415                        .on_click(forward),
416                ));
417            }
418        }
419        HStack::new(items).spacing(SPACING.s2).align(Align::Center)
420    }
421
422    /// A step through the search results, going around at the ends.
423    fn stepper(&self, delta: isize) -> impl Fn() + use<> {
424        let state = self.state.clone();
425        let document = self.document.clone();
426        move || {
427            let query = state.query.get();
428            let found = state.cache.borrow_mut().matches(&document, &query);
429            if found.is_empty() {
430                return;
431            }
432            let count = found.len() as isize;
433            let next = (state.current_match.get() as isize + delta).rem_euclid(count) as usize;
434            state.current_match.set(next);
435            state.page.set(found[next].page);
436        }
437    }
438
439    fn zoom_controls(&self, labels: &ViewerLabels, zoom: f32, fit_width: f32) -> impl View + use<> {
440        let (out, into, fit) = (self.state.zoom.clone(), self.state.zoom.clone(), self.state.zoom.clone());
441        HStack::new((
442            IconButton::new(IconName::ZoomOut)
443                .label(labels.zoom_out.clone())
444                .size(ButtonSize::Small)
445                .on_click(move || out.update(|z| *z = (*z - ZOOM_STEP).max(ZOOM_MIN))),
446            Text::new(format!("{:.0}%", zoom * 100.0))
447                .style(TextStyle::Callout)
448                .font_weight(Weight::SEMIBOLD),
449            IconButton::new(IconName::ZoomIn)
450                .label(labels.zoom_in.clone())
451                .size(ButtonSize::Small)
452                .on_click(move || into.update(|z| *z = (*z + ZOOM_STEP).min(ZOOM_MAX))),
453            IconButton::new(IconName::Maximize2)
454                .label(labels.fit_width.clone())
455                .size(ButtonSize::Small)
456                .on_click(move || fit.set(fit_width.clamp(ZOOM_MIN, ZOOM_MAX))),
457        ))
458        .spacing(SPACING.s1)
459        .align(Align::Center)
460    }
461
462    fn thumbnails_column(
463        &self,
464        labels: &ViewerLabels,
465        current: usize,
466        available_height: f32,
467        cx: &mut BuildCx<'_>,
468    ) -> NodeId {
469        let pages = self.document.pages.len();
470        let tall =
471            self.document.pages.first().map_or(842.0, |p| p.height) * THUMBNAIL_ZOOM + 2.0 * SPACING.s6;
472        let fits = ((available_height / tall) as usize).clamp(1, THUMBNAILS_MAX);
473        let start = current.saturating_sub(fits / 2).min(pages.saturating_sub(fits));
474        let items: Vec<AnyView> = (start..(start + fits).min(pages))
475            .map(|page| {
476                Box::new(Thumbnail {
477                    document: self.document.clone(),
478                    state: self.state.clone(),
479                    page,
480                    current: page == current,
481                    label: format!("{} {}", labels.thumbnail_of_page, page + 1),
482                }) as AnyView
483            })
484            .collect();
485        cx.child(
486            &VStack::new(items)
487                .spacing(SPACING.s2)
488                .padding(SPACING.s3)
489                .align(Align::Center)
490                .background(Semantic::Surface),
491        )
492    }
493}
494
495impl View for DocumentViewer {
496    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
497        let document = &self.document;
498        let state = &self.state;
499        let pages = document.pages.len().max(1);
500        let current = state.page.get().min(pages - 1);
501        let zoom = state.zoom.get();
502        let query = state.query.get();
503        let matches = {
504            let mut cache = state.cache.borrow_mut();
505            cache.for_document(document);
506            cache.matches(document, &query)
507        };
508        let current_match = state.current_match.get().min(matches.len().saturating_sub(1));
509        let page_width = document.pages.get(current).map_or(595.0, |p| p.width);
510        let viewport = cx.viewport();
511        let thumbnails = state.thumbnails.get();
512        let sidebar = if thumbnails { page_width * THUMBNAIL_ZOOM + 2.0 * SPACING.s3 + 1.0 } else { 0.0 };
513        let fit_width = (viewport.width - sidebar - 2.0 * SPACING.s6) / page_width.max(1.0);
514
515        // Unless told otherwise, it speaks as the rest of the application.
516        let labels = &self.labels.clone().unwrap_or_else(|| ViewerLabels::for_culture(&cx.labels().culture));
517        let toggle = state.thumbnails.clone();
518        let mut bar: Vec<AnyView> = vec![Box::new(
519            IconButton::new(IconName::PanelLeft)
520                .label(labels.thumbnails.clone())
521                .size(ButtonSize::Small)
522                .on_click(move || toggle.update(|shown| *shown = !*shown)),
523        )];
524        if let Some(leading) = &self.leading {
525            bar.extend(leading());
526        }
527        // Compact: another document must not move what is beside it.
528        bar.push(Box::new(Pagination::new(state.page.binding(), pages).compact().labels(
529            labels.previous.clone(),
530            labels.next.clone(),
531            labels.page.clone(),
532        )));
533        bar.push(Box::new(Spacer::new()));
534        bar.push(Box::new(self.search_bar(labels, &matches, current_match)));
535        bar.push(Box::new(self.zoom_controls(labels, zoom, fit_width)));
536        if self.print_button {
537            let printed = document.clone();
538            bar.push(Box::new(
539                Button::new(labels.print.clone())
540                    .icon(IconName::Printer)
541                    .style(ButtonStyle::Secondary)
542                    .size(ButtonSize::Small)
543                    .on_click(move || print(&printed)),
544            ));
545        }
546        if let Some(trailing) = &self.trailing {
547            bar.extend(trailing());
548        }
549        let toolbar = cx.child(
550            &HStack::new(bar)
551                .spacing(SPACING.s3)
552                .align(Align::Center)
553                .wrap()
554                .padding(Insets::symmetric(SPACING.s2, SPACING.s4))
555                .background(Semantic::Surface),
556        );
557        let line = cx.child(&Divider::horizontal());
558
559        let theme = *cx.theme();
560        let highlights: Vec<(Rect, bool)> = {
561            let mut cache = state.cache.borrow_mut();
562            matches
563                .iter()
564                .enumerate()
565                .filter(|(_, found)| found.page == current)
566                .filter_map(|(index, found)| {
567                    Some((cache.match_rect(document, found)?, index == current_match))
568                })
569                .collect()
570        };
571        let mut body: Vec<AnyView> = Vec::new();
572        for message in document.errors.iter().take(ERRORS_SHOWN) {
573            body.push(Box::new(Alert::new(labels.report_error.clone(), message.clone()).tone(Tone::Danger)));
574        }
575        if let Some(sheet) = document.pages.get(current) {
576            let scene = state.cache.borrow_mut().scene(document, current, zoom);
577            body.push(Box::new(PageView {
578                scene,
579                highlights,
580                highlight: with_alpha(theme.color(Semantic::Warning), 0.35),
581                highlight_current: with_alpha(theme.color(Semantic::Warning), 0.7),
582                zoom,
583                width: sheet.width * zoom,
584                height: sheet.height * zoom,
585                text: document.page_text(current).join(" "),
586            }));
587        }
588        // At least the width of the page, and otherwise the width of the view,
589        // wherever the app has put it, so the page stays centered.
590        let main = cx.child(
591            &ScrollView::new((PagesColumn { pages: body, min_width: page_width * zoom + 2.0 * SPACING.s6 },))
592                .horizontal()
593                .align(Align::Stretch)
594                .grow(1.0),
595        );
596        let row_children = if thumbnails {
597            // The window less what is usually above the pages: toolbars and a status bar.
598            let column = self.thumbnails_column(labels, current, viewport.height - CHROME_HEIGHT, cx);
599            let separator = cx.child(&Divider::vertical());
600            vec![column, separator, main]
601        } else {
602            vec![main]
603        };
604        let row = cx.container(
605            LayoutStyle {
606                grow: 1.0,
607                min_height: Some(0.0),
608                align: Align::Stretch,
609                overflow_hidden: true,
610                ..LayoutStyle::stack(Axis::Horizontal)
611            },
612            &row_children,
613            Paint::None,
614        );
615
616        let (up, down) = (state.page.clone(), state.page.clone());
617        // Whole percents: each zoom gets its pages drawn again, and a gesture passes through many.
618        let pinched = state.zoom.clone();
619        // A touchpad can send movements too small to change a whole percent. They add up.
620        let exact: Rc<std::cell::Cell<f32>> = cx.use_state(|| Rc::new(std::cell::Cell::new(0.0))).get();
621        cx.on_pinch(
622            row,
623            Rc::new(move |delta| {
624                let shown = pinched.get();
625                // The buttons may have changed the zoom since the last pinch.
626                let from = if (exact.get() - shown).abs() <= 0.005 { exact.get() } else { shown };
627                let next = (from * (1.0 + delta)).clamp(ZOOM_MIN, ZOOM_MAX);
628                exact.set(next);
629                let rounded = (next * 100.0).round() / 100.0;
630                if rounded != shown {
631                    pinched.set(rounded);
632                }
633            }),
634        );
635        cx.shortcut(Key::PageUp, false, Rc::new(move || up.update(|p| *p = p.saturating_sub(1))));
636        cx.shortcut(Key::PageDown, false, Rc::new(move || down.update(|p| *p = (*p + 1).min(pages - 1))));
637        cx.container(
638            LayoutStyle {
639                grow: 1.0,
640                min_height: Some(0.0),
641                overflow_hidden: true,
642                align: Align::Stretch,
643                ..LayoutStyle::stack(Axis::Vertical)
644            },
645            &[toolbar, line, row],
646            Paint::None,
647        )
648    }
649}
650
651fn with_alpha(color: Color, alpha: f32) -> Color {
652    Color::rgba(color.r, color.g, color.b, alpha)
653}
654
655/// One page, with what a search found on it.
656struct PageView {
657    scene: Rc<Scene>,
658    highlights: Vec<(Rect, bool)>,
659    highlight: Color,
660    highlight_current: Color,
661    zoom: f32,
662    width: f32,
663    height: f32,
664    text: String,
665}
666
667impl View for PageView {
668    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
669        let scene = self.scene.clone();
670        let highlights = self.highlights.clone();
671        let (normal, strong, zoom) = (self.highlight, self.highlight_current, self.zoom);
672        let canvas = Canvas::new(move |frame, out| {
673            draw_at(&scene, frame.origin, out);
674            for (rect, current) in &highlights {
675                let box_on_screen = Rect::new(
676                    frame.x() + rect.x() * zoom,
677                    frame.y() + rect.y() * zoom,
678                    rect.width() * zoom,
679                    rect.height() * zoom,
680                );
681                out.push_quad(Quad::new(box_on_screen, if *current { strong } else { normal }).radius(2.0));
682            }
683        })
684        .size(self.width, self.height);
685        let canvas = cx.child(&canvas);
686        let node = cx.container(LayoutStyle::stack(Axis::Vertical), &[canvas], Paint::None);
687        // Screen readers get the page's text; everyone else sees the drawing.
688        cx.semantics(node, Semantics::new(Role::Image).name(self.text.clone()));
689        node
690    }
691}
692
693/// A small picture of a page that goes to it when clicked.
694struct Thumbnail {
695    document: Rc<Document>,
696    state: ViewerState,
697    page: usize,
698    current: bool,
699    label: String,
700}
701
702impl View for Thumbnail {
703    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
704        let theme = *cx.theme();
705        let scene = self.state.cache.borrow_mut().scene(&self.document, self.page, THUMBNAIL_ZOOM);
706        let sheet = self.document.pages.get(self.page);
707        let (width, height) =
708            sheet.map_or((0.0, 0.0), |p| (p.width * THUMBNAIL_ZOOM, p.height * THUMBNAIL_ZOOM));
709        let picture = Canvas::new(move |frame, out| draw_at(&scene, frame.origin, out)).size(width, height);
710        let caption = Text::new((self.page + 1).to_string())
711            .style(TextStyle::Caption)
712            .foreground(if self.current { Semantic::Accent } else { Semantic::InkMuted });
713        let content = cx.child(&VStack::new((picture, caption)).spacing(SPACING.s1).align(Align::Center));
714        let border =
715            self.current.then(|| ravenrs_core::Border { width: 2.0, color: theme.color(Semantic::Accent) });
716        let hovered = cx.is_hover_within();
717        let fill = if hovered && !self.current {
718            theme.color(Semantic::SurfaceElevated)
719        } else {
720            Color::TRANSPARENT
721        };
722        let node = cx.container(
723            LayoutStyle { padding: Insets::uniform(SPACING.s1), ..LayoutStyle::stack(Axis::Vertical) },
724            &[content],
725            Paint::Quad(QuadPaint { corner_radius: 6.0, border, ..QuadPaint::new(fill) }),
726        );
727        let (page, target) = (self.state.page.clone(), self.page);
728        cx.on_click(node, Rc::new(move || page.set(target)));
729        cx.focusable(node, 6.0);
730        cx.cursor(node, CursorIcon::Pointer);
731        cx.semantics(node, Semantics::new(Role::Button).name(self.label.clone()).selected(self.current));
732        node
733    }
734}