1use std::cell::Cell;
4use std::fmt;
5use std::rc::Rc;
6
7use ravenrs_core::{
8 Color, CursorIcon, Effect, ImageData, Insets, Key, PathFill, Quad, Rect, Role, Scene, Size,
9};
10use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
11use ravenrs_reactive::{Binding, Observer};
12use ravenrs_style::{Semantic, TextStyle, Weight};
13
14use super::overlays::{fitted, gesture_paint, picture_inks};
15use super::{ZoomContent, ZoomControl, ZoomPanView, ZoomView, triangle};
16use crate::context::{BuildCx, DragPhase, NodeId, Paint, Placement, QuadPaint, Semantics};
17use crate::{ButtonSize, ButtonStyle, IconButton, IconName, Motion, View, util};
18
19#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
25#[non_exhaustive]
26#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
27pub enum CompareMode {
28 #[default]
30 SideBySide,
31 Divider,
33}
34
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
38pub enum CompareSide {
39 First,
41 Second,
43}
44
45const COMPARE_GAP: f32 = 2.0;
47const DIVIDER_KNOB: f32 = 15.0;
48
49fn paint_fitted(scene: &mut Scene, picture: &ZoomContent, content: Size, area: Rect, window: Rect) {
51 let placed = fitted(area, content);
52 let scale = placed.width() / content.width.max(f32::EPSILON);
53 picture.paint(scene, window, placed, scale, 0.0);
54}
55
56#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
78pub struct CompareView {
79 first: ImageData,
80 second: ImageData,
81 mode: CompareMode,
82 divider: Option<Binding<f32>>,
83 chosen: Option<Binding<CompareSide>>,
84 captions: Option<(Option<String>, Option<String>)>,
85 width: Option<f32>,
86 height: Option<f32>,
87 grow: f32,
88 label: Option<String>,
89}
90
91impl fmt::Debug for CompareView {
92 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93 f.debug_struct("CompareView").field("mode", &self.mode).finish_non_exhaustive()
94 }
95}
96
97#[cfg_attr(feature = "live", ravenrs_live::expose)]
98impl CompareView {
99 pub fn new(first: ImageData, second: ImageData) -> Self {
101 Self {
102 first,
103 second,
104 mode: CompareMode::SideBySide,
105 divider: None,
106 chosen: None,
107 captions: Some((None, None)),
108 width: None,
109 height: None,
110 grow: 1.0,
111 label: None,
112 }
113 }
114
115 pub fn mode(mut self, mode: CompareMode) -> Self {
117 self.mode = mode;
118 self
119 }
120
121 pub fn divider(mut self, divider: Binding<f32>) -> Self {
124 self.divider = Some(divider);
125 self
126 }
127
128 pub fn chosen(mut self, chosen: Binding<CompareSide>) -> Self {
130 self.chosen = Some(chosen);
131 self
132 }
133
134 pub fn captions(mut self, first: impl Into<String>, second: impl Into<String>) -> Self {
137 self.captions = Some((Some(first.into()), Some(second.into())));
138 self
139 }
140
141 pub fn no_captions(mut self) -> Self {
143 self.captions = None;
144 self
145 }
146
147 pub fn size(mut self, width: f32, height: f32) -> Self {
149 self.width = Some(width);
150 self.height = Some(height);
151 self.grow = 0.0;
152 self
153 }
154
155 pub fn grow(mut self, grow: f32) -> Self {
157 self.grow = grow;
158 self
159 }
160
161 pub fn label(mut self, label: impl Into<String>) -> Self {
163 self.label = Some(label.into());
164 self
165 }
166}
167
168fn compare_caption(cx: &mut BuildCx<'_>, text: &str, x: f32, chosen: bool) -> NodeId {
170 let theme = *cx.theme();
171 let (fill, ink) = if chosen {
172 (theme.color(Semantic::Accent), theme.color(Semantic::OnAccent))
173 } else {
174 (theme.color(Semantic::SurfaceElevated), theme.color(Semantic::Ink))
175 };
176 let mut parts = Vec::with_capacity(2);
177 if chosen {
178 parts.push(util::icon(cx, IconName::Check, 12.0, ink));
179 }
180 parts.push(util::label(cx, text, TextStyle::Caption, Some(Weight::MEDIUM), ink));
181 cx.container(
182 LayoutStyle {
183 absolute: Some((x, theme.spacing.s2)),
184 padding: Insets::symmetric(2.0, 8.0),
185 spacing: theme.spacing.s1,
186 align: Align::Center,
187 ..LayoutStyle::stack(Axis::Horizontal)
188 },
189 &parts,
190 Paint::Quad(QuadPaint { corner_radius: theme.radii.sm, ..QuadPaint::new(fill) }),
191 )
192}
193
194fn caption_width(cx: &mut BuildCx<'_>, text: &str, chosen: bool) -> f32 {
196 let theme = *cx.theme();
197 let attrs = util::attrs(&theme, TextStyle::Caption, Some(Weight::MEDIUM));
198 let extra = if chosen { 12.0 + theme.spacing.s1 } else { 0.0 };
199 cx.measure_text(text, &attrs).width.ceil() + 16.0 + extra
200}
201
202impl View for CompareView {
203 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
204 let theme = *cx.theme();
205 let labels = cx.labels();
206 let live = cx.use_state(|| false);
207 let is_live = live.get();
208 let own = cx.use_state(|| 0.5_f32);
209 let divider = self.divider.clone().unwrap_or_else(|| own.binding());
210 let at = if is_live { Observer::untracked(|| divider.get()) } else { divider.get() };
211 let chosen = self.chosen.as_ref().map(Binding::get);
212 let side_by_side = self.mode == CompareMode::SideBySide;
213 let (first, second) =
214 (ZoomContent::Image(self.first.clone()), ZoomContent::Image(self.second.clone()));
215 let size_of = |image: &ImageData| Size::new(image.width() as f32, image.height() as f32);
216 let (first_size, second_size) = (size_of(&self.first), size_of(&self.second));
217 let (background, accent) = (theme.color(Semantic::Canvas), theme.color(Semantic::Accent));
218 let (light, dark) = picture_inks(&theme);
219 let shown_divider = divider.clone();
220 let paint = gesture_paint(is_live, move |frame: Rect, scene: &mut Scene| {
221 scene.push_quad(Quad::new(frame, background));
222 if side_by_side {
223 let half = (frame.width() - COMPARE_GAP) / 2.0;
224 let left = Rect::new(frame.x(), frame.y(), half, frame.height());
225 let right = Rect::new(left.max_x() + COMPARE_GAP, frame.y(), half, frame.height());
226 paint_fitted(scene, &first, first_size, left, left);
227 paint_fitted(scene, &second, second_size, right, right);
228 if let Some(side) = chosen {
229 let pane = if side == CompareSide::First { left } else { right };
230 scene.push_quad(Quad::new(pane, Color::TRANSPARENT).border(2.0, accent));
231 }
232 return;
233 }
234 let x = frame.x() + frame.width() * shown_divider.get().clamp(0.0, 1.0);
235 let left = Rect::new(frame.x(), frame.y(), x - frame.x(), frame.height());
236 let right = Rect::new(x, frame.y(), frame.max_x() - x, frame.height());
237 paint_fitted(scene, &first, first_size, frame, left);
238 paint_fitted(scene, &second, second_size, frame, right);
239 scene.push_quad(Quad::new(
241 Rect::new(x - 1.5, frame.y(), 3.0, frame.height()),
242 dark.with_alpha(0.5),
243 ));
244 scene.push_quad(Quad::new(Rect::new(x - 0.5, frame.y(), 1.0, frame.height()), light));
245 let middle = frame.center().y;
246 let knob =
247 Rect::new(x - DIVIDER_KNOB, middle - DIVIDER_KNOB, DIVIDER_KNOB * 2.0, DIVIDER_KNOB * 2.0);
248 scene.push_quad(Quad::new(knob, light).radius(DIVIDER_KNOB).border(1.0, dark.with_alpha(0.5)));
249 for (tip, base) in [(-9.0, -3.5), (9.0, 3.5)] {
250 let arrow = triangle([(x + tip, middle), (x + base, middle - 4.5), (x + base, middle + 4.5)]);
251 scene.push_path(PathFill::solid(arrow, dark));
252 }
253 });
254 let width = cx.scroll_viewport().map_or(self.width.unwrap_or(0.0), |s| s.width);
255 let mut captions = Vec::with_capacity(2);
256 if let Some((a, b)) = &self.captions {
257 let a = a.clone().unwrap_or_else(|| labels.compare_first.clone());
258 let b = b.clone().unwrap_or_else(|| labels.compare_second.clone());
259 let inset = theme.spacing.s2;
260 let (a_chosen, b_chosen) =
261 (chosen == Some(CompareSide::First), chosen == Some(CompareSide::Second));
262 captions.push(compare_caption(cx, &a, inset, side_by_side && a_chosen));
263 let x = if side_by_side {
264 (width + COMPARE_GAP) / 2.0 + inset
265 } else {
266 width - inset - caption_width(cx, &b, false)
267 };
268 captions.push(compare_caption(cx, &b, x, side_by_side && b_chosen));
269 }
270 let node = cx.container(
271 LayoutStyle {
272 width: self.width,
273 height: self.height,
274 grow: self.grow,
275 align_self: Some(if self.width.is_some() { Align::Start } else { Align::Stretch }),
276 overflow_hidden: true,
277 ..LayoutStyle::stack(Axis::Vertical)
278 },
279 &captions,
280 paint,
281 );
282 cx.report_frame(node);
283 let name = self.label.clone().unwrap_or_else(|| labels.compare.clone());
284 cx.focusable(node, theme.radii.sm);
285 if side_by_side {
286 let mut semantics = Semantics::new(Role::Group).name(name);
287 if let (Some(side), Some((a, b))) = (chosen, &self.captions) {
288 let (a, b) = (
289 a.clone().unwrap_or_else(|| labels.compare_first.clone()),
290 b.clone().unwrap_or_else(|| labels.compare_second.clone()),
291 );
292 semantics = semantics.value(if side == CompareSide::First { a } else { b });
293 }
294 cx.semantics(node, semantics);
295 let Some(chosen) = self.chosen.clone() else { return node };
296 cx.cursor(node, CursorIcon::Pointer);
297 let clicked = chosen.clone();
298 cx.on_drag(
299 node,
300 Rc::new(move |d| {
301 if d.phase == DragPhase::Start {
302 let side = if d.position.x < d.frame.center().x {
303 CompareSide::First
304 } else {
305 CompareSide::Second
306 };
307 if clicked.get() != side {
308 clicked.set(side);
309 }
310 }
311 }),
312 );
313 cx.on_key(
314 node,
315 Rc::new(move |key, _| {
316 let side = match key {
317 Key::ArrowLeft => CompareSide::First,
318 Key::ArrowRight => CompareSide::Second,
319 _ => return false,
320 };
321 chosen.set(side);
322 true
323 }),
324 );
325 return node;
326 }
327 let percent = (at.clamp(0.0, 1.0) * 100.0).round();
328 cx.semantics(
329 node,
330 Semantics::new(Role::Slider).name(name).value(format!("{percent}%")).numeric(
331 f64::from(percent),
332 0.0,
333 100.0,
334 Some(1.0),
335 ),
336 );
337 cx.cursor(node, CursorIcon::ColResize);
338 let dragged = divider.clone();
339 cx.on_drag(
340 node,
341 Rc::new(move |d| {
342 match d.phase {
343 DragPhase::Start => live.set_if_changed(true),
344 DragPhase::End => live.set_if_changed(false),
345 DragPhase::Move => {}
346 }
347 let share = d.fraction_x();
348 if share != dragged.get() {
349 dragged.set(share);
350 }
351 }),
352 );
353 let middle = divider.clone();
354 cx.on_double_click(node, Rc::new(move || middle.set(0.5)));
355 cx.on_key(
356 node,
357 Rc::new(move |key, modifiers| {
358 let step = if modifiers.shift { 0.1 } else { 0.01 };
359 let at = divider.get();
360 let next = match key {
361 Key::ArrowLeft => at - step,
362 Key::ArrowRight => at + step,
363 Key::Home => 0.0,
364 Key::End => 1.0,
365 _ => return false,
366 };
367 divider.set(next.clamp(0.0, 1.0));
368 true
369 }),
370 );
371 node
372 }
373}
374
375#[derive(Clone, Debug)]
381#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
382pub struct LightboxItem {
383 image: ImageData,
384 caption: Option<String>,
385}
386
387#[cfg_attr(feature = "live", ravenrs_live::expose)]
388impl LightboxItem {
389 pub fn new(image: ImageData) -> Self {
391 Self { image, caption: None }
392 }
393
394 pub fn caption(mut self, caption: impl Into<String>) -> Self {
396 self.caption = Some(caption.into());
397 self
398 }
399}
400
401#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
421pub struct ImageLightbox {
422 open: Binding<Option<usize>>,
423 items: Rc<[LightboxItem]>,
424 wrap: bool,
425 label: Option<String>,
426}
427
428impl fmt::Debug for ImageLightbox {
429 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
430 f.debug_struct("ImageLightbox").field("items", &self.items.len()).finish_non_exhaustive()
431 }
432}
433
434#[cfg_attr(feature = "live", ravenrs_live::expose)]
435impl ImageLightbox {
436 pub fn new(open: Binding<Option<usize>>, items: impl IntoIterator<Item = LightboxItem>) -> Self {
438 Self { open, items: items.into_iter().collect(), wrap: false, label: None }
439 }
440
441 pub fn wrap(mut self, wrap: bool) -> Self {
443 self.wrap = wrap;
444 self
445 }
446
447 pub fn label(mut self, label: impl Into<String>) -> Self {
449 self.label = Some(label.into());
450 self
451 }
452}
453
454struct LightboxLayer<'a> {
455 open: &'a Binding<Option<usize>>,
456 items: &'a Rc<[LightboxItem]>,
457 index: usize,
458 wrap: bool,
459 label: Option<&'a str>,
460}
461
462fn lightbox_row(cx: &mut BuildCx<'_>, y: f32, justify: Justify, children: &[NodeId]) -> NodeId {
464 let viewport = cx.viewport();
465 let inset = cx.theme().spacing.s3;
466 cx.container(
467 LayoutStyle {
468 absolute: Some((0.0, y)),
469 width: Some(viewport.width),
470 padding: Insets::symmetric(0.0, inset),
471 justify,
472 align: Align::Center,
473 ..LayoutStyle::stack(Axis::Horizontal)
474 },
475 children,
476 Paint::None,
477 )
478}
479
480fn lightbox_chip(cx: &mut BuildCx<'_>, children: &[NodeId]) -> NodeId {
482 let theme = *cx.theme();
483 let node = cx.container(
484 LayoutStyle {
485 padding: Insets::symmetric(theme.spacing.s1, theme.spacing.s2),
486 spacing: theme.spacing.s2,
487 align: Align::Center,
488 ..LayoutStyle::stack(Axis::Horizontal)
489 },
490 children,
491 Paint::Quad(QuadPaint {
492 corner_radius: theme.radii.md,
493 ..QuadPaint::new(theme.color(Semantic::SurfaceElevated))
494 }),
495 );
496 cx.cursor(node, CursorIcon::Default);
498 node
499}
500
501impl View for LightboxLayer<'_> {
502 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
503 let theme = *cx.theme();
504 let labels = cx.labels();
505 let viewport = cx.viewport();
506 let count = self.items.len();
507 let index = self.index;
508 let item = &self.items[index];
509 let view = cx.use_state(ZoomView::fit);
510 let seen = cx.use_state(|| Rc::new(Cell::new(index))).get();
511 if seen.replace(index) != index {
513 view.set(ZoomView::fit());
514 }
515 let scrim = {
516 let color = theme.color(Semantic::Scrim);
517 let node = cx.container(
518 LayoutStyle {
519 absolute: Some((0.0, 0.0)),
520 width: Some(viewport.width),
521 height: Some(viewport.height),
522 ..LayoutStyle::leaf()
523 },
524 &[],
525 Paint::Quad(QuadPaint::new(color.with_alpha(color.a.max(0.92)))),
527 );
528 let m = theme.motion;
529 cx.motion(node, Motion::new().enter(Effect::hidden(), m.base).exit(Effect::hidden(), m.base));
530 node
531 };
532 let mut stage =
533 ZoomPanView::image(view.binding(), item.image.clone()).size(viewport.width, viewport.height);
534 stage.defer_arrows = true;
535 stage.autofocus = true;
536 let stage = cx.keyed_child(index as u64, &stage);
538 let stage = cx.container(
539 LayoutStyle {
540 absolute: Some((0.0, 0.0)),
541 width: Some(viewport.width),
542 height: Some(viewport.height),
543 ..LayoutStyle::stack(Axis::Vertical)
544 },
545 &[stage],
546 Paint::None,
547 );
548 let open = self.open.clone();
549 let wrap = self.wrap;
550 let target = move |by: isize| -> Option<usize> {
551 let next = index as isize + by;
552 if (0..count as isize).contains(&next) {
553 Some(next as usize)
554 } else if wrap && count > 0 {
555 Some(next.rem_euclid(count as isize) as usize)
556 } else {
557 None
558 }
559 };
560 let go = |by: isize| {
561 let open = open.clone();
562 Rc::new(move || {
563 if let Some(next) = target(by) {
564 open.set(Some(next));
565 }
566 })
567 };
568 let (previous, next) = (go(-1), go(1));
569 cx.shortcut(Key::ArrowLeft, false, previous.clone());
570 cx.shortcut(Key::ArrowRight, false, next.clone());
571 let to_first = open.clone();
572 cx.shortcut(Key::Home, false, Rc::new(move || to_first.set(Some(0))));
573 let to_last = open.clone();
574 cx.shortcut(Key::End, false, Rc::new(move || to_last.set(Some(count.saturating_sub(1)))));
575
576 let closing = open.clone();
577 let zoom = cx.child(&ZoomControl::new(view.binding()));
578 let close = cx.child(
579 &IconButton::new(IconName::X)
580 .label(labels.close.clone())
581 .size(ButtonSize::Small)
582 .on_click(move || closing.set(None)),
583 );
584 let top = lightbox_chip(cx, &[zoom, close]);
585 let top = lightbox_row(cx, theme.spacing.s3, Justify::End, &[top]);
586
587 let arrow = |cx: &mut BuildCx<'_>, icon: IconName, name: &str, enabled: bool, run: Rc<dyn Fn()>| {
588 let button = IconButton::new(icon)
589 .label(name.to_owned())
590 .style(ButtonStyle::Secondary)
591 .disabled(!enabled)
592 .on_click(move || run());
593 cx.child(&button)
594 };
595 let back =
596 arrow(cx, IconName::ChevronLeft, &labels.lightbox_previous, target(-1).is_some(), previous);
597 let onward = arrow(cx, IconName::ChevronRight, &labels.lightbox_next, target(1).is_some(), next);
598 let arrows = lightbox_row(cx, viewport.height / 2.0 - 18.0, Justify::SpaceBetween, &[back, onward]);
599
600 let ink = theme.color(Semantic::Ink);
601 let mut words = Vec::with_capacity(2);
602 if let Some(caption) = &item.caption {
603 words.push(util::label(cx, caption, TextStyle::Callout, None, ink));
604 }
605 let counter = labels.lightbox_counter(index + 1, count);
606 words.push(util::label(cx, &counter, TextStyle::Caption, None, theme.color(Semantic::InkMuted)));
607 let words = lightbox_chip(cx, &words);
608 let bottom = lightbox_row(cx, viewport.height - 44.0, Justify::Center, &[words]);
609
610 let root = cx.container(
611 LayoutStyle {
612 width: Some(viewport.width),
613 height: Some(viewport.height),
614 ..LayoutStyle::stack(Axis::Vertical)
615 },
616 &[scrim, stage, top, arrows, bottom],
617 Paint::None,
618 );
619 let name = self
620 .label
621 .map(str::to_owned)
622 .or_else(|| item.caption.clone())
623 .unwrap_or_else(|| labels.lightbox.clone());
624 cx.semantics(root, Semantics::new(Role::Dialog).name(name).value(counter).modal());
625 root
626 }
627}
628
629impl View for ImageLightbox {
630 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
631 let anchor = cx.empty();
632 let Some(index) = self.open.get().filter(|i| *i < self.items.len()) else { return anchor };
633 let open = self.open.clone();
634 cx.overlay(
635 anchor,
636 Placement::Fill,
637 &LightboxLayer {
638 open: &self.open,
639 items: &self.items,
640 index,
641 wrap: self.wrap,
642 label: self.label.as_deref(),
643 },
644 Some(Rc::new(move || open.set(None))),
645 );
646 anchor
647 }
648}