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:
1 parent
14a8990793
commit
9854cfa460
32 files changed
+4269
-411
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user