mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-11 04:40:05 -06:00
Compare commits
15
Commits
+2
-7
File diff suppressed because one or more lines are too long
@@ -108,8 +108,8 @@ pub fn formats() -> Vec<String> {
|
||||
|
||||
/// The picture on the clipboard: copied files, else image pixels, else (Wayland) an encoded
|
||||
/// picture in a format arboard doesn't decode. A picture that is there but can't be read says
|
||||
/// why (#833). The UI checks the sizes before inserting anything; on Windows and Wayland they are
|
||||
/// checked from the header before anything is decoded (#3623).
|
||||
/// why (#833). The UI checks the sizes before inserting anything; on Windows they are checked
|
||||
/// from the header before anything is decoded (#3623).
|
||||
pub fn picture() -> Option<ClipboardPicture> {
|
||||
let mut clipboard = match arboard::Clipboard::new() {
|
||||
Ok(c) => c,
|
||||
@@ -136,10 +136,6 @@ fn decoded_picture(_: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
|
||||
/// The picture arboard decodes from the clipboard, else an encoded one read past it.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn decoded_picture(clipboard: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
|
||||
#[cfg(all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))]
|
||||
if let Some(picture) = platform::picture() {
|
||||
return Some(picture);
|
||||
}
|
||||
match clipboard.get_image() {
|
||||
Ok(image) => Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() }),
|
||||
Err(arboard::Error::ContentNotAvailable) => platform::encoded_picture().map(ClipboardPicture::Encoded),
|
||||
@@ -164,7 +160,6 @@ mod platform {
|
||||
use std::io::Read;
|
||||
|
||||
use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents, get_mime_types_ordered};
|
||||
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
|
||||
|
||||
fn wayland() -> bool {
|
||||
std::env::var_os("WAYLAND_DISPLAY").is_some()
|
||||
@@ -348,22 +343,6 @@ mod platform {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Wayland clipboard's picture, read encoded under the paste's byte cap and refused from
|
||||
/// its header when a paste can't take its size, before anything decodes it. arboard decodes
|
||||
/// and converts the PNG to RGBA with no size limits. `None` when Wayland offers no picture
|
||||
/// (or isn't reachable): arboard is asked then, which also covers X11.
|
||||
pub fn picture() -> Option<ClipboardPicture> {
|
||||
if !wayland() {
|
||||
return None;
|
||||
}
|
||||
let offered = formats();
|
||||
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
|
||||
Some(match read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES) {
|
||||
Some(bytes) => wordcraft_ui_egui::paste_picture::encoded_picture(bytes),
|
||||
None => ClipboardPicture::Unreadable("the clipboard picture is larger than 200 MB, or couldn't be read".into()),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -348,7 +348,7 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
0 | 1 | 4 => PartKind::Header,
|
||||
_ => PartKind::Footer,
|
||||
};
|
||||
let blocks = header_story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
if !blocks.is_empty() {
|
||||
let id = doc.add_part(kind, blocks);
|
||||
hf_ids.push((si, k, id));
|
||||
@@ -436,36 +436,6 @@ fn story_blocks(ctx: &WalkCtx<'_>, a: u32, b: u32, pics: &mut media::Pictures, m
|
||||
Ok(table::assemble(paras))
|
||||
}
|
||||
|
||||
/// A header or footer story's blocks without its guard paragraph mark. [MS-DOC] 2.3.3: a
|
||||
/// non-empty header story ends with a paragraph mark that only separates it from the next story
|
||||
/// and is not part of its contents, so a one-line header is stored as `text\r\r`. Laid out, the
|
||||
/// guard would add an empty paragraph to every header and footer and push the body down when
|
||||
/// the header reaches past the top margin (#5922).
|
||||
///
|
||||
/// Only an empty last paragraph after an ordinary one is dropped: a story that is nothing but a
|
||||
/// mark stays a part (an empty header, not one linked to the previous section), and a mark right
|
||||
/// after a table row stays the paragraph that closes the table.
|
||||
fn header_story_blocks(
|
||||
ctx: &WalkCtx<'_>,
|
||||
a: u32,
|
||||
b: u32,
|
||||
pics: &mut media::Pictures,
|
||||
max_paragraphs: usize,
|
||||
) -> Result<wordcraft_doc::Blocks, DocbinError> {
|
||||
let mut paras = walk(ctx, a, b, pics);
|
||||
if ctx.paragraphs.exceeded.get() {
|
||||
return Err(paragraph_limit(max_paragraphs));
|
||||
}
|
||||
let outside_table = |p: &table::ParaOut| p.row.as_ref().is_none_or(|r| !r.in_table && !r.ttp && !r.cell_mark);
|
||||
if let [.., prev, last] = paras.as_slice() {
|
||||
let guard = outside_table(last) && last.para.text.is_empty() && last.para.objects.is_empty() && last.para.section.is_none();
|
||||
if guard && outside_table(prev) {
|
||||
paras.pop();
|
||||
}
|
||||
}
|
||||
Ok(table::assemble(paras))
|
||||
}
|
||||
|
||||
/// A story's CP range `[a, b)`, relative to a subdocument starting at `base` with `len`
|
||||
/// CPs, as absolute CPs clamped to that subdocument; `None` when empty.
|
||||
fn story(base: u32, len: u32, (a, b): (u32, u32)) -> Option<(u32, u32)> {
|
||||
|
||||
@@ -1157,58 +1157,6 @@ fn header_story_becomes_part() {
|
||||
assert!(part.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains("Header text"))));
|
||||
}
|
||||
|
||||
/// The odd header's blocks for a one-section document whose header story is `story`, with
|
||||
/// `papx_entries` over the whole stream (the header story starts at CP 5, after `body\r`):
|
||||
/// paragraph texts, a table as `<table>`; `None` when the slot has no part.
|
||||
fn odd_header_blocks(story: &'static str, papx_entries: Vec<(u32, u16, Vec<u8>)>) -> Option<Vec<String>> {
|
||||
let spec = StructSpec {
|
||||
text: "body\r",
|
||||
header_stories: vec![story],
|
||||
sections: vec![(5, Vec::new())],
|
||||
chpx_runs: vec![(0, Vec::new())],
|
||||
papx_entries,
|
||||
plf_lst: Vec::new(),
|
||||
plf_lfo: Vec::new(),
|
||||
note_stories: vec![],
|
||||
endnote_stories: vec![],
|
||||
extra_pairs: Vec::new(),
|
||||
data_stream: Vec::new(),
|
||||
};
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let id = doc.last_section.headers.default?;
|
||||
let part = doc.parts.get(&id).expect("part");
|
||||
Some(part.blocks.iter().map(|b| b.as_para().map_or_else(|| "<table>".to_string(), |p| p.text.clone())).collect())
|
||||
}
|
||||
|
||||
/// #5922: [MS-DOC] 2.3.3 ends every non-empty header story with a guard paragraph mark that
|
||||
/// is not part of its contents. Read as a paragraph, it put an empty line under every header
|
||||
/// and pushed the first body line down when the header reached past the top margin.
|
||||
#[test]
|
||||
fn header_story_guard_mark_is_not_a_paragraph() {
|
||||
let plain = || vec![(0, 0, Vec::new())];
|
||||
// One line typed into the header: content "Header\r" + guard.
|
||||
assert_eq!(odd_header_blocks("Header\r\r", plain()), Some(vec!["Header".to_string()]));
|
||||
// Enter pressed after it: the empty paragraph is content and stays, only the guard goes.
|
||||
assert_eq!(odd_header_blocks("Header\r\r\r", plain()), Some(vec!["Header".to_string(), String::new()]));
|
||||
// An empty header (one empty content paragraph + guard) is still a header of its own.
|
||||
assert_eq!(odd_header_blocks("\r\r", plain()), Some(vec![String::new()]));
|
||||
// A story that is only a mark (no guard) keeps its paragraph and its part.
|
||||
assert_eq!(odd_header_blocks("\r", plain()), Some(vec![String::new()]));
|
||||
// A story without a guard keeps its last paragraph.
|
||||
assert_eq!(odd_header_blocks("Header\r", plain()), Some(vec!["Header".to_string()]));
|
||||
}
|
||||
|
||||
/// #5922: a mark right after a table row in a header story is kept: it is the paragraph that
|
||||
/// closes the table, not a guard to drop.
|
||||
#[test]
|
||||
fn header_story_mark_after_a_table_row_is_kept() {
|
||||
let in_tbl = grpprl(&[(0x2416, &[0x01])]);
|
||||
let ttp: Vec<u8> = [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01])]), t_def_table(&[0, 2880])].concat();
|
||||
// Stream: "body\r" (CPs 0-4), then the header story "a\x07" (5-6), row end "\x07" (7), "\r" (8).
|
||||
let papx = vec![(0, 0, Vec::new()), (5, 0, in_tbl), (7, 0, ttp), (8, 0, Vec::new())];
|
||||
assert_eq!(odd_header_blocks("a\u{7}\u{7}\r", papx), Some(vec!["<table>".to_string(), String::new()]));
|
||||
}
|
||||
|
||||
/// A `sprmTDefTable` Prl for `edges` twip boundaries and TC80s of 20 bytes per column,
|
||||
/// built per the spec (cb counts the bytes after it, plus one).
|
||||
fn t_def_table(edges: &[i16]) -> Vec<u8> {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use wordcraft_doc::math::MathJc;
|
||||
|
||||
use super::{
|
||||
CharProps, El, InlineObject, Item, MAX_FORM, MAX_INSTR, NoteKind, PB, PTabAlign, PTabBase, Phase, Reader, Rels, RevisionKind, RunCtx, StoryCtx,
|
||||
TextColor, children_of_choice, clean_text, int, measure, on_off_attr, placeholder_of, sink,
|
||||
@@ -15,7 +13,6 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an inline child element (`w:r`, `m:oMath`, `m:oMathPara`, `w:ins`, etc.).
|
||||
fn read_inline(&mut self, sc: &mut StoryCtx, pb: &mut PB, k: &El, rels: &Rels, ctx: &RunCtx, depth: usize) {
|
||||
match k.name.as_str() {
|
||||
"w:r" => self.read_run(sc, pb, k, rels, ctx, depth),
|
||||
@@ -79,38 +76,25 @@ impl Reader<'_> {
|
||||
}
|
||||
"w:smartTag" | "w:customXml" | "w:dir" | "w:bdo" => self.read_inline_children(sc, pb, k, rels, ctx, depth + 1),
|
||||
"m:oMath" => {
|
||||
let props = self.run_props_none(ctx);
|
||||
self.emit_omath(sc, pb, k, Default::default(), false, &props);
|
||||
let mut props = self.run_props_none(ctx);
|
||||
let mut math = crate::read::math::read_omath(k, Default::default());
|
||||
if let Some(c) = crate::read::math::lift_color(&mut math) {
|
||||
props.color = Some(TextColor::Rgb(c));
|
||||
}
|
||||
let linear = wordcraft_doc::math::to_linear(&math.nodes);
|
||||
self.emit_obj(sc, pb, InlineObject::Equation { linear, display: false, math }, &props);
|
||||
}
|
||||
"m:oMathPara" => {
|
||||
let base_props = self.run_props_none(ctx);
|
||||
let props = self.run_props_none(ctx);
|
||||
let jc = crate::read::math::para_jc(k);
|
||||
for c in k.els() {
|
||||
match c.name.as_str() {
|
||||
"m:oMath" => self.emit_omath(sc, pb, c, jc, true, &base_props),
|
||||
"w:ins" | "w:moveTo" => {
|
||||
let mut sub_ctx = ctx.clone();
|
||||
sub_ctx.ins =
|
||||
Some(if c.name == "w:moveTo" { self.move_revision(true, c, false) } else { self.revision(RevisionKind::Insert, c) });
|
||||
let sub_props = self.run_props_none(&sub_ctx);
|
||||
for m in c.children("m:oMath") {
|
||||
self.emit_omath(sc, pb, m, jc, true, &sub_props);
|
||||
}
|
||||
}
|
||||
"w:del" | "w:moveFrom" => {
|
||||
let mut sub_ctx = ctx.clone();
|
||||
sub_ctx.del = Some(if c.name == "w:moveFrom" {
|
||||
self.move_revision(false, c, false)
|
||||
} else {
|
||||
self.revision(RevisionKind::Delete, c)
|
||||
});
|
||||
let sub_props = self.run_props_none(&sub_ctx);
|
||||
for m in c.children("m:oMath") {
|
||||
self.emit_omath(sc, pb, m, jc, true, &sub_props);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
for m in k.children("m:oMath") {
|
||||
let mut math = crate::read::math::read_omath(m, jc);
|
||||
let mut props = props.clone();
|
||||
if let Some(c) = crate::read::math::lift_color(&mut math) {
|
||||
props.color = Some(TextColor::Rgb(c));
|
||||
}
|
||||
let linear = wordcraft_doc::math::to_linear(&math.nodes);
|
||||
self.emit_obj(sc, pb, InlineObject::Equation { linear, display: true, math }, &props);
|
||||
}
|
||||
}
|
||||
"mc:AlternateContent" => {
|
||||
@@ -400,17 +384,6 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an `m:oMath` element and emit its [`InlineObject::Equation`].
|
||||
fn emit_omath(&mut self, sc: &mut StoryCtx, pb: &mut PB, m: &El, jc: MathJc, display: bool, props: &CharProps) {
|
||||
let mut math = crate::read::math::read_omath(m, jc);
|
||||
let mut props = props.clone();
|
||||
if let Some(color) = crate::read::math::lift_color(&mut math) {
|
||||
props.color = Some(TextColor::Rgb(color));
|
||||
}
|
||||
let linear = wordcraft_doc::math::to_linear(&math.nodes);
|
||||
self.emit_obj(sc, pb, InlineObject::Equation { linear, display, math }, &props);
|
||||
}
|
||||
|
||||
pub(super) fn emit_obj(&mut self, sc: &mut StoryCtx, pb: &mut PB, o: InlineObject, props: &CharProps) {
|
||||
match sink(sc) {
|
||||
Some(f) => {
|
||||
|
||||
@@ -4,7 +4,6 @@ use super::section::comment_reference_style;
|
||||
use super::{CharProps, Fill, InlineObject, NoteKind, PartRels, TextColor, W, Wrap, Writer, emu, n, rpr};
|
||||
|
||||
impl Writer<'_> {
|
||||
/// Write an inline object (`Bookmark`, `Comment`, `Equation`, `Field`, `Shape`, etc.).
|
||||
pub(super) fn object(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) {
|
||||
match o {
|
||||
// Written by `para_content` (inline) or `blocks` (around paragraphs).
|
||||
@@ -52,12 +51,10 @@ impl Writer<'_> {
|
||||
Some(TextColor::Rgb(c)) => Some(c),
|
||||
_ => None,
|
||||
};
|
||||
self.rev_open(w, props);
|
||||
match crate::write::math::resolve_numbers(math, *display, &mut self.eq_number) {
|
||||
Some(m) => crate::write::math::write_equation(w, linear, *display, &m, color),
|
||||
None => crate::write::math::write_equation(w, linear, *display, math, color),
|
||||
}
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
InlineObject::Field { instr, result, locked, dirty, form, .. } => {
|
||||
self.field(w, FieldData { instr, result, locked: *locked, dirty: *dirty, form }, props)
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
//! Line numbering (`w:lnNumType`, ECMA-376 Part 1 §17.6.8) and the built-in "line number"
|
||||
//! character style that formats the numbers (#6018).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::section::{Columns, LineNumberRestart, LineNumbering};
|
||||
use wordcraft_doc::{CharProps, Document, Rgb, Style, StyleKind, TextColor};
|
||||
|
||||
/// Every line-numbering option and a localised "line number" style (German Word calls its id
|
||||
/// `Zeilennummer`) survive a save and reopen, beside two columns.
|
||||
#[test]
|
||||
fn line_numbering_options_and_style_round_trip() {
|
||||
let mut d = Document::from_text("one\ntwo\nthree");
|
||||
let numbering = LineNumbering { count_by: 5, start: 3, distance: 21.6, restart: LineNumberRestart::Continuous };
|
||||
d.last_section.line_numbers = Some(numbering.clone());
|
||||
d.last_section.columns = Columns { count: 2, space: 36.0, separator: false, widths: Vec::new() };
|
||||
let red = TextColor::Rgb(Rgb(0xC0, 0, 0));
|
||||
Arc::make_mut(&mut d.styles).upsert(Style {
|
||||
id: "Zeilennummer".into(),
|
||||
name: "Line Number".into(),
|
||||
kind: StyleKind::Character,
|
||||
chr: CharProps { size: Some(8.0), color: Some(red), italic: Some(true), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(back.last_section.line_numbers, Some(numbering));
|
||||
assert_eq!(back.last_section.columns.count, 2);
|
||||
let st = back.styles.find("line number").expect("the line number style");
|
||||
assert_eq!(st.id, "Zeilennummer");
|
||||
assert_eq!((st.chr.size, st.chr.color, st.chr.italic), (Some(8.0), Some(red), Some(true)));
|
||||
}
|
||||
@@ -20,7 +20,6 @@ mod glossary;
|
||||
mod heading_levels;
|
||||
mod legacy_forms;
|
||||
mod ligatures;
|
||||
mod line_numbering;
|
||||
mod malformed;
|
||||
mod missing_pictures;
|
||||
mod nested_block_controls;
|
||||
|
||||
@@ -994,80 +994,6 @@ fn tracked_changes_round_trip() {
|
||||
assert!(r.settings.track_changes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_equations_round_trip() {
|
||||
// Issue #6011: equations saved with tracked revision marks (w:ins / w:del) around math.
|
||||
let mut d = Document::new();
|
||||
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Alice".into(), date: "2026-05-01T10:00:00Z".into(), move_name: None });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Bob".into(), date: "2026-05-02T10:00:00Z".into(), move_name: None });
|
||||
d.settings.track_changes = true;
|
||||
let ins = CharProps { ins: Some(0), ..Default::default() };
|
||||
let del = CharProps { del: Some(1), ..Default::default() };
|
||||
let plain = CharProps::default();
|
||||
let mut p = Paragraph::with_text("Formula: ", CharProps::default());
|
||||
let end = p.len();
|
||||
p.insert_object(end, InlineObject::Equation { linear: "x+1".into(), display: false, math: Default::default() }, &ins).unwrap();
|
||||
let end = p.len();
|
||||
p.insert_object(end, InlineObject::Equation { linear: "E=mc^2".into(), display: true, math: Default::default() }, &del).unwrap();
|
||||
let end = p.len();
|
||||
p.insert_object(end, InlineObject::Equation { linear: "a^2+b^2=c^2".into(), display: true, math: Default::default() }, &ins).unwrap();
|
||||
let end = p.len();
|
||||
p.insert_object(end, InlineObject::Equation { linear: "y=mx+b".into(), display: false, math: Default::default() }, &del).unwrap();
|
||||
let end = p.len();
|
||||
p.insert_object(end, InlineObject::Equation { linear: "z=1".into(), display: false, math: Default::default() }, &plain).unwrap();
|
||||
d.body = vec![para_block(p.clone())];
|
||||
let bytes = wordcraft_docx::write(&d).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
assert!(xml.contains(r#"<w:ins w:id="1" w:author="Alice" w:date="2026-05-01T10:00:00Z"><m:oMath>"#), "ins directly encloses inline math: {xml}");
|
||||
assert!(
|
||||
xml.contains(r#"<w:del w:id="2" w:author="Bob" w:date="2026-05-02T10:00:00Z"><m:oMathPara>"#),
|
||||
"del directly encloses display math: {xml}"
|
||||
);
|
||||
assert!(
|
||||
xml.contains(r#"<w:ins w:id="3" w:author="Alice" w:date="2026-05-01T10:00:00Z"><m:oMathPara>"#),
|
||||
"ins directly encloses display math: {xml}"
|
||||
);
|
||||
assert!(xml.contains(r#"<w:del w:id="4" w:author="Bob" w:date="2026-05-02T10:00:00Z"><m:oMath>"#), "del directly encloses inline math: {xml}");
|
||||
assert!(xml.contains(r#"</w:del><m:oMath>"#), "plain equation follows closing tag without revision wrapper: {xml}");
|
||||
let r = wordcraft_docx::read(&bytes).expect("read");
|
||||
assert_eq!(r.revisions, d.revisions);
|
||||
let got = ¶s(&r)[0];
|
||||
assert_eq!(got.objects.len(), 5);
|
||||
let eq_summary: Vec<(bool, String)> = got
|
||||
.objects
|
||||
.iter()
|
||||
.map(|o| match o {
|
||||
InlineObject::Equation { display, linear, .. } => (*display, linear.clone()),
|
||||
_ => panic!("expected equation object, got {o:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
eq_summary,
|
||||
vec![(false, "x+1".into()), (true, "E=mc^2".into()), (true, "a^2+b^2=c^2".into()), (false, "y=mx+b".into()), (false, "z=1".into()),]
|
||||
);
|
||||
assert_eq!(got.runs, p.runs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_omath_para_revisions_read() {
|
||||
// Defensive reader check: w:ins and w:del nested inside m:oMathPara.
|
||||
let mut d = Document::new();
|
||||
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Ada".into(), date: "2026-05-01T10:00:00Z".into(), move_name: None });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Charles".into(), date: "2026-05-02T10:00:00Z".into(), move_name: None });
|
||||
let doc_xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"><w:body><w:p><m:oMathPara><w:ins w:id="1" w:author="Ada" w:date="2026-05-01T10:00:00Z"><m:oMath><m:r><m:t>x+1</m:t></m:r></m:oMath></w:ins><w:del w:id="2" w:author="Charles" w:date="2026-05-02T10:00:00Z"><m:oMath><m:r><m:t>y-1</m:t></m:r></m:oMath></w:del></m:oMathPara></w:p></w:body></w:document>"#;
|
||||
let base = wordcraft_docx::write(&d).unwrap();
|
||||
let bytes = replace_part(&base, "word/document.xml", doc_xml.as_bytes());
|
||||
let r = wordcraft_docx::read(&bytes).expect("read");
|
||||
let got = ¶s(&r)[0];
|
||||
assert_eq!(got.objects.len(), 2);
|
||||
assert!(matches!(&got.objects[0], InlineObject::Equation { display: true, linear, .. } if linear == "x+1"));
|
||||
assert!(matches!(&got.objects[1], InlineObject::Equation { display: true, linear, .. } if linear == "y-1"));
|
||||
assert_eq!(got.runs[0].props.ins, Some(0));
|
||||
assert_eq!(got.runs[1].props.del, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_paragraph_marks_round_trip() {
|
||||
// Issue #229: an inserted / deleted paragraph mark is `w:ins` / `w:del` in the mark's
|
||||
|
||||
@@ -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())?;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: _,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: _,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1177,8 +1177,7 @@ impl Styles {
|
||||
"graphic-properties" if in_graphic_default && cur.is_none() => self.default_graphic = super::frames::Graphic::read(&a),
|
||||
props if in_table_default == Some(props) && cur.is_none() => self.read_table_default(props, &a),
|
||||
"text-properties" if in_default && cur.is_none() => {
|
||||
// A later default style without a (usable) font keeps the earlier one (#4501).
|
||||
self.default_font = default_font(&a, &self.faces).or(self.default_font.take());
|
||||
self.default_font = default_font(&a, &self.faces);
|
||||
if let Some(sz) = get(&a, "fo:font-size").and_then(length).filter(|v| v.is_finite() && *v >= 1.0) {
|
||||
self.default_size = Some(sz.min(1638.0));
|
||||
}
|
||||
|
||||
@@ -1289,36 +1289,6 @@ fn meta_creation_date_timezones_and_fallbacks() {
|
||||
assert_eq!(m2.author, "Solo Creator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_later_default_style_without_a_font_keeps_the_default_font() {
|
||||
// #4501 follow-up: styles read twice (styles.xml, then content.xml) must not wipe the font.
|
||||
let mut styles = styles::Styles::default();
|
||||
let with_font = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties style:font-name="DejaVu Serif"/></style:default-style></office:styles></office:document-styles>"#
|
||||
);
|
||||
let without = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt"/></style:default-style></office:styles></office:document-styles>"#
|
||||
);
|
||||
styles.read(with_font.as_bytes(), false);
|
||||
styles.read(without.as_bytes(), false);
|
||||
assert_eq!(styles.doc_font().as_deref(), Some("DejaVu Serif"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_later_monospaced_default_style_keeps_the_proportional_default_font() {
|
||||
// #4501 follow-up: a monospaced default font is rejected and must not wipe the earlier one.
|
||||
let mut styles = styles::Styles::default();
|
||||
let with_font = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties style:font-name="DejaVu Serif"/></style:default-style></office:styles></office:document-styles>"#
|
||||
);
|
||||
let mono = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties style:font-name="Courier New"/></style:default-style></office:styles></office:document-styles>"#
|
||||
);
|
||||
styles.read(with_font.as_bytes(), false);
|
||||
styles.read(mono.as_bytes(), false);
|
||||
assert_eq!(styles.doc_font().as_deref(), Some("DejaVu Serif"));
|
||||
}
|
||||
|
||||
/// The `content.xml` of a package.
|
||||
pub(super) fn content_of(bytes: &[u8]) -> String {
|
||||
let mut z = archive(bytes);
|
||||
|
||||
@@ -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'*' => {
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,10 +7,8 @@ use super::{
|
||||
ParaLayout, Paragraph, Rect, SectionProps, StoryRef, ViewMode, Wrap, contour, math, para, styleref, textbox,
|
||||
};
|
||||
|
||||
/// The character style Word formats line numbers with: `w:styleId="LineNumber"` in English
|
||||
/// Word; other languages localise the id (German `Zeilennummer`) but keep the built-in name.
|
||||
/// The character style Word formats line numbers with (`w:styleId="LineNumber"`).
|
||||
const LINE_NUMBER_STYLE: &str = "LineNumber";
|
||||
const LINE_NUMBER_STYLE_NAME: &str = "line number";
|
||||
|
||||
/// A layout pass: finished, or to be done again with the page totals it found, or with the
|
||||
/// floats placed above their anchors it found ([`EarlyFloats`]).
|
||||
@@ -201,11 +199,7 @@ impl Ctx<'_> {
|
||||
// Word formats line numbers with the "Line Number" character style over the document's
|
||||
// defaults (measured in Word for Mac: Aptos 12 pt black in a document whose defaults are
|
||||
// Aptos 12 pt), not in a fixed small grey.
|
||||
let styles = &self.doc.styles;
|
||||
let style = styles
|
||||
.get(LINE_NUMBER_STYLE)
|
||||
.or_else(|| styles.find(LINE_NUMBER_STYLE_NAME).filter(|s| s.kind == wordcraft_doc::StyleKind::Character))
|
||||
.map(|s| s.id.clone());
|
||||
let style = self.doc.styles.get(LINE_NUMBER_STYLE).map(|s| s.id.clone());
|
||||
let mut p = Paragraph::with_text(&n.to_string(), CharProps { style, ..Default::default() });
|
||||
p.props.space_after = Some(0.0);
|
||||
p.props.space_before = Some(0.0);
|
||||
|
||||
@@ -777,8 +777,7 @@ fn add_notes(pb: &mut PageBuilder, mut new_notes: Vec<NoteBox>, notes: &[(usize,
|
||||
}
|
||||
}
|
||||
|
||||
/// Line numbers for lines `l0..l1` of `pl` laid out from `y`, left of the column they are in:
|
||||
/// each column of a multi-column section gets its own (#6018), as in Word.
|
||||
/// Line numbers in the left margin for lines `l0..l1` of `pl` laid out from `y`.
|
||||
fn line_numbers(ctx: &mut Ctx, pb: &mut PageBuilder, pl: &ParaLayout, (l0, l1): (usize, usize), y: f32) -> Vec<Placed> {
|
||||
let mut items = Vec::new();
|
||||
if let Some(ln) = pb.sect.line_numbers.clone().filter(|_| !pb.web && pl.rp.style != "Header" && !pl.rp.suppress_line_numbers)
|
||||
@@ -801,7 +800,7 @@ fn line_numbers(ctx: &mut Ctx, pb: &mut PageBuilder, pl: &ParaLayout, (l0, l1):
|
||||
para: num.clone(),
|
||||
l0: 0,
|
||||
l1: 1,
|
||||
x: pb.col_x() - dist - w,
|
||||
x: pb.sect.margin_left + pb.sect.gutter - dist - w,
|
||||
y: ly,
|
||||
turn: TextDirection::Horizontal,
|
||||
});
|
||||
|
||||
+10
-28
@@ -270,21 +270,12 @@ fn section_tops(ctx: &mut Ctx, hf: &HfLinks, (si, sect): (usize, &SectionProps),
|
||||
Tops { body, first, foot, first_foot }
|
||||
}
|
||||
|
||||
/// Set up the walk state for a section before its header and footer room is measured: no grid yet
|
||||
/// (the previous section's must not leak into headers, footers and separators), the field values
|
||||
/// and the footnote separators.
|
||||
fn begin_section_state<'p>(
|
||||
ctx: &mut Ctx,
|
||||
pb: &mut PageBuilder<'p>,
|
||||
(si, sect, page_format): (usize, &'p SectionProps, wordcraft_doc::section::NumFormat),
|
||||
totals: &Totals,
|
||||
web: bool,
|
||||
) {
|
||||
let doc = ctx.doc;
|
||||
ctx.grid = para::Grid::default();
|
||||
/// Set up the walk state for a section: its grid, field values and footnote separators.
|
||||
fn begin_section_state<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, sec: &Sec<'p>, totals: &Totals, web: bool) {
|
||||
let (doc, sect, si) = (ctx.doc, sec.sect, sec.si);
|
||||
pb.sect = sect;
|
||||
pb.page_format = page_format;
|
||||
ctx.fields.page_format = page_format;
|
||||
pb.page_format = sec.page_format;
|
||||
ctx.fields.page_format = sec.page_format;
|
||||
pb.sect_idx = si;
|
||||
pb.sect_beneath = doc.note_options(sect, false).pos == wordcraft_doc::section::NotePos::BeneathText;
|
||||
if !web && !doc.footnote_separators.is_empty() {
|
||||
@@ -292,11 +283,7 @@ fn begin_section_state<'p>(
|
||||
}
|
||||
ctx.fields.section = u32::try_from(si).unwrap_or(u32::MAX).saturating_add(1);
|
||||
ctx.fields.section_pages = totals.section(si);
|
||||
}
|
||||
|
||||
/// The section's own grid and line numbering, once its header and footer room is known.
|
||||
fn apply_section_grid(ctx: &mut Ctx, pb: &mut PageBuilder<'_>, sect: &SectionProps) {
|
||||
ctx.grid = para::Grid::of(ctx.doc, sect);
|
||||
ctx.grid = para::Grid::of(doc, sect);
|
||||
if sect.line_numbers.as_ref().is_some_and(|l| l.restart == wordcraft_doc::section::LineNumberRestart::Section) {
|
||||
pb.line_no = 0;
|
||||
}
|
||||
@@ -554,22 +541,17 @@ pub(crate) fn layout_pass(
|
||||
_ if mode.reflow => sect_ref,
|
||||
_ => sect,
|
||||
};
|
||||
ctx.fields.page_format = page_format;
|
||||
let tops = section_tops(&mut ctx, &hf, (si, sect), mode.reflow || turn.is_turned());
|
||||
let start = if si == 0 { SectionStart::NextPage } else { sect.start };
|
||||
let sec = Sec { si, sect, page_format, start, tops };
|
||||
if !mode.web && !pb.pages.is_empty() && (start != SectionStart::Continuous || turn != prev_turn) {
|
||||
// The page the section before ends on is finished with its own margins and footnote
|
||||
// separators, before this section's replace them: the footnotes pending on it
|
||||
// belong to the section whose page it is (#5289).
|
||||
pb.finish_page();
|
||||
}
|
||||
// This section's fields and separators, without the previous section's grid, before its
|
||||
// header and footer room is measured; its own grid once that room is known (#4501).
|
||||
begin_section_state(&mut ctx, &mut pb, (si, sect, page_format), totals, mode.web);
|
||||
let tops = section_tops(&mut ctx, &hf, (si, sect), mode.reflow || turn.is_turned());
|
||||
// Measuring the room took the (reflow) section's own number format; the body keeps the
|
||||
// document section's.
|
||||
ctx.fields.page_format = page_format;
|
||||
apply_section_grid(&mut ctx, &mut pb, sect);
|
||||
let sec = Sec { si, sect, page_format, start, tops };
|
||||
begin_section_state(&mut ctx, &mut pb, &sec, totals, mode.web);
|
||||
if mode.web && si > 0 {
|
||||
// one long page
|
||||
} else if pb.pages.is_empty() || start != SectionStart::Continuous || turn != prev_turn {
|
||||
|
||||
@@ -4055,76 +4055,6 @@ fn line_numbers_follow_the_line_number_style() {
|
||||
assert!(styled.iter().all(|(size, color)| *size == 8.0 && *color == red), "{styled:?}");
|
||||
}
|
||||
|
||||
/// #6018: German Word saves the "line number" style as `Zeilennummer`; the numbers still take
|
||||
/// its formatting, found by the built-in name.
|
||||
#[test]
|
||||
fn line_numbers_follow_a_localised_line_number_style() {
|
||||
let mut d = Document::from_text("one\ntwo\nthree");
|
||||
d.last_section.line_numbers = Some(Default::default());
|
||||
let red = wordcraft_doc::TextColor::Rgb(wordcraft_doc::Rgb(0xC0, 0, 0));
|
||||
Arc::make_mut(&mut d.styles).upsert(wordcraft_doc::Style {
|
||||
id: "Zeilennummer".into(),
|
||||
name: "Line Number".into(),
|
||||
kind: wordcraft_doc::StyleKind::Character,
|
||||
chr: CharProps { size: Some(8.0), color: Some(red), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
let l = lay(&d);
|
||||
let runs: Vec<_> = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Lines { story, para, .. } if *story == DECOR_STORY => para.styles.first().map(|s| (s.size, s.rc.color)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(runs.len(), 3);
|
||||
assert!(runs.iter().all(|(size, color)| *size == 8.0 && *color == red), "{runs:?}");
|
||||
}
|
||||
|
||||
/// #6018: in two columns each column has its numbers at its own left edge, as in Word; the
|
||||
/// second column's used to be drawn in the page margin over the first column's.
|
||||
#[test]
|
||||
fn line_numbers_stand_beside_each_column() {
|
||||
crate::test_fonts();
|
||||
let text = (1..=120).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n");
|
||||
let mut d = Document::from_text(&text);
|
||||
d.last_section.line_numbers = Some(Default::default());
|
||||
d.last_section.columns = wordcraft_doc::section::Columns { count: 2, space: 36.0, separator: false, widths: Vec::new() };
|
||||
let s = d.last_section.clone();
|
||||
let col2 = s.margin_left + s.gutter + (s.text_width() - 36.0) / 2.0 + 36.0;
|
||||
let l = lay(&d);
|
||||
let p = &l.pages[0];
|
||||
// Right edge and top of every number.
|
||||
let numbers: Vec<(f32, f32)> = p
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Lines { story, para, x, y, .. } if *story == DECOR_STORY => {
|
||||
Some((x + para.lines.first().and_then(|l| l.xs.last().copied()).unwrap_or(0.0), *y))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let body_xs: Vec<f32> = p
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Lines { story: StoryRef::Body, x, .. } => Some(*x),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(body_xs.iter().any(|x| (x - col2).abs() < 0.5), "the text runs into the second column: {body_xs:?}");
|
||||
let in_col2 = body_xs.iter().filter(|x| (*x - col2).abs() < 0.5).count();
|
||||
let right: Vec<_> = numbers.iter().filter(|(r, _)| *r > s.margin_left + 1.0).collect();
|
||||
assert_eq!(right.len(), in_col2, "one number per second-column line");
|
||||
// Numbers end 18 pt (auto distance) left of their column.
|
||||
assert!(right.iter().all(|(r, _)| (r - (col2 - 18.0)).abs() < 0.5), "{right:?}");
|
||||
let left = numbers.len() - right.len();
|
||||
assert!(numbers.iter().filter(|(r, _)| *r <= s.margin_left + 1.0).all(|(r, _)| (r - (s.margin_left - 18.0)).abs() < 0.5));
|
||||
assert!(left > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_numbers_borders_text_boxes() {
|
||||
crate::test_fonts();
|
||||
|
||||
@@ -627,61 +627,6 @@ fn a_page_ends_when_a_multiple_spaced_lines_text_no_longer_fits() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sections_header_is_measured_without_the_previous_sections_grid() {
|
||||
// #4501 follow-up: section 1 has a line grid, section 2 a tall header of its own. The header
|
||||
// room is measured before section 2's grid is on, so section 1's pitch never reaches it.
|
||||
let body_top = |grid: Option<wordcraft_doc::section::DocGrid>| {
|
||||
let mut d = Document::from_text("First section\nSecond section");
|
||||
let id = d.add_part(PartKind::Header, paras("Header line", 8));
|
||||
d.last_section.headers.default = Some(id);
|
||||
let first = SectionProps { doc_grid: grid, headers: Default::default(), ..d.last_section.clone() };
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(first));
|
||||
let l = lay(&d);
|
||||
assert_eq!(l.pages.len(), 2);
|
||||
l.pages[1].body.y
|
||||
};
|
||||
let grid = wordcraft_doc::section::DocGrid { kind: wordcraft_doc::section::DocGridType::Lines, line_pitch: 18.0, char_space: 0 };
|
||||
let plain = body_top(None);
|
||||
assert!(plain > 72.0, "the header reaches past the top margin: {plain}");
|
||||
assert_eq!(body_top(Some(grid)), plain, "section 1's grid leaked into section 2's header");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sections_header_is_measured_with_its_own_section_page_count() {
|
||||
// #4501 follow-up: section 1 has one page ("one"), section 2 twenty-three ("twenty-three").
|
||||
// Section 2's header room is measured with its own SECTIONPAGES, so its body starts below it.
|
||||
// (The total in the body makes layout run again with the page totals found.)
|
||||
let mut d = Document::from_text(&format!("First section\n{}", "Second section\n".repeat(22)));
|
||||
for i in 1..d.body.len() {
|
||||
d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.page_break_before = Some(i > 1);
|
||||
}
|
||||
let field = InlineObject::Field {
|
||||
instr: r"SECTIONPAGES \* CardText".into(),
|
||||
result: String::new(),
|
||||
locked: false,
|
||||
dirty: true,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
};
|
||||
let first_para = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
first_para.insert_object(first_para.len(), field.clone(), &Default::default()).unwrap();
|
||||
let mut header = Paragraph::new();
|
||||
for _ in 0..60 {
|
||||
header.insert_object(header.len(), field.clone(), &Default::default()).unwrap();
|
||||
}
|
||||
let id = d.add_part(PartKind::Header, vec![para_block(header)]);
|
||||
d.last_section.headers.default = Some(id);
|
||||
let first = SectionProps { headers: Default::default(), ..d.last_section.clone() };
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(first));
|
||||
let l = lay(&d);
|
||||
assert_eq!(l.pages.len(), 24);
|
||||
let p = &l.pages[1];
|
||||
let (_, header_bottom) = extent(&p.header, StoryRef::Part(id)).unwrap();
|
||||
assert!(header_bottom > 72.0, "the header reaches past the top margin: {header_bottom}");
|
||||
assert!(header_bottom <= p.body.y + 0.01, "the body starts at {} inside the header ending at {header_bottom}", p.body.y);
|
||||
}
|
||||
|
||||
/// A body PAGE field shows the page number in the section's number format, as Word does (a
|
||||
/// cardinal-text section shows "one"), and `\*` overrides it.
|
||||
#[test]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -175,9 +175,7 @@ fn handle_page_pointer(app: &mut WordApp, pointer: PagePointer<'_>) {
|
||||
return;
|
||||
}
|
||||
if pressed {
|
||||
if handle_primary_press(app, &pointer, mods, hf_zone, hf_enter) {
|
||||
return;
|
||||
}
|
||||
handle_primary_press(app, &pointer, mods, hf_zone, hf_enter);
|
||||
} else if app.canvas.dragging
|
||||
&& ui.input(|i| i.pointer.primary_down())
|
||||
&& let Some((from, last)) = app.canvas.column_drag
|
||||
@@ -231,14 +229,13 @@ fn handle_page_pointer(app: &mut WordApp, pointer: PagePointer<'_>) {
|
||||
show_hover_link(app, &pointer);
|
||||
}
|
||||
|
||||
/// A press. `true` when it was handled and the pointer is done (no hover tooltip this frame).
|
||||
fn handle_primary_press(
|
||||
app: &mut WordApp,
|
||||
pointer: &PagePointer<'_>,
|
||||
mods: egui::Modifiers,
|
||||
hf_zone: Option<(Option<StoryRef>, bool)>,
|
||||
hf_enter: bool,
|
||||
) -> bool {
|
||||
) {
|
||||
let PagePointer { ui, layout, p, page, x, y, story, .. } = *pointer;
|
||||
let now = ui.input(|i| i.time);
|
||||
let (delay, dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist));
|
||||
@@ -272,14 +269,14 @@ fn handle_primary_press(
|
||||
}
|
||||
app.session.page_hint = page;
|
||||
}
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
// A press in the header/footer area from the body leaves the caret where it is, as in
|
||||
// Word (#2103): moving it to the nearest body text scrolled the view there, away from the
|
||||
// footer the double-click was aiming at.
|
||||
if hf_switch && !mods.shift && hf_zone.is_some() && story == StoryRef::Body && app.session.sel.focus.story == StoryRef::Body {
|
||||
app.canvas.column_drag = None;
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
if !mods.shift
|
||||
&& let Some((at, inner)) = layout.equation_hit(page, x, y, story)
|
||||
@@ -287,7 +284,7 @@ fn handle_primary_press(
|
||||
app.session.sel = wordcraft_engine::Selection::caret(at);
|
||||
let _ = app.run("equation.edit", json!({"pos": serde_json::to_value(&inner).unwrap_or_default()}));
|
||||
app.session.page_hint = page;
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
if app.session.math.is_some() {
|
||||
let _ = app.run("equation.exit", json!({}));
|
||||
@@ -327,15 +324,15 @@ fn handle_primary_press(
|
||||
};
|
||||
app.canvas.unit_drag = Some(UnitDrag { unit, clicks, start, end, last: line });
|
||||
app.canvas.dragging = true;
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
let Some(pos) = layout.hit(page, x, y, story) else { return true };
|
||||
let Some(pos) = layout.hit(page, x, y, story) else { return };
|
||||
// Ctrl/⌘+click follows a hyperlink.
|
||||
if mods.command
|
||||
&& let Some(link) = link_under(app, layout, page, x, y, &pos)
|
||||
{
|
||||
follow_link(app, link);
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
let pj = serde_json::to_value(&pos).unwrap_or_default();
|
||||
// Alt+drag selects a column (block) of text.
|
||||
@@ -344,7 +341,7 @@ fn handle_primary_press(
|
||||
app.session.page_hint = page;
|
||||
app.canvas.column_drag = Some(((page, x, y), (page, x, y)));
|
||||
app.canvas.dragging = true;
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
app.canvas.column_drag = None;
|
||||
let _ = app.run("caret.set", json!({"pos": pj, "extend": mods.shift}));
|
||||
@@ -365,7 +362,6 @@ fn handle_primary_press(
|
||||
app.session.page_hint = page;
|
||||
app.canvas.dragging = true;
|
||||
// Format painter applies on mouse up
|
||||
false
|
||||
}
|
||||
|
||||
fn pointer_story(app: &WordApp, layout: &DocLayout, page: usize, x: f32, y: f32, story: StoryRef) -> StoryRef {
|
||||
|
||||
@@ -2887,40 +2887,6 @@ fn right_clicking_a_link_hides_the_hover_tooltip() {
|
||||
assert_eq!(tips(&h), 0, "the link tooltip is gone, so it can't cover the menu");
|
||||
}
|
||||
|
||||
/// #4501 follow-up: a press the canvas handles (here Ctrl+click following a link) is done with
|
||||
/// the pointer, so the link tooltip stands down that frame; plain hovering shows it again.
|
||||
#[test]
|
||||
fn a_handled_press_skips_the_link_tooltip() {
|
||||
use egui::{Event, Modifiers, PointerButton};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("See here")), Default::default());
|
||||
a.run("select.text", json!({"text": "here"})).unwrap();
|
||||
a.run("insert.link", json!({"url": "https://example.com/"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
steps(&mut h, 6);
|
||||
let c = h.state_mut().session.layout().caret(&Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 5 }).unwrap();
|
||||
let at = screen(&mut h, c.x, c.top + c.height / 2.0);
|
||||
h.event(Event::PointerMoved(at));
|
||||
steps(&mut h, 2);
|
||||
let tips = |h: &egui_kittest::Harness<'_, WordApp>| h.query_all_by_label_contains("Click to follow link").count();
|
||||
assert!(tips(&h) > 0, "hovering the link shows its target");
|
||||
h.event(Event::ModifiersChanged(Modifiers::COMMAND));
|
||||
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::COMMAND });
|
||||
h.step();
|
||||
assert_eq!(tips(&h), 0, "the Ctrl+click was handled: no tooltip that frame");
|
||||
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::COMMAND });
|
||||
h.event(Event::ModifiersChanged(Modifiers::NONE));
|
||||
steps(&mut h, 2);
|
||||
assert!(tips(&h) > 0, "hovering again shows the tooltip");
|
||||
}
|
||||
|
||||
/// #688: the drag auto-scroll waits in the middle, ramps in from both edges and never leaves
|
||||
/// the scrollable range.
|
||||
#[test]
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -67,16 +67,11 @@ fn too_large_to_paste(width: u32, height: u32) -> ClipboardPicture {
|
||||
ClipboardPicture::Unreadable(format!("the clipboard picture is too large to paste ({width} × {height} pixels)"))
|
||||
}
|
||||
|
||||
/// Whether the renderer can decode `bytes` within its budget (a 16-bit picture takes more than its pixel count says).
|
||||
fn probe_fits(bytes: &[u8]) -> bool {
|
||||
wordcraft_geom::picture::probe(bytes).is_ok_and(|p| p.fits(wordcraft_geom::MAX_DECODED_BYTES))
|
||||
}
|
||||
|
||||
/// An encoded picture the host read from the clipboard (the Windows "PNG" format), refused from
|
||||
/// its header when a paste can't take its size, before anything decodes it (#3623).
|
||||
pub fn encoded_picture(bytes: Vec<u8>) -> ClipboardPicture {
|
||||
match wordcraft_geom::picture::probe(&bytes).map(|p| (p.width, p.height)) {
|
||||
Ok((w, h)) if pastes(w, h) && probe_fits(&bytes) => ClipboardPicture::Encoded(bytes),
|
||||
Ok((w, h)) if pastes(w, h) => ClipboardPicture::Encoded(bytes),
|
||||
Ok((w, h)) => too_large_to_paste(w, h),
|
||||
Err(e) => ClipboardPicture::Unreadable(e.to_string()),
|
||||
}
|
||||
@@ -263,22 +258,6 @@ mod tests {
|
||||
image::GrayImage::new(20_000, 1).write_to(&mut std::io::Cursor::new(&mut wide), image::ImageFormat::Png).unwrap();
|
||||
assert!(matches!(encoded_picture(wide), ClipboardPicture::Unreadable(e) if e.contains("too large")));
|
||||
assert!(matches!(encoded_picture(b"not a picture".to_vec()), ClipboardPicture::Unreadable(_)));
|
||||
// 8000 × 6000 passes the pixel limits, but 16-bit RGBA needs 12 bytes a pixel to decode.
|
||||
let mut big = Vec::new();
|
||||
image::DynamicImage::ImageRgba16(image::ImageBuffer::new(1, 1))
|
||||
.write_to(&mut std::io::Cursor::new(&mut big), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
big[16..20].copy_from_slice(&8000u32.to_be_bytes());
|
||||
big[20..24].copy_from_slice(&6000u32.to_be_bytes());
|
||||
let mut crc = 0xFFFF_FFFFu32;
|
||||
for &b in &big[12..29] {
|
||||
crc ^= u32::from(b);
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 1 == 1 { 0xEDB8_8320 ^ (crc >> 1) } else { crc >> 1 };
|
||||
}
|
||||
}
|
||||
big[29..33].copy_from_slice(&(!crc).to_be_bytes());
|
||||
assert!(matches!(encoded_picture(big), ClipboardPicture::Unreadable(e) if e.contains("too large")));
|
||||
// BGRA in memory: a half-transparent red pixel and an opaque blue one.
|
||||
let ClipboardPicture::Pixels { width, height, rgba } = dib_picture(dib(2, 1, &[[0, 0, 255, 128], [255, 0, 0, 255]])) else {
|
||||
panic!("pixels")
|
||||
|
||||
@@ -70,10 +70,7 @@ use shape::shape_format;
|
||||
use table::{table_design, table_layout};
|
||||
#[cfg(test)]
|
||||
use tabs::MIN_TAB_W;
|
||||
use tabs::{
|
||||
CONTENT_SWITCH_SLIDE, MORE_TABS_W, TAB_ENTRY_SECS, TAB_ENTRY_SLIDE, ease_out_cubic, note_contextual_tabs, reading_toolbar, slide_underline,
|
||||
tab_content_entry, tab_entry,
|
||||
};
|
||||
use tabs::{MORE_TABS_W, TAB_ENTRY_SECS, TAB_ENTRY_SLIDE, ease_out_cubic, note_contextual_tabs, reading_toolbar, tab_entry};
|
||||
pub use tabs::{
|
||||
StripPlan, TABS, TabSpec, contextual_tabs, fit_tabs, has_chart_selected, has_picture_selected, has_shape_selected, has_smart_art_selected,
|
||||
in_table_public, plan_strip, resolve_tab,
|
||||
@@ -280,13 +277,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
let active = app.ui.tab == tab && !app.ui.backstage;
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, shown));
|
||||
let hover = ui.ctx().animate_bool_responsive(resp.id.with("hover"), resp.hovered() && !active && ghost.is_none());
|
||||
if hover > 0.0 {
|
||||
ui.painter().rect_filled(
|
||||
r.shrink2(vec2(0.0, 4.0)),
|
||||
4.0,
|
||||
egui::Color32::TRANSPARENT.lerp_to_gamma(t.hover, hover),
|
||||
);
|
||||
if resp.hovered() && !active && ghost.is_none() {
|
||||
ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover);
|
||||
}
|
||||
// Entering and departing contextual tabs move and fade without changing layout.
|
||||
let entry = match ghost {
|
||||
@@ -324,7 +316,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let resp = if cut && ghost.is_none() { resp.on_hover_text(shown) } else { resp };
|
||||
if active {
|
||||
let u = Rect::from_center_size(pos2(r.center().x + shift.x, r.max.y - 2.0), vec2(w - 16.0, 3.0));
|
||||
slide_underline(ui, tab, u, t.accent.gamma_multiply(entry), now, animate);
|
||||
ui.painter().rect_filled(u, 2.0, t.accent.gamma_multiply(entry));
|
||||
}
|
||||
if resp.clicked() {
|
||||
let _ = app.run("ui.tab", json!({"tab": tab}));
|
||||
@@ -345,13 +337,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
.collect();
|
||||
if !left_out.is_empty() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(MORE_TABS_W, 30.0), Sense::click());
|
||||
let hover = ui.ctx().animate_bool_responsive(resp.id.with("hover"), resp.hovered());
|
||||
if hover > 0.0 {
|
||||
ui.painter().rect_filled(
|
||||
r.shrink2(vec2(0.0, 4.0)),
|
||||
4.0,
|
||||
egui::Color32::TRANSPARENT.lerp_to_gamma(t.hover, hover),
|
||||
);
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover);
|
||||
}
|
||||
// The active tab is in the menu: the button says so.
|
||||
let holds_active = left_out.iter().any(|(tab, _)| *tab == app.ui.tab);
|
||||
@@ -389,13 +376,6 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin { left: 8, right: 8, top: 4, bottom: 4 }).stroke(Stroke::new(1.0, t.border)),
|
||||
)
|
||||
.show(ui, |ui| {
|
||||
// Bring newly selected ribbon content in from the side of its tab.
|
||||
let index = tabs.iter().position(|(name, _)| name == &app.ui.tab).unwrap_or(0);
|
||||
let (settled, direction) = tab_content_entry(ui.ctx(), &app.ui.tab, index, now, animate);
|
||||
let shift = vec2(direction * (1.0 - settled) * CONTENT_SWITCH_SLIDE, 0.0);
|
||||
let mut shifted = ui.new_child(egui::UiBuilder::new().max_rect(ui.max_rect().translate(shift)).layout(*ui.layout()));
|
||||
shifted.set_opacity(0.2 + 0.8 * settled);
|
||||
let ui = &mut shifted;
|
||||
// Right-click on the ribbon (outside a button): Customize Ribbon. Registered first, so
|
||||
// the buttons drawn over it keep their own clicks.
|
||||
// No document open (#1129): every command is greyed out but Help's.
|
||||
|
||||
@@ -158,78 +158,6 @@ pub(super) fn ease_out_cubic(p: f32) -> f32 {
|
||||
1.0 - (1.0 - p.clamp(0.0, 1.0)).powi(3)
|
||||
}
|
||||
|
||||
const UNDERLINE_SLIDE_SECS: f64 = 0.2;
|
||||
const CONTENT_SWITCH_SECS: f64 = 0.16;
|
||||
pub(super) const CONTENT_SWITCH_SLIDE: f32 = 14.0;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Underline {
|
||||
tab: String,
|
||||
from: (f32, f32),
|
||||
to: (f32, f32),
|
||||
start: f64,
|
||||
}
|
||||
|
||||
fn underline_at(u: &Underline, now: f64) -> (f32, f32) {
|
||||
let e = ease_out_cubic(((now - u.start) / UNDERLINE_SLIDE_SECS) as f32);
|
||||
(egui::lerp(u.from.0..=u.to.0, e), egui::lerp(u.from.1..=u.to.1, e))
|
||||
}
|
||||
|
||||
pub(super) fn slide_underline(ui: &egui::Ui, tab: &str, target: egui::Rect, color: egui::Color32, now: f64, animate: bool) {
|
||||
let id = egui::Id::unique("tab_underline");
|
||||
let prev: Option<Underline> = ui.ctx().data(|d| d.get_temp(id));
|
||||
let snap = Underline { tab: tab.to_owned(), from: (target.center().x, target.width()), to: (target.center().x, target.width()), start: now };
|
||||
let next = match prev {
|
||||
Some(p) if animate && p.tab != tab => Underline { from: underline_at(&p, now), ..snap },
|
||||
Some(p) if animate && now - p.start < UNDERLINE_SLIDE_SECS => Underline { to: (target.center().x, target.width()), ..p },
|
||||
_ => snap,
|
||||
};
|
||||
let (x, width) = underline_at(&next, now);
|
||||
if next.from != next.to && now - next.start < UNDERLINE_SLIDE_SECS {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
ui.ctx().data_mut(|d| d.insert_temp(id, next));
|
||||
ui.painter().rect_filled(egui::Rect::from_center_size(egui::pos2(x, target.center().y), egui::vec2(width, target.height())), 2.0, color);
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct TabChange {
|
||||
tab: String,
|
||||
index: usize,
|
||||
start: f64,
|
||||
dir: f32,
|
||||
}
|
||||
|
||||
pub(super) fn tab_content_entry(ctx: &egui::Context, tab: &str, index: usize, now: f64, animate: bool) -> (f32, f32) {
|
||||
let id = egui::Id::unique("ribbon_tab_change");
|
||||
let prev: Option<TabChange> = ctx.data(|d| d.get_temp(id));
|
||||
let next = match prev {
|
||||
Some(p) if p.tab != tab => TabChange {
|
||||
tab: tab.to_owned(),
|
||||
index,
|
||||
start: now,
|
||||
dir: if !animate || p.index == index {
|
||||
0.0
|
||||
} else if index > p.index {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
},
|
||||
},
|
||||
Some(p) => p,
|
||||
None => TabChange { tab: tab.to_owned(), index, start: f64::MIN, dir: 0.0 },
|
||||
};
|
||||
let p = ((now - next.start) / CONTENT_SWITCH_SECS).clamp(0.0, 1.0) as f32;
|
||||
let dir = next.dir;
|
||||
ctx.data_mut(|d| d.insert_temp(id, next));
|
||||
if p < 1.0 && dir != 0.0 {
|
||||
ctx.request_repaint();
|
||||
(ease_out_cubic(p), dir)
|
||||
} else {
|
||||
(1.0, 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Track contextual tabs between frames and return those still animating out.
|
||||
pub(super) fn note_contextual_tabs(ctx: &egui::Context, shown: &[(&str, usize)], now: f64, animate: bool) -> Vec<(String, usize, f64)> {
|
||||
let (shown_id, leaving_id) = (egui::Id::unique("contextual_tabs_shown"), egui::Id::unique("contextual_tabs_leaving"));
|
||||
@@ -418,40 +346,4 @@ mod transition_tests {
|
||||
note_contextual_tabs(&ctx, &both[..1], 7.0, true);
|
||||
assert!(note_contextual_tabs(&ctx, &both, 7.1, true).is_empty(), "back again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_underline_slides_and_can_reverse_mid_motion() {
|
||||
let ctx = egui::Context::default();
|
||||
let draw = |tab: &str, x: f32, now: f64| {
|
||||
ctx.run_ui(egui::RawInput { time: Some(now), ..Default::default() }, |ui| {
|
||||
slide_underline(
|
||||
ui,
|
||||
tab,
|
||||
egui::Rect::from_min_size(egui::pos2(x - 25.0, 10.0), egui::vec2(50.0, 3.0)),
|
||||
egui::Color32::WHITE,
|
||||
now,
|
||||
true,
|
||||
);
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
};
|
||||
draw("Home", 40.0, 1.0);
|
||||
draw("Insert", 140.0, 2.0);
|
||||
let frame = ctx.run_ui(egui::RawInput { time: Some(2.1), ..Default::default() }, |ui| {
|
||||
slide_underline(ui, "Draw", egui::Rect::from_min_size(egui::pos2(215.0, 10.0), egui::vec2(50.0, 3.0)), egui::Color32::WHITE, 2.1, true);
|
||||
});
|
||||
assert!(!frame.shapes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_content_motion_comes_from_the_new_tab_side() {
|
||||
let ctx = egui::Context::default();
|
||||
assert_eq!(tab_content_entry(&ctx, "Home", 1, 1.0, true), (1.0, 0.0));
|
||||
let (p, dir) = tab_content_entry(&ctx, "Insert", 2, 2.0, true);
|
||||
assert_eq!((p, dir), (0.0, 1.0));
|
||||
let (p, dir) = tab_content_entry(&ctx, "Home", 1, 2.05, true);
|
||||
assert!(p < 1.0);
|
||||
assert_eq!(dir, -1.0);
|
||||
assert_eq!(tab_content_entry(&ctx, "Draw", 3, 3.0, false), (1.0, 0.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -35,19 +35,12 @@ pub fn enabled(app: &WordApp, id: &str) -> bool {
|
||||
}
|
||||
|
||||
pub(crate) fn bg(ui: &Ui, r: Rect, resp: &Response, checked: bool, t: &Tokens) {
|
||||
bg_if(ui, r, resp, checked, t, true);
|
||||
}
|
||||
|
||||
/// Paints a button background with an optional hover/press response. Disabled ribbon commands
|
||||
/// pass `false` so their visual state cannot imply that they are interactive.
|
||||
pub(crate) fn bg_if(ui: &Ui, r: Rect, resp: &Response, checked: bool, t: &Tokens, on: bool) {
|
||||
let hover = ui.ctx().animate_bool_responsive(resp.id.with("hover"), resp.hovered() && on);
|
||||
let fill = if resp.is_pointer_button_down_on() && on {
|
||||
let fill = if resp.is_pointer_button_down_on() {
|
||||
t.pressed
|
||||
} else if checked {
|
||||
t.checked
|
||||
} else if hover > 0.0 {
|
||||
Color32::TRANSPARENT.lerp_to_gamma(t.hover, hover)
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
@@ -112,7 +105,7 @@ fn big_button(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str,
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::click());
|
||||
keytip_badge(ui, r, id, &label.replace('\n', " "));
|
||||
let on = enabled(app, id);
|
||||
bg_if(ui, r, &resp, checked, &t, on);
|
||||
bg(ui, r, &resp, checked, &t);
|
||||
let ic = Rect::from_center_size(pos2(r.center().x, r.min.y + BIG_ICON_Y), vec2(32.0, 32.0));
|
||||
let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) };
|
||||
icons::paint(ui.painter(), ic, icon, c, a);
|
||||
@@ -153,7 +146,7 @@ pub fn small(ui: &mut Ui, app: &mut WordApp, icon: &str, label: Option<&str>, ti
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(24.0 + text_w, 22.0), Sense::click());
|
||||
keytip_badge(ui, r, id, tip);
|
||||
let on = enabled(app, id);
|
||||
bg_if(ui, r, &resp, checked, &t, on);
|
||||
bg(ui, r, &resp, checked, &t);
|
||||
let rtl = crate::rtl::flows(ui);
|
||||
let ic = Rect::from_center_size(pos2(crate::rtl::lead_x(rtl, r, 12.0), r.center().y), vec2(17.0, 17.0));
|
||||
let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) };
|
||||
@@ -188,8 +181,8 @@ pub fn split(
|
||||
keytip_badge(ui, r, id, tip);
|
||||
let (ar, aresp) = ui.allocate_exact_size(vec2(11.0, 22.0), Sense::click());
|
||||
let on = enabled(app, id);
|
||||
bg_if(ui, r, &resp, checked, &t, on);
|
||||
bg_if(ui, ar, &aresp, false, &t, on);
|
||||
bg(ui, r, &resp, checked, &t);
|
||||
bg(ui, ar, &aresp, false, &t);
|
||||
let ic = Rect::from_center_size(r.center(), vec2(17.0, 17.0));
|
||||
let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) };
|
||||
icons::paint(ui.painter(), ic, icon, c, a);
|
||||
@@ -259,7 +252,7 @@ pub fn menu_button(ui: &mut Ui, app: &mut WordApp, options: MenuButtonOptions<'_
|
||||
let w = (galley_w + 22.0).max(44.0);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::click());
|
||||
keytip_badge(ui, r, "menu", tip);
|
||||
bg_if(ui, r, &resp, false, &t, on);
|
||||
bg(ui, r, &resp, false, &t);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.min.y + BIG_ICON_Y), vec2(32.0, 32.0)), icon, c, a);
|
||||
let lines: Vec<&str> = label.unwrap_or("").split('\n').collect();
|
||||
let mut y = r.min.y + BIG_LABEL_Y;
|
||||
@@ -272,7 +265,7 @@ pub fn menu_button(ui: &mut Ui, app: &mut WordApp, options: MenuButtonOptions<'_
|
||||
let text_w = label.map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x) + 6.0).unwrap_or(0.0);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(24.0 + text_w + 10.0, 22.0), Sense::click());
|
||||
keytip_badge(ui, r, "menu", tip);
|
||||
bg_if(ui, r, &resp, false, &t, on);
|
||||
bg(ui, r, &resp, false, &t);
|
||||
let rtl = crate::rtl::flows(ui);
|
||||
let lead = |inset: f32| crate::rtl::lead_x(rtl, r, inset);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(lead(12.0), r.center().y), vec2(17.0, 17.0)), icon, c, a);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#[cfg(test)]
|
||||
use super::TALL_GROUPS;
|
||||
use super::buttons::{BIG_ICON_Y, BIG_LABEL_Y, CONTENT_H, DROPDOWN, LABEL_H, bg, label_line};
|
||||
use super::{Align2, Color32, Rect, Response, Sense, Stroke, Tokens, Ui, WordApp, icons, pos2, regular, vec2};
|
||||
use super::{Align2, Rect, Response, Sense, Stroke, Tokens, Ui, WordApp, icons, pos2, regular, vec2};
|
||||
|
||||
/// A ribbon group: content, a centred label below and a divider on the right.
|
||||
pub fn group(ui: &mut Ui, title: &str, launcher: Option<&str>, app: &mut WordApp, add: impl FnOnce(&mut Ui, &mut WordApp)) {
|
||||
@@ -26,8 +26,6 @@ pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<&
|
||||
// Not folded: a drop-down left open while it was folded doesn't come back by itself.
|
||||
ui.data_mut(|d| d.remove::<bool>(collapsed_open_id(ui, id_title, position)));
|
||||
let label_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(title.to_string(), regular(11.0), t.group_label).size().x);
|
||||
// Reserve the paint slot before children so the glow stays beneath the controls.
|
||||
let glow = ui.painter().add(egui::Shape::Noop);
|
||||
let inner = crate::rtl::vertical(ui, |ui| {
|
||||
ui.set_min_width(label_w + if launcher.is_some() { 22.0 } else { 10.0 });
|
||||
crate::ribbon_fit::set_compact(ui.ctx(), level == crate::ribbon_fit::COMPACT);
|
||||
@@ -44,13 +42,6 @@ pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<&
|
||||
let _ = content;
|
||||
});
|
||||
let r = inner.response.rect;
|
||||
let area = group_area(rtl, start, r);
|
||||
let over = ui.rect_contains_pointer(area);
|
||||
// Position is part of identity because duplicate custom group names can appear in tabs.
|
||||
let lit = ui.ctx().animate_bool_responsive(ui.scope_id().with(("group_glow", id_title, position)), over);
|
||||
if lit > 0.0 {
|
||||
ui.painter().set(glow, group_glow(area, t.accent, lit));
|
||||
}
|
||||
title_row(
|
||||
ui,
|
||||
Rect::from_min_max(pos2(if rtl { r.min.x } else { start.x }, start.y + CONTENT_H), pos2(r.max.x, start.y + CONTENT_H + LABEL_H)),
|
||||
@@ -67,13 +58,6 @@ pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<&
|
||||
crate::ribbon_fit::record(ui.ctx(), id_title, level, group_width(rtl, start_cursor, ui.cursor()));
|
||||
}
|
||||
|
||||
/// The hover area of a group. A right-to-left row has no finite left edge at the cursor, so the
|
||||
/// area starts at the content's own left edge there.
|
||||
fn group_area(rtl: bool, start: egui::Pos2, content: Rect) -> Rect {
|
||||
let left = if rtl { content.min.x } else { start.x };
|
||||
Rect::from_min_max(pos2(left, start.y), pos2(content.max.x, start.y + CONTENT_H + LABEL_H))
|
||||
}
|
||||
|
||||
/// The width a group took: the cursor's way along the row, from `before` to `after`. A
|
||||
/// right-to-left row fills from its right edge, where the cursor's left edge is negative infinity,
|
||||
/// so there the width is how far the right edge moved.
|
||||
@@ -81,25 +65,6 @@ fn group_width(rtl: bool, before: Rect, after: Rect) -> f32 {
|
||||
if rtl { before.max.x - after.max.x } else { after.min.x - before.min.x }
|
||||
}
|
||||
|
||||
/// A soft accent glow along the bottom of a hovered ribbon group.
|
||||
fn group_glow(area: Rect, accent: Color32, strength: f32) -> egui::Shape {
|
||||
let color = |alpha: f32| Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), (alpha * strength * 255.0) as u8);
|
||||
let r = Rect::from_min_max(pos2(area.min.x, area.max.y - 7.0), pos2(area.max.x, area.max.y + 2.0));
|
||||
let soft = (r.width() * 0.3).min(22.0);
|
||||
let xs = [r.min.x, r.min.x + soft, r.max.x - soft, r.max.x];
|
||||
let mut mesh = egui::epaint::Mesh::default();
|
||||
for (y, alpha) in [(r.max.y, 0.3), (r.min.y, 0.0)] {
|
||||
for (i, x) in xs.iter().enumerate() {
|
||||
mesh.colored_vertex(pos2(*x, y), color(if i == 0 || i == 3 { 0.0 } else { alpha }));
|
||||
}
|
||||
}
|
||||
for i in 0..3u32 {
|
||||
mesh.add_triangle(i, i + 1, i + 5);
|
||||
mesh.add_triangle(i, i + 5, i + 4);
|
||||
}
|
||||
egui::Shape::mesh(mesh)
|
||||
}
|
||||
|
||||
/// A group's title row (`r`): the centred title and, with a `launcher`, its dialog launcher.
|
||||
fn title_row(ui: &mut Ui, r: Rect, id_title: &str, title: &str, launcher: Option<&str>, app: &mut WordApp) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -299,15 +264,4 @@ mod tests {
|
||||
let after = Rect::from_min_max(pos2(180.0, 0.0), pos2(f32::INFINITY, 90.0));
|
||||
assert_eq!(group_width(false, before, after), 160.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_in_a_right_to_left_row_has_a_finite_hover_area() {
|
||||
let start = pos2(f32::NEG_INFINITY, 10.0);
|
||||
let content = Rect::from_min_max(pos2(600.0, 10.0), pos2(740.0, 80.0));
|
||||
let area = group_area(true, start, content);
|
||||
assert!(area.min.x.is_finite() && area.max.x.is_finite());
|
||||
assert_eq!((area.min.x, area.max.x), (600.0, 740.0));
|
||||
let next = group_area(true, start, Rect::from_min_max(pos2(450.0, 10.0), pos2(590.0, 80.0)));
|
||||
assert!(!area.intersects(next), "neighbouring groups do not overlap");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3
-4
File diff suppressed because one or more lines are too long
@@ -55,7 +55,7 @@ esac
|
||||
# Only binaries for this machine run here (the --version check at the end); a FreeBSD target built
|
||||
# on another OS never does.
|
||||
HOST_ARCH="$(release_arch "$(uname -m)" 2>/dev/null)" || HOST_ARCH="$(uname -m)"
|
||||
if [[ -n "${CARGO_BUILD_TARGET:-}" ]] && [[ "$(uname -s)" != FreeBSD ]]; then
|
||||
if [ -n "${CARGO_BUILD_TARGET:-}" ] && [ "$(uname -s)" != FreeBSD ]; then
|
||||
HOST_ARCH="$(uname -s) $HOST_ARCH"
|
||||
fi
|
||||
BASENAME="wordcraft-$VERSION-freebsd-$ARCH"
|
||||
@@ -96,12 +96,12 @@ mkdir -p "$DIST"
|
||||
tar -C "$WORK" -czf "$DIST/$BASENAME.tar.gz" "$BASENAME"
|
||||
echo "wrote $DIST/$BASENAME.tar.gz"
|
||||
|
||||
if [[ -z "${CARGO_BUILD_TARGET:-}" ]] && [[ "$ARCH" = "$HOST_ARCH" ]]; then
|
||||
if [ -z "${CARGO_BUILD_TARGET:-}" ] && [ "$ARCH" = "$HOST_ARCH" ]; then
|
||||
"$STAGE/bin/wordcraft-cli" --version
|
||||
else
|
||||
# They cannot run here, so check what they are instead: executables, not libraries or objects.
|
||||
# An older file(1) calls a PIE a shared object; its interpreter still marks it as a program.
|
||||
want="$ARCH"; [[ "$ARCH" != x86_64 ]] || want=x86-64
|
||||
want="$ARCH"; [ "$ARCH" != x86_64 ] || want=x86-64
|
||||
for b in "$STAGE"/bin/*; do
|
||||
case "$(file -b "$b")" in
|
||||
ELF*" executable, "*"$want"*FreeBSD* | ELF*"$want"*FreeBSD*interpreter*) ;;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user