Compare commits

..
Author SHA1 Message Date
Martin Ball ae22f35312 Merge remote-tracking branch 'origin/main' into land-5616
; Conflicts:
;	crates/engine/src/session/documents.rs
;	crates/engine/src/tests.rs
;	crates/ui-egui/src/keys/tests.rs
;	docs/ui-parity.md
2026-11-09 19:35:34 +00:00
Martin BallandClaude Opus 5.5 cc125c4732 #1537: merge — restore the closing brace of the range Paste Formatting test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8G9hbs6XYZFsgjbL
2026-11-08 09:32:02 +00:00
Martin BallandClaude Opus 5.5 c13e9a53b9 #1537: review — an expired paste wait drops the chord's noted Mod+Shift too (CodeRabbit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8G9hbs6XYZFsgjbL
2026-11-08 09:31:36 +00:00
Martin BallandClaude Opus 5.5 f6a4f594ea #1537: review — the wait for Mod+Shift+V's browser paste ends after a second, so a later menu-bar paste isn't dropped (FredApps)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8G9hbs6XYZFsgjbL
2026-11-08 09:26:52 +00:00
Martin Ball dab09ef6ff Merge remote-tracking branch 'origin/main' into fix/1537-ctrl-shift-v-keep-text-only
; Conflicts:
;	crates/engine/src/tests.rs
;	docs/ui-parity.md
2026-11-08 09:21:08 +00:00
Martin BallandClaude Opus 5.5 c254d7c269 #1537: review — a dropped paste uses up its modifiers; range Paste Formatting keeps the link (alehel)
- The browser paste event dropped after a formatting paste now clears paste_event_mods too, so a
  later menu-bar paste isn't read as Keep Text Only. Note the no-text clipboard case.
