mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-04 01:10:37 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05215ee69e | ||
|
|
d5c84e6dac |
+1
-5
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
20467b63558f04fe075e83aaea501bd10f7fa684
|
||||
059cbf95c2a3eb95d637ca8584faca1129d17f48
|
||||
|
||||
@@ -276,9 +276,6 @@ fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec<Node> {
|
||||
// text. A scan pays for what it reads; earlier bracket matches are looked up in `known`.
|
||||
let ticks = Backticks::new(s);
|
||||
let mut links = LinkScan { text: s, refs, ticks: &ticks, known: HashMap::new(), budget: s.len().saturating_mul(8).saturating_add(8192) };
|
||||
// Where the last comment end is: a comment opener after it has none to find, so a run of
|
||||
// unclosed `<!--` doesn't search the rest of the text once each.
|
||||
let last_comment_end = s.rfind("-->");
|
||||
let b = s.as_bytes();
|
||||
let mut i = 0usize;
|
||||
let mut text_start = 0usize;
|
||||
@@ -369,7 +366,7 @@ fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec<Node> {
|
||||
}
|
||||
}
|
||||
b'<' => {
|
||||
if let Some((end, parsed)) = angle_inlines(s, i, base, last_comment_end) {
|
||||
if let Some((end, parsed)) = angle_inlines(s, i, base) {
|
||||
flush!();
|
||||
nodes.extend(parsed);
|
||||
i = end;
|
||||
@@ -397,9 +394,8 @@ fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec<Node> {
|
||||
nodes
|
||||
}
|
||||
|
||||
fn angle_inlines(s: &str, start: usize, base: &Fmt, last_comment_end: Option<usize>) -> Option<(usize, Vec<Node>)> {
|
||||
let closable = last_comment_end.is_some_and(|e| e >= start + 4);
|
||||
if let Some(pos) = s.get(start..).and_then(|rest| rest.strip_prefix("<!--")).filter(|_| closable).and_then(|rest| rest.find("-->")) {
|
||||
fn angle_inlines(s: &str, start: usize, base: &Fmt) -> Option<(usize, Vec<Node>)> {
|
||||
if let Some(pos) = s.get(start..).and_then(|rest| rest.strip_prefix("<!--")).and_then(|rest| rest.find("-->")) {
|
||||
return Some((start + 4 + pos + 3, Vec::new()));
|
||||
}
|
||||
let rest = s.get(start + 1..).unwrap_or("");
|
||||
|
||||
@@ -714,16 +714,3 @@ fn exported_link_destinations_read_back_unchanged() {
|
||||
assert_eq!(md("https://example.com/a b"), "[x](<https://example.com/a b>)");
|
||||
assert_eq!(md("https://e.com/x\ny"), "[x](https://e.com/xy)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unclosed_inline_comments_are_text_without_a_search_each() {
|
||||
// #3714-style: each unclosed `<!--` searched the rest of the paragraph for `-->`.
|
||||
let src = "<!--".repeat(200_000);
|
||||
let t = std::time::Instant::now();
|
||||
let text: String = inl(&src).into_iter().filter_map(|i| if let Inline::Text(t, _) = i { Some(t) } else { None }).collect();
|
||||
assert_eq!(text, src);
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed());
|
||||
// A comment that does close still goes, after an unclosed one too.
|
||||
let text: String = inl("a <!-- b --> c <!-- d").into_iter().filter_map(|i| if let Inline::Text(t, _) = i { Some(t) } else { None }).collect();
|
||||
assert_eq!(text, "a c <!-- d");
|
||||
}
|
||||
|
||||
@@ -103,8 +103,8 @@ pub(super) fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter,
|
||||
let top = (anchor.y + if over_page { 4.0 } else { -12.0 }).max(next_y);
|
||||
let active = app.canvas.balloon == Some(*id);
|
||||
// Lay out a read-only balloon's text: the comment, then each reply under a rule,
|
||||
// each with who wrote it and when. (galley, gap above, a rule above, height shown)
|
||||
let mut lines: Vec<(craft_ui::text::AlignedGalley, f32, bool, f32)> = Vec::new();
|
||||
// each with who wrote it and when. (galley, gap above, a rule above)
|
||||
let mut lines: Vec<(craft_ui::text::AlignedGalley, f32, bool)> = Vec::new();
|
||||
let card = if active {
|
||||
Rect::from_min_size(pos2(x0, top), vec2(w, app.canvas.balloon_h.max(24.0)))
|
||||
} else {
|
||||
@@ -113,21 +113,15 @@ pub(super) fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter,
|
||||
for (i, cid) in std::iter::once(*id).chain(thread.iter().copied()).enumerate() {
|
||||
let Some(r) = app.session.doc.comments.get(&cid) else { continue };
|
||||
let gap = if i == 0 { 0.0 } else { 9.0 };
|
||||
let g = balloon_text(painter, author_name(&r.author), semibold(fs), t.text, w - 16.0);
|
||||
let h = g.height();
|
||||
lines.push((g, gap, i > 0, h));
|
||||
lines.push((balloon_text(painter, author_name(&r.author), semibold(fs), t.text, w - 16.0), gap, i > 0));
|
||||
let when = crate::local_time::comment_time(r);
|
||||
if !when.is_empty() {
|
||||
let g = balloon_text(painter, when, small.clone(), t.text_dim, w - 16.0);
|
||||
let h = g.height();
|
||||
lines.push((g, 1.0, false, h));
|
||||
lines.push((balloon_text(painter, when, small.clone(), t.text_dim, w - 16.0), 1.0, false));
|
||||
}
|
||||
let text = app.session.doc.plain_text(StoryRef::Part(r.part));
|
||||
let g = balloon_text(painter, text.trim().to_string(), regular(fs), dim(resolved), w - 16.0);
|
||||
let h = shown_height(&g);
|
||||
lines.push((g, 4.0, false, h));
|
||||
lines.push((balloon_text(painter, text.trim().to_string(), regular(fs), dim(resolved), w - 16.0), 4.0, false));
|
||||
}
|
||||
let h = lines.iter().map(|(_, gap, _, h)| h + gap).sum::<f32>() + 12.0;
|
||||
let h = lines.iter().map(|(g, gap, _)| g.height() + gap).sum::<f32>() + 12.0;
|
||||
Rect::from_min_size(pos2(x0, top), vec2(w, h))
|
||||
};
|
||||
if !card.intersects(clip) {
|
||||
@@ -151,19 +145,14 @@ pub(super) fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter,
|
||||
card_shapes(painter, &t, card, color, selected, None);
|
||||
let text_painter = painter.with_clip_rect(card.intersect(clip));
|
||||
let mut y = card.min.y + 6.0;
|
||||
for (g, gap, rule, h) in lines {
|
||||
for (g, gap, rule) in lines {
|
||||
y += gap;
|
||||
if rule {
|
||||
// A reply starts: a hairline between it and what it answers.
|
||||
painter.hline(card.min.x + 10.0..=card.max.x - 8.0, y - gap / 2.0, Stroke::new(1.0, t.border));
|
||||
}
|
||||
// A body cut off at its shown rows paints none of the rows below them.
|
||||
let shown = if h < g.height() {
|
||||
text_painter.with_clip_rect(text_painter.clip_rect().intersect(Rect::from_x_y_ranges(card.x_range(), y..=y + h)))
|
||||
} else {
|
||||
text_painter.clone()
|
||||
};
|
||||
g.paint(&shown, pos2(card.min.x + 10.0, y), t.text);
|
||||
let h = g.height();
|
||||
g.paint(&text_painter, pos2(card.min.x + 10.0, y), t.text);
|
||||
y += h;
|
||||
}
|
||||
let r = ui.interact(card, ui.scope_id().with(("balloon", *id)), Sense::click());
|
||||
@@ -274,15 +263,6 @@ fn card_shapes(painter: &egui::Painter, t: &Tokens, card: Rect, color: Color32,
|
||||
}
|
||||
}
|
||||
|
||||
/// The height of a balloon body's first [`COMMENT_ROWS`](crate::panes::COMMENT_ROWS) rows; the
|
||||
/// rest is cut off until the balloon is selected and its field scrolls.
|
||||
fn shown_height(g: &craft_ui::text::AlignedGalley) -> f32 {
|
||||
match g {
|
||||
craft_ui::text::AlignedGalley::Aligned { galley, .. } => galley.rows.get(crate::panes::COMMENT_ROWS).map_or(g.height(), |r| r.pos.y),
|
||||
craft_ui::text::AlignedGalley::InvalidWidth { .. } => g.height(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A read-only balloon's text, wrapped to `width`. The fork's layout orders right-to-left and
|
||||
/// mixed-direction paragraphs itself; `TextDirection::Auto` takes each paragraph's first strong
|
||||
/// character. Place each already-shaped row against the card's start edge, including neutral
|
||||
|
||||
@@ -4809,87 +4809,3 @@ fn the_view_key_changes_when_a_drag_takes_an_object_off_the_page() {
|
||||
assert_ne!(hidden, view_key(&app, false, None), "the page without the dragged object is rendered again");
|
||||
assert_eq!(hidden, view_key(&app, false, Some(&object)), "the same drag keeps the key: no re-render every frame");
|
||||
}
|
||||
|
||||
/// A two-paragraph document with a 200-line comment (`line` gives each line's text) on the first
|
||||
/// and a short one on the second, drawn without the Comments pane; returns both comment ids.
|
||||
fn long_comment_balloons(line: fn(usize) -> String) -> (egui_kittest::Harness<'static, WordApp>, u32, u32) {
|
||||
let mut a = WordApp::new(
|
||||
wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Alpha paragraph one.\n\nBeta paragraph two.")),
|
||||
Default::default(),
|
||||
);
|
||||
a.run("select.text", json!({"text": "Alpha"})).unwrap();
|
||||
let long = (1..=200).map(line).collect::<Vec<_>>().join("\n");
|
||||
let first = a.run("review.newComment", json!({"text": long})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
a.run("select.text", json!({"text": "Beta"})).unwrap();
|
||||
let second = a.run("review.newComment", json!({"text": "Short note."})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
a.run("view.commentsPane", json!({"value": false})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
for _ in 0..6 {
|
||||
h.step();
|
||||
}
|
||||
(h, first, second)
|
||||
}
|
||||
|
||||
fn balloon_rect(h: &egui_kittest::Harness<'static, WordApp>, id: u32) -> Option<egui::Rect> {
|
||||
h.state().canvas.balloon_rects.iter().find(|(i, _)| *i == id).map(|(_, r)| *r)
|
||||
}
|
||||
|
||||
/// #3382: a long comment's balloon, once selected, stops at about ten lines of text: its field
|
||||
/// scrolls, so its buttons and the next comment's balloon stay in view.
|
||||
#[test]
|
||||
fn a_long_comment_selected_balloon_keeps_the_next_one_in_view() {
|
||||
let (mut h, first, second) = long_comment_balloons(|i| format!("Line {i}"));
|
||||
click_balloon(&mut h, first);
|
||||
let selected = balloon_rect(&h, first).expect("the selected balloon");
|
||||
assert!(selected.height() < 350.0, "the selected balloon's field scrolls instead of growing: {selected:?}");
|
||||
assert!(balloon_rect(&h, second).is_some_and(|r| r.max.y < 900.0), "the next balloon is in view");
|
||||
}
|
||||
|
||||
/// #3382: a long comment's balloon that isn't selected shows its first ten lines and cuts off the
|
||||
/// rest, in either reading direction, so the next comment's balloon stays in view.
|
||||
#[test]
|
||||
fn a_long_comment_balloon_shows_ten_lines_and_keeps_the_next_one_in_view() {
|
||||
for line in [(|i| format!("Line {i}")) as fn(usize) -> String, |i| format!("سطر {i}")] {
|
||||
let (h, first, second) = long_comment_balloons(line);
|
||||
let card = balloon_rect(&h, first).expect("the long balloon");
|
||||
assert!(card.height() < 350.0, "the balloon stops at ten lines of its comment: {card:?}");
|
||||
assert!(balloon_rect(&h, second).is_some_and(|r| r.max.y < 900.0), "the next balloon is in view");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3382: a long comment's balloon that isn't selected paints its first ten rows inside the
|
||||
/// card's clip and none of the rows after them, in either reading direction.
|
||||
#[test]
|
||||
fn a_long_comment_balloon_paints_only_its_first_ten_rows() {
|
||||
for line in [(|i| format!("Line {i}")) as fn(usize) -> String, |i| format!("سطر {i}")] {
|
||||
let (mut h, first, _) = long_comment_balloons(line);
|
||||
h.step();
|
||||
let body = (1..=200).map(line).collect::<Vec<_>>().join("\n");
|
||||
let card = balloon_rect(&h, first).expect("the long balloon");
|
||||
let (clip, rows) = h
|
||||
.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.find_map(|clipped| match &clipped.shape {
|
||||
egui::Shape::Text(text) if text.galley.text() == body => {
|
||||
Some((clipped.clip_rect, text.galley.rows.iter().map(|r| r.rect().translate(text.pos.to_vec2())).collect::<Vec<_>>()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("the long comment's body is painted");
|
||||
assert!(card.contains_rect(clip), "the body is clipped to its card: {clip:?} in {card:?}");
|
||||
let (shown, hidden) = rows.split_at(crate::panes::COMMENT_ROWS);
|
||||
assert!(
|
||||
shown.iter().all(|r| r.min.y >= clip.min.y - 0.5 && r.max.y <= clip.max.y + 0.5),
|
||||
"the first ten rows are inside the clip: {shown:?}, clip {clip:?}"
|
||||
);
|
||||
assert!(hidden.iter().all(|r| r.min.y >= clip.max.y - 0.5), "the rows after the tenth are cut off: {:?}, clip {clip:?}", hidden.first());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,7 @@ mod state;
|
||||
#[cfg(test)]
|
||||
pub(crate) use comments::comment_draft;
|
||||
use comments::comments;
|
||||
pub(crate) use comments::{
|
||||
COMMENT_ROWS, CommentDrafts, comment_editor, commit_comment, commit_reply, flush_comment_drafts, has_comment_drafts, reply_editor,
|
||||
};
|
||||
pub(crate) use comments::{CommentDrafts, comment_editor, commit_comment, commit_reply, flush_comment_drafts, has_comment_drafts, reply_editor};
|
||||
use host::Content;
|
||||
#[cfg(test)]
|
||||
pub(crate) use host::apply;
|
||||
|
||||
@@ -119,17 +119,16 @@ pub(crate) fn comment_editor(app: &mut WordApp, ui: &mut Ui, id: u32, font: Opti
|
||||
let mut text = draft.unwrap_or_else(|| app.session.doc.plain_text(StoryRef::Part(part)));
|
||||
// Read-only where comments can't change (a protected document), so nothing is typed in vain.
|
||||
let editable = crate::widgets::enabled(app, "review.editComment");
|
||||
let field = field_id(app, ui, "comment_edit", id);
|
||||
let mut edit = egui::TextEdit::multiline(&mut text)
|
||||
.id(field)
|
||||
.id(field_id(app, ui, "comment_edit", id))
|
||||
.interactive(editable)
|
||||
.desired_rows(1)
|
||||
.desired_width(f32::INFINITY)
|
||||
.hint_text(tl!("Add a comment"));
|
||||
if let Some(f) = &font {
|
||||
edit = edit.font(f.clone());
|
||||
if let Some(f) = font {
|
||||
edit = edit.font(f);
|
||||
}
|
||||
let r = capped_field(ui, field.with("scroll"), font.as_ref(), edit);
|
||||
let r = ui.add(edit);
|
||||
if focus {
|
||||
r.request_focus();
|
||||
}
|
||||
@@ -147,24 +146,8 @@ pub(crate) fn comment_editor(app: &mut WordApp, ui: &mut Ui, id: u32, font: Opti
|
||||
}
|
||||
}
|
||||
|
||||
/// Lines of a comment's text that its card or balloon shows; longer text scrolls in its field and
|
||||
/// is cut off in a balloon that isn't selected (#3382).
|
||||
pub(crate) const COMMENT_ROWS: usize = 10;
|
||||
|
||||
/// The height a text field's frame and padding add to its text, in points.
|
||||
const FIELD_FRAME: f32 = 4.0;
|
||||
|
||||
/// A comment or reply field at most [`COMMENT_ROWS`] lines tall. Longer text scrolls inside it,
|
||||
/// and the field keeps its caret in view.
|
||||
fn capped_field(ui: &mut Ui, scroll_id: egui::Id, font: Option<&egui::FontId>, edit: egui::TextEdit<'_>) -> egui::Response {
|
||||
let font = font.cloned().unwrap_or_else(|| egui::FontSelection::Default.resolve(ui.style()));
|
||||
let row = ui.fonts_mut(|f| f.row_height(&font));
|
||||
let max = row * COMMENT_ROWS as f32 + FIELD_FRAME;
|
||||
egui::ScrollArea::vertical().id_salt(scroll_id).max_height(max).auto_shrink([false, true]).show(ui, |ui| ui.add(edit)).inner
|
||||
}
|
||||
|
||||
/// A comment field's widget id, per document: egui's own state for the field (its cursor, undo
|
||||
/// history and scroll position) never carries over to another document's comment with the same id.
|
||||
/// A comment field's widget id, per document: egui's own state for the field (its cursor and
|
||||
/// undo history) never carries over to another document's comment with the same id.
|
||||
fn field_id(app: &WordApp, ui: &Ui, kind: &str, id: u32) -> egui::Id {
|
||||
ui.scope_id().with((kind, app.session.document_id(), id))
|
||||
}
|
||||
@@ -196,17 +179,16 @@ fn thread_root(app: &WordApp, id: u32) -> u32 {
|
||||
pub(crate) fn reply_editor(app: &mut WordApp, ui: &mut Ui, id: u32, font: Option<egui::FontId>, focus: bool) -> bool {
|
||||
let mut text = drafts(app).replies.get(&id).cloned().unwrap_or_default();
|
||||
let editable = crate::widgets::enabled(app, "review.reply");
|
||||
let field = field_id(app, ui, "comment_reply", id);
|
||||
let mut edit = egui::TextEdit::multiline(&mut text)
|
||||
.id(field)
|
||||
.id(field_id(app, ui, "comment_reply", id))
|
||||
.interactive(editable)
|
||||
.desired_rows(1)
|
||||
.desired_width(f32::INFINITY)
|
||||
.hint_text(tl!("Write a reply"));
|
||||
if let Some(f) = &font {
|
||||
edit = edit.font(f.clone());
|
||||
if let Some(f) = font {
|
||||
edit = edit.font(f);
|
||||
}
|
||||
let r = capped_field(ui, field.with("scroll"), font.as_ref(), edit);
|
||||
let r = ui.add(edit);
|
||||
if focus {
|
||||
r.request_focus();
|
||||
}
|
||||
|
||||
@@ -445,26 +445,3 @@ fn built_in_styles_grey_out_delete_in_the_styles_pane() {
|
||||
assert!(delete_is_disabled("Normal"), "the built-in style's Delete is greyed out");
|
||||
assert!(!delete_is_disabled("Callout"), "the custom style's Delete stays enabled");
|
||||
}
|
||||
|
||||
/// #3382: a long comment's card in the Comments pane stops at ten lines of text, so its buttons
|
||||
/// and the card after it stay in view.
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn a_long_comment_card_keeps_its_buttons_and_the_next_card_in_view() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
let mut app = crate::WordApp::new(
|
||||
wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Alpha paragraph one.\n\nBeta paragraph two.")),
|
||||
crate::Services::default(),
|
||||
);
|
||||
app.run("select.text", serde_json::json!({"text": "Alpha"})).unwrap();
|
||||
let long = (1..=200).map(|i| format!("Line {i}")).collect::<Vec<_>>().join("\n");
|
||||
app.run("review.newComment", serde_json::json!({"text": long})).unwrap();
|
||||
app.run("select.text", serde_json::json!({"text": "Beta"})).unwrap();
|
||||
app.run("review.newComment", serde_json::json!({"text": "Short note."})).unwrap();
|
||||
app.run("view.commentsPane", serde_json::json!({"value": true})).unwrap();
|
||||
let h = pane_harness(app);
|
||||
let go_to: Vec<egui::Rect> = h.get_all_by_label("Go to").map(|n| n.rect()).collect();
|
||||
assert_eq!(go_to.len(), 2, "one Go to button per card");
|
||||
assert!(go_to.iter().all(|r| r.max.y < 900.0), "both cards' buttons are in view: {go_to:?}");
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+2
-3
File diff suppressed because one or more lines are too long
@@ -126,6 +126,6 @@ modules:
|
||||
# the two equal).
|
||||
- type: git
|
||||
url: https://github.com/storytold/craft-i18n
|
||||
commit: 20467b63558f04fe075e83aaea501bd10f7fa684
|
||||
commit: 059cbf95c2a3eb95d637ca8584faca1129d17f48
|
||||
dest: craft-i18n
|
||||
- cargo-sources.json
|
||||
|
||||
Reference in New Issue
Block a user