1use 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
19const MARGIN: f32 = 16.0;
21const PAGE_GAP: f32 = 12.0;
23const LEAST_ZOOM: f32 = 0.1;
24const MOST_ZOOM: f32 = 8.0;
25
26#[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 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 pub fn page(mut self, page: Binding<usize>) -> Self {
82 self.page = Some(page);
83 self
84 }
85
86 pub fn zoom(mut self, zoom: Binding<ZoomView>) -> Self {
88 self.zoom = Some(zoom);
89 self
90 }
91
92 pub fn height(mut self, height: f32) -> Self {
94 self.height = Some(height);
95 self
96 }
97
98 pub fn label(mut self, label: impl Into<String>) -> Self {
100 self.label = Some(label.into());
101 self
102 }
103}
104
105struct 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 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 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 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 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}