- Test that Paste Formatting over a selection keeps the target's hyperlink.
- session.rs: copied_format holds a Format Painter pick too.
- ROADMAP: entry moved up with the other 2026-11-04 entries; stray blank line dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 23:09:10 +00:00
Martin BallandClaude Opus 5.5 f8cd82b89e #1537: review — the docs say a Format Painter pick counts as picked-up formatting too (CodeRabbit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:08:39 +00:00
Martin BallandClaude Opus 5.5 3e236af3e7 #1537: review — the text-only paste test checks the destination's own formatting stays (CodeRabbit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 18:42:39 +00:00
Martin BallandClaude Opus 5.5 97c72d9063 #1537: review — an unrelated key's modifiers can't steer a later paste event
A separate paste_event_mods is set by Mod changes and Mod+V's press, and cleared by any other key, a pointer press or focus change; the release path keeps paste_mods.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 15:24:10 +00:00
Martin BallandClaude Opus 5.5 23e40916dd #1537: review — a caret Paste Formatting keeps the target's own link and tracked marks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:58:36 +00:00
Martin BallandClaude Opus 5.5 8f9400ce78 #1537: review — shared format buffer, no tracked paragraph changes, pointer press ends the paste wait
- picked_format strips ParaProps fmt_change/num_change
- Format Painter pick is stored in copied_format too; a used-up or cancelled brush leaves it
- a pointer press clears ignore_next_paste
- docs: repeat-until-replaced wording

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:27:43 +00:00
Martin BallandClaude Opus 5.5 5c537a6cf4 #1537: review — one format buffer, formatting-only copy, browser paste suppression only where it exists
- ignore_next_paste armed only for Mod+Shift+V on platforms that send a paste event after it (web); never for a rebound key
- the ignored paste is dropped before equation and picture-file handling
- Event::Paste takes paste_mods so they don't leak into later pastes
- Copy Formatting and Format Painter replace each other; a used-up brush no longer hands over to older copied formatting
- copied formatting carries no hyperlink or revision marks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 13:52:02 +00:00
Martin BallandClaude Opus 5.5 749a0b22be #1537: review — copied formatting stays for every Mod+Shift+V; a browser's paste after it is ignored (wanikhawar)
Copy Formatting keeps its formatting apart from the one-shot Format Painter brush, so a second
Mod+Shift+V pastes formatting again instead of text, and Mod+Shift+C no longer arms the brush.
After Mod+Shift+V pastes formatting, the browser's paste event for the same keystroke is ignored;
paste events read the modifiers held in event order. Only Mod+Shift+V itself falls back to Keep
Text Only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYPZSei56X4AGFPSu5
2026-11-07 13:38:42 +00:00
Martin BallandClaude Opus 5.5 8661fa6c66 #1537: review — a browser's Mod+Shift+V paste event pastes text only, once (CodeRabbit)
On the web the key no longer pastes as well: the paste event that follows carries the text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 13:38:33 +00:00
Martin BallandClaude Opus 5.5 e11bdc5e7b #1537: Ctrl/Cmd+Shift+V pastes text only when no formatting was copied, as in Word for Windows
With formatting picked up by Ctrl/Cmd+Shift+C it still pastes the formatting.
Closes #1537, #4943; takes over #1562.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 13:38:33 +00:00
25 changed files with 527 additions and 348 deletions
+2 -4
View File
File diff suppressed because one or more lines are too long
+25 -12
View File
@@ -163,7 +163,9 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("edit.formatPainter", "Format Painter", "Home › Clipboard", painter).params(r#"{"sticky"?: bool}"#).pure(),
CommandSpec::new("edit.copyFormat", "Copy Formatting", "Home › Clipboard", |s, _| {
painter_pick(s);
// One format buffer, as in Word: Copy Formatting replaces an armed Format Painter.
s.painter = None;
s.copied_format = Some(picked_format(s));
sel_result(s)
})
.key("Mod+Shift+C")
@@ -819,11 +821,21 @@ fn goto(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
fn painter_pick(s: &mut Session) {
/// The character and paragraph formatting at the start of the selection.
fn picked_format(s: &Session) -> (wordcraft_doc::props::CharProps, wordcraft_doc::props::ParaProps) {
let f = s.sel.ordered().0;
// A symbol's own font isn't formatting to paint onto text.
let chr = s.doc.para_at(&f).map(|p| p.props_of_char(f.off).clone().without_symbol()).unwrap_or_default();
let para = s.doc.para_at(&f).map(|p| p.props.clone()).unwrap_or_default();
// Formatting only: not the source's hyperlink or revision marks (#5616).
let chr = s.doc.para_at(&f).map(|p| p.props_of_char(f.off).clone().without_symbol().formatting()).unwrap_or_default();
let para = s.doc.para_at(&f).map(|p| p.props.clone().formatting()).unwrap_or_default();
(chr, para)
}
fn painter_pick(s: &mut Session) {
let (chr, para) = picked_format(s);
// One format buffer, as in Word: the pick is also what the next Paste Formatting applies,
// and stays after the brush is used up or cancelled.
s.copied_format = Some((chr.clone(), para.clone()));
s.painter = Some((chr, para, false));
}
@@ -839,24 +851,25 @@ fn painter(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"active": true}))
}
/// Apply the format painter's formatting to the selection.
/// Apply the format painter's formatting to the selection, or else what Copy Formatting picked
/// up (which stays for the next Paste Formatting).
fn paste_format(s: &mut Session, _: &Value) -> CmdResult {
let Some((chr, para, sticky)) = s.painter.clone() else { return Err(CmdError::Failed("no formatting copied".into())) };
let (chr, para, sticky) = match (s.painter.clone(), s.copied_format.clone()) {
(Some(brush), _) => brush,
(None, Some((chr, para))) => (chr, para, true),
(None, None) => return Err(CmdError::Failed("no formatting copied".into())),
};
let (a, b) = s.sel.ordered();
if a == b {
// No selection: apply paragraph formatting (and word formatting like Word).
let f = s.sel.focus.clone();
if let Some(pp) = s.doc.para_at(&f) {
let (x, y) = pp.word_at(f.off);
s.doc.format_range(&Pos { off: x, ..f.clone() }, &Pos { off: y, ..f.clone() }, &|c| *c = chr.cleared().overlaid(&chr))?;
s.doc.format_range(&Pos { off: x, ..f.clone() }, &Pos { off: y, ..f.clone() }, &|c| *c = c.cleared().overlaid(&chr))?;
}
s.doc.format_paragraphs(&a, &b, &|p| *p = para.clone())?;
} else {
s.doc.format_range(&a, &b, &|c| {
let link = c.link.clone();
*c = c.cleared().overlaid(&chr);
c.link = link;
})?;
s.doc.format_range(&a, &b, &|c| *c = c.cleared().overlaid(&chr))?;
if a.path != b.path || a.off == 0 {
s.doc.format_paragraphs(&a, &b, &|p| *p = para.clone())?;
}
+4
View File
@@ -88,6 +88,10 @@ pub struct Session {
pub initials: String,
/// Format painter: copied formatting waiting to be applied (and whether it stays on).
pub painter: Option<(CharProps, wordcraft_doc::props::ParaProps, bool)>,
/// Formatting picked up by Copy Formatting (Mod+Shift+C): Paste Formatting applies it as often
/// as asked, as in Word, until the next Copy Formatting. A Format Painter pick is stored here
/// as well.
pub copied_format: Option<(CharProps, wordcraft_doc::props::ParaProps)>,
/// Last message for the status bar / agents.
pub status: String,
/// Headers/footers `insert.createHeader`/`insert.createFooter` made for a page that had none
+1
View File
@@ -250,6 +250,7 @@ impl Session {
author: _,
initials: _,
painter: _,
copied_format: _,
// What the drawn window knows of the others: its front end sets it as it draws.
windows: _,
status: _,
+1
View File
@@ -107,6 +107,7 @@ impl Session {
author: "WordCraft User".into(),
initials: String::new(),
painter: None,
copied_format: None,
status: String::new(),
new_header_parts,
hf_return,
+1
View File
@@ -90,6 +90,7 @@ impl Session {
author: _,
initials: _,
painter: _,
copied_format: _,
status: _,
// Headers just created to edit: closing checks they still exist and are empty.
new_header_parts: _,
+155
View File
@@ -13041,6 +13041,161 @@ fn page_down_extends_a_selection_that_ends_in_collapsed_outline_text() {
assert!(s.layout().caret(&s.sel.focus).is_some(), "to a place the outline shows");
}
/// #5616: Copy Formatting's formatting stays for every Paste Formatting, as in Word; it doesn't
/// arm the one-shot Format Painter brush.
#[test]
fn copied_formatting_pastes_again_and_again() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Bold one two"}));
run(&mut s, "select.text", json!({"text": "Bold"}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "edit.copyFormat", json!({}));
assert!(s.painter.is_none());
for word in ["one", "two"] {
run(&mut s, "select.text", json!({"text": word}));
run(&mut s, "edit.pasteFormat", json!({}));
assert_eq!(s.run("format.state", &json!({})).unwrap()["bold"], json!(true), "{word}");
}
assert_eq!(s.doc.plain_text(wordcraft_doc::StoryRef::Body), "Bold one two");
}
/// #5616: Copy Formatting and the Format Painter share one format buffer: each replaces the other,
/// so a used-up brush never hands over to older copied formatting.
#[test]
fn copy_formatting_and_the_format_painter_replace_each_other() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Bold one two"}));
run(&mut s, "select.text", json!({"text": "Bold"}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "edit.formatPainter", json!({}));
assert!(s.painter.is_some());
run(&mut s, "edit.copyFormat", json!({}));
assert!(s.painter.is_none(), "Copy Formatting clears an armed brush");
assert!(s.copied_format.is_some());
run(&mut s, "edit.formatPainter", json!({}));
assert!(s.painter.is_some());
run(&mut s, "select.text", json!({"text": "one"}));
run(&mut s, "edit.pasteFormat", json!({}));
assert!(s.painter.is_none(), "a used-up brush is gone");
assert!(s.copied_format.is_some(), "its pick stays for Paste Formatting");
}
/// #5616: arming and cancelling the Format Painter leaves Copy Formatting's pick; a brush's pick
/// is what Paste Formatting applies next.
#[test]
fn cancelling_the_format_painter_keeps_the_copied_formatting() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Bold one two"}));
run(&mut s, "select.text", json!({"text": "Bold"}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "edit.copyFormat", json!({}));
run(&mut s, "edit.formatPainter", json!({}));
run(&mut s, "edit.formatPainter", json!({}));
assert!(s.painter.is_none());
run(&mut s, "select.text", json!({"text": "one"}));
run(&mut s, "edit.pasteFormat", json!({}));
assert_eq!(s.run("format.state", &json!({})).unwrap()["bold"], json!(true));
// A brush picked from plain text replaces the bold pick.
run(&mut s, "select.text", json!({"text": "two"}));
run(&mut s, "edit.formatPainter", json!({}));
run(&mut s, "select.text", json!({"text": "Bold"}));
run(&mut s, "edit.pasteFormat", json!({}));
assert_ne!(s.run("format.state", &json!({})).unwrap()["bold"], json!(true));
}
/// #5616: copied formatting carries no tracked paragraph formatting or numbering change.
#[test]
fn copied_paragraph_formatting_carries_no_tracked_changes() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "one\ntwo"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "select.text", json!({"text": "one"}));
run(&mut s, "para.alignCenter", json!({}));
run(&mut s, "review.trackChanges", json!({"value": false}));
run(&mut s, "select.text", json!({"text": "one"}));
let f = s.sel.focus.clone();
assert!(s.doc.para_at(&f).unwrap().props.fmt_change.is_some(), "the source paragraph has a tracked formatting change");
run(&mut s, "edit.copyFormat", json!({}));
let (_, para) = s.copied_format.clone().unwrap();
assert!(para.fmt_change.is_none() && para.num_change.is_none());
run(&mut s, "select.text", json!({"text": "two"}));
run(&mut s, "edit.pasteFormat", json!({}));
let f = s.sel.focus.clone();
let p = s.doc.para_at(&f).unwrap().props.clone();
assert!(p.fmt_change.is_none() && p.num_change.is_none());
}
/// #5616: copied formatting carries no hyperlink or revision marks, even into a collapsed caret.
#[test]
fn copied_formatting_carries_no_hyperlink() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "source target"}));
run(&mut s, "select.text", json!({"text": "source"}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "insert.link", json!({"url": "https://example.com/"}));
run(&mut s, "select.text", json!({"text": "so"}));
run(&mut s, "edit.copyFormat", json!({}));
let (chr, _) = s.copied_format.clone().unwrap();
assert_eq!(chr.bold, Some(true));
assert!(chr.link.is_none() && chr.ins.is_none() && chr.del.is_none() && chr.fmt_change.is_none());
run(&mut s, "select.text", json!({"text": "target"}));
run(&mut s, "select.collapse", json!({}));
run(&mut s, "caret.left", json!({}));
run(&mut s, "edit.pasteFormat", json!({}));
let f = s.sel.focus.clone();
let link = s.doc.para_at(&f).map(|p| p.props_of_char(f.off).link.clone());
assert_eq!(link, Some(None), "the target didn't become a link");
}
/// #5616: Paste Formatting at a bare caret keeps the word's own hyperlink and tracked insertion.
#[test]
fn a_caret_paste_formatting_keeps_the_targets_link_and_tracked_marks() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "source target"}));
run(&mut s, "select.text", json!({"text": "source"}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "edit.copyFormat", json!({}));
run(&mut s, "select.text", json!({"text": "target"}));
run(&mut s, "insert.link", json!({"url": "https://example.com/"}));
run(&mut s, "select.collapse", json!({}));
run(&mut s, "caret.right", json!({}));
run(&mut s, "edit.pasteFormat", json!({}));
let at_caret = |s: &Session| {
let f = s.sel.focus.clone();
s.doc.para_at(&f).map(|p| p.props_of_char(f.off).clone()).unwrap_or_default()
};
let c = at_caret(&s);
assert!(c.link.is_some(), "the target kept its hyperlink");
assert_eq!(c.bold, Some(true));
// A tracked insertion stays tracked.
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "text.insert", json!({"text": " added"}));
run(&mut s, "review.trackChanges", json!({"value": false}));
run(&mut s, "caret.left", json!({}));
assert!(at_caret(&s).ins.is_some(), "the insertion is tracked before");
run(&mut s, "edit.pasteFormat", json!({}));
assert!(at_caret(&s).ins.is_some(), "Paste Formatting kept the insertion mark");
}
/// #5616: Paste Formatting over a selection keeps the target's hyperlink too.
#[test]
fn a_range_paste_formatting_keeps_the_targets_link() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "source target"}));
run(&mut s, "select.text", json!({"text": "source"}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "edit.copyFormat", json!({}));
run(&mut s, "select.text", json!({"text": "target"}));
run(&mut s, "insert.link", json!({"url": "https://example.com/"}));
run(&mut s, "select.text", json!({"text": "target"}));
run(&mut s, "edit.pasteFormat", json!({}));
let (a, _) = s.sel.ordered();
let c = s.doc.para_at(&a).map(|p| p.props_of_char(a.off).clone()).unwrap_or_default();
assert!(c.link.is_some(), "the target kept its hyperlink");
assert_eq!(c.bold, Some(true));
}
/// A picture an OpenDocument file gives no size is shown at its own, fitted to the text as other
/// formats' are; one with a stated size keeps it, in line too.
#[test]
+3 -5
View File
@@ -123,15 +123,13 @@ mod tests {
assert_eq!(parse_color("#f00"), Some(Rgb(255, 0, 0)));
assert_eq!(parse_color("#f00f"), Some(Rgb(255, 0, 0)));
assert_eq!(parse_color("#0080FF"), Some(Rgb(0, 128, 255)));
assert_eq!(parse_color("#0080FFff"), Some(Rgb(0, 128, 255)));
// A colour with alpha is composited over the white page it lands on (#0080ff at 50%).
assert_eq!(parse_color("#0080ff80"), Some(Rgb(127, 191, 255)));
assert_eq!(parse_color("#0080ff80"), Some(Rgb(0, 128, 255)));
assert_eq!(parse_color("rgb(1, 2, 3)"), Some(Rgb(1, 2, 3)));
assert_eq!(parse_color("rgb(1 2 3)"), Some(Rgb(1, 2, 3)));
assert_eq!(parse_color("rgb(1 2 3 / 50%)"), Some(Rgb(128, 129, 129)));
assert_eq!(parse_color("rgb(1 2 3 / 50%)"), Some(Rgb(1, 2, 3)));
assert_eq!(parse_color("hsl(0, 100%, 50%)"), Some(Rgb(255, 0, 0)));
assert_eq!(parse_color("hsl(120deg 100% 50%)"), Some(Rgb(0, 255, 0)));
assert_eq!(parse_color("hsla(240, 100%, 50%, 0.8)"), Some(Rgb(51, 51, 255)));
assert_eq!(parse_color("hsla(240, 100%, 50%, 0.8)"), Some(Rgb(0, 0, 255)));
assert_eq!(parse_color("navy"), Some(Rgb(0, 0, 128)));
assert_eq!(parse_color("#zz"), None);
assert_eq!(parse_color("rgb(1)"), None);
+9 -73
View File
@@ -34,59 +34,24 @@ fn hsl_to_rgb(h: f64, s: f64, l: f64) -> Rgb {
Rgb(r, g, b)
}
/// Composite `rgb` over the white page a paste lands on, at CSS alpha `a` (0..1): `rgba(0, 0, 0,
/// 0.45)` reads like the rgb(140, 140, 140) it looks like on the site, instead of solid black.
fn over_white(r: u8, g: u8, b: u8, a: f32) -> Rgb {
let mix = |c: u8| (c as f32 * a + 255.0 * (1.0 - a)).round().clamp(0.0, 255.0) as u8;
Rgb(mix(r), mix(g), mix(b))
}
/// The alpha of a CSS colour's `/ alpha` or 4th-value part, where present: 1.0 (opaque) otherwise.
fn css_alpha(after: Option<&str>) -> f32 {
let Some(a) = after else { return 1.0 };
let a = a.trim();
// CSS Colour 4's `none` is a missing component, and a missing alpha is transparent.
if a.eq_ignore_ascii_case("none") {
return 0.0;
}
// A value that is not a finite number (`nan`, `inf`) is no alpha at all: clamp would keep
// NaN, and `over_white` would turn the colour black.
let parse = |t: &str| t.parse::<f32>().ok().filter(|f| f.is_finite());
if let Some(p) = a.strip_suffix('%') {
return parse(p.trim()).map(|f| f.clamp(0.0, 100.0) / 100.0).unwrap_or(1.0);
}
parse(a).map(|f| f.clamp(0.0, 1.0)).unwrap_or(1.0)
}
/// Parse a CSS / HTML colour: `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb(...)`, `hsl(...)` or a basic name.
/// A colour with alpha is composited over white: the model keeps opaque colours, and a paste lands
/// on a white page.
pub fn parse_color(s: &str) -> Option<Rgb> {
let s = s.trim().to_ascii_lowercase();
if let Some(h) = s.strip_prefix('#') {
if (h.len() == 3 || h.len() == 4) && h.is_ascii() {
let d = |i: usize| h.get(i..i + 1).and_then(|x| u8::from_str_radix(x, 16).ok()).map(|v| v * 17);
let (r, g, b) = (d(0)?, d(1)?, d(2)?);
// A 4th digit is the alpha, composited over the page like `rgba(...)`'s is.
let a = if h.len() == 4 { d(3)? as f32 / 255.0 } else { 1.0 };
return Some(over_white(r, g, b, a));
return Some(Rgb(d(0)?, d(1)?, d(2)?));
}
if h.len() == 8 && h.bytes().all(|b| b.is_ascii_hexdigit()) {
let p = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok());
return Some(over_white(p(0)?, p(2)?, p(4)?, p(6)? as f32 / 255.0));
return Some(Rgb(p(0)?, p(2)?, p(4)?));
}
return Rgb::parse(h);
}
if let Some(inner) = s.strip_prefix("rgb(").or_else(|| s.strip_prefix("rgba(")) {
let inner = inner.trim_end_matches(')').trim();
let (rgb_part, alpha_part) = inner.split_once('/').map_or((inner, None), |(a, b)| (a, Some(b)));
// Legacy comma syntax carries the alpha as a 4th value (`rgba(0, 0, 0, 0.45)`).
let parts: Vec<&str> = if rgb_part.contains(',') { rgb_part.split(',').collect() } else { rgb_part.split_whitespace().collect() };
let alpha = match alpha_part {
Some(a) => css_alpha(Some(a)),
None if parts.len() >= 4 => css_alpha(Some(parts[3])),
_ => 1.0,
};
let before_slash = inner.split('/').next().unwrap_or(inner);
let parts: Vec<&str> = if before_slash.contains(',') { before_slash.split(',').collect() } else { before_slash.split_whitespace().collect() };
let v: Vec<u8> = parts
.iter()
.take(3)
@@ -100,19 +65,14 @@ pub fn parse_color(s: &str) -> Option<Rgb> {
})
.collect();
if let [r, g, b] = v[..] {
return Some(over_white(r, g, b, alpha));
return Some(Rgb(r, g, b));
}
return None;
}
if let Some(inner) = s.strip_prefix("hsl(").or_else(|| s.strip_prefix("hsla(")) {
let inner = inner.trim_end_matches(')').trim();
let (hsl_part, alpha_part) = inner.split_once('/').map_or((inner, None), |(a, b)| (a, Some(b)));
let parts: Vec<&str> = if hsl_part.contains(',') { hsl_part.split(',').collect() } else { hsl_part.split_whitespace().collect() };
let alpha = match alpha_part {
Some(a) => css_alpha(Some(a)),
None if parts.len() >= 4 => css_alpha(Some(parts[3])),
_ => 1.0,
};
let before_slash = inner.split('/').next().unwrap_or(inner);
let parts: Vec<&str> = if before_slash.contains(',') { before_slash.split(',').collect() } else { before_slash.split_whitespace().collect() };
if parts.len() < 3 {
return None;
}
@@ -136,8 +96,7 @@ pub fn parse_color(s: &str) -> Option<Rgb> {
};
let s = parse_pct(parts[1])?;
let l = parse_pct(parts[2])?;
let Rgb(r, g, b) = hsl_to_rgb(h, s, l);
return Some(over_white(r, g, b, alpha));
return Some(hsl_to_rgb(h, s, l));
}
Some(match s.as_str() {
"black" => Rgb(0, 0, 0),
@@ -168,29 +127,6 @@ mod tests {
#[test]
fn eight_digit_hex_rejects_signs() {
assert_eq!(parse_color("#+1+2+3+4"), None);
// #11223344 is alpha 0x44 over white.
assert_eq!(parse_color("#11223344"), Some(Rgb(192, 196, 201)));
}
#[test]
fn colors_with_alpha_composite_over_white() {
// The secondary text colour of dark-theme sites: solid black would read as the site's
// own emphasis, white is invisible on the page; the composite is what the eye saw.
assert_eq!(parse_color("rgba(0, 0, 0, 0.45)"), Some(Rgb(140, 140, 140)));
assert_eq!(parse_color("rgb(0 0 0 / 0.45)"), Some(Rgb(140, 140, 140)));
assert_eq!(parse_color("rgba(0, 0, 0, 0)"), Some(Rgb(255, 255, 255)));
assert_eq!(parse_color("rgba(255, 0, 0, 1)"), Some(Rgb(255, 0, 0)));
assert_eq!(parse_color("rgba(255, 0, 0, 45%)"), Some(Rgb(255, 140, 140)));
assert_eq!(parse_color("hsla(120, 100%, 50%, 0.5)"), Some(Rgb(128, 255, 128)));
// A 4-digit hex's last digit is the alpha, too: #0008 is black at 8/15 over white.
assert_eq!(parse_color("#0008"), Some(Rgb(119, 119, 119)));
// `none` is a missing alpha, and a missing alpha is transparent, not the opaque default
// an omitted alpha takes.
assert_eq!(parse_color("rgb(0 0 0 / none)"), Some(Rgb(255, 255, 255)));
assert_eq!(parse_color("rgb(0 0 0)"), Some(Rgb(0, 0, 0)));
// An alpha that is not a finite number is no alpha: the colour stays opaque, not black.
assert_eq!(parse_color("rgba(0, 0, 0, nan)"), Some(Rgb(0, 0, 0)));
assert_eq!(parse_color("rgba(0, 0, 0, inf)"), Some(Rgb(0, 0, 0)));
assert_eq!(parse_color("rgba(0, 0, 0, nan%)"), Some(Rgb(0, 0, 0)));
assert_eq!(parse_color("#11223344"), Some(Rgb(0x11, 0x22, 0x33)));
}
}
+2 -18
View File
@@ -135,13 +135,7 @@ fn parse_full(bytes: &[u8], marks: bool) -> Result<Parsed, String> {
}
match n {
b'\'' => {
// `from_str_radix` takes a leading `+`: a pair that isn't both hex digits
// is not an escape at all.
let h = bytes
.get(i + 2..i + 4)
.and_then(|h| std::str::from_utf8(h).ok())
.filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit()))
.and_then(|h| u8::from_str_radix(h, 16).ok());
let h = bytes.get(i + 2..i + 4).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok());
match h {
Some(v) => {
if r.st.dest == Dest::Pict {
@@ -156,17 +150,7 @@ fn parse_full(bytes: &[u8], marks: bool) -> Result<Parsed, String> {
}
i += 4;
}
// Not two hex digits: the escape is malformed. Consume the escape and
// any hex digits of a truncated pair, and leave every other byte,
// `}` or a control word's `\`, to be read as the structure it is.
None => {
i += 2;
let mut n = 0;
while n < 2 && bytes.get(i).is_some_and(u8::is_ascii_hexdigit) {
i += 1;
n += 1;
}
}
None => i += 2,
}
}
b'*' => {
-67
View File
@@ -37,73 +37,6 @@ fn basic_reader() {
assert!(matches!(&l.inlines[0], Inline::Text(_, f) if f.link.as_deref() == Some("http://a.b")));
}
#[test]
fn stylesheet_names_take_unicode_escapes() {
// \u116?=t \u105?=i \u116?=t \u108?=l \u101?=e — how Word writes a non-Latin style name.
let rtf = br"{\rtf1\ansi{\stylesheet{\s0 Normal;}{\s2 \u116?\u105?\u116?\u108?\u101?;}}\pard\s2 T\par}";
let f = parse(rtf).unwrap();
assert_eq!(texts(&f)[0], "Title:T");
}
#[test]
fn stylesheet_names_take_negative_escapes() {
// 't' as a signed 16-bit escape: 116 - 65536 = -65420.
let rtf = br"{\rtf1\ansi{\stylesheet{\s0 Normal;}{\s2 \u-65420?\u105?\u116?\u108?\u101?;}}\pard\s2 T\par}";
let f = parse(rtf).unwrap();
assert_eq!(texts(&f)[0], "Title:T");
}
#[test]
fn stylesheet_names_honour_the_uc_fallback_count() {
// \uc2: two fallback bytes follow each \uN? — here a second `?`.
let rtf = br"{\rtf1\ansi{\stylesheet{\s0 Normal;}{\s2 \uc2\u116??\u105??\u116??\u108??\u101??;}}\pard\s2 T\par}";
let f = parse(rtf).unwrap();
assert_eq!(texts(&f)[0], "Title:T");
}
#[test]
fn a_malformed_hex_escape_leaves_no_stray_text() {
// A `\'` that is not followed by two hex digits is malformed: the escape itself goes, any
// hex digits of a truncated pair go with it, and everything else — non-hex bytes, `}` —
// is read as what it is. `\'gZ` keeps "gZ" as text; `\'4}` keeps the closing brace.
let rtf = br"{\rtf1\ansi \'gZok\par}";
let f = parse(rtf).unwrap();
assert_eq!(texts(&f)[0], "Normal:gZok");
// The bytes after a truncated pair must not swallow the group's closing brace or the next
// control word: the group and its destination state end where they should. Paragraph text
// alone would not show that: `survivor` reads the same inside the bold group, so the run's
// formatting is checked too.
let rtf = br"{\rtf1\ansi{\b \'4}survivor\par}";
let f = parse(rtf).unwrap();
assert!(texts(&f).iter().any(|t| t.contains("survivor")), "{:?}", texts(&f));
let survivor_bold = f.blocks.iter().find_map(|b| match b {
FBlock::Para(p) => p.inlines.iter().find_map(|i| match i {
Inline::Text(t, fmt) if t.contains("survivor") => Some(fmt.bold),
_ => None,
}),
FBlock::Table(_) => None,
});
assert_eq!(survivor_bold, Some(false), "survivor must not be inside the bold group: {:?}", f.blocks);
// The truncated pair's hex digit goes with the escape: no stray "4" is left in the text.
assert!(!texts(&f).iter().any(|t| t.contains('4')), "{:?}", texts(&f));
// A leading `+` is not a hex digit: `\'+4` is no escape: it stays text, not the byte 0x04.
let rtf = br"{\rtf1\ansi \'+4z\par}";
let f = parse(rtf).unwrap();
assert_eq!(texts(&f)[0], "Normal:+4z");
let rtf = br"{\rtf1\ansi{\'}}after\par}";
let f = parse(rtf).unwrap();
assert!(texts(&f).iter().any(|t| t.contains("after")), "{:?}", texts(&f));
// A hex digit that starts a valid pair with the byte after it is part of the escape:
// \'4} reads '4' as the escape's byte and leaves `}` to close the group.
let rtf = br"{\rtf1\ansi\'4}x\par}";
let f = parse(rtf).unwrap();
assert!(texts(&f).iter().any(|t| t.contains('x') && !t.contains('}')), "{:?}", texts(&f));
}
#[test]
fn unicode_fallback_uses_the_declared_count() {
for (rtf, expected) in [
+1 -3
View File
@@ -403,8 +403,7 @@ impl Reader {
}
true
}
// Style names take Unicode escapes (with fallback skipping) like the body.
Dest::StyleSheet if !matches!(word, "u" | "uc") => {
Dest::StyleSheet => {
let def = &mut self.style_def_cur;
match word {
"s" | "cs" | "ds" | "ts" => {
@@ -421,7 +420,6 @@ impl Reader {
}
true
}
Dest::StyleSheet => false,
Dest::ListTable => {
self.list_table_word(word, value);
true
-1
View File
@@ -92,7 +92,6 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
if drag.drag_started() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
crate::chrome::note_title_drag(ui.ctx(), band);
// The chevron's stroke starts at the content edge.
let c = if rtl {
pos2(band.max.x - 26.0, band.center().y)
+16
View File
@@ -253,6 +253,18 @@ pub struct CanvasState {
pub(crate) v_pressed: bool,
/// The modifiers last held with Mod, for a Mod+V whose Mod is released before V.
pub(crate) paste_mods: Option<egui::Modifiers>,
/// The modifiers a paste event is read with: those of Mod+V's press or a Mod change, until an
/// unrelated key, a pointer press or a focus change. (`paste_mods` also serves the release path.)
pub(crate) paste_event_mods: Option<egui::Modifiers>,
/// On the web, Mod+Shift+V pasted formatting from its key press: the browser's paste event
/// that follows is the same keystroke and pastes nothing (#5616). Any other key press clears it.
pub(crate) ignore_next_paste: bool,
/// When `ignore_next_paste` was set (input time, seconds): the browser's paired paste comes
/// with the key, so the wait ends after `PASTE_WAIT` and a later paste isn't dropped.
pub(crate) paste_wait_since: Option<f64>,
/// The platform sends a paste event after Mod+Shift+V's key (a browser does; a desktop shell
/// doesn't). Only then does the key leave a paste event to ignore or to let paste text.
pub(crate) paste_event_follows_chord: bool,
/// Draw Table or Eraser is on (`table_pen`): presses draw instead of moving the caret.
pub table_tool: Option<crate::table_pen::TableTool>,
/// The Draw Table stroke (or eraser press) in progress.
@@ -436,6 +448,10 @@ impl Default for CanvasState {
pasted: false,
v_pressed: false,
paste_mods: None,
paste_event_mods: None,
ignore_next_paste: false,
paste_wait_since: None,
paste_event_follows_chord: cfg!(target_arch = "wasm32"),
swallow_text: false,
ime_join_text: None,
ime_reset: false,
-22
View File
@@ -281,24 +281,6 @@ fn autosave_knob_x(rtl: bool, on: bool, r: Rect) -> f32 {
crate::rtl::lead_x(rtl != on, r, 8.0)
}
/// The `ctx.data` key for the part of the title bar that drags this document window.
fn title_drag_id(ctx: &egui::Context) -> egui::Id {
egui::Id::unique(("title_drag_rect", ctx.viewport_id()))
}
/// Notes `rect` as the part of the title bar that drags the window in this frame: behind a modal
/// dialog its backdrop has the pointer there, and moves the window instead ([`title_drag_rect`]).
pub(crate) fn note_title_drag(ctx: &egui::Context, rect: Rect) {
let (key, pass) = (title_drag_id(ctx), ctx.cumulative_pass_nr());
ctx.data_mut(|d| d.insert_temp(key, (rect, pass)));
}
/// The part of the title bar that drags the window, if it was drawn in this frame.
pub(crate) fn title_drag_rect(ctx: &egui::Context) -> Option<Rect> {
let (key, pass) = (title_drag_id(ctx), ctx.cumulative_pass_nr());
ctx.data(|d| d.get_temp::<(Rect, u64)>(key)).filter(|(_, p)| *p == pass).map(|(r, _)| r)
}
/// Draws the title bar with window dragging, AutoSave, quick access, account, community and search
/// controls, and the centred clickable document title.
pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
@@ -319,10 +301,6 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
if drag.drag_started() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
// Elsewhere the system's own title bar above this one moves the window.
if mac {
note_title_drag(ui.ctx(), full);
}
if drag.double_clicked() {
let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false));
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
-1
View File
@@ -455,7 +455,6 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
let resp = egui::Modal::new(Id::unique("wordcraft.colorsDialog")).show(ctx, |ui| body(ui, &mut d));
// egui's Modal blocks the window behind it, but not the document's keyboard.
crate::dialogs::release_keyboard(app, ctx);
crate::dialogs::drag_by_title_bar(ctx, &resp.backdrop_response, resp.response.rect);
let mut done = resp.inner;
if done.is_none() && resp.should_close() {
done = Some(false);
+1 -3
View File
@@ -34,9 +34,7 @@ use bookmarks::bookmark_at_caret;
#[cfg(test)]
use bookmarks::bookmark_names;
pub use chrome::show;
pub(crate) use chrome::{
block_behind, buttons, cancel_key_pressed, color_menu, drag_by_title_bar, forget_nudge, ok_cancel_responses, release_keyboard,
};
pub(crate) use chrome::{block_behind, buttons, cancel_key_pressed, color_menu, forget_nudge, ok_cancel_responses, release_keyboard};
pub(crate) use dispatch::search_results;
pub(crate) use find::swallow_shortcut_text;
pub use find::{FindExtra, FindTab, GoToWhat};
-11
View File
@@ -67,16 +67,6 @@ fn count_dialog_frame(ctx: &egui::Context, id: egui::Id) {
}
}
/// Behind a modal window, whose `backdrop` has the pointer, the app still moves by its title bar,
/// as in Word for Mac (#5124); not from just inside the window's edge, where it reaches over the
/// title bar.
pub(crate) fn drag_by_title_bar(ctx: &egui::Context, backdrop: &egui::Response, window: egui::Rect) {
let origin = ctx.input(|i| i.pointer.press_origin()).filter(|p| !window.contains(*p));
if backdrop.drag_started() && origin.is_some_and(|p| crate::chrome::title_drag_rect(ctx).is_some_and(|r| r.contains(p))) {
ctx.send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
}
/// Makes `window` modal (#3859): the document gives up the keyboard now, and from the next frame
/// nothing beneath the window takes the pointer or Tab. A press beside it nudges it ([`nudge`]).
pub(crate) fn block_behind(app: &WordApp, ctx: &egui::Context, window: &egui::Response) {
@@ -91,7 +81,6 @@ pub(crate) fn block_behind(app: &WordApp, ctx: &egui::Context, window: &egui::Re
.inner;
// The backdrop also has the pointer just inside the window's edge (egui's hit radius).
let beside = ctx.input(|i| i.pointer.any_pressed() && i.pointer.press_origin().is_some_and(|p| !window.rect.contains(p)));
drag_by_title_bar(ctx, &backdrop, window.rect);
ctx.move_to_top(layer);
ctx.memory_mut(|m| m.set_modal_layer(layer));
ctx.accesskit_node_builder(window.id, |node| node.set_modal());
-110
View File
@@ -2078,116 +2078,6 @@ fn the_page_waits_for_the_keyboard_until_a_modal_dialog_closes() {
assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "!", "the page has it after the dialog");
}
/// Drags from `at` to 30 points right in `h`, and counts the window drags the app asked for.
fn window_drags(h: &mut egui_kittest::Harness<WordApp>, at: egui::Pos2) -> usize {
let mut drags = 0;
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
let events = [egui::Event::PointerMoved(at), button(at, true)]
.into_iter()
.chain([10.0, 20.0, 30.0].map(|dx| egui::Event::PointerMoved(at + vec2(dx, 0.0))))
.chain([button(at + vec2(30.0, 0.0), false)]);
for e in events {
h.event(e);
h.step();
drags += h.output().viewport_output.values().flat_map(|v| &v.commands).filter(|c| matches!(c, egui::ViewportCommand::StartDrag)).count();
}
drags
}
/// #5124: behind a modal dialog the window still moves by its title bar, as in Word for Mac, where
/// WordCraft draws it, or by the top of File's side menu, which covers it there; elsewhere the
/// system's title bar does, and WordCraft's bar under it doesn't.
#[test]
fn the_window_moves_by_its_title_bar_behind_a_modal_dialog() {
for (mac, backstage) in [(true, false), (true, true), (false, false), (false, true)] {
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.integrated_titlebar = mac;
a.ui.backstage = backstage;
let mut h = app_harness(a);
h.run_steps(6);
let font = Dialog::open("font", h.state_mut());
h.state_mut().dialog = font;
h.run_steps(4);
let at = egui::pos2(if backstage { 100.0 } else { 300.0 }, 12.0);
assert_eq!(window_drags(&mut h, at), usize::from(mac), "macOS: {mac}, File: {backstage}");
assert!(matches!(h.state().dialog, Some(Dialog::Font { .. })), "Font stays");
assert_eq!(h.state().ui.backstage, backstage, "nothing behind it opened or closed");
}
}
/// Opens a modal state in `a`: the built-in file browser, a dialog by name, or Colors.
fn open_modal_state(a: &mut WordApp, state: &str) {
match state {
"none" => {}
"fileBrowser" => {
let document = a.session.document_id();
a.show_file_browser(
crate::file_dialogs::FileDialogRequest::Open { purpose: "document".into() },
crate::file_dialogs::AfterPick::OpenDocument { document },
false,
);
}
"colors" => a.colors = Some(crate::colors::ColorsDialog::new(None, crate::colors::Target::cmd("format.color"))),
dialog => a.dialog = Dialog::open(dialog, a),
}
}
/// The file browser, a modal dialog and Colors each block the window; nothing open or a modeless
/// dialog doesn't.
#[test]
fn modal_open_is_true_for_the_file_browser_a_modal_dialog_and_colors() {
for (state, modal) in [("none", false), ("find", false), ("font", true), ("fileBrowser", true), ("colors", true)] {
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default());
open_modal_state(&mut a, state);
assert_eq!(a.modal_open(), modal, "{state}");
}
}
/// #5124: whatever is open, a drag on the title bar macOS draws moves the window; where the system
/// draws it, WordCraft's bar under it only does while nothing blocks it.
#[test]
fn the_title_bar_moves_the_window_in_every_modal_state() {
for (state, modal) in [("none", false), ("find", false), ("font", true), ("fileBrowser", true), ("colors", true)] {
for mac in [true, false] {
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.integrated_titlebar = mac;
let mut h = app_harness(a);
h.run_steps(6);
open_modal_state(h.state_mut(), state);
h.run_steps(4);
assert_eq!(h.state().modal_open(), modal, "{state}");
assert_eq!(window_drags(&mut h, egui::pos2(300.0, 12.0)), usize::from(mac || !modal), "{state}, macOS: {mac}");
assert_eq!(h.state().modal_open(), modal, "{state} stays open");
}
}
}
/// #5124: where a dialog reaches over the title bar (a short window), a drag on the dialog is the
/// dialog's own and doesn't move the window.
#[test]
fn a_drag_on_a_dialog_over_the_title_bar_doesnt_move_the_window() {
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.integrated_titlebar = true;
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 420.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
h.run_steps(6);
let font = Dialog::open("font", h.state_mut());
h.state_mut().dialog = font;
h.run_steps(4);
let rect = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", "Font")))).expect("the dialog is open");
let at = egui::pos2(rect.center().x, rect.top() + 8.0);
assert!(at.y < 30.0, "the dialog reaches over the title bar: {rect:?}");
assert_eq!(window_drags(&mut h, at), 0, "a drag on its title");
let rect = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", "Font")))).expect("the dialog is open");
assert_eq!(window_drags(&mut h, egui::pos2(rect.center().x, rect.top() + 1.0)), 0, "nor just inside its edge");
}
#[test]
fn font_sample_props_map_each_field_to_its_property() {
use super::font::font_sample_props;
+65 -4
View File
@@ -46,6 +46,8 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) {
interrupted_preedit: std::mem::take(&mut app.canvas.ime_interrupted_preedit),
interrupted_replacement: EquationReplacementDrop::None,
};
let now = ctx.input(|i| i.time);
expire_paste_wait(app, now);
let held = ctx.input(|i| i.modifiers);
if held.command {
app.canvas.paste_mods = Some(held);
@@ -60,7 +62,8 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) {
continue;
}
note_key_state(app, &e);
if drop_refused_replacement(app, &e, &mut batch)
if ignored_paste(app, &e)
|| drop_refused_replacement(app, &e, &mut batch)
|| prefilter(app, ctx, &e, alt_codes)
|| paste_release(app, &e)
|| equation_event(app, ctx, &e, &mut batch)
@@ -69,6 +72,27 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) {
}
handle_event(app, ctx, e);
}
if app.canvas.ignore_next_paste && app.canvas.paste_wait_since.is_none() {
app.canvas.paste_wait_since = Some(now);
}
}
/// How long the paste event paired with Mod+Shift+V is waited for. A browser sends it with the
/// key, in the same frame; with no text on the clipboard it sends none at all.
const PASTE_WAIT: f64 = 1.0;
/// Ends a wait for the paired paste event that never came, so a later paste (a browser menu's,
/// say) isn't dropped (#5616).
fn expire_paste_wait(app: &mut WordApp, now: f64) {
if !app.canvas.ignore_next_paste {
app.canvas.paste_wait_since = None;
} else if app.canvas.paste_wait_since.is_some_and(|t| now - t > PASTE_WAIT) {
app.canvas.ignore_next_paste = false;
app.canvas.paste_wait_since = None;
// No key came since the chord (any key ends the wait), so noted modifiers are the
// chord's: a later paste reads the modifiers held then.
app.canvas.paste_event_mods = None;
}
}
/// State carried across the events of one raw batch.
@@ -158,20 +182,52 @@ fn stale_composition(app: &mut WordApp, e: &egui::Event, b: &mut Batch) -> bool
fn note_key_state(app: &mut WordApp, e: &egui::Event) {
// Which presses reached the canvas, and the modifiers held with Mod, in event order: Mod
// can go down and up within one frame, leaving the frame's modifiers without it.
if matches!(e, egui::Event::Key { pressed: true, key, .. } if !is_modifier_key(*key))
|| matches!(e, egui::Event::WindowFocused(_) | egui::Event::PointerButton { pressed: true, .. })
{
app.canvas.ignore_next_paste = false;
}
match &e {
egui::Event::ModifiersChanged(m) if m.command => app.canvas.paste_mods = Some(*m),
egui::Event::Key { key: Key::V | Key::Paste, pressed: true, .. } => app.canvas.v_pressed = true,
egui::Event::ModifiersChanged(m) => {
app.canvas.paste_event_mods = m.command.then_some(*m);
if m.command {
app.canvas.paste_mods = Some(*m);
}
}
egui::Event::Key { key: Key::V | Key::Paste, pressed: true, modifiers, .. } => {
app.canvas.v_pressed = true;
app.canvas.paste_event_mods = modifiers.command.then_some(*modifiers);
}
egui::Event::Key { key, pressed: true, modifiers, .. } if !is_modifier_key(*key) => {
app.canvas.paste_mods = modifiers.command.then_some(*modifiers);
// An unrelated key's modifiers mustn't steer a later keyless paste.
app.canvas.paste_event_mods = None;
}
egui::Event::PointerButton { pressed: true, .. } => app.canvas.paste_event_mods = None,
egui::Event::WindowFocused(_) => {
app.canvas.v_pressed = false;
app.canvas.paste_mods = None;
app.canvas.paste_event_mods = None;
}
_ => {}
}
}
/// The browser's paste event that follows a Mod+Shift+V that pasted formatting is the same
/// keystroke: ignored before an equation or a picture-file paste can take it (#5616).
/// With no text on the clipboard eframe sends no paste event, so the wait ends at the next key,
/// pointer press or focus change, or after `PASTE_WAIT`.
fn ignored_paste(app: &mut WordApp, e: &egui::Event) -> bool {
if matches!(e, egui::Event::Paste(_)) && std::mem::take(&mut app.canvas.ignore_next_paste) {
// Not a Mod+V with no text either: the release mustn't paste a picture.
app.canvas.pasted = true;
// Its Mod+Shift is used up too, or a later menu-bar paste would read as Keep Text Only.
app.canvas.paste_event_mods = None;
return true;
}
false
}
/// The replacement paired with a refused equation deletion is dropped.
fn drop_refused_replacement(app: &mut WordApp, e: &egui::Event, b: &mut Batch) -> bool {
// The replacement is the event immediately after the refused deletion: one `Commit`,
@@ -509,10 +565,15 @@ fn handle_event(app: &mut WordApp, ctx: &egui::Context, e: egui::Event) {
egui::Event::Ime(egui::ImeEvent::Commit(text)) => ime_commit(app, text),
egui::Event::Paste(t) => {
// Mod+Alt+V arrives as a paste too (the shell reads the clipboard): Paste Special.
let m = ctx.input(|i| i.modifiers);
// The modifiers held with Mod in event order: Shift may be up by the frame's end.
app.canvas.paste_mods = None;
let m = app.canvas.paste_event_mods.take().unwrap_or_else(|| ctx.input(|i| i.modifiers));
// A browser sends Mod+Shift+V as a paste too: Keep Text Only, as the key does on the
// desktop (#1537). (egui-winit sends that chord as a key, and Shift+Insert has no Mod.)
let id = match (m.alt, m.shift) {
(true, true) => "edit.pasteText",
(true, false) => "edit.pasteSpecial",
(false, true) if m.command => "edit.pasteText",
_ => "edit.paste",
};
let _ = app.run(id, json!({"text": t}));
+20 -1
View File
@@ -132,7 +132,26 @@ pub(super) fn dispatch(app: &mut WordApp, key: Key, m: Modifiers) -> bool {
if m.alt && !crate::widgets::enabled(app, spec.id) {
return false;
}
let _ = app.run(spec.id, json!({}));
// Mod+Shift+V pastes the formatting Mod+Shift+C (or the Format Painter) picked up; with
// none picked up it pastes the clipboard as text only, as Word for Windows does (#1537,
// #4943). Another key bound to Paste Formatting only pastes formatting.
let id = if spec.id == "edit.pasteFormat" {
let formatting = app.session.painter.is_some() || app.session.copied_format.is_some();
let chord = key == Key::V && m.command && m.shift && !m.alt;
// A browser sends a paste event after that chord (the desktop doesn't): after
// pasting formatting it's ignored; with none, it pastes the text there, once. A
// rebinding to another key makes no paste event.
let paste_follows = chord && app.canvas.paste_event_follows_chord;
app.canvas.ignore_next_paste = formatting && paste_follows;
app.canvas.paste_wait_since = None;
if !formatting && paste_follows {
return true;
}
if !formatting && chord { "edit.pasteText" } else { spec.id }
} else {
spec.id
};
let _ = app.run(id, json!({}));
return true;
}
let Some(name) = name else { return false };
+179
View File
@@ -133,6 +133,44 @@ fn only_plain_paste_releases_paste_a_clipboard_picture() {
assert!(!is_paste_special_release(Key::V, m(true, true, true)) && !is_paste_special_release(Key::V, m(true, false, false)));
}
/// #1537: a browser sends Mod+Shift+V as a paste event; it pastes text only, in the destination's
/// formatting, though the clipboard also holds bold HTML. Plain Mod+V keeps the HTML's bold.
#[test]
fn a_mod_shift_paste_event_keeps_text_only() {
for (modifiers, bold) in [(Modifiers::COMMAND | Modifiers::SHIFT, false), (Modifiers::COMMAND, true)] {
let services = crate::Services { clipboard_html: Some(Box::new(|| Some("<p><b>external</b></p>".into()))), ..Default::default() };
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), services);
let ctx = egui::Context::default();
mods_frame(&ctx, &mut a, modifiers, vec![egui::Event::Paste("external".into())]);
assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body), "external", "{modifiers:?}");
let state = a.session.run("format.state", &json!({})).unwrap();
assert_eq!(state.get("bold").and_then(serde_json::Value::as_bool) == Some(true), bold, "{modifiers:?}: {state}");
}
}
/// #5616: a browser sends Mod+Shift+V as its key and then a paste event. After Mod+Shift+C the
/// key pastes the formatting and the paste event that follows doesn't replace the text; a later
/// Mod+V still pastes.
#[test]
fn a_paste_event_after_mod_shift_v_pasted_formatting_is_ignored() {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
a.canvas.paste_event_follows_chord = true;
a.run("text.insert", json!({"text": "source target"})).unwrap();
a.run("select.text", json!({"text": "source"})).unwrap();
a.run("format.bold", json!({})).unwrap();
a.run("edit.copyFormat", json!({})).unwrap();
a.run("select.text", json!({"text": "target"})).unwrap();
let ctx = egui::Context::default();
let chord = Modifiers::COMMAND | Modifiers::SHIFT;
mods_frame(&ctx, &mut a, chord, vec![key_event(Key::V, true, chord), egui::Event::Paste("clipboard text".into())]);
let body = |a: &WordApp| a.session.doc.plain_text(wordcraft_doc::StoryRef::Body);
assert_eq!(body(&a), "source target", "the paste event didn't replace the formatted text");
let bold = a.session.run("format.state", &json!({})).unwrap().get("bold").and_then(serde_json::Value::as_bool);
assert_eq!(bold, Some(true), "the key pasted the formatting");
mods_frame(&ctx, &mut a, Modifiers::COMMAND, vec![key_event(Key::V, true, Modifiers::COMMAND), egui::Event::Paste("pasted".into())]);
assert_eq!(body(&a), "source pasted", "the next Mod+V pastes");
}
/// An empty document whose clipboard holds a 4 × 4 picture.
fn picture_app() -> WordApp {
use crate::paste_picture::ClipboardPicture;
@@ -154,6 +192,129 @@ fn mods_frame(ctx: &egui::Context, a: &mut WordApp, modifiers: Modifiers, events
ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| canvas_events(a, ui.ctx())).drop_without_applying_deltas();
}
/// A source word in bold with Copy Formatting picked up, and "target" selected.
fn copied_bold_app() -> WordApp {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
a.run("text.insert", json!({"text": "source target"})).unwrap();
a.run("select.text", json!({"text": "source"})).unwrap();
a.run("format.bold", json!({})).unwrap();
a.run("edit.copyFormat", json!({})).unwrap();
a.run("select.text", json!({"text": "target"})).unwrap();
a
}
/// #5616: on a desktop no paste event follows Mod+Shift+V's key, so none is swallowed: a later
/// paste (from a menu, say) pastes.
#[test]
fn a_desktop_paste_after_mod_shift_v_is_not_swallowed() {
let mut a = copied_bold_app();
assert!(!a.canvas.paste_event_follows_chord || cfg!(target_arch = "wasm32"));
a.canvas.paste_event_follows_chord = false;
let ctx = egui::Context::default();
let chord = Modifiers::COMMAND | Modifiers::SHIFT;
mods_frame(&ctx, &mut a, chord, vec![key_event(Key::V, true, chord)]);
assert!(!a.canvas.ignore_next_paste);
mods_frame(&ctx, &mut a, Modifiers::NONE, vec![egui::Event::Paste("later".into())]);
assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body), "source later");
}
/// #5616: a pointer press ends the wait for the browser's paste event, so a later keyless paste
/// (context menu, touch) isn't swallowed.
#[test]
fn a_pointer_press_ends_the_wait_for_the_ignored_paste() {
let mut a = copied_bold_app();
a.canvas.ignore_next_paste = true;
let ctx = egui::Context::default();
let press =
egui::Event::PointerButton { pos: egui::pos2(5.0, 5.0), button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE };
mods_frame(&ctx, &mut a, Modifiers::NONE, vec![press]);
assert!(!a.canvas.ignore_next_paste);
}
/// #5616: the modifiers noted for a paste are used up by it: a later paste with no Mod held
/// keeps the HTML's formatting instead of reading the stale Mod+Shift.
#[test]
fn a_paste_event_uses_up_the_noted_paste_modifiers() {
let services = crate::Services { clipboard_html: Some(Box::new(|| Some("<p><b>external</b></p>".into()))), ..Default::default() };
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), services);
let ctx = egui::Context::default();
mods_frame(&ctx, &mut a, Modifiers::COMMAND | Modifiers::SHIFT, vec![egui::Event::Paste("external".into())]);
assert!(a.canvas.paste_mods.is_none());
a.run("select.all", json!({})).unwrap();
mods_frame(&ctx, &mut a, Modifiers::NONE, vec![egui::Event::Paste("external".into())]);
let bold = a.session.run("format.state", &json!({})).unwrap().get("bold").and_then(serde_json::Value::as_bool);
assert_eq!(bold, Some(true), "the later paste was a plain paste");
}
/// #5616: the dropped browser paste uses up its Mod+Shift, so a later menu-bar paste with no key
/// or pointer press in between isn't read as Keep Text Only.
#[test]
fn the_ignored_paste_uses_up_its_paste_modifiers() {
let mut a = copied_bold_app();
a.canvas.paste_event_follows_chord = true;
let ctx = egui::Context::default();
let chord = Modifiers::COMMAND | Modifiers::SHIFT;
mods_frame(&ctx, &mut a, chord, vec![key_event(Key::V, true, chord), egui::Event::Paste("x".into())]);
assert!(!a.canvas.ignore_next_paste);
assert!(a.canvas.paste_event_mods.is_none());
}
/// #5616: with no text on the clipboard the browser sends no paste event after Mod+Shift+V; the
/// wait for it ends, so a later menu-bar paste with no key or pointer press in between pastes.
#[test]
fn a_menu_bar_paste_after_an_unpaired_mod_shift_v_pastes() {
let mut a = copied_bold_app();
a.canvas.paste_event_follows_chord = true;
let ctx = egui::Context::default();
let chord = Modifiers::COMMAND | Modifiers::SHIFT;
let frame = |a: &mut WordApp, time: f64, events: Vec<egui::Event>| {
ctx.run_ui(egui::RawInput { time: Some(time), events, ..Default::default() }, |ui| canvas_events(a, ui.ctx())).drop_without_applying_deltas();
};
frame(&mut a, 10.0, vec![egui::Event::ModifiersChanged(chord), key_event(Key::V, true, chord)]);
assert!(a.canvas.ignore_next_paste, "the key waits for its paired paste");
// No Mod release reaches the canvas (the menu bar takes it).
frame(&mut a, 10.5, vec![]);
assert!(a.canvas.ignore_next_paste, "still within the wait");
frame(&mut a, 12.0, vec![]);
assert!(!a.canvas.ignore_next_paste, "the wait is over");
assert!(a.canvas.paste_event_mods.is_none(), "the chord's Mod+Shift is used up");
frame(&mut a, 12.1, vec![egui::Event::Paste("menu".into())]);
assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body), "source menu", "the menu-bar paste pasted");
}
/// #5616: the browser paste event after a formatting paste is dropped before an open equation
/// can type it.
#[test]
fn the_ignored_paste_event_never_reaches_an_open_equation() {
let mut a = copied_bold_app();
a.canvas.paste_event_follows_chord = true;
a.run("insert.equation", json!({"linear": "abc"})).unwrap();
a.canvas.ignore_next_paste = true;
let ctx = egui::Context::default();
let before = a.session.math.clone();
mods_frame(&ctx, &mut a, Modifiers::COMMAND, vec![egui::Event::Paste("zzz".into())]);
assert_eq!(a.session.math, before, "the ignored paste typed nothing into the equation");
assert!(!a.canvas.ignore_next_paste);
}
/// #5616: only Mod+Shift+V leaves a browser paste event; Paste Formatting on another key neither
/// arms the ignore flag nor waits for a paste.
#[test]
fn paste_formatting_on_another_key_leaves_no_paste_to_ignore() {
for formatting in [true, false] {
let mut a = if formatting {
copied_bold_app()
} else {
WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default())
};
a.canvas.paste_event_follows_chord = true;
a.session.keymap = wordcraft_engine::keymap::KeyMap::from_value(&json!({"assigned": {"Mod+Alt+P": "edit.pasteFormat"}}));
let m = Modifiers::COMMAND | Modifiers::ALT;
super::shortcuts::dispatch(&mut a, Key::P, m);
assert!(!a.canvas.ignore_next_paste, "formatting={formatting}");
}
}
/// A press or release of `key` with `modifiers`.
fn key_event(key: Key, pressed: bool, modifiers: Modifiers) -> egui::Event {
egui::Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers }
@@ -1336,6 +1497,24 @@ fn typing_with_smart_art_selected_types_into_an_item() {
assert_eq!(g.items[0].text, "Ada", "one undo step for the typing");
}
/// #5616: modifiers recorded for an unrelated key (Mod+Shift+C) don't steer a later keyless paste,
/// whether Mod was released or is still held with no change event.
#[test]
fn an_unrelated_keys_modifiers_do_not_steer_a_later_paste() {
for mod_still_held in [false, true] {
let services = crate::Services { clipboard_html: Some(Box::new(|| Some("<p><b>external</b></p>".into()))), ..Default::default() };
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), services);
let ctx = egui::Context::default();
let chord = Modifiers::COMMAND | Modifiers::SHIFT;
mods_frame(&ctx, &mut a, chord, vec![key_event(Key::C, true, chord)]);
// Shift released, Mod still held: Mod+C's chord isn't the paste's modifiers.
let after = if mod_still_held { Modifiers::COMMAND } else { Modifiers::NONE };
mods_frame(&ctx, &mut a, after, vec![egui::Event::Paste("external".into())]);
let bold = a.session.run("format.state", &json!({})).unwrap().get("bold").and_then(serde_json::Value::as_bool);
assert_eq!(bold, Some(true), "held={mod_still_held}: a plain paste keeps the HTML's bold");
}
}
#[test]
fn delete_previous_sentence_shortcut_dispatches_and_can_be_reassigned() {
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
+39 -3
View File
@@ -4416,7 +4416,8 @@ fn ctrl_shift_c_and_v_copy_and_paste_formatting_not_text() {
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
h.event(key(egui::Key::C, modifiers));
h.step();
assert!(h.state().session.painter.is_some(), "{modifiers:?}: Copy Formatting picked up the selection's formatting");
assert!(h.state().session.copied_format.is_some(), "{modifiers:?}: Copy Formatting picked up the selection's formatting");
assert!(h.state().session.painter.is_none(), "{modifiers:?}: Copy Formatting doesn't arm the Format Painter brush");
assert_eq!(h.state().session.clip_history.len(), clipboard_entries, "{modifiers:?}: Copy Formatting preserved clipboard history");
assert_eq!(h.state().session.clipboard_text, "plain", "{modifiers:?}: the existing clipboard text was preserved");
assert!(copied(&h).is_empty(), "{modifiers:?}: no text reached the system clipboard: {:?}", copied(&h));
@@ -4427,14 +4428,49 @@ fn ctrl_shift_c_and_v_copy_and_paste_formatting_not_text() {
h.event(key(egui::Key::V, modifiers));
h.step();
assert_eq!(bold(&mut h), Some(true), "{modifiers:?}: Paste Formatting made the target bold");
assert!(h.state().session.painter.is_none(), "{modifiers:?}: the non-sticky painter is used up");
assert_eq!(body_text(h.state()), "Bold plain", "{modifiers:?}: pasting formatting left the document text alone");
assert!(h.state().session.copied_format.is_some(), "{modifiers:?}: copied formatting stays for the next Paste Formatting");
// Pasted again, as in Word: formatting again, never the clipboard's text (#5616).
h.state_mut().run("caret.docEnd", json!({})).unwrap();
h.state_mut().run("text.insert", json!({"text": " more"})).unwrap();
h.state_mut().run("select.text", json!({"text": "more"})).unwrap();
// It types on in the pasted bold: make it plain first.
h.state_mut().run("format.bold", json!({})).unwrap();
assert_eq!(bold(&mut h), Some(false));
h.event(key(egui::Key::V, modifiers));
h.step();
assert_eq!(bold(&mut h), Some(true), "{modifiers:?}: a second Paste Formatting made the next target bold");
assert_eq!(body_text(h.state()), "Bold plain more", "{modifiers:?}: the second paste left the text alone");
assert_eq!(h.state().session.clip_history.len(), clipboard_entries, "{modifiers:?}: Paste Formatting preserved clipboard history");
assert_eq!(h.state().session.clipboard_text, "plain", "{modifiers:?}: Paste Formatting preserved the clipboard text");
assert!(copied(&h).is_empty(), "{modifiers:?}: pasting formatting put no text on the system clipboard: {:?}", copied(&h));
}
}
/// #1537/#4943: with no formatting picked up, Ctrl/Cmd+Shift+V pastes the clipboard as text
/// only (Word for Windows): the text arrives in the destination's formatting, not the source's.
#[test]
fn ctrl_shift_v_without_copied_formatting_pastes_text_only() {
let mut a = app();
a.run("text.insert", json!({"text": "Bold plain"})).unwrap();
a.run("select.text", json!({"text": "Bold"})).unwrap();
a.run("format.bold", json!({})).unwrap();
a.run("edit.copy", json!({})).unwrap();
a.run("select.text", json!({"text": "plain"})).unwrap();
// The destination has formatting of its own, so a paste that dropped it would show.
a.run("format.italic", json!({})).unwrap();
assert!(a.session.painter.is_none());
let mut h = harness(a);
h.run_steps(6);
let modifiers = egui::Modifiers::COMMAND | egui::Modifiers::SHIFT;
h.event(egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: true, repeat: false, modifiers });
h.step();
assert_eq!(body_text(h.state()), "Bold Bold", "the clipboard text replaced the selection");
let bold = h.state_mut().run("format.state", json!({})).unwrap().get("bold").and_then(Value::as_bool);
assert_ne!(bold, Some(true), "the pasted text took the destination's formatting, not the copied bold");
let italic = h.state_mut().run("format.state", json!({})).unwrap().get("italic").and_then(Value::as_bool);
assert_eq!(italic, Some(true), "the pasted text kept the destination's italic");
}
/// #1669: with Shift held, Backspace still erases (#707) and Delete cuts the selection, doing
/// nothing without one, as in Word for Windows. (Windows sends Shift+Delete as a Cut; macOS and
/// Linux send the key, which reached no command.)
+3 -3
View File
File diff suppressed because one or more lines are too long
-7
View File
@@ -1,9 +1,2 @@
sonar.projectKey=storytold_wordcraft
sonar.organization=storytold
# S4790 (weak hash): SHA-1 is a start-key and "SHA1/1K" checksum algorithm that ODF package
# encryption (ODF 1.3 Part 2 §4.16) names, and older office suites wrote it. It is needed to open
# those files, not chosen for new security: WordCraft writes a SHA-256 start key and checksum.
sonar.issue.ignore.multicriteria=odt_sha1
sonar.issue.ignore.multicriteria.odt_sha1.ruleKey=rust:S4790
sonar.issue.ignore.multicriteria.odt_sha1.resourceKey=crates/formats/src/odt/crypt.rs