Compare commits

..
Author SHA1 Message Date
FredApps 12869b8e02 Merge branch 'main' into sonar/format-mandated-sha1 2026-11-10 18:23:24 +01:00
8548b33be1 MacOS: the window moves by its title bar behind a modal dialog (#5124) (#5125)
* #5124: the window moves by its title bar behind a modal dialog

On macOS WordCraft draws its own title bar, and since modal dialogs
(#3859, #4076) the dialog's backdrop covers it, so the window couldn't be
dragged while a dialog was open. Word for Mac lets you drag the document
window by its title bar behind its Font dialog (and beeps on the click).

- The title bar (and the Backstage's top band on macOS) notes the part
  that drags the window each frame. When the modal backdrop's drag starts
  there, it moves the window; the press still shakes the dialog, as Word
  beeps.
- Only the backdrop's own drag counts, so a drag on the dialog, its tabs
  or its popups, where it reaches over the title bar, moves only the
  dialog.

Tested by the developer on a Mac.

Fixes #5124.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5124: only the macOS title bar moves the window behind a dialog

Review (CodeRabbit):
- Elsewhere the system's title bar already moves the window; WordCraft's
  own bar under it now doesn't, behind a modal dialog either, so nothing
  changes on Linux and Windows.
- A drag from just inside the dialog's edge, where it reaches over the
  title bar, moves only the dialog, as the nudge already tells it apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5124: the window moves by its title bar behind Colors too; modal_open tests

Colors is an egui Modal, not a window with the backdrop of block_behind, so
a drag on the macOS title bar did nothing behind it. It now shares the check
with the other modal windows.

The new tests pin modal_open with nothing open, a modeless dialog, a modal
dialog, the file browser and Colors, and that the title bar moves the window
in each of those while the dialog stays open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5124: the modal drag test covers the top of File's side menu

On macOS File's side menu covers the title bar, and its top band moves the
window. The test now drags there too, so losing the band's note_title_drag
call fails it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-10 14:04:45 +01:00
FredApps abd5a6e957 sonar: cite the ODF section the SHA-1 algorithms come from 2026-11-10 11:21:57 +01:00
FredApps 8dac5c7802 sonar: SHA-1 in ODT decryption is the format's, not a choice 2026-11-10 11:21:35 +01:00
2922fe566b RTF: style-sheet names take Unicode escapes; a malformed \' escape leaks no bytes (#6165)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: galagersharp-code <319712501+galagersharp-code@users.noreply.github.com>
2026-11-10 01:24:23 +01:00
43f94a5dca Paste: composite CSS colours with alpha over the page instead of dropping it (#6164)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: galagersharp-code <319712501+galagersharp-code@users.noreply.github.com>
2026-11-10 01:09:36 +01:00
25 changed files with 348 additions and 527 deletions
+4 -2
View File
File diff suppressed because one or more lines are too long
+12 -25
View File
@@ -163,9 +163,7 @@ 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, _| {
// One format buffer, as in Word: Copy Formatting replaces an armed Format Painter.
s.painter = None;
s.copied_format = Some(picked_format(s));
painter_pick(s);
sel_result(s)
})
.key("Mod+Shift+C")
@@ -821,21 +819,11 @@ fn goto(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
/// The character and paragraph formatting at the start of the selection.
fn picked_format(s: &Session) -> (wordcraft_doc::props::CharProps, wordcraft_doc::props::ParaProps) {
fn painter_pick(s: &mut Session) {
let f = s.sel.ordered().0;
// A symbol's own font isn't formatting to paint onto text.
// 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()));
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();
s.painter = Some((chr, para, false));
}
@@ -851,25 +839,24 @@ fn painter(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"active": true}))
}
/// Apply the format painter's formatting to the selection, or else what Copy Formatting picked
/// up (which stays for the next Paste Formatting).
/// Apply the format painter's formatting to the selection.
fn paste_format(s: &mut Session, _: &Value) -> CmdResult {
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 Some((chr, para, sticky)) = s.painter.clone() else { 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 = c.cleared().overlaid(&chr))?;
s.doc.format_range(&Pos { off: x, ..f.clone() }, &Pos { off: y, ..f.clone() }, &|c| *c = chr.cleared().overlaid(&chr))?;
}
s.doc.format_paragraphs(&a, &b, &|p| *p = para.clone())?;
} else {
s.doc.format_range(&a, &b, &|c| *c = c.cleared().overlaid(&chr))?;
s.doc.format_range(&a, &b, &|c| {
let link = c.link.clone();
*c = c.cleared().overlaid(&chr);
c.link = link;
})?;
if a.path != b.path || a.off == 0 {
s.doc.format_paragraphs(&a, &b, &|p| *p = para.clone())?;
}
-4
View File
@@ -88,10 +88,6 @@ 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,7 +250,6 @@ 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,7 +107,6 @@ 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,7 +90,6 @@ 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,161 +13041,6 @@ 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]
+5 -3
View File
@@ -123,13 +123,15 @@ 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("#0080ff80"), 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("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(1, 2, 3)));
assert_eq!(parse_color("rgb(1 2 3 / 50%)"), Some(Rgb(128, 129, 129)));
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(0, 0, 255)));
assert_eq!(parse_color("hsla(240, 100%, 50%, 0.8)"), Some(Rgb(51, 51, 255)));
assert_eq!(parse_color("navy"), Some(Rgb(0, 0, 128)));
assert_eq!(parse_color("#zz"), None);
assert_eq!(parse_color("rgb(1)"), None);
+73 -9
View File
@@ -34,24 +34,59 @@ 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);
return Some(Rgb(d(0)?, d(1)?, d(2)?));
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));
}
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(Rgb(p(0)?, p(2)?, p(4)?));
return Some(over_white(p(0)?, p(2)?, p(4)?, p(6)? as f32 / 255.0));
}
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 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 (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 v: Vec<u8> = parts
.iter()
.take(3)
@@ -65,14 +100,19 @@ pub fn parse_color(s: &str) -> Option<Rgb> {
})
.collect();
if let [r, g, b] = v[..] {
return Some(Rgb(r, g, b));
return Some(over_white(r, g, b, alpha));
}
return None;
}
if let Some(inner) = s.strip_prefix("hsl(").or_else(|| s.strip_prefix("hsla(")) {
let inner = inner.trim_end_matches(')').trim();
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 (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,
};
if parts.len() < 3 {
return None;
}
@@ -96,7 +136,8 @@ pub fn parse_color(s: &str) -> Option<Rgb> {
};
let s = parse_pct(parts[1])?;
let l = parse_pct(parts[2])?;
return Some(hsl_to_rgb(h, s, l));
let Rgb(r, g, b) = hsl_to_rgb(h, s, l);
return Some(over_white(r, g, b, alpha));
}
Some(match s.as_str() {
"black" => Rgb(0, 0, 0),
@@ -127,6 +168,29 @@ mod tests {
#[test]
fn eight_digit_hex_rejects_signs() {
assert_eq!(parse_color("#+1+2+3+4"), None);
assert_eq!(parse_color("#11223344"), Some(Rgb(0x11, 0x22, 0x33)));
// #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)));
}
}
+18 -2
View File
@@ -135,7 +135,13 @@ fn parse_full(bytes: &[u8], marks: bool) -> Result<Parsed, String> {
}
match n {
b'\'' => {
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());
// `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());
match h {
Some(v) => {
if r.st.dest == Dest::Pict {
@@ -150,7 +156,17 @@ fn parse_full(bytes: &[u8], marks: bool) -> Result<Parsed, String> {
}
i += 4;
}
None => i += 2,
// 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;
}
}
}
}
b'*' => {
+67
View File
@@ -37,6 +37,73 @@ 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 [
+3 -1
View File
@@ -403,7 +403,8 @@ impl Reader {
}
true
}
Dest::StyleSheet => {
// Style names take Unicode escapes (with fallback skipping) like the body.
Dest::StyleSheet if !matches!(word, "u" | "uc") => {
let def = &mut self.style_def_cur;
match word {
"s" | "cs" | "ds" | "ts" => {
@@ -420,6 +421,7 @@ impl Reader {
}
true
}
Dest::StyleSheet => false,
Dest::ListTable => {
self.list_table_word(word, value);
true
+1
View File
@@ -92,6 +92,7 @@ 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,18 +253,6 @@ 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.
@@ -448,10 +436,6 @@ 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,6 +281,24 @@ 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) {
@@ -301,6 +319,10 @@ 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,6 +455,7 @@ 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);
+3 -1
View File
@@ -34,7 +34,9 @@ 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, forget_nudge, ok_cancel_responses, release_keyboard};
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 dispatch::search_results;
pub(crate) use find::swallow_shortcut_text;
pub use find::{FindExtra, FindTab, GoToWhat};
+11
View File
@@ -67,6 +67,16 @@ 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) {
@@ -81,6 +91,7 @@ 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,6 +2078,116 @@ 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;
+4 -65
View File
@@ -46,8 +46,6 @@ 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);
@@ -62,8 +60,7 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) {
continue;
}
note_key_state(app, &e);
if ignored_paste(app, &e)
|| drop_refused_replacement(app, &e, &mut batch)
if drop_refused_replacement(app, &e, &mut batch)
|| prefilter(app, ctx, &e, alt_codes)
|| paste_release(app, &e)
|| equation_event(app, ctx, &e, &mut batch)
@@ -72,27 +69,6 @@ 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.
@@ -182,52 +158,20 @@ 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) => {
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::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::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`,
@@ -565,15 +509,10 @@ 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.
// 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 m = ctx.input(|i| i.modifiers);
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}));
+1 -20
View File
@@ -132,26 +132,7 @@ pub(super) fn dispatch(app: &mut WordApp, key: Key, m: Modifiers) -> bool {
if m.alt && !crate::widgets::enabled(app, spec.id) {
return false;
}
// 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!({}));
let _ = app.run(spec.id, json!({}));
return true;
}
let Some(name) = name else { return false };
-179
View File
@@ -133,44 +133,6 @@ 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;
@@ -192,129 +154,6 @@ 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 }
@@ -1497,24 +1336,6 @@ 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());
+3 -39
View File
@@ -4416,8 +4416,7 @@ 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.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!(h.state().session.painter.is_some(), "{modifiers:?}: Copy Formatting picked up the selection's formatting");
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));
@@ -4428,49 +4427,14 @@ 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.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!(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_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,2 +1,9 @@
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