Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 9ed29eb8bf style: rustfmt 2026-11-10 21:38:35 -05:00
Dylan M. Taylor e2ae3ac924 fix(metafile): hold a placed pen width to what covers the frame 2026-11-10 21:38:35 -05:00
28 changed files with 117 additions and 704 deletions
+3 -3
View File
File diff suppressed because one or more lines are too long
+17 -17
View File
@@ -164,7 +164,7 @@ 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;
use wordcraft_ui_egui::paste_picture::{self, ClipboardPicture};
fn wayland() -> bool {
std::env::var_os("WAYLAND_DISPLAY").is_some()
@@ -348,22 +348,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::*;
@@ -385,6 +369,22 @@ mod platform {
assert!(complete(0, 0, 0));
}
}
/// 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(target_os = "windows")]
+1 -31
View File
@@ -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)> {
-52
View File
@@ -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> {
+15 -42
View File
@@ -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) => {
-3
View File
@@ -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)
-31
View File
@@ -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)));
}
-1
View File
@@ -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;
-74
View File
@@ -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 = &paras(&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 = &paras(&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
+1 -2
View File
@@ -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));
}
-30
View File
@@ -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);
+2 -8
View File
@@ -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);
+2 -3
View File
@@ -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
View File
@@ -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 {
-70
View File
@@ -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();
-55
View File
@@ -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]
+8 -2
View File
@@ -136,8 +136,14 @@ impl Picture {
match item {
Item::Path { segs, fill, stroke, even_odd } => {
let path = tf * bez(segs);
if in_range(path.bounding_box()) {
let stroke = stroke.map(|(c, w)| (c, f64::from(w) * k));
let bounds = path.bounding_box();
if in_range(bounds) {
// A pen wider than twice the distance across the frame and the path
// already covers the whole frame: wider adds nothing the frame shows, only
// work for the renderer flattening its round caps (the width is file data).
let u = bounds.union(r);
let widest = 2.0 * u.width().hypot(u.height());
let stroke = stroke.map(|(c, w)| (c, (f64::from(w) * k).min(widest)));
out.push(PlacedItem::Path { path, fill: *fill, stroke, even_odd: *even_odd });
}
}
+28
View File
@@ -891,6 +891,34 @@ fn place_fits_the_frame_scales_strokes_and_drops_far_paths() {
assert_eq!(stroke.map(|s| s.1), Some(8.0));
}
#[test]
fn a_huge_pen_is_held_to_what_covers_the_frame() {
// A pen of 1e30 units on a short path: round caps that wide took the renderer forever to
// flatten. Past twice the distance across the frame and the path it covers no more.
let path = |w: f32| crate::Picture {
width: 100.0,
height: 50.0,
items: vec![Item::Path {
segs: vec![Seg::Move(0.0, 0.0), Seg::Line(10.0, 0.0)],
fill: None,
stroke: Some(([0, 0, 0, 255], w)),
even_odd: true,
}],
};
let width = |w: f32| match pic_place(&path(w)).first() {
Some(crate::PlacedItem::Path { stroke, .. }) => stroke.map(|s| s.1),
_ => None,
};
let cap = 2.0 * 200f64.hypot(100.0);
assert_eq!(width(1e30), Some(cap));
assert_eq!(width(f32::MAX), Some(cap));
assert_eq!(width(4.0), Some(8.0), "an ordinary pen is untouched");
}
fn pic_place(pic: &crate::Picture) -> Vec<crate::PlacedItem> {
pic.place(kurbo::Rect::new(0.0, 0.0, 200.0, 100.0), [0.0; 4])
}
/// #1899: a crop keeps what the file says down to a sliver (a header's bar is 2.7% of a page-sized
/// metafile), and junk sides give a finite part of the source with some area.
#[test]
+9 -13
View File
@@ -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 {
-34
View File
@@ -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]
+6 -26
View File
@@ -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.
-108
View File
@@ -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));
}
}
+9 -16
View File
@@ -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 -47
View File
@@ -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
+2 -2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -2
View File
File diff suppressed because one or more lines are too long