Compare commits

..
Author SHA1 Message Date
Brandon Thomas 05215ee69e Merge remote-tracking branch 'origin/main' into pr-5379
# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
2026-11-03 09:04:30 -05:00
Dylan M. Taylor d5c84e6dac fix(doc): charge copied text box stories for the formatting they clone 2026-11-03 04:57:30 +00:00
12 changed files with 28 additions and 198 deletions
+1 -5
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
20467b63558f04fe075e83aaea501bd10f7fa684
059cbf95c2a3eb95d637ca8584faca1129d17f48
+3 -7
View File
@@ -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("");
-13
View File
@@ -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");
}
+9 -29
View File
@@ -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
-84
View File
@@ -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());
}
}
+1 -3
View File
@@ -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;
+10 -28
View File
@@ -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();
}
-23
View File
@@ -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:?}");
}
-1
View File
File diff suppressed because one or more lines are too long
+2 -3
View File
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