1mod 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;
53const THUMBNAILS_MAX: usize = 12;
55const SCENES_MAX: usize = 96;
57const CHROME_HEIGHT: f32 = 200.0;
59const ERRORS_SHOWN: usize = 3;
61
62struct 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#[derive(Clone, Debug)]
87#[non_exhaustive]
88pub struct ViewerLabels {
89 pub previous: String,
91 pub next: String,
93 pub page: String,
95 pub search: String,
97 pub previous_match: String,
99 pub next_match: String,
101 pub no_matches: String,
103 pub of: String,
105 pub thumbnails: String,
107 pub thumbnail_of_page: String,
109 pub zoom_out: String,
111 pub zoom_in: String,
113 pub fit_width: String,
115 pub print: String,
117 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 pub fn for_culture(culture: &str) -> Self {
130 if culture.starts_with("pt") { Self::pt_br() } else { Self::default() }
131 }
132
133 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 pub fn pt_br() -> Self {
156 Self::from_texts(&Texts::new(Language::BrazilianPortuguese))
157 }
158}
159
160#[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 pub fn new() -> Self {
189 Self::sharing(State::new(0), State::new(1.0))
190 }
191
192 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 pub fn page(&self) -> &State<usize> {
206 &self.page
207 }
208
209 pub fn zoom(&self) -> &State<f32> {
211 &self.zoom
212 }
213
214 pub fn query(&self) -> &State<String> {
216 &self.query
217 }
218
219 pub fn thumbnails(&self) -> &State<bool> {
221 &self.thumbnails
222 }
223}
224
225#[derive(Default)]
227struct Cache {
228 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 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
293fn 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
317type ToolbarItems = Rc<dyn Fn() -> Vec<AnyView>>;
319
320pub 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 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 pub fn labels(mut self, labels: ViewerLabels) -> Self {
351 self.labels = Some(labels);
352 self
353 }
354
355 pub fn print_button(mut self, show: bool) -> Self {
357 self.print_button = show;
358 self
359 }
360
361 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 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 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 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 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 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 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 let pinched = state.zoom.clone();
619 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 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
655struct 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 cx.semantics(node, Semantics::new(Role::Image).name(self.text.clone()));
689 node
690 }
691}
692
693struct 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}