Compare commits

...
Author SHA1 Message Date
skylar a782b534f4 chore: merge current main for nextest update 2026-10-26 14:38:32 -06:00
skylar 05275a5020 ci: require nextest 0.9.150
Use the fixed release for GHSA-vc25-qg58-w57c.
2026-10-26 14:13:06 -06:00
Dylan M. Taylor d859c673c1 Sonar: reduce cognitive complexity in highlight, review and outline commands 2026-10-26 16:01:36 -04:00
19 changed files with 219 additions and 181 deletions
+3
View File
@@ -0,0 +1,3 @@
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
nextest-version = "0.9.150"
+1 -1
View File
@@ -570,7 +570,7 @@ impl P {
}
out.pop()
};
if !matches!(c, '^' | '_') && !(matches!(c, '┬' | '┴') && self.no_limits == 0) && !is_combining(c) {
if !(matches!(c, '^' | '_') || matches!(c, '┬' | '┴') && self.no_limits == 0 || is_combining(c)) {
break;
}
// The base is already deep: the operator is text (see `too_deep`).
+6 -12
View File
@@ -377,21 +377,15 @@ impl Props {
}
0x0104 => self.pib = Some(op),
0x0181 => self.fill_color = color(op),
0x01BF => {
// fFilled (bit 4) is valid when fUsefFilled (bit 20) is set.
if op & (1 << 20) != 0 {
self.filled = Some(op & (1 << 4) != 0);
}
}
// fFilled (bit 4) is valid when fUsefFilled (bit 20) is set.
0x01BF if op & (1 << 20) != 0 => self.filled = Some(op & (1 << 4) != 0),
0x01BF => {}
0x01C0 => self.line_color = color(op),
0x01CB => self.line_width = Some(op),
0x01D0 | 0x01D1 => self.arrowhead |= op != 0,
0x01FF => {
// fLine (bit 3) is valid when fUsefLine (bit 19) is set.
if op & (1 << 19) != 0 {
self.line = Some(op & (1 << 3) != 0);
}
}
// fLine (bit 3) is valid when fUsefLine (bit 19) is set.
0x01FF if op & (1 << 19) != 0 => self.line = Some(op & (1 << 3) != 0),
0x01FF => {}
0x0384..=0x0387 => self.wrap_dist[(pid - 0x0384) as usize] = Some(op),
0x038F => self.posh = Some(op),
0x0390 => self.posrelh = Some(op),
+3 -4
View File
@@ -732,11 +732,10 @@ impl Reader<'_> {
};
self.emit_text(sc, pb, c, props);
}
"w:cr" => {
if !sc.fields.iter().any(|f| f.phase == Phase::Instr) {
self.emit_text(sc, pb, "\n", props);
}
"w:cr" if !sc.fields.iter().any(|f| f.phase == Phase::Instr) => {
self.emit_text(sc, pb, "\n", props);
}
"w:cr" => {}
"w:noBreakHyphen" => self.emit_text(sc, pb, "\u{2011}", props),
"w:softHyphen" => self.emit_text(sc, pb, "\u{00AD}", props),
"w:sym" => {
+72 -47
View File
@@ -313,6 +313,22 @@ mod tests {
}
}
/// The per-case shape/geometry of one scaled-group reroute case.
struct ScaledGroupSpec {
w: f32,
h: f32,
kind: ShapeKind,
rot: f32,
flip_h: bool,
flip_v: bool,
}
impl ScaledGroupSpec {
fn new(w: f32, h: f32, kind: ShapeKind, rot: f32, flip_h: bool, flip_v: bool) -> Self {
Self { w, h, kind, rot, flip_h, flip_v }
}
}
fn visual_ends(group: &InlineObject) -> ((f32, f32), (f32, f32)) {
let InlineObject::Group { w, h, float, .. } = group else { panic!("group") };
let rects = group.group_rects(0.0, 0.0, *w, *h);
@@ -327,6 +343,61 @@ mod tests {
(turn((x, y)), turn((x + w, y + h)))
}
/// One scaled-group case: rerouting keeps the visual free end and the resolved sites.
fn assert_scaled_group_reroute(spec: ScaledGroupSpec, free: Option<ConnEnd>, free_start: bool) {
let attached = Some(ConnEnd { id: 7, site: 3 });
let (start, end) = if free_start { (free, attached) } else { (attached, free) };
let group = InlineObject::Group {
w: spec.w,
h: spec.h,
ch_w: 200.0,
ch_h: 300.0,
float: Float { rot: 25.0, flip_h: true, ..Default::default() },
canvas: None,
children: vec![
GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, ShapeExtra { id: 7, ..Default::default() }, Float::default()) },
GroupChild {
x: 10.0,
y: 30.0,
obj: shape(
spec.kind,
ShapeExtra { start, end, ..Default::default() },
Float { rot: spec.rot, flip_h: spec.flip_h, flip_v: spec.flip_v, ..Default::default() },
),
},
],
};
// Rendering scales each child rectangle before applying its own spin.
let before = visual_ends(&group);
let expected = if free_start { before.0 } else { before.1 };
let mut s = Session::new(wordcraft_doc::Document::new());
let pos = s.doc.start_of(StoryRef::Body);
s.doc.para_mut(pos.story, &pos.path).unwrap().insert_object(pos.off, group, &Default::default()).unwrap();
assert!(reroute(&mut s));
let group = s.doc.para_at(&pos).unwrap().object_at(pos.off).unwrap();
let after = visual_ends(group);
let actual = if free_start { after.0 } else { after.1 };
assert!(
(actual.0 - expected.0).abs() < 0.001 && (actual.1 - expected.1).abs() < 0.001,
"{}x{}, {:?}, {}, {}, {}, {}: {:?} != {:?}",
spec.w,
spec.h,
spec.kind,
spec.rot,
spec.flip_h,
spec.flip_v,
free_start,
actual,
expected
);
let InlineObject::Group { children, .. } = group else { panic!("group") };
let c = &children[1];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!("connector") };
let routed = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
assert_eq!(if free_start { routed.1 } else { routed.0 }, (140.0, 210.0), "resolved sites stay in child coordinates");
assert_eq!(float.rot, 0.0);
assert!(!reroute(&mut s), "rerouting must settle");
}
#[test]
fn rerouting_scaled_group_preserves_visual_free_and_unresolved_ends() {
for (w, h) in [(200.0, 300.0), (400.0, 600.0), (400.0, 150.0)] {
@@ -335,53 +406,7 @@ mod tests {
for (flip_h, flip_v) in [(false, false), (true, false), (false, true), (true, true)] {
for free in [None, Some(ConnEnd { id: 404, site: 0 }), Some(ConnEnd { id: 7, site: 99 })] {
for free_start in [false, true] {
let attached = Some(ConnEnd { id: 7, site: 3 });
let (start, end) = if free_start { (free, attached) } else { (attached, free) };
let group = InlineObject::Group {
w,
h,
ch_w: 200.0,
ch_h: 300.0,
float: Float { rot: 25.0, flip_h: true, ..Default::default() },
canvas: None,
children: vec![
GroupChild {
x: 100.0,
y: 200.0,
obj: shape(ShapeKind::Rectangle, ShapeExtra { id: 7, ..Default::default() }, Float::default()),
},
GroupChild {
x: 10.0,
y: 30.0,
obj: shape(
kind,
ShapeExtra { start, end, ..Default::default() },
Float { rot, flip_h, flip_v, ..Default::default() },
),
},
],
};
// Rendering scales each child rectangle before applying its own spin.
let before = visual_ends(&group);
let expected = if free_start { before.0 } else { before.1 };
let mut s = Session::new(wordcraft_doc::Document::new());
let pos = s.doc.start_of(StoryRef::Body);
s.doc.para_mut(pos.story, &pos.path).unwrap().insert_object(pos.off, group, &Default::default()).unwrap();
assert!(reroute(&mut s));
let group = s.doc.para_at(&pos).unwrap().object_at(pos.off).unwrap();
let after = visual_ends(group);
let actual = if free_start { after.0 } else { after.1 };
assert!(
(actual.0 - expected.0).abs() < 0.001 && (actual.1 - expected.1).abs() < 0.001,
"{w}x{h}, {kind:?}, {rot}, {flip_h}, {flip_v}, {free_start}: {actual:?} != {expected:?}"
);
let InlineObject::Group { children, .. } = group else { panic!("group") };
let c = &children[1];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!("connector") };
let routed = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
assert_eq!(if free_start { routed.1 } else { routed.0 }, (140.0, 210.0), "resolved sites stay in child coordinates");
assert_eq!(float.rot, 0.0);
assert!(!reroute(&mut s), "rerouting must settle");
assert_scaled_group_reroute(ScaledGroupSpec::new(w, h, kind, rot, flip_h, flip_v), free, free_start);
}
}
}
+34 -26
View File
@@ -401,19 +401,9 @@ fn target_has_highlight(s: &Session, h: Highlight) -> bool {
if let Some(rows) = column_rows(s) {
for (a, b) in rows {
let Some(p) = s.doc.para_at(&a) else { continue };
if a == b || (a.off == 0 && b.off == p.len()) {
seen = true;
if !matches(p.props.style.as_deref(), &p.mark) {
return false;
}
}
for (r, c) in p.run_ranges() {
if r.end > a.off && r.start < b.off {
seen = true;
if !matches(p.props.style.as_deref(), c) {
return false;
}
}
let mark = a == b || (a.off == 0 && b.off == p.len());
if !span_matches(p, mark, a.off, b.off, &matches, &mut seen) {
return false;
}
}
if seen {
@@ -426,19 +416,9 @@ fn target_has_highlight(s: &Session, h: Highlight) -> bool {
let from = if path == a.path { a.off } else { 0 };
let to = if path == b.path { b.off } else { p.len() };
// Match format_range: fully selected paragraphs include their mark.
if to == p.len() && (from == 0 || path != a.path) {
seen = true;
if !matches(p.props.style.as_deref(), &p.mark) {
return false;
}
}
for (r, c) in p.run_ranges() {
if r.end > from && r.start < to {
seen = true;
if !matches(p.props.style.as_deref(), c) {
return false;
}
}
let mark = to == p.len() && (from == 0 || path != a.path);
if !span_matches(p, mark, from, to, &matches, &mut seen) {
return false;
}
}
}
@@ -446,6 +426,34 @@ fn target_has_highlight(s: &Session, h: Highlight) -> bool {
seen || resolved(s).iter().all(|r| r.highlight == h.rgb())
}
/// Whether the paragraph's mark (when `mark`) and the runs overlapping `from..to` all match
/// `matches`; `seen` records that something was checked.
fn span_matches(
p: &wordcraft_doc::Paragraph,
mark: bool,
from: usize,
to: usize,
matches: &impl Fn(Option<&str>, &CharProps) -> bool,
seen: &mut bool,
) -> bool {
let style = p.props.style.as_deref();
if mark {
*seen = true;
if !matches(style, &p.mark) {
return false;
}
}
for (r, c) in p.run_ranges() {
if r.end > from && r.start < to {
*seen = true;
if !matches(style, c) {
return false;
}
}
}
true
}
/// Explicit palette/automation colours set highlighting; the shortcut (no colour) toggles
/// the last choice off only when the whole formatting target already has that colour.
fn highlight(s: &mut Session, v: &Value) -> CmdResult {
+46 -40
View File
@@ -460,6 +460,51 @@ pub(crate) fn move_shown(s: &mut Session, up: bool) -> Option<CmdResult> {
Some(sel_result(s))
}
/// Keeps collapsed headings collapsed after an edit: each one moves to the nearest heading with the
/// same text that is still free.
fn remap_collapsed_headings(s: &mut Session) {
let hashes: Vec<Option<u64>> = (0..s.doc.body.len()).map(|i| text_hash(&s.doc, i)).collect();
let levels = ol::levels(&s.doc);
// The headings not yet taken, by text hash.
let mut by_hash: HashMap<u64, BTreeSet<usize>> = HashMap::new();
for (j, h) in hashes.iter().enumerate() {
if let Some(h) = h
&& levels.get(j).is_some_and(|l| *l < BODY)
{
by_hash.entry(*h).or_default().insert(j);
}
}
let mut used = BTreeSet::new();
let mut out = Vec::new();
for (i, h) in std::mem::take(&mut s.view.outline.collapsed) {
let free = |j: usize, used: &BTreeSet<usize>| levels.get(j).is_some_and(|l| *l < BODY) && !used.contains(&j);
let same = |j: usize| hashes.get(j).copied().flatten() == Some(h);
let found = if free(i, &used) && same(i) {
Some(i)
} else {
// The nearest free heading with the same text (the earlier one on a tie).
let nearest = by_hash.get(&h).and_then(|set| {
let below = set.range(..=i).next_back().copied();
let above = set.range(i..).next().copied();
match (below, above) {
(Some(b), Some(a)) => Some(if a.abs_diff(i) < b.abs_diff(i) { a } else { b }),
(b, a) => b.or(a),
}
});
nearest.or_else(|| free(i, &used).then_some(i))
};
if let Some(j) = found {
used.insert(j);
if let Some(set) = hashes.get(j).copied().flatten().and_then(|hj| by_hash.get_mut(&hj)) {
set.remove(&j);
}
out.push((j, hashes.get(j).copied().flatten().unwrap_or(h)));
}
}
out.sort_unstable();
s.view.outline.collapsed = out;
}
/// After every command: collapsed headings are found again where edits moved them (by their
/// text; one whose text changed in place stays collapsed), and in Outline view a caret the
/// outline doesn't show (in collapsed text, or past a body paragraph's first line) moves on to
@@ -467,46 +512,7 @@ pub(crate) fn move_shown(s: &mut Session, up: bool) -> Option<CmdResult> {
/// `changed` says whether the document changed (its revision moved); if not, nothing moved.
pub(crate) fn after_command(s: &mut Session, before: &Selection, changed: bool) {
if changed && !s.view.outline.collapsed.is_empty() {
let hashes: Vec<Option<u64>> = (0..s.doc.body.len()).map(|i| text_hash(&s.doc, i)).collect();
let levels = ol::levels(&s.doc);
// The headings not yet taken, by text hash.
let mut by_hash: HashMap<u64, BTreeSet<usize>> = HashMap::new();
for (j, h) in hashes.iter().enumerate() {
if let Some(h) = h
&& levels.get(j).is_some_and(|l| *l < BODY)
{
by_hash.entry(*h).or_default().insert(j);
}
}
let mut used = BTreeSet::new();
let mut out = Vec::new();
for (i, h) in std::mem::take(&mut s.view.outline.collapsed) {
let free = |j: usize, used: &BTreeSet<usize>| levels.get(j).is_some_and(|l| *l < BODY) && !used.contains(&j);
let same = |j: usize| hashes.get(j).copied().flatten() == Some(h);
let found = if free(i, &used) && same(i) {
Some(i)
} else {
// The nearest free heading with the same text (the earlier one on a tie).
let nearest = by_hash.get(&h).and_then(|set| {
let below = set.range(..=i).next_back().copied();
let above = set.range(i..).next().copied();
match (below, above) {
(Some(b), Some(a)) => Some(if a.abs_diff(i) < b.abs_diff(i) { a } else { b }),
(b, a) => b.or(a),
}
});
nearest.or_else(|| free(i, &used).then_some(i))
};
if let Some(j) = found {
used.insert(j);
if let Some(set) = hashes.get(j).copied().flatten().and_then(|hj| by_hash.get_mut(&hj)) {
set.remove(&j);
}
out.push((j, hashes.get(j).copied().flatten().unwrap_or(h)));
}
}
out.sort_unstable();
s.view.outline.collapsed = out;
remap_collapsed_headings(s);
}
if s.view.mode != ViewMode::Outline || !s.sel.is_collapsed() || s.sel.focus.story != StoryRef::Body {
return;
+39 -38
View File
@@ -606,6 +606,44 @@ fn resolve_sel_then(s: &mut Session, v: &Value, accept: bool) -> CmdResult {
resolve_sel(s, accept, p::bool(v, "next") == Some(true))
}
/// The span of the tracked change around `off` in `p`: its run, joined across the comment anchors
/// that split one revision's text into several runs.
fn revision_span(p: &Paragraph, off: usize) -> Option<std::ops::Range<usize>> {
let runs: Vec<_> = p.run_ranges().collect();
let i = runs.iter().position(|(r, c)| r.start <= off && off <= r.end && (c.ins.is_some() || c.del.is_some() || c.fmt_change.is_some()))?;
let (mut start, mut end) = (runs[i].0.start, runs[i].0.end);
let key = (runs[i].1.ins, runs[i].1.del);
if key == (None, None) {
return Some(start..end);
}
// Comment anchors split a revision's text into several runs; the revision goes on past them.
let is_anchor = |k: usize| matches!(p.object_at(runs[k].0.start), Some(InlineObject::CommentStart { .. } | InlineObject::CommentEnd { .. }));
let same = |k: usize| (runs[k].1.ins, runs[k].1.del) == key;
let mut k = i;
while k > 0 {
if same(k - 1) {
k -= 1;
} else if is_anchor(k - 1) && k > 1 && same(k - 2) {
k -= 2;
} else {
break;
}
start = runs[k].0.start;
}
let mut k = i;
while k + 1 < runs.len() {
if same(k + 1) {
k += 1;
} else if is_anchor(k + 1) && k + 2 < runs.len() && same(k + 2) {
k += 2;
} else {
break;
}
end = runs[k].0.end;
}
Some(start..end)
}
fn resolve_sel(s: &mut Session, accept: bool, next: bool) -> CmdResult {
let (a, b) = s.sel.ordered();
// A caret right before a tracked paragraph mark (and no tracked text around it): that mark.
@@ -616,44 +654,7 @@ fn resolve_sel(s: &mut Session, accept: bool, next: bool) -> CmdResult {
let (a, b) = if a == b {
// The change at the caret: the run around it, else the paragraph mark right after it,
// else the paragraph's formatting, else the table cell's.
let found = s.doc.para_at(&a).and_then(|p| {
let runs: Vec<_> = p.run_ranges().collect();
let i = runs
.iter()
.position(|(r, c)| r.start <= a.off && a.off <= r.end && (c.ins.is_some() || c.del.is_some() || c.fmt_change.is_some()))?;
let (mut start, mut end) = (runs[i].0.start, runs[i].0.end);
let key = (runs[i].1.ins, runs[i].1.del);
if key == (None, None) {
return Some(start..end);
}
// Comment anchors split a revision's text into several runs; the revision goes on past them.
let is_anchor =
|k: usize| matches!(p.object_at(runs[k].0.start), Some(InlineObject::CommentStart { .. } | InlineObject::CommentEnd { .. }));
let same = |k: usize| (runs[k].1.ins, runs[k].1.del) == key;
let mut k = i;
while k > 0 {
if same(k - 1) {
k -= 1;
} else if is_anchor(k - 1) && k > 1 && same(k - 2) {
k -= 2;
} else {
break;
}
start = runs[k].0.start;
}
let mut k = i;
while k + 1 < runs.len() {
if same(k + 1) {
k += 1;
} else if is_anchor(k + 1) && k + 2 < runs.len() && same(k + 2) {
k += 2;
} else {
break;
}
end = runs[k].0.end;
}
Some(start..end)
});
let found = s.doc.para_at(&a).and_then(|p| revision_span(p, a.off));
let para_fmt = s.doc.para_at(&a).is_some_and(|p| p.props.fmt_change.is_some() || p.props.num_change.is_some());
let mark_fmt = s.doc.para_at(&a).is_some_and(|p| a.off == p.len() && p.mark.fmt_change.is_some());
match found {
+1 -1
View File
@@ -899,7 +899,7 @@ fn tab_indent_on(s: &Session) -> bool {
fn tab_indents(s: &mut Session, f: &Pos) -> bool {
let Some(para) = s.doc.para_at(f).filter(|p| !p.is_empty()) else { return false };
if f.off == 0 {
return !s.doc.styles.resolve_para(&para.props).numbering.is_some_and(|n| n.num != 0);
return s.doc.styles.resolve_para(&para.props).numbering.is_none_or(|n| n.num == 0);
}
let l = s.layout();
l.line_bounds(f, s.page_hint).is_some_and(|(a, _)| a == *f)
+1 -1
View File
@@ -1,5 +1,5 @@
//! Installed system fonts: found by family name by every lookup, whatever ran before it (#22).
#[allow(unused_imports)]
use std::path::{Path, PathBuf};
use super::*;
+1 -1
View File
@@ -4528,7 +4528,7 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl
&& !pb.at_top()
&& !fresh
&& rest.is_none()
&& !ri.checked_sub(1).and_then(|prev| kept.get(prev)).is_some_and(|&(end, _)| end >= ri)
&& ri.checked_sub(1).and_then(|prev| kept.get(prev)).is_none_or(|&(end, _)| end < ri)
&& kept.get(ri).is_some_and(|&(end, height)| {
if end == ri {
return false;
+1
View File
@@ -1,6 +1,7 @@
//! Right-to-left and bidirectional layout: Persian paragraphs, mixed Persian/Latin lines,
//! numbers and punctuation, caret and selection geometry.
#[allow(unused_imports)]
use super::*;
use crate::para::{bidi_levels, visual_order};
use wordcraft_doc::props::{Align, ParaProps};
+1
View File
@@ -1,6 +1,7 @@
//! Document grid (`w:docGrid`): line pitch and character pitch (#391). Assertions are relations
//! to the pitch, never font metrics (fonts differ between machines; CJK glyphs may be missing).
#[allow(unused_imports)]
use super::*;
use wordcraft_doc::props::LineSpacing;
use wordcraft_doc::section::{DocGrid, DocGridType};
+1
View File
@@ -1,5 +1,6 @@
//! Text boxes' text direction, alignment and linked boxes (Shape Format › Text).
#[allow(unused_imports)]
use super::*;
use wordcraft_doc::props::{TextVert, VAlign};
use wordcraft_doc::{PartKind, Pos, TextBody, para_block};
+5 -6
View File
@@ -314,13 +314,12 @@ fn parse_aff(aff: &str) -> Result<Rules, String> {
match dir {
"SET" | "LANG" | "NAME" | "VERSION" | "HOME" | "WORDCHARS" | "MAXNGRAMSUGS" | "MAXDIFF" | "ONLYMAXDIFF" | "NOSPLITSUGS"
| "SUGSWITHDOTS" | "WARN" | "FORBIDWARN" | "MAXCPDSUGS" => {}
"FLAG" => {
// Per-character flag modes (`FLAG UTF-8`, as VERO's pt_BR dictionary uses) work
// with the one-character parsing here; only the multi-character encodings differ.
if !matches!(at(1), "UTF-8" | "DEFAULT" | "utf-8" | "default" | "UTF8" | "") {
return Err(format!("line {}: FLAG {} is not supported (one-character flags only)", n + 1, at(1)));
}
// Per-character flag modes (`FLAG UTF-8`, as VERO's pt_BR dictionary uses) work
// with the one-character parsing here; only the multi-character encodings differ.
"FLAG" if !matches!(at(1), "UTF-8" | "DEFAULT" | "utf-8" | "default" | "UTF8" | "") => {
return Err(format!("line {}: FLAG {} is not supported (one-character flags only)", n + 1, at(1)));
}
"FLAG" => {}
directive @ ("FORBIDDENWORD" | "NOSUGGEST") => {
let mut it = at(1).chars().filter(|&c| c != EMOJI_SELECTOR);
match (it.next(), it.next()) {
+1 -1
View File
@@ -952,7 +952,7 @@ fn alert(ui: &mut Ui, text: &Option<String>) {
fn split_cells_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SplitCellsForm) -> bool {
// Several cells split one by one keep their rows.
let rows_on = !(f.cells > 1 && !f.merge_first);
let rows_on = f.cells <= 1 || f.merge_first;
egui::Grid::new("split_cells").num_columns(2).show(ui, |ui| {
ui.label(tl!("Number of columns:"));
ui.add(egui::DragValue::new(&mut f.columns).range(1..=63));
+1 -1
View File
@@ -773,7 +773,7 @@ pub fn logic(app: &mut WordApp, ctx: &egui::Context) {
}
// Alt off while an Alt key is still down (AltGr on Linux turns Level 3 Shift on, not
// Alt): the key's release ends the press, not this.
egui::Event::ModifiersChanged(modifiers) if !(app.ui.alt_key_down && !modifiers.alt) => {
egui::Event::ModifiersChanged(modifiers) if !app.ui.alt_key_down || modifiers.alt => {
alt_released |= alt_transition(app, modifiers.alt, *modifiers);
}
egui::Event::PointerButton { pressed: true, .. } => {
+1 -1
View File
@@ -272,7 +272,7 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
let adding = ui.input(|i| i.modifiers.shift || i.modifiers.command);
// Ctrl on the object that is already selected starts a copy-drag instead.
let again = selected(app).is_some_and(|(p, _)| p == object.pos());
if pressed && adding && grab == Grab::Move && selected(app).is_some() && !(again && !ui.input(|i| i.modifiers.shift)) {
if pressed && adding && grab == Grab::Move && selected(app).is_some() && (ui.input(|i| i.modifiers.shift) || !again) {
let _ = app.run("select.addObject", json!({"pos": object.pos()}));
return true;
}
+1 -1
View File
@@ -37,7 +37,7 @@ pub fn run(args: &[&str]) -> Result<(), String> {
None => 100,
};
let pos: Vec<&str> =
args.iter().enumerate().filter(|(i, a)| !a.starts_with("--") && !(*i > 0 && args[i - 1] == "--dpi")).map(|(_, a)| *a).collect();
args.iter().enumerate().filter(|(i, a)| !(a.starts_with("--") || *i > 0 && args[i - 1] == "--dpi")).map(|(_, a)| *a).collect();
let [reference, test, out] = pos.as_slice() else {
return Err("usage: cargo xtask visual-diff REF.pdf TEST.pdf OUTDIR [--private] [--dpi N]".into());
};