use std::rc::Rc; use gpui::{ App, Bounds, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Window, canvas, div, img, point, prelude::*, }; use super::{ find::{Find, reveal}, thumbs::PageThumbnailList, }; use crate::{ buttons::{ButtonVariant, IconButton, ToggleButton, ToggleItem}, documents::blocks::source, editor::FindWidget, overlays::HoverCard, primitives::{Icon, IconName}, shell::ToolbarSeparator, theme::{ActiveTheme, ControlSize, Elevation, IconSize, Radius, TextSize}, typography::tabular, }; /// Zoom steps, as shares of a page's own size. const STEPS: [f32; 9] = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]; /// A page the app drew: its picture, a file or a web address, and its size in points. #[derive(Clone, Debug, PartialEq)] pub struct DocPage { pub source: SharedString, pub width: f32, pub height: f32, } /// A stretch of a page a search found, in the page's points. #[derive(Clone, Debug, PartialEq)] pub struct PageHit { pub page: usize, pub bounds: Bounds, } /// A note pinned to a page, at a point in its points. #[derive(Clone, Debug, PartialEq)] pub struct PageNote { pub page: usize, pub at: Point, pub text: SharedString, } type OnNote = Rc, &mut Window, &mut App)>; type OnZoom = Rc; /// The zoom step past `zoom` in `direction`, when there is one. pub(crate) fn step(zoom: f32, up: bool) -> Option { if up { STEPS.into_iter().find(|step| *step > zoom + f32::EPSILON) } else { STEPS .into_iter() .rev() .find(|step| *step < zoom - f32::EPSILON) } } /// The first page whose bottom lies past `y`, or the last; the first before any layout. pub(crate) fn page_at(bottoms: &[Pixels], y: Pixels) -> usize { bottoms .iter() .position(|bottom| *bottom >= y) .unwrap_or(bottoms.len().saturating_sub(1)) } /// The page under the middle of the view, from the last layout. fn page_in_view(scroll: &ScrollHandle, count: usize) -> usize { let middle = scroll.bounds().center().y - scroll.offset().y; let bottoms: Vec = (0..count) .map_while(|ix| scroll.bounds_for_item(ix)) .map(|page| page.bottom()) .collect(); page_at(&bottoms, middle) } /// Pages the app drew, one under another on a desk: the page in view and its count, zoom by steps or to the width, the pages as thumbnails beside, a find bar whose hits light on the page, and notes pinned where a press puts them. #[derive(IntoElement)] pub struct DocumentViewer { id: ElementId, title: SharedString, pages: Rc>, zoom: f32, on_zoom: Option, thumbnails: bool, find: Option, notes: Vec, on_note: Option, } impl DocumentViewer { pub fn new( id: impl Into, title: impl Into, pages: impl Into>>, ) -> Self { let pages = pages.into(); for page in pages.iter() { assert!( page.width > 0.0 && page.height > 0.0, "page {} has no size", page.source ); } Self { id: id.into(), title: title.into(), pages, zoom: 1.0, on_zoom: None, thumbnails: false, find: None, notes: Vec::new(), on_note: None, } } /// Scales pages from their size in points; the owner keeps the zoom the steps and fit ask for. pub fn zoom( mut self, zoom: f32, on_zoom: impl Fn(f32, &mut Window, &mut App) + 'static, ) -> Self { assert!(zoom > 0.0, "a zoom of {zoom} shows nothing"); self.zoom = zoom; self.on_zoom = Some(Rc::new(on_zoom)); self } /// Shows the pages small beside the large ones. pub fn thumbnails(mut self) -> Self { self.thumbnails = true; self } /// The owner's find box, floating over the pages; the owner searches them, and `hits` light where they are, the current one scrolled into view. pub fn find(mut self, widget: FindWidget, hits: Vec, current: Option) -> Self { assert!( current.is_none_or(|current| current < hits.len()), "hit {current:?} of {}", hits.len() ); self.find = Some(Find { widget, hits, current, }); self } /// Notes on the pages; with `on_note`, a press on a page while notes are on asks the owner to pin one there. pub fn notes( mut self, notes: Vec, on_note: impl Fn(usize, Point, &mut Window, &mut App) + 'static, ) -> Self { self.notes = notes; self.on_note = Some(Rc::new(on_note)); self } } /// The viewer's own state: its scroll, the page in view, the width it fits, whether presses pin notes, and the hit last brought into view. #[derive(Default)] struct Desk { scroll: ScrollHandle, page: usize, width: f32, pinning: bool, shown_hit: Option, } impl RenderOnce for DocumentViewer { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let desk = window.use_keyed_state((self.id.clone(), "desk"), cx, |_, _| Desk::default()); let (scroll, page_now, width, pinning) = { let desk = desk.read(cx); (desk.scroll.clone(), desk.page, desk.width, desk.pinning) }; let zoom = self.zoom; if let Some(Find { hits, current: Some(current), .. }) = &self.find && desk.read(cx).shown_hit != Some(*current) { let hit = &hits[*current]; log::info!("document viewer: hit {current} on page {}", hit.page); reveal(&scroll, hit, zoom); desk.update(cx, |desk, _| desk.shown_hit = Some(*current)); } let theme = cx.theme(); let colors = theme.colors.clone(); let count = self.pages.len(); let go = |page: usize, scroll: &ScrollHandle| { let scroll = scroll.clone(); move |_: &gpui::ClickEvent, _: &mut Window, _: &mut App| { scroll.scroll_to_top_of_item(page) } }; let zoom_to = |next: Option, icon: IconName, tip: &'static str| { let on_zoom = self.on_zoom.clone(); IconButton::new((self.id.clone(), tip), icon) .variant(ButtonVariant::Ghost) .size(ControlSize::Sm) .tooltip(tip) .disabled(next.is_none() || on_zoom.is_none()) .when_some(next.zip(on_zoom), |button, (next, on_zoom)| { button.on_click(move |_, window, cx| on_zoom(next, window, cx)) }) }; let widest = self.pages.iter().map(|page| page.width).fold(0.0, f32::max); let fit = (width > 0.0 && widest > 0.0) .then(|| (width / widest).clamp(STEPS[0], STEPS[STEPS.len() - 1])); let header = div() .flex() .items_center() .gap_2() .px_3() .h(theme.control_height(ControlSize::Lg)) .border_b_1() .border_color(colors.border) .child( div() .flex_1() .min_w_0() .text_color(colors.fg_muted) .child(self.title.clone()), ) .child( IconButton::new((self.id.clone(), "previous"), IconName::ChevronUp) .variant(ButtonVariant::Ghost) .size(ControlSize::Sm) .tooltip("Previous page") .disabled(page_now == 0) .on_click(go(page_now.saturating_sub(1), &scroll)), ) .child( tabular(div().text_size(theme.text_size(TextSize::Sm))) .child(format!("{} of {count}", page_now + 1)), ) .child( IconButton::new((self.id.clone(), "next"), IconName::ChevronDown) .variant(ButtonVariant::Ghost) .size(ControlSize::Sm) .tooltip("Next page") .disabled(page_now + 1 >= count) .on_click(go((page_now + 1).min(count.saturating_sub(1)), &scroll)), ) .child(ToolbarSeparator) .child(zoom_to(step(zoom, false), IconName::ZoomOut, "Zoom out")) .child( tabular( div() .min_w_10() .text_center() .text_size(theme.text_size(TextSize::Sm)), ) .child(format!("{:.0}%", zoom * 100.0)), ) .child(zoom_to(step(zoom, true), IconName::ZoomIn, "Zoom in")) .child(zoom_to(fit, IconName::Maximize2, "Fit to width")) .when(self.on_note.is_some(), |header| { let desk = desk.clone(); header.child( ToggleButton::new( (self.id.clone(), "pin"), ToggleItem::new("notes") .icon(IconName::MessageSquare) .tooltip("Pin notes"), pinning, ) .size(ControlSize::Sm) .on_toggle(move |on, _, cx| { desk.update(cx, |desk, cx| { desk.pinning = on; cx.notify(); }) }), ) }); let (widget, hits, current) = match self.find { Some(Find { widget, hits, current, }) => (Some(widget), hits, current), None => (None, Vec::new(), None), }; let pages = self.pages.iter().enumerate().map(|(ix, page)| { let (on_note, scroll) = (self.on_note.clone().filter(|_| pinning), scroll.clone()); div() .id((self.id.clone(), format!("page-{ix}"))) .relative() .flex_none() .w(Pixels::from(page.width * zoom)) .h(Pixels::from(page.height * zoom)) .bg(colors.paper) .shadow(theme.elevation(Elevation::Raised)) .when(pinning, |page| page.cursor_crosshair()) .when_some(on_note, |page, on_note| { page.on_mouse_down(MouseButton::Left, move |event, window, cx| { let Some(bounds) = scroll.bounds_for_item(ix) else { return; }; let at = event.position - bounds.origin - scroll.offset(); let at = point(f32::from(at.x) / zoom, f32::from(at.y) / zoom); log::info!("document viewer: note asked on page {ix} at {at:?}"); on_note(ix, at, window, cx); }) }) .child( img(source(&page.source)) .id((self.id.clone(), format!("picture-{ix}"))) .size_full(), ) .children( hits.iter() .enumerate() .filter(|(_, hit)| hit.page == ix) .map(|(at, hit)| { div() .absolute() .left(Pixels::from(hit.bounds.origin.x * zoom)) .top(Pixels::from(hit.bounds.origin.y * zoom)) .w(Pixels::from(hit.bounds.size.width * zoom)) .h(Pixels::from(hit.bounds.size.height * zoom)) .rounded(theme.radius(Radius::Sm)) .bg(if current == Some(at) { colors.warning.opacity(0.45) } else { colors.warning.opacity(0.2) }) }), ) .children( self.notes .iter() .enumerate() .filter(|(_, note)| note.page == ix) .map(|(at, note)| { let text = note.text.clone(); div() .absolute() .left(Pixels::from(note.at.x * zoom)) .top(Pixels::from(note.at.y * zoom)) .child(HoverCard::new( (self.id.clone(), format!("note-{at}")), div() .size_6() .flex() .items_center() .justify_center() .rounded(theme.radius(Radius::Sm)) .bg(colors.warning) .child( Icon::new(IconName::MessageSquare) .size(IconSize::Xs) .color(colors.on_accent), ), move |_, _| div().max_w_64().child(text.clone()), )) }), ) }); let (tracked, measured) = (desk.clone(), desk.clone()); let body = div() .flex() .flex_1() .min_h_0() .when(self.thumbnails, |body| { let scroll = scroll.clone(); body.child( div() .flex_none() .border_r_1() .border_color(colors.border) .child( PageThumbnailList::new( (self.id.clone(), "thumbs"), self.pages.clone(), page_now, ) .on_pick(move |page, _, _| scroll.scroll_to_top_of_item(page)), ), ) }) .child( div() .relative() .flex_1() .min_w_0() .bg(colors.sunken) .child( div() .id((self.id.clone(), "pages")) .size_full() .overflow_scroll() .track_scroll(&scroll) .flex() .flex_col() .items_center() .gap_4() .p_6() .children(pages), ) .child( canvas( move |bounds, window, cx| { let (page, width) = { let desk = tracked.read(cx); ( page_in_view(&desk.scroll, count), f32::from(bounds.size.width), ) }; if (page, width) != (tracked.read(cx).page, tracked.read(cx).width) { measured.update(cx, |desk, cx| { desk.page = page; desk.width = width; cx.notify(); }); window.request_animation_frame(); } }, |_, _, _, _| {}, ) .absolute() .top_0() .left_0() .size_full(), ) .children( widget.map(|widget| div().absolute().top_2().right_2().child(widget)), ), ); div() .id(self.id) .size_full() .flex() .flex_col() .rounded(theme.radius(Radius::Lg)) .border_1() .border_color(colors.border) .overflow_hidden() .bg(colors.surface) .child(header) .child(body) } } #[cfg(test)] mod tests { use gpui::px; use super::{page_at, step}; #[test] fn the_page_in_view_is_the_one_under_the_middle() { let bottoms = [px(100.0), px(220.0), px(340.0)]; assert_eq!(page_at(&bottoms, px(50.0)), 0); assert_eq!(page_at(&bottoms, px(150.0)), 1); assert_eq!(page_at(&bottoms, px(500.0)), 2, "past the end is the last"); assert_eq!(page_at(&[], px(50.0)), 0, "no layout yet"); } #[test] fn zoom_steps_walk_the_scale_and_stop_at_its_ends() { assert_eq!(step(1.0, true), Some(1.25)); assert_eq!(step(1.0, false), Some(0.75)); assert_eq!( step(1.1, false), Some(1.0), "between steps goes to the nearer side" ); assert_eq!(step(4.0, true), None); assert_eq!(step(0.25, false), None); } }