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 fb993ffceb
commit cc82acbe20
32 files changed
+4269 -411

No files matched your search

+3
View File
@@ -4,6 +4,8 @@ mod keys;
mod kind;
mod media;
mod table;
#[cfg(all(test, feature = "test-support"))]
mod tests;
mod view;
use std::{collections::HashMap, rc::Rc};
@@ -15,6 +17,7 @@ use gpui::{
pub use kind::{Align, BlockKind, Media};
pub(crate) use kind::{matching, shortcut};
pub(crate) use media::source;
use super::markdown::markdown_highlights;
use crate::forms::{History, InputEvent, TextInput, code_highlights};
+373
View File
@@ -0,0 +1,373 @@
use gpui::{AppContext as _, Entity, Focusable, TestAppContext, VisualTestContext};
use super::{BlockData, BlockEditor, BlockKind};
use crate::{forms, theme::Theme};
fn open(
blocks: Vec<BlockData>,
cx: &mut TestAppContext,
) -> (Entity<BlockEditor>, &mut VisualTestContext) {
cx.update(|cx| {
Theme::init(cx);
forms::bind_keys(cx);
crate::documents::bind_keys(cx);
});
let (editor, cx) = cx.add_window_view(|window, cx| BlockEditor::new(blocks, window, cx));
cx.update(|window, _| window.activate_window());
settle(cx);
(editor, cx)
}
fn settle(cx: &mut VisualTestContext) {
cx.run_until_parked();
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
fn blocks(
editor: &Entity<BlockEditor>,
cx: &mut VisualTestContext,
) -> Vec<(BlockKind, Vec<String>)> {
cx.update(|_, cx| {
editor
.read(cx)
.blocks(cx)
.into_iter()
.map(|block| (block.kind, block.texts))
.collect()
})
}
/// Puts the caret in block `ix`'s first field at byte `at`.
fn caret(editor: &Entity<BlockEditor>, ix: usize, at: usize, cx: &mut VisualTestContext) {
cx.update(|window, cx| {
let field = editor.read(cx).blocks[ix].fields[0].clone();
field.update(cx, |field, cx| field.select(at..at, cx));
window.focus(&field.focus_handle(cx));
});
settle(cx);
}
fn text(kind: BlockKind, text: &str) -> (BlockKind, Vec<String>) {
(kind, vec![text.to_string()])
}
#[gpui::test]
fn enter_splits_prose_at_the_caret(cx: &mut TestAppContext) {
let (editor, cx) = open(vec![BlockData::new(BlockKind::Bullet, ["lift"])], cx);
caret(&editor, 0, 2, cx);
cx.simulate_keystrokes("enter");
settle(cx);
assert_eq!(
blocks(&editor, cx),
[text(BlockKind::Bullet, "li"), text(BlockKind::Bullet, "ft")]
);
}
#[gpui::test]
fn backspace_at_the_start_joins_up_and_undo_parts_them(cx: &mut TestAppContext) {
let (editor, cx) = open(
vec![
BlockData::new(BlockKind::Paragraph, ["tone "]),
BlockData::new(BlockKind::Paragraph, ["lift"]),
],
cx,
);
caret(&editor, 1, 0, cx);
cx.simulate_keystrokes("backspace");
settle(cx);
assert_eq!(
blocks(&editor, cx),
[text(BlockKind::Paragraph, "tone lift")]
);
cx.simulate_keystrokes("cmd-z");
settle(cx);
assert_eq!(
blocks(&editor, cx),
[
text(BlockKind::Paragraph, "tone "),
text(BlockKind::Paragraph, "lift")
],
"undo brings the joined block back"
);
}
#[gpui::test]
fn markdown_typed_at_a_paragraphs_start_changes_its_kind(cx: &mut TestAppContext) {
let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, [""])], cx);
caret(&editor, 0, 0, cx);
cx.simulate_input("## Lift");
settle(cx);
assert_eq!(blocks(&editor, cx), [text(BlockKind::Heading(2), "Lift")]);
}
#[gpui::test]
fn backspace_turns_a_heading_back_into_text(cx: &mut TestAppContext) {
let (editor, cx) = open(vec![BlockData::new(BlockKind::Heading(1), ["Lift"])], cx);
caret(&editor, 0, 0, cx);
cx.simulate_keystrokes("backspace");
settle(cx);
assert_eq!(blocks(&editor, cx), [text(BlockKind::Paragraph, "Lift")]);
}
#[gpui::test]
fn a_press_in_a_blocks_text_puts_the_caret_there(cx: &mut TestAppContext) {
let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["lift"])], cx);
cx.simulate_click(
gpui::point(gpui::px(300.0), gpui::px(10.0)),
gpui::Modifiers::none(),
);
settle(cx);
let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx));
assert!(focused.is_some(), "the press focuses the block's field");
cx.simulate_keystrokes("enter");
settle(cx);
assert_eq!(blocks(&editor, cx).len(), 2);
}
/// The editor at a fixed width inside a row, as a page holds it.
struct Hosted(Entity<BlockEditor>);
impl gpui::Render for Hosted {
fn render(
&mut self,
_: &mut gpui::Window,
_: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
use gpui::{ParentElement, Styled};
gpui::div()
.flex()
.child(gpui::div().w(gpui::px(760.0)).child(self.0.clone()))
}
}
#[gpui::test]
fn a_press_past_the_words_still_lands_in_the_block(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::init(cx);
forms::bind_keys(cx);
crate::documents::bind_keys(cx);
});
let (host, cx) = cx.add_window_view(|window, cx| {
let editor = cx.new(|cx| {
BlockEditor::new(
vec![BlockData::new(BlockKind::Paragraph, ["lift"])],
window,
cx,
)
});
Hosted(editor)
});
settle(cx);
cx.simulate_click(
gpui::point(gpui::px(700.0), gpui::px(10.0)),
gpui::Modifiers::none(),
);
settle(cx);
let editor = cx.update(|_, cx| host.read(cx).0.clone());
let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx));
assert!(focused.is_some(), "the press focuses the block's field");
}
#[gpui::test]
fn a_press_beside_a_blocks_text_puts_the_caret_at_its_end(cx: &mut TestAppContext) {
let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["lift"])], cx);
cx.simulate_click(
gpui::point(gpui::px(300.0), gpui::px(1.0)),
gpui::Modifiers::none(),
);
settle(cx);
let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx));
assert_eq!(
focused,
Some((editor.read_with(cx, |editor, _| editor.blocks[0].key), 0))
);
}
/// Puts block `ix`'s field `field` in focus with `range` selected.
fn select(
editor: &Entity<BlockEditor>,
ix: usize,
field: usize,
range: std::ops::Range<usize>,
cx: &mut VisualTestContext,
) {
cx.update(|window, cx| {
let field = editor.read(cx).blocks[ix].fields[field].clone();
field.update(cx, |field, cx| field.select(range, cx));
window.focus(&field.focus_handle(cx));
});
settle(cx);
}
#[gpui::test]
fn enter_over_a_selection_drops_it_and_splits_there(cx: &mut TestAppContext) {
let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["abcdef"])], cx);
select(&editor, 0, 0, 2..4, cx);
cx.simulate_keystrokes("enter");
settle(cx);
assert_eq!(
blocks(&editor, cx),
[
text(BlockKind::Paragraph, "ab"),
text(BlockKind::Paragraph, "ef")
]
);
cx.simulate_keystrokes("cmd-z");
settle(cx);
assert_eq!(
blocks(&editor, cx),
[text(BlockKind::Paragraph, "abcdef")],
"one step undoes it"
);
}
#[gpui::test]
fn undo_puts_the_caret_back_where_the_change_began(cx: &mut TestAppContext) {
let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["abcdef"])], cx);
caret(&editor, 0, 3, cx);
cx.simulate_keystrokes("enter");
settle(cx);
cx.simulate_keystrokes("cmd-z");
settle(cx);
let first = cx.update(|_, cx| editor.read(cx).blocks[0].key);
let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx));
assert_eq!(focused, Some((first, 0)));
cx.simulate_keystrokes("cmd-shift-z");
settle(cx);
assert_eq!(
blocks(&editor, cx),
[
text(BlockKind::Paragraph, "abc"),
text(BlockKind::Paragraph, "def")
],
"redo splits again"
);
cx.simulate_keystrokes("cmd-z");
settle(cx);
cx.simulate_input("x");
settle(cx);
assert_eq!(
blocks(&editor, cx),
[text(BlockKind::Paragraph, "abcxdef")],
"typing lands at the caret undo put back"
);
}
#[gpui::test]
fn keys_into_a_merged_cell_land_on_the_cell_that_shows(cx: &mut TestAppContext) {
let table = BlockKind::Table {
columns: 2,
merged: vec![(1, 1)],
};
let (editor, cx) = open(vec![BlockData::new(table, ["A", "B", "c", ""])], cx);
select(&editor, 0, 0, 1..1, cx);
cx.update(|window, cx| {
let field = editor.read(cx).blocks[0].fields[1].clone();
window.focus(&field.focus_handle(cx));
});
settle(cx);
cx.simulate_keystrokes("down");
settle(cx);
let key = cx.update(|_, cx| editor.read(cx).blocks[0].key);
let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx));
assert_eq!(
focused,
Some((key, 2)),
"down from B lands on the cell c spans"
);
}
#[gpui::test]
fn undoing_a_kind_change_brings_back_its_styling(cx: &mut TestAppContext) {
let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["**lift**"])], cx);
cx.update(|window, cx| {
let key = editor.read(cx).blocks[0].key;
editor.update(cx, |editor, cx| {
editor.turn_into(key, BlockKind::Code, window, cx)
});
});
settle(cx);
caret(&editor, 0, 0, cx);
cx.simulate_keystrokes("cmd-z");
settle(cx);
let bold = cx.update(|_, cx| {
let field = editor.read(cx).blocks[0].fields[0].clone();
field
.read(cx)
.highlights(cx)
.iter()
.any(|(_, highlight)| highlight.weight.is_some())
});
assert_eq!(
blocks(&editor, cx),
[text(BlockKind::Paragraph, "**lift**")]
);
assert!(bold, "the paragraph styles its markdown again");
}
#[gpui::test]
fn undo_of_typing_restores_the_selection_it_replaced(cx: &mut TestAppContext) {
let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["abcdef"])], cx);
select(&editor, 0, 0, 2..4, cx);
cx.simulate_keystrokes("x");
settle(cx);
assert_eq!(blocks(&editor, cx), [text(BlockKind::Paragraph, "abxef")]);
cx.simulate_keystrokes("cmd-z");
settle(cx);
let selection = cx.update(|_, cx| editor.read(cx).blocks[0].fields[0].read(cx).selection());
assert_eq!(blocks(&editor, cx), [text(BlockKind::Paragraph, "abcdef")]);
assert_eq!(selection, 2..4, "undo selects what the typing replaced");
}
#[gpui::test]
fn up_into_a_table_skips_its_hidden_cells(cx: &mut TestAppContext) {
let table = BlockKind::Table {
columns: 2,
merged: vec![(1, 1)],
};
let (editor, cx) = open(
vec![
BlockData::new(table, ["A", "B", "c", ""]),
BlockData::new(BlockKind::Paragraph, ["after"]),
],
cx,
);
caret(&editor, 1, 0, cx);
cx.simulate_keystrokes("up");
settle(cx);
let key = cx.update(|_, cx| editor.read(cx).blocks[0].key);
let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx));
assert_eq!(
focused,
Some((key, 2)),
"up lands on the cell that shows in the last row"
);
}
#[gpui::test]
fn undo_of_a_delete_or_a_paste_restores_the_selection(cx: &mut TestAppContext) {
let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["abcdef"])], cx);
let selection = |cx: &mut VisualTestContext| {
cx.update(|_, cx| editor.read(cx).blocks[0].fields[0].read(cx).selection())
};
select(&editor, 0, 0, 2..4, cx);
cx.simulate_keystrokes("backspace");
settle(cx);
assert_eq!(blocks(&editor, cx), [text(BlockKind::Paragraph, "abef")]);
cx.simulate_keystrokes("cmd-z");
settle(cx);
assert_eq!(selection(cx), 2..4, "undo of a delete selects what it took");
cx.update(|_, cx| cx.write_to_clipboard(gpui::ClipboardItem::new_string("xy".into())));
select(&editor, 0, 0, 2..4, cx);
cx.simulate_keystrokes("cmd-v");
settle(cx);
assert_eq!(blocks(&editor, cx), [text(BlockKind::Paragraph, "abxyef")]);
cx.simulate_keystrokes("cmd-z");
settle(cx);
assert_eq!(
selection(cx),
2..4,
"undo of a paste selects what it replaced"
);
}
+244
View File
@@ -0,0 +1,244 @@
use std::rc::Rc;
use gpui::{
App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce,
ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
};
use super::{reading::ReadingProgress, render::MarkdownRenderer};
use crate::{
buttons::{Button, ButtonVariant, IconButton, ToggleButton, ToggleItem},
primitives::IconName,
theme::{ActiveTheme, ContainerSize, ControlSize, Radius, TextSize},
typography::tabular,
};
/// Type sizes a reader steps through.
const SIZES: [TextSize; 4] = [TextSize::Sm, TextSize::Base, TextSize::Lg, TextSize::Xl];
/// A chapter of a book the app opened: its title and its text, as markdown.
#[derive(Clone, Debug, PartialEq)]
pub struct Chapter {
pub title: SharedString,
pub markdown: SharedString,
}
/// Where the reader is: the chapter, the type size, whether the chapters show, and the chapter's scroll.
struct Place {
chapter: usize,
size: usize,
contents: bool,
scroll: ScrollHandle,
}
/// A book read a chapter at a time: the chapter in a column sized for reading with how far through it the reader is, the chapters listed to jump between, the type a step larger or smaller, and the chapters before and after a press away.
#[derive(IntoElement)]
pub struct EpubReader {
id: ElementId,
title: SharedString,
chapters: Rc<Vec<Chapter>>,
}
impl EpubReader {
pub fn new(
id: impl Into<ElementId>,
title: impl Into<SharedString>,
chapters: impl Into<Rc<Vec<Chapter>>>,
) -> Self {
let chapters = chapters.into();
assert!(!chapters.is_empty(), "a book holds a chapter");
Self {
id: id.into(),
title: title.into(),
chapters,
}
}
}
fn set(place: &gpui::Entity<Place>, cx: &mut App, change: impl FnOnce(&mut Place)) {
place.update(cx, |place, cx| {
change(place);
cx.notify();
});
}
impl RenderOnce for EpubReader {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let place = window.use_keyed_state((self.id.clone(), "place"), cx, |_, _| Place {
chapter: 0,
size: 1,
contents: false,
scroll: ScrollHandle::new(),
});
let (chapter, size, contents, scroll) = {
let place = place.read(cx);
(
place.chapter.min(self.chapters.len() - 1),
place.size,
place.contents,
place.scroll.clone(),
)
};
let theme = cx.theme();
let colors = theme.colors.clone();
let count = self.chapters.len();
let turn = |to: usize| {
let (place, scroll) = (place.clone(), scroll.clone());
move |_: &gpui::ClickEvent, _: &mut Window, cx: &mut App| {
log::info!("epub reader: chapter {to}");
scroll.set_offset(gpui::Point::default());
set(&place, cx, |place| place.chapter = to);
}
};
let sized = |to: Option<usize>, icon: IconName, tip: &'static str| {
let place = place.clone();
IconButton::new((self.id.clone(), tip), icon)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip(tip)
.disabled(to.is_none())
.when_some(to, |button, to| {
button.on_click(move |_, _, cx| set(&place, cx, |place| place.size = to))
})
};
let shown = &self.chapters[chapter];
let listed = contents.then(|| {
div()
.flex_none()
.w(theme.sidebar_width(false))
.flex()
.flex_col()
.gap_0p5()
.p_2()
.border_r_1()
.border_color(colors.border)
.children(self.chapters.iter().enumerate().map(|(ix, entry)| {
div()
.id((self.id.clone(), format!("chapter-{ix}")))
.px_2()
.py_1()
.rounded(theme.radius(Radius::Sm))
.cursor_pointer()
.text_color(if ix == chapter {
colors.fg
} else {
colors.fg_muted
})
.when(ix == chapter, |row| row.bg(colors.active))
.hover(|row| row.bg(colors.hover))
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(turn(ix))
.child(entry.title.clone())
}))
});
let toggle = place.clone();
div()
.id(self.id.clone())
.size_full()
.flex()
.flex_col()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(colors.border)
.overflow_hidden()
.bg(colors.surface)
.child(
div()
.flex()
.items_center()
.gap_2()
.px_2()
.h(theme.control_height(ControlSize::Lg))
.child(
ToggleButton::new(
(self.id.clone(), "contents"),
ToggleItem::new("contents")
.icon(IconName::List)
.tooltip("Chapters"),
contents,
)
.size(ControlSize::Sm)
.on_toggle(move |on, _, cx| set(&toggle, cx, |place| place.contents = on)),
)
.child(
div()
.flex_1()
.min_w_0()
.text_color(colors.fg_muted)
.child(self.title.clone()),
)
.child(
tabular(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_subtle),
)
.child(format!("{} of {count}", chapter + 1)),
)
.child(sized(
size.checked_sub(1),
IconName::AArrowDown,
"Smaller type",
))
.child(sized(
Some(size + 1).filter(|size| *size < SIZES.len()),
IconName::AArrowUp,
"Larger type",
)),
)
.child(ReadingProgress::new(&scroll))
.child(
div().flex().flex_1().min_h_0().children(listed).child(
div()
.id((self.id.clone(), "page"))
.flex_1()
.min_w_0()
.overflow_y_scroll()
.track_scroll(&scroll)
.child(
div()
.mx_auto()
.max_w(theme.container_width(ContainerSize::Sm))
.px_8()
.py_8()
.flex()
.flex_col()
.gap_6()
.child(
MarkdownRenderer::new(
(self.id.clone(), format!("text-{chapter}")),
shown.markdown.clone(),
)
.text_size(SIZES[size]),
)
.child(
div()
.flex()
.justify_between()
.pt_4()
.border_t_1()
.border_color(colors.border)
.child(div().children(chapter.checked_sub(1).map(
|previous| {
Button::new(
(self.id.clone(), "previous"),
format!("← {}", self.chapters[previous].title),
)
.variant(ButtonVariant::Ghost)
.on_click(turn(previous))
},
)))
.child(div().children((chapter + 1 < count).then(|| {
Button::new(
(self.id.clone(), "next"),
format!("{} →", self.chapters[chapter + 1].title),
)
.variant(ButtonVariant::Ghost)
.on_click(turn(chapter + 1))
}))),
),
),
),
)
}
}
+26
View File
@@ -0,0 +1,26 @@
use gpui::{Pixels, ScrollHandle, point};
use super::viewer::PageHit;
use crate::editor::FindWidget;
/// The owner's find box over the pages, the hits it found and the current one.
pub(crate) struct Find {
pub widget: FindWidget,
pub hits: Vec<PageHit>,
pub current: Option<usize>,
}
/// Scrolls `hit` a third of the way down the view; before the pages are laid out, its page to the top.
pub(crate) fn reveal(scroll: &ScrollHandle, hit: &PageHit, zoom: f32) {
let Some(page) = scroll.bounds_for_item(hit.page) else {
scroll.scroll_to_top_of_item(hit.page);
return;
};
let view = scroll.bounds();
let target = page.top() + Pixels::from(hit.bounds.origin.y * zoom);
let offset = scroll.offset();
scroll.set_offset(point(
offset.x,
view.top() + view.size.height / 3.0 - target,
));
}
+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);
}
}
+418
View File
@@ -0,0 +1,418 @@
use std::{ops::Range, rc::Rc};
use gpui::{
App, ElementId, FontWeight, HighlightStyle, InteractiveElement, IntoElement, MouseButton,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, StyledText,
Window, div, prelude::*,
};
use jiff::Timestamp;
use crate::{
buttons::{Button, ButtonVariant, ConfirmButton, ConfirmMode, IconButton},
feedback::EmptyState,
forms::{OnValue, Pick, Run},
motion::Reorder,
primitives::IconName,
theme::{ActiveTheme, ControlSize, Radius, TextSize},
typography::{RelativeTime, format::plural},
};
/// A page that links here: its icon and title, and the words around the link, the link's range among them.
#[derive(Clone, Debug, PartialEq)]
pub struct Backlink {
pub icon: SharedString,
pub page: SharedString,
pub context: SharedString,
pub mention: Range<usize>,
}
/// The pages that link to this one, each with the words around its link, the link lit; a press opens that page.
#[derive(IntoElement)]
pub struct Backlinks {
id: ElementId,
links: Vec<Backlink>,
on_open: Option<Pick>,
}
impl Backlinks {
pub fn new(id: impl Into<ElementId>, links: impl IntoIterator<Item = Backlink>) -> Self {
let links: Vec<Backlink> = links.into_iter().collect();
for link in &links {
assert!(
link.mention.end <= link.context.len()
&& link.context.is_char_boundary(link.mention.start)
&& link.context.is_char_boundary(link.mention.end),
"the mention {:?} lies outside {:?}",
link.mention,
link.context
);
}
Self {
id: id.into(),
links,
on_open: None,
}
}
pub fn on_open(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_open = Some(Rc::new(handler));
self
}
}
impl RenderOnce for Backlinks {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
div()
.flex()
.flex_col()
.gap_1()
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(plural(self.links.len() as u64, "backlink", "backlinks")),
)
.children(self.links.into_iter().enumerate().map(|(ix, link)| {
let open = self.on_open.clone();
let lit = HighlightStyle {
color: Some(colors.link),
font_weight: Some(FontWeight::MEDIUM),
..HighlightStyle::default()
};
div()
.id((self.id.clone(), format!("link-{ix}")))
.flex()
.flex_col()
.gap_0p5()
.p_2()
.rounded(theme.radius(Radius::Md))
.cursor_pointer()
.hover(|row| row.bg(colors.hover))
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.when_some(open, |row, open| {
row.on_click(move |_, window, cx| {
log::info!("backlinks: open {ix}");
open(ix, window, cx)
})
})
.child(
div()
.flex()
.gap_1p5()
.font_weight(FontWeight::MEDIUM)
.child(link.icon.clone())
.child(link.page.clone()),
)
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(
StyledText::new(link.context.clone())
.with_highlights([(link.mention.clone(), lit)]),
),
)
}))
}
}
/// A page kept close at hand: its key, icon and title.
#[derive(Clone, Debug, PartialEq)]
pub struct Favorite {
pub key: SharedString,
pub icon: SharedString,
pub title: SharedString,
}
type OnMove = Rc<dyn Fn(usize, usize, &mut Window, &mut App)>;
/// Pages pinned for quick reach: a press opens one, dragging reorders them, and the star lets one go.
#[derive(IntoElement)]
pub struct Favorites {
id: ElementId,
pages: Vec<Favorite>,
on_open: Option<OnValue>,
on_unpin: Option<OnValue>,
on_move: Option<OnMove>,
}
impl Favorites {
pub fn new(id: impl Into<ElementId>, pages: impl IntoIterator<Item = Favorite>) -> Self {
Self {
id: id.into(),
pages: pages.into_iter().collect(),
on_open: None,
on_unpin: None,
on_move: None,
}
}
pub fn on_open(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_open = Some(Rc::new(handler));
self
}
pub fn on_unpin(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_unpin = Some(Rc::new(handler));
self
}
/// Asks to move the page at `from` to place `to`.
pub fn on_move(
mut self,
handler: impl Fn(usize, usize, &mut Window, &mut App) + 'static,
) -> Self {
self.on_move = Some(Rc::new(handler));
self
}
}
impl RenderOnce for Favorites {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let moved = self.on_move.clone();
let list = self.pages.iter().fold(
Reorder::new((self.id.clone(), "pages")).on_reorder(move |from, to, window, cx| {
if let Some(moved) = &moved {
log::info!("favorites: {from} to {to}");
moved(from, to, window, cx);
}
}),
|list, page| {
let (open, unpin, key) = (
self.on_open.clone(),
self.on_unpin.clone(),
page.key.clone(),
);
let group = SharedString::from(format!("favorite-{}", page.key));
let unpin_key = key.clone();
list.row(
page.key.clone(),
div()
.id((self.id.clone(), format!("page-{key}")))
.group(group.clone())
.flex()
.items_center()
.gap_2()
.px_2()
.py_1()
.rounded(theme.radius(Radius::Sm))
.cursor_pointer()
.hover(|row| row.bg(colors.hover))
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.when_some(open, |row, open| {
row.on_click(move |_, window, cx| open(&key, window, cx))
})
.child(page.icon.clone())
.child(div().flex_1().min_w_0().child(page.title.clone()))
.child(
div()
.opacity(0.0)
.group_hover(group, |star| star.opacity(1.0))
.child(
IconButton::new(
(self.id.clone(), format!("unpin-{unpin_key}")),
IconName::Star,
)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip("Remove from favorites")
.when_some(
unpin,
|button, unpin| {
button.on_click(move |_, window, cx| {
cx.stop_propagation();
unpin(&unpin_key, window, cx)
})
},
),
),
),
)
},
);
div()
.flex()
.flex_col()
.gap_1()
.child(
div()
.px_2()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.child("Favorites"),
)
.child(list)
}
}
/// A page in the trash: its key, icon, title, where it lived and when it went.
#[derive(Clone, Debug, PartialEq)]
pub struct Trashed {
pub key: SharedString,
pub icon: SharedString,
pub title: SharedString,
pub place: SharedString,
pub deleted: Timestamp,
}
/// Deleted pages, the newest first, with where each lived and when it went: restore one, delete one for good, or empty it all, each final step asked twice.
#[derive(IntoElement)]
pub struct TrashBin {
id: ElementId,
pages: Vec<Trashed>,
on_restore: Option<OnValue>,
on_delete: Option<OnValue>,
on_empty: Option<Run>,
}
impl TrashBin {
pub fn new(id: impl Into<ElementId>, pages: impl IntoIterator<Item = Trashed>) -> Self {
let mut pages: Vec<Trashed> = pages.into_iter().collect();
pages.sort_by_key(|page| std::cmp::Reverse(page.deleted));
Self {
id: id.into(),
pages,
on_restore: None,
on_delete: None,
on_empty: None,
}
}
pub fn on_restore(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_restore = Some(Rc::new(handler));
self
}
pub fn on_delete(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_delete = Some(Rc::new(handler));
self
}
pub fn on_empty(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_empty = Some(Rc::new(handler));
self
}
}
impl RenderOnce for TrashBin {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
if self.pages.is_empty() {
return EmptyState::new(
(self.id.clone(), "empty"),
IconName::Trash2,
"The trash is empty",
)
.body("Deleted pages wait here until they are emptied.")
.into_any_element();
}
let empty = self.on_empty.clone();
div()
.flex()
.flex_col()
.gap_1()
.child(
div()
.flex()
.items_center()
.justify_between()
.pb_1()
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(plural(self.pages.len() as u64, "page", "pages")),
)
.children(empty.map(|empty| {
ConfirmButton::new(
(self.id.clone(), "empty-all"),
"Empty trash",
ConfirmMode::Twice,
)
.size(ControlSize::Sm)
.on_confirm(move |window, cx| {
log::info!("trash: emptied");
empty(window, cx)
})
})),
)
.children(self.pages.into_iter().map(|page| {
let (restore, delete, key) = (
self.on_restore.clone(),
self.on_delete.clone(),
page.key.clone(),
);
let restore_key = key.clone();
div()
.flex()
.items_center()
.gap_3()
.px_2()
.py_1p5()
.rounded(theme.radius(Radius::Sm))
.child(page.icon.clone())
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.child(page.title.clone())
.child(
div()
.flex()
.gap_1()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.child(format!("In {} ·", page.place))
.child(RelativeTime::new(
(self.id.clone(), format!("when-{key}")),
page.deleted,
)),
),
)
.child(
Button::new((self.id.clone(), format!("restore-{key}")), "Restore")
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.when_some(restore, |button, restore| {
button.on_click(move |_, window, cx| {
log::info!("trash: restore {restore_key}");
restore(&restore_key, window, cx)
})
}),
)
.children(delete.map(|delete| {
let gone = key.clone();
ConfirmButton::new(
(self.id.clone(), format!("delete-{key}")),
"Delete",
ConfirmMode::Twice,
)
.size(ControlSize::Sm)
.on_confirm(move |window, cx| {
log::info!("trash: delete {gone} for good");
delete(&gone, window, cx)
})
}))
}))
.into_any_element()
}
}
+20 -1
View File
@@ -1,30 +1,49 @@
mod blocks;
mod counts;
mod epub;
mod find;
mod format;
mod history;
mod knowledge;
mod link;
mod markdown;
mod modes;
mod pages;
mod print;
mod reading;
mod render;
mod rich;
mod suggest;
mod templates;
#[cfg(all(test, feature = "test-support"))]
mod tests;
mod thumbs;
mod todo;
mod toolbar;
mod tree;
mod viewer;
use gpui::App;
pub use blocks::{Align, BlockData, BlockEditor, BlockEvent, BlockKind, Media};
pub use counts::{ReadingTime, WordCount};
pub use epub::{Chapter, EpubReader};
pub use format::{Format, format};
pub use history::{PageHistoryDiff, Version, VersionHistory};
pub use knowledge::{Backlink, Backlinks, Favorite, Favorites, TrashBin, Trashed};
pub use link::LinkEditor;
pub use markdown::markdown_highlights;
pub use modes::{MarkdownEditor, MarkdownMode};
pub use render::MarkdownRenderer;
pub use pages::{PageCover, PageIcon};
pub use print::{Margins, Paper, PrintPreview, PrintSettings, sheet};
pub use reading::{Footnote, Glossary, ReadingProgress, TooltipTerm, ZenMode};
pub use render::{MarkdownRenderer, outline};
pub use rich::RichTextEditor;
pub use templates::{Template, TemplatePicker};
pub use thumbs::PageThumbnailList;
pub use todo::{Checklist, TodoItem};
pub use toolbar::FixedFormatToolbar;
pub use viewer::{DocPage, DocumentViewer, PageHit, PageNote};
/// Binds the documents keys. `init` calls it.
pub(crate) fn bind_keys(cx: &mut App) {
+184
View File
@@ -0,0 +1,184 @@
use std::rc::Rc;
use gpui::{
App, ElementId, InteractiveElement, IntoElement, ObjectFit, ParentElement, RenderOnce,
SharedString, StatefulInteractiveElement, Styled, Window, div, relative,
};
use crate::{
buttons::{Button, ButtonVariant},
documents::blocks::source,
forms::{EmojiPicker, OnValue},
overlays::Popover,
primitives::{IconName, Image},
theme::{ActiveTheme, ControlSize, Radius},
};
type OnCover = Rc<dyn Fn(Option<SharedString>, &mut Window, &mut App)>;
/// A page's cover, a picture across its top; on hover, covers to switch to and a way to take it off.
#[derive(IntoElement)]
pub struct PageCover {
id: ElementId,
source: SharedString,
choices: Vec<SharedString>,
on_change: Option<OnCover>,
}
impl PageCover {
/// `source` is a file or a web address.
pub fn new(id: impl Into<ElementId>, source: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
source: source.into(),
choices: Vec::new(),
on_change: None,
}
}
/// Offers `choices` to switch to, and a way to take the cover off: `on_change` gets the new cover, or none.
pub fn choices(
mut self,
choices: impl IntoIterator<Item = impl Into<SharedString>>,
on_change: impl Fn(Option<SharedString>, &mut Window, &mut App) + 'static,
) -> Self {
self.choices = choices.into_iter().map(Into::into).collect();
self.on_change = Some(Rc::new(on_change));
self
}
}
impl RenderOnce for PageCover {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let group = SharedString::from(format!("cover-{}", self.id));
let tools =
self.on_change.clone().map(|on_change| {
let (choices, pick, remove) = (self.choices.clone(), on_change.clone(), on_change);
let id = self.id.clone();
div()
.absolute()
.bottom_2()
.right_2()
.flex()
.gap_1()
.opacity(0.0)
.group_hover(group.clone(), |tools| tools.opacity(1.0))
.child(
Popover::new((self.id.clone(), "change"), "Change cover", move |_, cx| {
let theme = cx.theme();
div().grid().grid_cols(3).gap_2().children(
choices.iter().enumerate().map(|(ix, choice)| {
let (pick, choice) = (pick.clone(), choice.clone());
div()
.id((id.clone(), format!("choice-{ix}")))
.w(theme.grid_column())
.h(theme.grid_column() * 0.6)
.rounded(theme.radius(Radius::Sm))
.overflow_hidden()
.cursor_pointer()
.on_click(move |_, window, cx| {
log::info!("page cover: {choice}");
pick(Some(choice.clone()), window, cx)
})
.child(
Image::new(
(id.clone(), format!("choice-picture-{ix}")),
source(&choices[ix]),
)
.size_full(),
)
}),
)
})
.icon(IconName::Image)
.variant(ButtonVariant::Secondary),
)
.child(
Button::new((self.id.clone(), "remove"), "Remove")
.variant(ButtonVariant::Secondary)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
log::info!("page cover: removed");
remove(None, window, cx)
}),
)
});
div()
.id(self.id.clone())
.group(group.clone())
.relative()
.w_full()
.h(theme.page_cover())
.child(
Image::new((self.id.clone(), "picture"), source(&self.source))
.fit(ObjectFit::Cover)
.size_full(),
)
.children(tools)
}
}
/// A page's icon, an emoji set large; on hover, a way to pick another.
#[derive(IntoElement)]
pub struct PageIcon {
id: ElementId,
emoji: SharedString,
on_change: Option<OnValue>,
}
impl PageIcon {
pub fn new(id: impl Into<ElementId>, emoji: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
emoji: emoji.into(),
on_change: None,
}
}
pub fn on_change(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for PageIcon {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let group = SharedString::from(format!("icon-{}", self.id));
let picker = self.on_change.map(|on_change| {
let (id, emoji) = (self.id.clone(), self.emoji.clone());
div()
.opacity(0.0)
.group_hover(group.clone(), |picker| picker.opacity(1.0))
.child(
Popover::new((self.id.clone(), "change"), "Change icon", move |_, _| {
EmojiPicker::new((id.clone(), "picker"))
.selected(emoji.clone())
.on_change(move |glyph, window, cx| {
log::info!("page icon: {glyph}");
on_change(&SharedString::from(glyph.to_string()), window, cx)
})
})
.icon(IconName::Smile)
.variant(ButtonVariant::Ghost),
)
});
div()
.id(self.id.clone())
.group(group.clone())
.flex()
.items_end()
.gap_2()
.child(
div()
.text_size(theme.page_icon())
.line_height(relative(1.0))
.child(self.emoji.clone()),
)
.children(picker)
}
}
+349
View File
@@ -0,0 +1,349 @@
use std::rc::Rc;
use gpui::{
App, ElementId, InteractiveElement, IntoElement, ObjectFit, ParentElement, RenderOnce,
StatefulInteractiveElement, Styled, Window, div,
};
use super::viewer::DocPage;
use crate::{
buttons::{Button, ButtonVariant, SegmentedControl},
documents::blocks::source,
forms::{Choice, NumberInput, Run, Select},
primitives::Image,
theme::{ActiveTheme, ControlSize, Elevation, Radius, TextSize},
typography::{format::plural, tabular},
};
/// Sheets the app can print on.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Paper {
#[default]
Letter,
A4,
Legal,
}
/// Space kept around what prints.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Margins {
None,
Narrow,
#[default]
Normal,
}
/// How a print goes: paper, turn, margins and copies.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PrintSettings {
pub paper: Paper,
pub landscape: bool,
pub margins: Margins,
pub copies: u32,
}
impl Default for PrintSettings {
fn default() -> Self {
Self {
paper: Paper::default(),
landscape: false,
margins: Margins::default(),
copies: 1,
}
}
}
const PAPERS: [(Paper, &str); 3] = [
(Paper::Letter, "Letter"),
(Paper::A4, "A4"),
(Paper::Legal, "Legal"),
];
const MARGINS: [(Margins, &str); 3] = [
(Margins::None, "None"),
(Margins::Narrow, "Narrow"),
(Margins::Normal, "Normal"),
];
/// Most copies one print makes.
const MOST_COPIES: f64 = 99.0;
type OnSettings = Rc<dyn Fn(PrintSettings, &mut Window, &mut App)>;
/// A print before it goes: the pages the app laid out for the settings, small on a desk, and the settings beside, paper, turn, margins and copies; each change asks the app for new pages.
#[derive(IntoElement)]
pub struct PrintPreview {
id: ElementId,
pages: Rc<Vec<DocPage>>,
settings: PrintSettings,
on_change: OnSettings,
on_print: OnSettings,
on_cancel: Run,
}
impl PrintPreview {
pub fn new(
id: impl Into<ElementId>,
pages: impl Into<Rc<Vec<DocPage>>>,
settings: PrintSettings,
on_change: impl Fn(PrintSettings, &mut Window, &mut App) + 'static,
on_print: impl Fn(PrintSettings, &mut Window, &mut App) + 'static,
on_cancel: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
pages: pages.into(),
settings,
on_change: Rc::new(on_change),
on_print: Rc::new(on_print),
on_cancel: Rc::new(on_cancel),
}
}
}
fn row(label: &'static str, control: impl IntoElement, cx: &App) -> impl IntoElement {
div()
.flex()
.flex_col()
.gap_1()
.child(
div()
.text_size(cx.theme().text_size(TextSize::Sm))
.text_color(cx.theme().colors.fg_muted)
.child(label),
)
.child(control)
}
impl RenderOnce for PrintPreview {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let settings = self.settings;
let paper = {
let on_change = self.on_change.clone();
Select::new(
(self.id.clone(), "paper"),
PAPERS.map(|(_, name)| Choice::new(name, name)),
)
.selected(
PAPERS
.iter()
.find(|(paper, _)| *paper == settings.paper)
.expect("a known paper")
.1,
)
.size(ControlSize::Sm)
.on_change(move |name, window, cx| {
let (paper, _) = PAPERS
.into_iter()
.find(|(_, named)| *named == name.as_ref())
.expect("a listed paper");
on_change(PrintSettings { paper, ..settings }, window, cx);
})
};
let turn = {
let on_change = self.on_change.clone();
SegmentedControl::new(
(self.id.clone(), "turn"),
if settings.landscape {
"landscape"
} else {
"portrait"
},
)
.size(ControlSize::Sm)
.segment("portrait", "Portrait", None)
.segment("landscape", "Landscape", None)
.on_change(move |value, window, cx| {
on_change(
PrintSettings {
landscape: value.as_ref() == "landscape",
..settings
},
window,
cx,
)
})
};
let margins = {
let on_change = self.on_change.clone();
Select::new(
(self.id.clone(), "margins"),
MARGINS.map(|(_, name)| Choice::new(name, name)),
)
.selected(
MARGINS
.iter()
.find(|(margins, _)| *margins == settings.margins)
.expect("known margins")
.1,
)
.size(ControlSize::Sm)
.on_change(move |name, window, cx| {
let (margins, _) = MARGINS
.into_iter()
.find(|(_, named)| *named == name.as_ref())
.expect("listed margins");
on_change(
PrintSettings {
margins,
..settings
},
window,
cx,
);
})
};
let copies = {
let on_change = self.on_change.clone();
NumberInput::new((self.id.clone(), "copies"), settings.copies as f64)
.range(1.0, MOST_COPIES)
.step(1.0)
.size(ControlSize::Sm)
.on_change(move |copies, window, cx| {
on_change(
PrintSettings {
copies: copies as u32,
..settings
},
window,
cx,
)
})
};
let (print, cancel) = (self.on_print.clone(), self.on_cancel.clone());
let thumb = theme.label_width();
div()
.flex()
.size_full()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(colors.border)
.overflow_hidden()
.child(
div()
.id((self.id.clone(), "desk"))
.flex_1()
.min_w_0()
.overflow_y_scroll()
.bg(colors.sunken)
.p_6()
.flex()
.flex_wrap()
.content_start()
.gap_4()
.children(self.pages.iter().enumerate().map(|(ix, page)| {
let (width, height) = sheet(settings);
div()
.flex()
.flex_col()
.items_center()
.gap_1()
.child(
div()
.w(thumb)
.h(thumb * (height / width))
.bg(colors.paper)
.shadow(theme.elevation(Elevation::Raised))
.p(thumb * margin(settings.margins))
.child(
Image::new(
(self.id.clone(), format!("sheet-{ix}")),
source(&page.source),
)
.fit(ObjectFit::Contain)
.size_full(),
),
)
.child(
tabular(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle),
)
.child(format!("{}", ix + 1)),
)
})),
)
.child(
div()
.flex_none()
.w(theme.sidebar_width(false))
.flex()
.flex_col()
.gap_4()
.p_4()
.border_l_1()
.border_color(colors.border)
.bg(colors.surface)
.child(div().text_color(colors.fg_muted).child(format!(
"{} · {}",
plural(self.pages.len() as u64, "page", "pages"),
plural(settings.copies as u64, "copy", "copies")
)))
.child(row("Paper", paper, cx))
.child(row("Turn", turn, cx))
.child(row("Margins", margins, cx))
.child(row("Copies", copies, cx))
.child(div().flex_1())
.child(
div()
.flex()
.justify_end()
.gap_2()
.child(
Button::new((self.id.clone(), "cancel"), "Cancel")
.variant(ButtonVariant::Ghost)
.on_click(move |_, window, cx| cancel(window, cx)),
)
.child(
Button::new((self.id.clone(), "print"), "Print")
.variant(ButtonVariant::Primary)
.on_click(move |_, window, cx| {
log::info!("print preview: print {settings:?}");
print(settings, window, cx)
}),
),
),
)
}
}
/// The share of a sheet's width each margin keeps.
fn margin(margins: Margins) -> f32 {
match margins {
Margins::None => 0.0,
Margins::Narrow => 0.04,
Margins::Normal => 0.08,
}
}
/// The paper's size in points, turned when landscape, for the app laying out pages.
pub fn sheet(settings: PrintSettings) -> (f32, f32) {
let (width, height) = match settings.paper {
Paper::Letter => (612.0, 792.0),
Paper::A4 => (595.0, 842.0),
Paper::Legal => (612.0, 1008.0),
};
if settings.landscape {
(height, width)
} else {
(width, height)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_sheet_turns_on_its_side() {
let settings = PrintSettings {
paper: Paper::A4,
landscape: true,
..PrintSettings::default()
};
assert_eq!(sheet(settings), (842.0, 595.0));
assert_eq!(sheet(PrintSettings::default()), (612.0, 792.0));
}
}
+348
View File
@@ -0,0 +1,348 @@
use std::{collections::BTreeMap, rc::Rc};
use gpui::{
AnyElement, App, Bounds, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement,
RenderOnce, ScrollHandle, SharedString, Styled, Window, canvas, div, fill, prelude::*, size,
};
use smallvec::SmallVec;
use crate::{
forms::Run,
layout::Collapsible,
overlays::HoverCard,
theme::{ActiveTheme, ContainerSize, TextSize},
};
/// How far a note's number rides above the line, in its own size.
const RAISE: f32 = 0.4;
/// A note's mark in running text, a small number; resting the pointer on it shows the note.
#[derive(IntoElement)]
pub struct Footnote {
id: ElementId,
number: usize,
note: SharedString,
}
impl Footnote {
pub fn new(id: impl Into<ElementId>, number: usize, note: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
number,
note: note.into(),
}
}
}
impl RenderOnce for Footnote {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let (link, note, number) = (theme.colors.link, self.note, self.number);
let prose = theme.prose_width();
HoverCard::new(
self.id,
div()
.relative()
.bottom(theme.text_size(TextSize::Xs) * RAISE)
.text_size(theme.text_size(TextSize::Xs))
.text_color(link)
.cursor_default()
.child(format!("{number}")),
move |_, _| div().max_w(prose).child(format!("{number}. {note}")),
)
}
}
/// A term whose meaning is a rest of the pointer away: underlined in dashes, its definition in a card.
#[derive(IntoElement)]
pub struct TooltipTerm {
id: ElementId,
term: SharedString,
meaning: SharedString,
}
impl TooltipTerm {
pub fn new(
id: impl Into<ElementId>,
term: impl Into<SharedString>,
meaning: impl Into<SharedString>,
) -> Self {
Self {
id: id.into(),
term: term.into(),
meaning: meaning.into(),
}
}
}
impl RenderOnce for TooltipTerm {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let (term, meaning, prose) = (self.term.clone(), self.meaning, theme.prose_width());
HoverCard::new(
self.id,
div()
.border_b_1()
.border_dashed()
.border_color(theme.colors.fg_subtle)
.child(self.term),
move |_, cx| {
div()
.max_w(prose)
.flex()
.flex_col()
.gap_1()
.child(div().font_weight(FontWeight::MEDIUM).child(term.clone()))
.child(
div()
.text_color(cx.theme().colors.fg_muted)
.child(meaning.clone()),
)
},
)
}
}
/// Terms and what they mean, in order, under the letter each starts with.
#[derive(IntoElement)]
pub struct Glossary {
entries: Vec<(SharedString, SharedString)>,
}
impl Glossary {
pub fn new(
entries: impl IntoIterator<Item = (impl Into<SharedString>, impl Into<SharedString>)>,
) -> Self {
Self {
entries: entries
.into_iter()
.map(|(term, meaning)| (term.into(), meaning.into()))
.collect(),
}
}
}
/// Entries under their first letter, the letters and each group's terms in order, ignoring case.
pub(crate) fn lettered(
entries: &[(SharedString, SharedString)],
) -> BTreeMap<char, Vec<(SharedString, SharedString)>> {
let mut groups: BTreeMap<char, Vec<(SharedString, SharedString)>> = BTreeMap::new();
for (term, meaning) in entries {
let letter = term
.chars()
.next()
.expect("a glossary term has a first letter")
.to_uppercase()
.next()
.expect("a letter has an upper case");
groups
.entry(letter)
.or_default()
.push((term.clone(), meaning.clone()));
}
for group in groups.values_mut() {
group.sort_by_key(|(term, _)| term.to_lowercase());
}
groups
}
impl RenderOnce for Glossary {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
div()
.flex()
.flex_col()
.gap_4()
.children(lettered(&self.entries).into_iter().map(|(letter, group)| {
div()
.flex()
.gap_4()
.child(
div()
.flex_none()
.w_6()
.text_size(theme.text_size(TextSize::Lg))
.font_weight(FontWeight::SEMIBOLD)
.text_color(colors.fg_subtle)
.child(letter.to_string()),
)
.child(div().flex_1().min_w_0().flex().flex_col().gap_2().children(
group.into_iter().map(|(term, meaning)| {
div()
.flex()
.flex_col()
.child(div().font_weight(FontWeight::MEDIUM).child(term))
.child(div().text_color(colors.fg_muted).child(meaning))
}),
))
}))
}
}
/// How far a reader is through what `scroll` scrolls, as a thin bar that fills from the left.
#[derive(IntoElement)]
pub struct ReadingProgress {
scroll: ScrollHandle,
}
impl ReadingProgress {
pub fn new(scroll: &ScrollHandle) -> Self {
Self {
scroll: scroll.clone(),
}
}
}
/// The share of `max` that `offset` has scrolled; all of it when nothing scrolls.
pub(crate) fn read_share(offset: f32, max: f32) -> f32 {
if max <= 0.0 {
return 1.0;
}
(-offset / max).clamp(0.0, 1.0)
}
impl RenderOnce for ReadingProgress {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let (track, bar) = (theme.colors.border, theme.colors.accent);
let scroll = self.scroll;
div()
.w_full()
.h(theme.progress_thickness())
.bg(track)
.child(
canvas(
|_, _, _| {},
move |bounds, _, window, _| {
let share = read_share(
f32::from(scroll.offset().y),
f32::from(scroll.max_offset().height),
);
let filled = Bounds::new(
bounds.origin,
size(bounds.size.width * share, bounds.size.height),
);
window.paint_quad(fill(filled, bar));
},
)
.size_full(),
)
}
}
/// Writing with nothing else in view: while on, the chrome folds away and the text sits in a centered column; Escape leaves.
#[derive(IntoElement)]
pub struct ZenMode {
id: ElementId,
on: bool,
chrome: SmallVec<[AnyElement; 2]>,
body: SmallVec<[AnyElement; 2]>,
on_exit: Option<Run>,
}
impl ZenMode {
pub fn new(id: impl Into<ElementId>, on: bool) -> Self {
Self {
id: id.into(),
on,
chrome: SmallVec::new(),
body: SmallVec::new(),
on_exit: None,
}
}
/// What folds away while on, such as a toolbar.
pub fn chrome(mut self, element: impl IntoElement) -> Self {
self.chrome.push(element.into_any_element());
self
}
/// Escape asks the owner to turn it off.
pub fn on_exit(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_exit = Some(Rc::new(handler));
self
}
}
impl ParentElement for ZenMode {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.body.extend(elements);
}
}
impl RenderOnce for ZenMode {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let (on, exit) = (self.on, self.on_exit);
div()
.id(self.id.clone())
.flex()
.flex_col()
.on_key_down(move |event, window, cx| {
if on
&& event.keystroke.key == "escape"
&& let Some(exit) = &exit
{
cx.stop_propagation();
log::info!("zen mode: leaves");
exit(window, cx);
}
})
.child(Collapsible::new((self.id.clone(), "chrome"), !on).children(self.chrome))
.child(
div().w_full().flex().justify_center().child(
div()
.w_full()
.when(on, |column| {
column
.max_w(theme.container_width(ContainerSize::Sm))
.py_8()
})
.children(self.body),
),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn terms_group_under_their_letter_in_order() {
let entries: Vec<(SharedString, SharedString)> = [
("lift", "a"),
("Accent", "b"),
("leading", "c"),
("alpha", "d"),
]
.into_iter()
.map(|(term, meaning)| (term.into(), meaning.into()))
.collect();
let groups = lettered(&entries);
let shape: Vec<(char, Vec<&str>)> = groups
.iter()
.map(|(letter, group)| {
(
*letter,
group.iter().map(|(term, _)| term.as_ref()).collect(),
)
})
.collect();
assert_eq!(
shape,
[
('A', vec!["Accent", "alpha"]),
('L', vec!["leading", "lift"])
]
);
}
#[test]
fn progress_is_the_share_scrolled() {
assert_eq!(read_share(-50.0, 200.0), 0.25);
assert_eq!(read_share(0.0, 0.0), 1.0, "a text that fits is read");
assert_eq!(read_share(-300.0, 200.0), 1.0);
}
}
+92 -6
View File
@@ -1,7 +1,7 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, FontStyle, FontWeight, HighlightStyle, InteractiveText,
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
IntoElement, ParentElement, RenderOnce, SharedString, StrikethroughStyle, Styled, StyledText,
UnderlineStyle, Window, div, prelude::*,
};
@@ -10,6 +10,7 @@ use pulldown_cmark::Alignment;
use super::tree::{Inline, Node, Style, parse};
use crate::{
editor::code_colors,
navigation::{Anchor, Sections},
primitives::{Icon, IconName},
theme::{ActiveTheme, IconSize, Radius, TextSize},
typography::Latex,
@@ -23,6 +24,8 @@ pub struct MarkdownRenderer {
id: ElementId,
source: SharedString,
on_link: Option<OnLink>,
size: TextSize,
sections: Option<Entity<Sections>>,
}
impl MarkdownRenderer {
@@ -31,9 +34,23 @@ impl MarkdownRenderer {
id: id.into(),
source: source.into(),
on_link: None,
size: TextSize::Base,
sections: None,
}
}
/// The body's size; headings keep theirs.
pub fn text_size(mut self, size: TextSize) -> Self {
self.size = size;
self
}
/// Marks each heading for a table of contents over `sections`; `outline` gives its entries.
pub fn sections(mut self, sections: &Entity<Sections>) -> Self {
self.sections = Some(sections.clone());
self
}
/// Takes link presses instead of the browser.
pub fn on_link(mut self, handler: impl Fn(&str, &mut Window, &mut App) + 'static) -> Self {
self.on_link = Some(Rc::new(handler));
@@ -46,6 +63,8 @@ struct Draw<'a> {
id: &'a ElementId,
on_link: &'a Option<OnLink>,
next: std::cell::Cell<usize>,
sections: &'a Option<Entity<Sections>>,
headings: std::cell::Cell<usize>,
}
impl Draw<'_> {
@@ -143,7 +162,7 @@ impl Draw<'_> {
3 => TextSize::Lg,
_ => TextSize::Md,
};
div()
let heading = div()
.pt_2()
.text_size(theme.text_size(size))
.font_weight(if *level <= 2 {
@@ -152,8 +171,15 @@ impl Draw<'_> {
FontWeight::MEDIUM
})
.text_color(colors.fg)
.child(self.inline(inline, cx))
.into_any_element()
.child(self.inline(inline, cx));
let ordinal = self.headings.get();
self.headings.set(ordinal + 1);
match self.sections {
Some(sections) => Anchor::new(sections, anchor(ordinal))
.child(heading)
.into_any_element(),
None => heading.into_any_element(),
}
}
Node::Quote(inner) => div()
.flex()
@@ -337,6 +363,39 @@ impl Draw<'_> {
}
}
/// The anchor of the document's `ordinal`th heading.
fn anchor(ordinal: usize) -> SharedString {
format!("heading-{ordinal}").into()
}
/// The headings of `markdown` in order, as a table of contents takes them: anchor, text and depth from 0.
pub fn outline(markdown: &str) -> Vec<(SharedString, SharedString, usize)> {
fn walk(nodes: &[Node], out: &mut Vec<(SharedString, SharedString, usize)>) {
for node in nodes {
match node {
Node::Heading(level, inline) => {
let ordinal = out.len();
out.push((
anchor(ordinal),
inline.text.clone().into(),
*level as usize - 1,
));
}
Node::Quote(inner) | Node::Footnote { body: inner, .. } => walk(inner, out),
Node::List { items, .. } => {
for (_, body) in items {
walk(body, out);
}
}
_ => {}
}
}
}
let mut out = Vec::new();
walk(&parse(markdown), &mut out);
out
}
impl RenderOnce for MarkdownRenderer {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
@@ -345,13 +404,40 @@ impl RenderOnce for MarkdownRenderer {
id: &self.id,
on_link: &self.on_link,
next: std::cell::Cell::new(0),
sections: &self.sections,
headings: std::cell::Cell::new(0),
};
div()
.flex()
.flex_col()
.gap_3()
.text_size(theme.text_size(TextSize::Base))
.line_height(theme.text_size(TextSize::Base) * 1.6)
.text_size(theme.text_size(self.size))
.line_height(theme.text_size(self.size) * 1.6)
.children(draw.nodes(&nodes, cx))
}
}
#[cfg(test)]
mod tests {
use super::outline;
#[test]
fn the_outline_numbers_headings_in_order_with_their_depth() {
let found =
outline("# Lift\n\ntext\n\n## In light\n\n> ### Quoted\n\n- #### Listed\n\n## In dark");
let shape: Vec<(&str, &str, usize)> = found
.iter()
.map(|(anchor, text, depth)| (anchor.as_ref(), text.as_ref(), *depth))
.collect();
assert_eq!(
shape,
[
("heading-0", "Lift", 0),
("heading-1", "In light", 1),
("heading-2", "Quoted", 2),
("heading-3", "Listed", 3),
("heading-4", "In dark", 1),
]
);
}
}
+294
View File
@@ -0,0 +1,294 @@
use std::rc::Rc;
use gpui::{
App, AppContext as _, Context, ElementId, Entity, FontWeight, InteractiveElement, IntoElement,
MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled,
Window, div, prelude::*,
};
use super::render::MarkdownRenderer;
use crate::{
buttons::{Button, ButtonVariant},
forms::{Choice, ChoiceChips, Input, Pick, TextInput},
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
};
/// Every category at once.
const ALL: &str = "All";
/// A page to start from: its name, icon, category, a line about it, and its text as markdown.
#[derive(Clone, Debug, PartialEq)]
pub struct Template {
pub name: SharedString,
pub icon: SharedString,
pub category: SharedString,
pub description: SharedString,
pub markdown: SharedString,
}
/// What the picker holds: its search, the category, and the template in view.
struct Picking {
search: Entity<TextInput>,
category: SharedString,
highlighted: usize,
}
/// The templates `query` and `category` keep, by index, in order.
pub(crate) fn kept(templates: &[Template], query: &str, category: &str) -> Vec<usize> {
let query = query.trim().to_lowercase();
templates
.iter()
.enumerate()
.filter(|(_, template)| category == ALL || template.category == category)
.filter(|(_, template)| {
query.is_empty()
|| template.name.to_lowercase().contains(&query)
|| template.description.to_lowercase().contains(&query)
})
.map(|(ix, _)| ix)
.collect()
}
/// Pages to start from: found by name, kept by category, the one in view shown as it will read; Use takes it.
#[derive(IntoElement)]
pub struct TemplatePicker {
id: ElementId,
templates: Rc<Vec<Template>>,
on_use: Option<Pick>,
}
impl TemplatePicker {
pub fn new(id: impl Into<ElementId>, templates: impl Into<Rc<Vec<Template>>>) -> Self {
let templates = templates.into();
assert!(!templates.is_empty(), "a template picker has templates");
Self {
id: id.into(),
templates,
on_use: None,
}
}
/// Gets the index of the template taken.
pub fn on_use(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_use = Some(Rc::new(handler));
self
}
}
impl RenderOnce for TemplatePicker {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let picking = window.use_keyed_state(
(self.id.clone(), "picking"),
cx,
|window, cx: &mut Context<Picking>| Picking {
search: cx.new(|cx| TextInput::new(window, cx).placeholder("Find a template")),
category: ALL.into(),
highlighted: 0,
},
);
let (search, category, highlighted) = {
let picking = picking.read(cx);
(
picking.search.clone(),
picking.category.clone(),
picking.highlighted,
)
};
let query = search.read(cx).text().to_string();
let shown = kept(&self.templates, &query, &category);
let current = shown
.iter()
.copied()
.find(|ix| *ix == highlighted)
.or(shown.first().copied());
let mut categories: Vec<SharedString> = vec![ALL.into()];
for template in self.templates.iter() {
if !categories.contains(&template.category) {
categories.push(template.category.clone());
}
}
let theme = cx.theme();
let colors = theme.colors.clone();
let chips = {
let picking = picking.clone();
ChoiceChips::new(
(self.id.clone(), "categories"),
categories
.iter()
.map(|name| Choice::new(name.clone(), name.clone())),
)
.selected([category.clone()])
.on_change(move |values, _, cx| {
let chosen = values.first().cloned().unwrap_or_else(|| ALL.into());
log::info!("template picker: {chosen}");
picking.update(cx, |picking, cx| {
picking.category = chosen;
cx.notify();
});
})
};
let rows = shown.iter().map(|&ix| {
let template = &self.templates[ix];
let lit = current == Some(ix);
let pick = picking.clone();
div()
.id((self.id.clone(), format!("template-{ix}")))
.flex()
.gap_2()
.p_2()
.rounded(theme.radius(Radius::Md))
.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())
.on_click(move |_, _, cx| {
pick.update(cx, |picking, cx| {
picking.highlighted = ix;
cx.notify();
})
})
.child(template.icon.clone())
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.child(
div()
.font_weight(FontWeight::MEDIUM)
.child(template.name.clone()),
)
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted)
.child(template.description.clone()),
),
)
});
let preview = match current {
Some(ix) => {
let take = self.on_use.clone();
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.child(
div()
.flex()
.items_center()
.justify_between()
.px_4()
.py_2()
.border_b_1()
.border_color(colors.border)
.child(
div()
.text_color(colors.fg_muted)
.child(self.templates[ix].name.clone()),
)
.child(
Button::new((self.id.clone(), "use"), "Use template")
.variant(ButtonVariant::Primary)
.size(ControlSize::Sm)
.disabled(take.is_none())
.when_some(take, |button, take| {
button.on_click(move |_, window, cx| {
log::info!("template picker: use {ix}");
take(ix, window, cx)
})
}),
),
)
.child(
div()
.id((self.id.clone(), "preview"))
.flex_1()
.overflow_y_scroll()
.p_4()
.child(MarkdownRenderer::new(
(self.id.clone(), format!("preview-{ix}")),
self.templates[ix].markdown.clone(),
)),
)
.into_any_element()
}
None => div()
.flex_1()
.flex()
.items_center()
.justify_center()
.text_color(colors.fg_subtle)
.child("No template matches")
.into_any_element(),
};
div()
.flex()
.size_full()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(colors.border)
.overflow_hidden()
.child(
div()
.flex_none()
.w(theme.sidebar_width(false))
.flex()
.flex_col()
.gap_2()
.p_2()
.border_r_1()
.border_color(colors.border)
.child(
Input::new(&search).size(ControlSize::Sm).prefix(
Icon::new(IconName::Search)
.size(IconSize::Sm)
.color(colors.fg_subtle),
),
)
.child(chips)
.child(
div()
.id((self.id.clone(), "list"))
.flex_1()
.overflow_y_scroll()
.flex()
.flex_col()
.gap_0p5()
.children(rows),
),
)
.child(preview)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn template(name: &str, category: &str, description: &str) -> Template {
Template {
name: name.to_string().into(),
icon: "📄".into(),
category: category.to_string().into(),
description: description.to_string().into(),
markdown: "".into(),
}
}
#[test]
fn templates_keep_by_category_and_query() {
let all = [
template("Meeting notes", "Team", "Agenda and actions"),
template("Roadmap", "Product", "Quarters ahead"),
template("Retro", "Team", "What went well"),
];
assert_eq!(kept(&all, "", ALL), [0, 1, 2]);
assert_eq!(kept(&all, "", "Team"), [0, 2]);
assert_eq!(kept(&all, "went", ALL), [2], "a description counts");
assert!(kept(&all, "zzz", ALL).is_empty());
}
}
+114 -342
View File
@@ -1,21 +1,66 @@
use gpui::{AppContext as _, Entity, Focusable, TestAppContext, VisualTestContext};
use std::{cell::RefCell, rc::Rc};
use super::{BlockData, BlockEditor, BlockKind};
use crate::{forms, theme::Theme};
use gpui::{
AppContext as _, Bounds, Context, Entity, IntoElement, Modifiers, ParentElement, Pixels, Point,
Render, ScrollDelta, ScrollWheelEvent, Styled, TestAppContext, TouchPhase, VisualTestContext,
Window, div, point, px, size,
};
fn open(
blocks: Vec<BlockData>,
cx: &mut TestAppContext,
) -> (Entity<BlockEditor>, &mut VisualTestContext) {
cx.update(|cx| {
Theme::init(cx);
forms::bind_keys(cx);
super::bind_keys(cx);
use super::{DocPage, DocumentViewer, PageHit};
use crate::{
editor::FindWidget,
forms::TextInput,
theme::{ActiveTheme, ControlSize, Theme},
};
type Noted = Rc<RefCell<Option<(usize, Point<f32>)>>>;
/// Three tall pages with one hit on the second; a press while pinning records the note asked.
struct Reader {
query: Entity<TextInput>,
current: Option<usize>,
noted: Noted,
}
impl Render for Reader {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let pages: Vec<DocPage> = (0..3)
.map(|ix| DocPage {
source: format!("page-{ix}.png").into(),
width: 400.0,
height: 1000.0,
})
.collect();
let hit = PageHit {
page: 1,
bounds: Bounds::new(point(40.0, 400.0), size(60.0, 12.0)),
};
let noted = self.noted.clone();
div().size_full().child(
DocumentViewer::new("viewer", "lift.pdf", pages)
.find(
FindWidget::new("find", &self.query, self.current, 1),
vec![hit],
self.current,
)
.notes(Vec::new(), move |page, at, _, _| {
*noted.borrow_mut() = Some((page, at))
}),
)
}
}
fn open(cx: &mut TestAppContext) -> (Entity<Reader>, Noted, &mut VisualTestContext) {
cx.update(Theme::init);
let noted = Noted::default();
let seen = noted.clone();
let (reader, cx) = cx.add_window_view(move |window, cx| Reader {
query: cx.new(|cx| TextInput::new(window, cx)),
current: None,
noted: seen,
});
let (editor, cx) = cx.add_window_view(|window, cx| BlockEditor::new(blocks, window, cx));
cx.update(|window, _| window.activate_window());
settle(cx);
(editor, cx)
(reader, noted, cx)
}
fn settle(cx: &mut VisualTestContext) {
@@ -24,350 +69,77 @@ fn settle(cx: &mut VisualTestContext) {
cx.run_until_parked();
}
fn blocks(
editor: &Entity<BlockEditor>,
cx: &mut VisualTestContext,
) -> Vec<(BlockKind, Vec<String>)> {
cx.update(|_, cx| {
editor
.read(cx)
.blocks(cx)
.into_iter()
.map(|block| (block.kind, block.texts))
.collect()
})
/// A point on the pages, below the header and the find bar, at the view's middle.
fn on_pages(cx: &mut VisualTestContext) -> Point<Pixels> {
cx.update(|window, _| point(window.viewport_size().width / 2.0, px(300.0)))
}
/// Puts the caret in block `ix`'s first field at byte `at`.
fn caret(editor: &Entity<BlockEditor>, ix: usize, at: usize, cx: &mut VisualTestContext) {
cx.update(|window, cx| {
let field = editor.read(cx).blocks[ix].fields[0].clone();
field.update(cx, |field, cx| field.select(at..at, cx));
window.focus(&field.focus_handle(cx));
fn wheel(down: f32, cx: &mut VisualTestContext) {
let at = on_pages(cx);
cx.simulate_event(ScrollWheelEvent {
position: at,
delta: ScrollDelta::Pixels(point(px(0.0), px(-down))),
modifiers: Modifiers::none(),
touch_phase: TouchPhase::Moved,
});
settle(cx);
}
fn text(kind: BlockKind, text: &str) -> (BlockKind, Vec<String>) {
(kind, vec![text.to_string()])
/// Turns pinning on with the header's last button, then presses the pages; returns the note asked.
fn pin(cx: &mut VisualTestContext, noted: &Noted) -> (usize, Point<f32>) {
let toggle = cx.update(|window, cx| {
let header = cx
.theme()
.control_height(ControlSize::Lg)
.to_pixels(window.rem_size());
point(
window.viewport_size().width - px(17.0),
px(1.0) + header / 2.0,
)
});
cx.simulate_click(toggle, Modifiers::none());
settle(cx);
let at = on_pages(cx);
cx.simulate_click(at, Modifiers::none());
settle(cx);
noted
.borrow_mut()
.take()
.expect("a press while pinning asks for a note")
}
#[gpui::test]
fn enter_splits_prose_at_the_caret(cx: &mut TestAppContext) {
let (editor, cx) = open(vec![BlockData::new(BlockKind::Bullet, ["lift"])], cx);
caret(&editor, 0, 2, cx);
cx.simulate_keystrokes("enter");
fn a_note_lands_where_the_press_is_on_a_scrolled_page(cx: &mut TestAppContext) {
let (_, noted, cx) = open(cx);
let (page, top) = pin(cx, &noted);
wheel(150.0, cx);
let at = on_pages(cx);
cx.simulate_click(at, Modifiers::none());
settle(cx);
assert_eq!(
blocks(&editor, cx),
[text(BlockKind::Bullet, "li"), text(BlockKind::Bullet, "ft")]
);
let (scrolled_page, scrolled) = noted.borrow_mut().take().expect("pinning stays on");
assert_eq!((scrolled_page, page), (0, 0));
assert_eq!(scrolled.x, top.x);
assert_eq!(scrolled.y, top.y + 150.0, "the press counts the scroll");
}
#[gpui::test]
fn backspace_at_the_start_joins_up_and_undo_parts_them(cx: &mut TestAppContext) {
let (editor, cx) = open(
vec![
BlockData::new(BlockKind::Paragraph, ["tone "]),
BlockData::new(BlockKind::Paragraph, ["lift"]),
],
cx,
);
caret(&editor, 1, 0, cx);
cx.simulate_keystrokes("backspace");
settle(cx);
assert_eq!(
blocks(&editor, cx),
[text(BlockKind::Paragraph, "tone lift")]
);
cx.simulate_keystrokes("cmd-z");
settle(cx);
assert_eq!(
blocks(&editor, cx),
[
text(BlockKind::Paragraph, "tone "),
text(BlockKind::Paragraph, "lift")
],
"undo brings the joined block back"
);
}
#[gpui::test]
fn markdown_typed_at_a_paragraphs_start_changes_its_kind(cx: &mut TestAppContext) {
let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, [""])], cx);
caret(&editor, 0, 0, cx);
cx.simulate_input("## Lift");
settle(cx);
assert_eq!(blocks(&editor, cx), [text(BlockKind::Heading(2), "Lift")]);
}
#[gpui::test]
fn backspace_turns_a_heading_back_into_text(cx: &mut TestAppContext) {
let (editor, cx) = open(vec![BlockData::new(BlockKind::Heading(1), ["Lift"])], cx);
caret(&editor, 0, 0, cx);
cx.simulate_keystrokes("backspace");
settle(cx);
assert_eq!(blocks(&editor, cx), [text(BlockKind::Paragraph, "Lift")]);
}
#[gpui::test]
fn a_press_in_a_blocks_text_puts_the_caret_there(cx: &mut TestAppContext) {
let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["lift"])], cx);
cx.simulate_click(
gpui::point(gpui::px(300.0), gpui::px(10.0)),
gpui::Modifiers::none(),
);
settle(cx);
let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx));
assert!(focused.is_some(), "the press focuses the block's field");
cx.simulate_keystrokes("enter");
settle(cx);
assert_eq!(blocks(&editor, cx).len(), 2);
}
/// The editor at a fixed width inside a row, as a page holds it.
struct Hosted(Entity<BlockEditor>);
impl gpui::Render for Hosted {
fn render(
&mut self,
_: &mut gpui::Window,
_: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
use gpui::{ParentElement, Styled};
gpui::div()
.flex()
.child(gpui::div().w(gpui::px(760.0)).child(self.0.clone()))
/// Scrolls `down`, makes the hit current, then pins a note at the same window point.
fn revealed(down: f32, cx: &mut TestAppContext) -> (usize, Point<f32>) {
let (reader, noted, cx) = open(cx);
if down > 0.0 {
wheel(down, cx);
}
}
#[gpui::test]
fn a_press_past_the_words_still_lands_in_the_block(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::init(cx);
forms::bind_keys(cx);
super::bind_keys(cx);
});
let (host, cx) = cx.add_window_view(|window, cx| {
let editor = cx.new(|cx| {
BlockEditor::new(
vec![BlockData::new(BlockKind::Paragraph, ["lift"])],
window,
cx,
)
});
Hosted(editor)
reader.update(cx, |reader, cx| {
reader.current = Some(0);
cx.notify();
});
settle(cx);
cx.simulate_click(
gpui::point(gpui::px(700.0), gpui::px(10.0)),
gpui::Modifiers::none(),
);
settle(cx);
let editor = cx.update(|_, cx| host.read(cx).0.clone());
let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx));
assert!(focused.is_some(), "the press focuses the block's field");
pin(cx, &noted)
}
#[gpui::test]
fn a_press_beside_a_blocks_text_puts_the_caret_at_its_end(cx: &mut TestAppContext) {
let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["lift"])], cx);
cx.simulate_click(
gpui::point(gpui::px(300.0), gpui::px(1.0)),
gpui::Modifiers::none(),
);
settle(cx);
let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx));
assert_eq!(
focused,
Some((editor.read_with(cx, |editor, _| editor.blocks[0].key), 0))
);
}
/// Puts block `ix`'s field `field` in focus with `range` selected.
fn select(
editor: &Entity<BlockEditor>,
ix: usize,
field: usize,
range: std::ops::Range<usize>,
cx: &mut VisualTestContext,
) {
cx.update(|window, cx| {
let field = editor.read(cx).blocks[ix].fields[field].clone();
field.update(cx, |field, cx| field.select(range, cx));
window.focus(&field.focus_handle(cx));
});
settle(cx);
}
#[gpui::test]
fn enter_over_a_selection_drops_it_and_splits_there(cx: &mut TestAppContext) {
let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["abcdef"])], cx);
select(&editor, 0, 0, 2..4, cx);
cx.simulate_keystrokes("enter");
settle(cx);
assert_eq!(
blocks(&editor, cx),
[
text(BlockKind::Paragraph, "ab"),
text(BlockKind::Paragraph, "ef")
]
);
cx.simulate_keystrokes("cmd-z");
settle(cx);
assert_eq!(
blocks(&editor, cx),
[text(BlockKind::Paragraph, "abcdef")],
"one step undoes it"
);
}
#[gpui::test]
fn undo_puts_the_caret_back_where_the_change_began(cx: &mut TestAppContext) {
let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["abcdef"])], cx);
caret(&editor, 0, 3, cx);
cx.simulate_keystrokes("enter");
settle(cx);
cx.simulate_keystrokes("cmd-z");
settle(cx);
let first = cx.update(|_, cx| editor.read(cx).blocks[0].key);
let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx));
assert_eq!(focused, Some((first, 0)));
cx.simulate_keystrokes("cmd-shift-z");
settle(cx);
assert_eq!(
blocks(&editor, cx),
[
text(BlockKind::Paragraph, "abc"),
text(BlockKind::Paragraph, "def")
],
"redo splits again"
);
cx.simulate_keystrokes("cmd-z");
settle(cx);
cx.simulate_input("x");
settle(cx);
assert_eq!(
blocks(&editor, cx),
[text(BlockKind::Paragraph, "abcxdef")],
"typing lands at the caret undo put back"
);
}
#[gpui::test]
fn keys_into_a_merged_cell_land_on_the_cell_that_shows(cx: &mut TestAppContext) {
let table = BlockKind::Table {
columns: 2,
merged: vec![(1, 1)],
};
let (editor, cx) = open(vec![BlockData::new(table, ["A", "B", "c", ""])], cx);
select(&editor, 0, 0, 1..1, cx);
cx.update(|window, cx| {
let field = editor.read(cx).blocks[0].fields[1].clone();
window.focus(&field.focus_handle(cx));
});
settle(cx);
cx.simulate_keystrokes("down");
settle(cx);
let key = cx.update(|_, cx| editor.read(cx).blocks[0].key);
let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx));
assert_eq!(
focused,
Some((key, 2)),
"down from B lands on the cell c spans"
);
}
#[gpui::test]
fn undoing_a_kind_change_brings_back_its_styling(cx: &mut TestAppContext) {
let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["**lift**"])], cx);
cx.update(|window, cx| {
let key = editor.read(cx).blocks[0].key;
editor.update(cx, |editor, cx| {
editor.turn_into(key, BlockKind::Code, window, cx)
});
});
settle(cx);
caret(&editor, 0, 0, cx);
cx.simulate_keystrokes("cmd-z");
settle(cx);
let bold = cx.update(|_, cx| {
let field = editor.read(cx).blocks[0].fields[0].clone();
field
.read(cx)
.highlights(cx)
.iter()
.any(|(_, highlight)| highlight.weight.is_some())
});
assert_eq!(
blocks(&editor, cx),
[text(BlockKind::Paragraph, "**lift**")]
);
assert!(bold, "the paragraph styles its markdown again");
}
#[gpui::test]
fn undo_of_typing_restores_the_selection_it_replaced(cx: &mut TestAppContext) {
let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["abcdef"])], cx);
select(&editor, 0, 0, 2..4, cx);
cx.simulate_keystrokes("x");
settle(cx);
assert_eq!(blocks(&editor, cx), [text(BlockKind::Paragraph, "abxef")]);
cx.simulate_keystrokes("cmd-z");
settle(cx);
let selection = cx.update(|_, cx| editor.read(cx).blocks[0].fields[0].read(cx).selection());
assert_eq!(blocks(&editor, cx), [text(BlockKind::Paragraph, "abcdef")]);
assert_eq!(selection, 2..4, "undo selects what the typing replaced");
}
#[gpui::test]
fn up_into_a_table_skips_its_hidden_cells(cx: &mut TestAppContext) {
let table = BlockKind::Table {
columns: 2,
merged: vec![(1, 1)],
};
let (editor, cx) = open(
vec![
BlockData::new(table, ["A", "B", "c", ""]),
BlockData::new(BlockKind::Paragraph, ["after"]),
],
cx,
);
caret(&editor, 1, 0, cx);
cx.simulate_keystrokes("up");
settle(cx);
let key = cx.update(|_, cx| editor.read(cx).blocks[0].key);
let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx));
assert_eq!(
focused,
Some((key, 2)),
"up lands on the cell that shows in the last row"
);
}
#[gpui::test]
fn undo_of_a_delete_or_a_paste_restores_the_selection(cx: &mut TestAppContext) {
let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["abcdef"])], cx);
let selection = |cx: &mut VisualTestContext| {
cx.update(|_, cx| editor.read(cx).blocks[0].fields[0].read(cx).selection())
};
select(&editor, 0, 0, 2..4, cx);
cx.simulate_keystrokes("backspace");
settle(cx);
assert_eq!(blocks(&editor, cx), [text(BlockKind::Paragraph, "abef")]);
cx.simulate_keystrokes("cmd-z");
settle(cx);
assert_eq!(selection(cx), 2..4, "undo of a delete selects what it took");
cx.update(|_, cx| cx.write_to_clipboard(gpui::ClipboardItem::new_string("xy".into())));
select(&editor, 0, 0, 2..4, cx);
cx.simulate_keystrokes("cmd-v");
settle(cx);
assert_eq!(blocks(&editor, cx), [text(BlockKind::Paragraph, "abxyef")]);
cx.simulate_keystrokes("cmd-z");
settle(cx);
assert_eq!(
selection(cx),
2..4,
"undo of a paste selects what it replaced"
);
fn a_current_hit_comes_to_one_place_from_any_scroll(cx: &mut TestAppContext) {
let from_top = revealed(0.0, cx);
let from_below = revealed(300.0, cx);
assert_eq!(from_top.0, 1, "the hit's page comes into view");
assert_eq!(from_top, from_below);
}
+101
View File
@@ -0,0 +1,101 @@
use std::rc::Rc;
use gpui::{
App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce,
StatefulInteractiveElement, Styled, Window, div, img, prelude::*,
};
use super::viewer::DocPage;
use crate::{
documents::blocks::source,
forms::Pick,
theme::{ActiveTheme, Radius, TextSize},
typography::tabular,
};
/// Share of the label column a thumbnail spans.
const THUMB: f32 = 0.75;
/// Pages as small pictures down a column, each numbered, the current one outlined; a press goes to a page.
#[derive(IntoElement)]
pub struct PageThumbnailList {
id: ElementId,
pages: Rc<Vec<DocPage>>,
current: usize,
on_pick: Option<Pick>,
}
impl PageThumbnailList {
pub fn new(
id: impl Into<ElementId>,
pages: impl Into<Rc<Vec<DocPage>>>,
current: usize,
) -> Self {
Self {
id: id.into(),
pages: pages.into(),
current,
on_pick: None,
}
}
pub fn on_pick(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_pick = Some(Rc::new(handler));
self
}
}
impl RenderOnce for PageThumbnailList {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let width = theme.label_width() * THUMB;
div()
.id(self.id.clone())
.h_full()
.overflow_y_scroll()
.flex()
.flex_col()
.items_center()
.gap_3()
.p_3()
.children(self.pages.iter().enumerate().map(|(ix, page)| {
let current = ix == self.current;
let pick = self.on_pick.clone();
div()
.id((self.id.clone(), format!("thumb-{ix}")))
.flex()
.flex_col()
.items_center()
.gap_1()
.cursor_pointer()
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.when_some(pick, |thumb, pick| {
thumb.on_click(move |_, window, cx| {
log::info!("page thumbnails: page {ix}");
pick(ix, window, cx)
})
})
.child(
div()
.w(width)
.h(width * (page.height / page.width))
.rounded(theme.radius(Radius::Sm))
.border_1()
.border_color(if current { colors.focus } else { colors.border })
.bg(colors.paper)
.child(
img(source(&page.source))
.id((self.id.clone(), format!("thumb-picture-{ix}")))
.size_full()
.rounded(theme.radius(Radius::Sm)),
),
)
.child(
tabular(div().text_size(theme.text_size(TextSize::Xs)))
.text_color(if current { colors.fg } else { colors.fg_subtle })
.child(format!("{}", ix + 1)),
)
}))
}
}
+487
View File
@@ -0,0 +1,487 @@
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);
}
}
+41 -35
View File
@@ -55,7 +55,7 @@ type OnOptions = Rc<dyn Fn(FindOptions, &mut Window, &mut App)>;
type OnBool = Rc<dyn Fn(bool, &mut Window, &mut App)>;
type OnClose = Rc<dyn Fn(&mut Window, &mut App)>;
/// A find box over code: the query with its options, where the current match stands among all, steps between them, and a replace row that folds away.
/// A find box: the query, its options when the owner takes them, where the current match stands among all, steps between them, and a replace row that folds away when the owner offers one.
#[derive(IntoElement)]
pub struct FindWidget {
id: ElementId,
@@ -208,45 +208,49 @@ impl RenderOnce for FindWidget {
.text_size(theme.text_size(TextSize::Xs))
.child(
row()
.child(icon_button(
"fold",
if showing {
IconName::ChevronDown
} else {
IconName::ChevronRight
},
"Toggle replace",
true,
self.on_toggle_replace.clone(),
!showing,
))
.children(self.on_toggle_replace.clone().map(|fold| {
icon_button(
"fold",
if showing {
IconName::ChevronDown
} else {
IconName::ChevronRight
},
"Toggle replace",
true,
Some(fold),
!showing,
)
}))
.child(
div()
.flex_1()
.min_w_0()
.child(Input::new(&self.find).size(ControlSize::Sm)),
)
.child(toggle(
"case",
IconName::CaseSensitive,
"Match case",
options.case,
|next, on| next.case = on,
))
.child(toggle(
"word",
IconName::WholeWord,
"Whole word",
options.word,
|next, on| next.word = on,
))
.child(toggle(
"regex",
IconName::Regex,
"Regular expression",
options.regex,
|next, on| next.regex = on,
))
.when(self.on_options.is_some(), |row| {
row.child(toggle(
"case",
IconName::CaseSensitive,
"Match case",
options.case,
|next, on| next.case = on,
))
.child(toggle(
"word",
IconName::WholeWord,
"Whole word",
options.word,
|next, on| next.word = on,
))
.child(toggle(
"regex",
IconName::Regex,
"Regular expression",
options.regex,
|next, on| next.regex = on,
))
})
.child(
div()
.w(theme.label_width() * 0.5)
@@ -285,7 +289,9 @@ impl RenderOnce for FindWidget {
.children(self.replace.as_ref().map(|field| {
let (one, all) = (self.on_replace.clone(), self.on_replace.clone());
row()
.pl(theme.control_height(ControlSize::Sm))
.when(self.on_toggle_replace.is_some(), |row| {
row.pl(theme.control_height(ControlSize::Sm))
})
.child(
div()
.flex_1()
+2
View File
@@ -33,6 +33,8 @@ macro_rules! icons {
}
icons! {
AArrowDown => "a-arrow-down",
AArrowUp => "a-arrow-up",
Accessibility => "accessibility",
Activity => "activity",
AlarmClock => "alarm-clock",
+10
View File
@@ -279,6 +279,16 @@ impl Theme {
px_to_rems(20.0)
}
/// A page's cover band.
pub fn page_cover(&self) -> Rems {
px_to_rems(208.0)
}
/// A page's icon, an emoji set large.
pub fn page_icon(&self) -> Rems {
px_to_rems(64.0)
}
/// A picture's height in a skeleton card.
pub fn skeleton_media(&self) -> Rems {
px_to_rems(128.0)