T21b and T21c: Documents, reading and knowledge

- 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
This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 04:34:32 -04:00
1 parent 14a8990793
commit 9854cfa460
32 files changed
+4269 -411

No files matched your search

+368
View File
@@ -0,0 +1,368 @@
use std::{ops::Range, rc::Rc};
use gpui::{
App, ElementId, FontWeight, HighlightStyle, InteractiveElement, IntoElement, MouseButton,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, StrikethroughStyle,
Styled, StyledText, Window, div, prelude::*,
};
use jiff::Timestamp;
use similar::{ChangeTag, TextDiff};
use super::{markdown::words, render::MarkdownRenderer};
use crate::{
buttons::{Button, ButtonVariant, SegmentedControl},
data_display::Avatar,
forms::Pick,
theme::{ActiveTheme, AvatarSize, ControlSize, Radius, TextSize},
typography::{DateTimeText, format::plural},
};
/// How a stretch of a prose diff changed.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Change {
Added,
Removed,
}
/// `before` and `after` as one text, word by word: every word of both, the added and removed ones marked.
pub(crate) fn prose_diff(before: &str, after: &str) -> (String, Vec<(Range<usize>, Change)>) {
let diff = TextDiff::configure().diff_unicode_words(before, after);
let mut text = String::new();
let mut marks: Vec<(Range<usize>, Change)> = Vec::new();
for change in diff.iter_all_changes() {
let start = text.len();
text.push_str(change.value());
let kind = match change.tag() {
ChangeTag::Equal => continue,
ChangeTag::Insert => Change::Added,
ChangeTag::Delete => Change::Removed,
};
match marks.last_mut() {
Some((range, last)) if *last == kind && range.end == start => range.end = text.len(),
_ => marks.push((start..text.len(), kind)),
}
}
(text, marks)
}
/// How many words the stretches marked `kind` hold.
pub(crate) fn changed_words(text: &str, marks: &[(Range<usize>, Change)], kind: Change) -> usize {
marks
.iter()
.filter(|(_, change)| *change == kind)
.map(|(range, _)| words(&text[range.clone()]))
.sum()
}
/// What changed between two versions of a page, word by word: added words washed, removed ones struck through.
#[derive(IntoElement)]
pub struct PageHistoryDiff {
before: SharedString,
after: SharedString,
}
impl PageHistoryDiff {
pub fn new(before: impl Into<SharedString>, after: impl Into<SharedString>) -> Self {
Self {
before: before.into(),
after: after.into(),
}
}
}
impl RenderOnce for PageHistoryDiff {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let (text, marks) = prose_diff(&self.before, &self.after);
let (added, removed) = (
changed_words(&text, &marks, Change::Added),
changed_words(&text, &marks, Change::Removed),
);
let styles = marks.iter().map(|(range, change)| {
let style = match change {
Change::Added => HighlightStyle {
background_color: Some(colors.success.opacity(0.16)),
..HighlightStyle::default()
},
Change::Removed => HighlightStyle {
color: Some(colors.danger),
strikethrough: Some(StrikethroughStyle {
thickness: theme.underline_thickness(),
color: Some(colors.danger),
}),
..HighlightStyle::default()
},
};
(range.clone(), style)
});
div()
.flex()
.flex_col()
.gap_2()
.child(
div()
.flex()
.gap_2()
.text_size(theme.text_size(TextSize::Sm))
.child(
div()
.text_color(colors.success)
.child(format!("+{}", plural(added as u64, "word", "words"))),
)
.child(
div()
.text_color(colors.danger)
.child(format!("−{}", plural(removed as u64, "word", "words"))),
),
)
.child(StyledText::new(text).with_highlights(styles.collect::<Vec<_>>()))
}
}
/// A saved version of a page: when, by whom, and its text as markdown.
#[derive(Clone, Debug, PartialEq)]
pub struct Version {
pub at: Timestamp,
pub author: SharedString,
pub markdown: SharedString,
}
/// A page's versions, newest first, by time and author: pick one to read it or to see what it changed from the one before, and restore it.
#[derive(IntoElement)]
pub struct VersionHistory {
id: ElementId,
versions: Vec<Version>,
selected: usize,
on_select: Option<Pick>,
on_restore: Option<Pick>,
}
impl VersionHistory {
/// `versions` newest first.
pub fn new(
id: impl Into<ElementId>,
versions: impl IntoIterator<Item = Version>,
selected: usize,
) -> Self {
let versions: Vec<Version> = versions.into_iter().collect();
assert!(
selected < versions.len(),
"version {selected} of {}",
versions.len()
);
assert!(
versions.windows(2).all(|pair| pair[0].at >= pair[1].at),
"versions come newest first"
);
Self {
id: id.into(),
versions,
selected,
on_select: None,
on_restore: None,
}
}
pub fn on_select(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
pub fn on_restore(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_restore = Some(Rc::new(handler));
self
}
}
impl RenderOnce for VersionHistory {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let changes = window.use_keyed_state((self.id.clone(), "changes"), cx, |_, _| false);
let showing_changes = *changes.read(cx);
let theme = cx.theme();
let colors = theme.colors.clone();
let version = &self.versions[self.selected];
let before = self.versions.get(self.selected + 1);
let list = self.versions.iter().enumerate().map(|(ix, version)| {
let pick = self.on_select.clone();
let lit = ix == self.selected;
div()
.id((self.id.clone(), format!("version-{ix}")))
.flex()
.items_center()
.gap_2()
.px_2()
.py_1p5()
.rounded(theme.radius(Radius::Sm))
.cursor_pointer()
.when(lit, |row| row.bg(colors.active))
.when(!lit, |row| row.hover(|row| row.bg(colors.hover)))
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.when_some(pick, |row, pick| {
row.on_click(move |_, window, cx| pick(ix, window, cx))
})
.child(
Avatar::new(
(self.id.clone(), format!("author-{ix}")),
version.author.clone(),
)
.size(AvatarSize::Xs),
)
.child(
div()
.flex()
.flex_col()
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.child(DateTimeText::new(version.at).pattern("%b %-d, %H:%M")),
)
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.child(if ix == 0 {
format!("{} · current", version.author)
} else {
version.author.to_string()
}),
),
)
});
let view = SegmentedControl::new(
(self.id.clone(), "view"),
if showing_changes { "changes" } else { "read" },
)
.size(ControlSize::Sm)
.segment("read", "Read", None)
.segment("changes", "Changes", None)
.on_change({
let changes = changes.clone();
move |value, _, cx| {
changes.update(cx, |changes, cx| {
*changes = value.as_ref() == "changes";
cx.notify();
})
}
});
let restore = self
.on_restore
.clone()
.filter(|_| self.selected > 0)
.map(|restore| {
let at = self.selected;
Button::new((self.id.clone(), "restore"), "Restore this version")
.variant(ButtonVariant::Primary)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
log::info!("version history: restore {at}");
restore(at, window, cx)
})
});
let body = match (showing_changes, before) {
(true, Some(before)) => {
PageHistoryDiff::new(before.markdown.clone(), version.markdown.clone())
.into_any_element()
}
(true, None) => div()
.text_color(colors.fg_subtle)
.child("The first version; nothing came before it.")
.into_any_element(),
(false, _) => MarkdownRenderer::new(
(self.id.clone(), format!("text-{}", self.selected)),
version.markdown.clone(),
)
.into_any_element(),
};
div()
.flex()
.size_full()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(colors.border)
.overflow_hidden()
.child(
div()
.id((self.id.clone(), "versions"))
.flex_none()
.w(theme.sidebar_width(false))
.overflow_y_scroll()
.flex()
.flex_col()
.gap_0p5()
.p_2()
.border_r_1()
.border_color(colors.border)
.child(
div()
.px_2()
.pb_1()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.font_weight(FontWeight::MEDIUM)
.child("Versions"),
)
.children(list),
)
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.child(
div()
.flex()
.items_center()
.justify_between()
.gap_2()
.px_4()
.py_2()
.border_b_1()
.border_color(colors.border)
.child(view)
.children(restore),
)
.child(
div()
.id((self.id.clone(), "body"))
.flex_1()
.overflow_y_scroll()
.p_4()
.child(body),
),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_prose_diff_keeps_both_sides_and_marks_the_changes() {
let (text, marks) = prose_diff("A lift blends color.", "A lift blends every color gently.");
let shown: Vec<(&str, Change)> = marks
.iter()
.map(|(range, change)| (&text[range.clone()], *change))
.collect();
assert_eq!(text, "A lift blends every color gently.");
assert_eq!(
shown,
[("every ", Change::Added), (" gently", Change::Added)]
);
let (text, marks) = prose_diff("keep old words", "keep new words");
let shown: Vec<(&str, Change)> = marks
.iter()
.map(|(range, change)| (&text[range.clone()], *change))
.collect();
assert_eq!(shown, [("old", Change::Removed), ("new", Change::Added)]);
}
#[test]
fn punctuation_is_not_a_changed_word() {
let (text, marks) = prose_diff("white in gamma", "white, gently and in gamma");
assert_eq!(changed_words(&text, &marks, Change::Added), 2);
assert_eq!(changed_words(&text, &marks, Change::Removed), 0);
}
}