- DocumentViewer: pages the app drew on a desk, the page under the view's middle counted, zoom steps and fit, thumbnails, notes pinned where a press lands, and the owner's FindWidget over the pages, hits lit and the current one revealed - PageThumbnailList, EpubReader (chapters, type size, progress), PrintPreview - MarkdownRenderer feeds TableOfContents through sections and outline - Footnote, TooltipTerm, Glossary, ReadingProgress, ZenMode - Backlinks, Favorites, TrashBin, PageCover, PageIcon, TemplatePicker, VersionHistory with PageHistoryDiff - editor::FindWidget shows the replace fold and option toggles only with handlers - block editor tests move to documents/blocks - icons: a-arrow-down, a-arrow-up - gallery: reading and knowledge sections; scripted find, changes and zen
488 lines
18 KiB
Rust
488 lines
18 KiB
Rust
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<f32>,
|
|
}
|
|
|
|
/// 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<f32>,
|
|
pub text: SharedString,
|
|
}
|
|
|
|
type OnNote = Rc<dyn Fn(usize, Point<f32>, &mut Window, &mut App)>;
|
|
type OnZoom = Rc<dyn Fn(f32, &mut Window, &mut App)>;
|
|
|
|
/// The zoom step past `zoom` in `direction`, when there is one.
|
|
pub(crate) fn step(zoom: f32, up: bool) -> Option<f32> {
|
|
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<Pixels> = (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<Vec<DocPage>>,
|
|
zoom: f32,
|
|
on_zoom: Option<OnZoom>,
|
|
thumbnails: bool,
|
|
find: Option<Find>,
|
|
notes: Vec<PageNote>,
|
|
on_note: Option<OnNote>,
|
|
}
|
|
|
|
impl DocumentViewer {
|
|
pub fn new(
|
|
id: impl Into<ElementId>,
|
|
title: impl Into<SharedString>,
|
|
pages: impl Into<Rc<Vec<DocPage>>>,
|
|
) -> 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<PageHit>, current: Option<usize>) -> 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<PageNote>,
|
|
on_note: impl Fn(usize, Point<f32>, &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<usize>,
|
|
}
|
|
|
|
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<f32>, 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);
|
|
}
|
|
}
|