M0.37: fix accessible controls and simplify reviewed engine paths

This commit is contained in:
skylartaylor
2026-10-25 08:31:14 +01:00
parent b350488906
commit 6bc2e25792
14 changed files with 564 additions and 410 deletions
+1
View File
@@ -368,6 +368,7 @@ mod placed_vector_tests {
/// An .indd opened without its links shows each graphic's preview: the asset has data, but its
/// file is still missing.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_missing_link_shown_by_its_preview_is_still_reported() {
let dir = tempfile::tempdir().unwrap();
+395 -347
View File
@@ -339,198 +339,234 @@ impl<'a> Parser<'a> {
let mut value = Node::Group(Vec::new());
while let Some(next) = work.pop() {
match next {
ParseWork::List { end, depth } => {
if depth >= MAX_NESTING {
value = Node::Group(Vec::new());
} else {
work.push(ParseWork::Sequence { kind: SequenceKind::List(end), depth: depth + 1, out: Vec::new() });
}
}
ParseWork::Sequence { kind, depth, mut out } => {
self.skip_ws();
let Some(c) = self.peek() else {
value = Node::Group(out);
continue;
};
if let SequenceKind::List(Some(end)) = kind
&& c == end
{
self.i += 1;
value = Node::Group(out);
continue;
}
if matches!(kind, SequenceKind::Fence)
&& self.s.get(self.i..).is_some_and(|s| s.starts_with(&['\\', 'r', 'i', 'g', 'h', 't']))
&& !self.s.get(self.i + 6).is_some_and(char::is_ascii_alphabetic)
{
self.i += 6;
value = Node::Group(out);
continue;
}
if (c == '^' || c == '_') && !matches!(kind, SequenceKind::Fence) {
self.i += 1;
work.push(ParseWork::Script { kind, depth, out, up: c == '^' });
work.push(ParseWork::Arg { depth });
} else if matches!(kind, SequenceKind::One) {
value = Node::Group(out);
} else {
let group = matches!(kind, SequenceKind::Fence);
work.push(ParseWork::Append { kind, depth, out: std::mem::take(&mut out), group });
work.push(if group { ParseWork::One { depth: depth + 1 } } else { ParseWork::Atom { depth } });
}
}
ParseWork::Append { kind, depth, mut out, group } => {
if group {
if let Node::Group(nodes) = &mut value {
out.append(nodes);
}
} else {
out.push(std::mem::replace(&mut value, Node::Group(Vec::new())));
}
work.push(ParseWork::Sequence { kind, depth, out });
}
ParseWork::Arg { depth } => {
if depth >= MAX_NESTING {
value = Node::Group(Vec::new());
continue;
}
self.skip_ws();
if self.peek() == Some('{') {
self.i += 1;
work.push(ParseWork::List { end: Some('}'), depth: depth + 1 });
} else {
work.push(ParseWork::Atom { depth: depth + 1 });
}
}
ParseWork::Script { kind, depth, mut out, up } => {
let argument = std::mem::replace(&mut value, Node::Group(Vec::new()));
let base = out.pop().unwrap_or(Node::Group(Vec::new()));
out.push(attach_script(base, argument, up));
work.push(ParseWork::Sequence { kind, depth, out });
}
ParseWork::FractionNumerator { depth } => {
let numerator = std::mem::replace(&mut value, Node::Group(Vec::new()));
work.push(ParseWork::FractionDenominator { numerator });
work.push(ParseWork::Arg { depth });
}
ParseWork::FractionDenominator { numerator } => {
value = Node::Frac(Box::new(numerator), Box::new(std::mem::replace(&mut value, Node::Group(Vec::new()))));
}
ParseWork::SqrtIndex { outer, depth } => {
*self = outer;
let index = Some(Box::new(std::mem::replace(&mut value, Node::Group(Vec::new()))));
work.push(ParseWork::SqrtArgument { index });
work.push(ParseWork::Arg { depth });
}
ParseWork::SqrtArgument { index } => {
value = Node::Sqrt(index, Box::new(std::mem::replace(&mut value, Node::Group(Vec::new()))));
}
ParseWork::Fence { open } => {
let close = self.delimiter();
value = Node::Fenced(open, Box::new(std::mem::replace(&mut value, Node::Group(Vec::new()))), close);
}
ParseWork::UntilRight { depth } => {
if depth >= MAX_NESTING {
value = Node::Group(Vec::new());
} else {
work.push(ParseWork::Sequence { kind: SequenceKind::Fence, depth, out: Vec::new() });
}
}
ParseWork::One { depth } => {
work.push(ParseWork::OneAtom { depth });
work.push(ParseWork::Atom { depth });
}
ParseWork::OneAtom { depth } => {
let out = vec![std::mem::replace(&mut value, Node::Group(Vec::new()))];
work.push(ParseWork::Sequence { kind: SequenceKind::One, depth, out });
}
ParseWork::Atom { depth } => {
self.skip_ws();
let Some(c) = self.peek() else {
value = Node::Group(Vec::new());
continue;
};
self.i += 1;
match c {
'{' => work.push(ParseWork::List { end: Some('}'), depth }),
'\\' => {
let name = self.command();
match name.as_str() {
"frac" | "dfrac" | "tfrac" => {
work.push(ParseWork::FractionNumerator { depth });
work.push(ParseWork::Arg { depth });
}
"sqrt" => {
self.skip_ws();
if self.peek() == Some('[') {
self.i += 1;
let start = self.i;
let mut end = start;
// Keep the existing first-']' index grammar, including EOF.
while let Some(c) = self.peek() {
self.i += 1;
if c == ']' {
break;
}
end = self.i;
}
let inner = self.s.get(start..end).unwrap_or_default();
let outer = std::mem::replace(self, Parser { s: inner, i: 0 });
work.push(ParseWork::SqrtIndex { outer, depth });
work.push(ParseWork::List { end: None, depth: depth + 1 });
} else {
work.push(ParseWork::SqrtArgument { index: None });
work.push(ParseWork::Arg { depth });
}
}
"left" => {
let open = self.delimiter();
work.push(ParseWork::Fence { open });
work.push(ParseWork::UntilRight { depth });
}
"text" | "mathrm" | "textrm" | "operatorname" => value = Node::Text(self.raw_group()),
"sin" | "cos" | "tan" | "log" | "ln" | "exp" | "lim" | "max" | "min" => value = Node::Text(name),
"," => value = Node::Space(3.0 / 18.0),
":" | ">" => value = Node::Space(4.0 / 18.0),
";" => value = Node::Space(5.0 / 18.0),
"quad" => value = Node::Space(1.0),
"qquad" => value = Node::Space(2.0),
"!" => value = Node::Space(-3.0 / 18.0),
" " => value = Node::Space(0.25),
"{" => value = Node::Sym("{".into(), Class::Punct),
"}" => value = Node::Sym("}".into(), Class::Punct),
"%" | "$" | "#" | "&" | "_" => value = Node::Sym(name, Class::Plain),
n => {
value = match (big_op(n), symbol(n)) {
(Some(op), _) => Node::Big(op.into()),
(None, Some((s, cl))) => Node::Sym(s.into(), cl),
_ => Node::Text(format!("\\{n}")),
}
}
}
}
c if c.is_ascii_digit() || c == '.' => {
let mut s = c.to_string();
while let Some(d) = self.peek().filter(|d| d.is_ascii_digit() || *d == '.') {
s.push(d);
self.i += 1;
}
value = Node::Sym(s, Class::Number);
}
c if c.is_alphabetic() => value = Node::Sym(c.to_string(), Class::Ident),
'+' => value = Node::Sym("+".into(), Class::Bin),
'-' => value = Node::Sym("−".into(), Class::Bin),
'*' => value = Node::Sym("∗".into(), Class::Bin),
'<'..='>' => value = Node::Sym(c.to_string(), Class::Rel),
',' | ';' => value = Node::Sym(c.to_string(), Class::Punct),
'\'' => value = Node::Sym("′".into(), Class::Plain),
c => value = Node::Sym(c.to_string(), Class::Plain),
}
}
ParseWork::List { end, depth } => Self::step_list(&mut work, &mut value, end, depth),
ParseWork::Sequence { kind, depth, out } => self.step_sequence(&mut work, &mut value, kind, depth, out),
ParseWork::Append { kind, depth, out, group } => Self::step_append(&mut work, &mut value, kind, depth, out, group),
ParseWork::Arg { depth } => self.step_arg(&mut work, &mut value, depth),
ParseWork::Atom { depth } => self.step_atom(&mut work, &mut value, depth),
ParseWork::Script { kind, depth, out, up } => Self::step_script(&mut work, &mut value, kind, depth, out, up),
ParseWork::FractionNumerator { depth } => Self::step_fraction_numerator(&mut work, &mut value, depth),
ParseWork::FractionDenominator { numerator } => Self::step_fraction_denominator(&mut value, numerator),
ParseWork::SqrtIndex { outer, depth } => self.step_sqrt_index(&mut work, &mut value, outer, depth),
ParseWork::SqrtArgument { index } => Self::step_sqrt_argument(&mut value, index),
ParseWork::Fence { open } => self.step_fence(&mut value, open),
ParseWork::UntilRight { depth } => Self::step_until_right(&mut work, &mut value, depth),
ParseWork::One { depth } => Self::step_one(&mut work, depth),
ParseWork::OneAtom { depth } => Self::step_one_atom(&mut work, &mut value, depth),
}
}
value
}
fn step_list(work: &mut Vec<ParseWork<'a>>, value: &mut Node, end: Option<char>, depth: usize) {
if depth >= MAX_NESTING {
*value = Node::Group(Vec::new());
} else {
work.push(ParseWork::Sequence { kind: SequenceKind::List(end), depth: depth + 1, out: Vec::new() });
}
}
fn step_sequence(&mut self, work: &mut Vec<ParseWork<'a>>, value: &mut Node, kind: SequenceKind, depth: usize, mut out: Vec<Node>) {
self.skip_ws();
let Some(c) = self.peek() else {
*value = Node::Group(out);
return;
};
if let SequenceKind::List(Some(end)) = kind
&& c == end
{
self.i += 1;
*value = Node::Group(out);
return;
}
if matches!(kind, SequenceKind::Fence)
&& self.s.get(self.i..).is_some_and(|s| s.starts_with(&['\\', 'r', 'i', 'g', 'h', 't']))
&& !self.s.get(self.i + 6).is_some_and(char::is_ascii_alphabetic)
{
self.i += 6;
*value = Node::Group(out);
return;
}
if (c == '^' || c == '_') && !matches!(kind, SequenceKind::Fence) {
self.i += 1;
work.push(ParseWork::Script { kind, depth, out, up: c == '^' });
work.push(ParseWork::Arg { depth });
} else if matches!(kind, SequenceKind::One) {
*value = Node::Group(out);
} else {
let group = matches!(kind, SequenceKind::Fence);
work.push(ParseWork::Append { kind, depth, out: std::mem::take(&mut out), group });
work.push(if group { ParseWork::One { depth: depth + 1 } } else { ParseWork::Atom { depth } });
}
}
fn step_append(work: &mut Vec<ParseWork<'a>>, value: &mut Node, kind: SequenceKind, depth: usize, mut out: Vec<Node>, group: bool) {
if group {
if let Node::Group(nodes) = &mut *value {
out.append(nodes);
}
} else {
out.push(std::mem::replace(value, Node::Group(Vec::new())));
}
work.push(ParseWork::Sequence { kind, depth, out });
}
fn step_arg(&mut self, work: &mut Vec<ParseWork<'a>>, value: &mut Node, depth: usize) {
if depth >= MAX_NESTING {
*value = Node::Group(Vec::new());
return;
}
self.skip_ws();
if self.peek() == Some('{') {
self.i += 1;
work.push(ParseWork::List { end: Some('}'), depth: depth + 1 });
} else {
work.push(ParseWork::Atom { depth: depth + 1 });
}
}
fn step_script(work: &mut Vec<ParseWork<'a>>, value: &mut Node, kind: SequenceKind, depth: usize, mut out: Vec<Node>, up: bool) {
let argument = std::mem::replace(value, Node::Group(Vec::new()));
let base = out.pop().unwrap_or(Node::Group(Vec::new()));
out.push(attach_script(base, argument, up));
work.push(ParseWork::Sequence { kind, depth, out });
}
fn step_fraction_numerator(work: &mut Vec<ParseWork<'a>>, value: &mut Node, depth: usize) {
let numerator = std::mem::replace(value, Node::Group(Vec::new()));
work.push(ParseWork::FractionDenominator { numerator });
work.push(ParseWork::Arg { depth });
}
fn step_fraction_denominator(value: &mut Node, numerator: Node) {
*value = Node::Frac(Box::new(numerator), Box::new(std::mem::replace(value, Node::Group(Vec::new()))));
}
fn step_sqrt_index(&mut self, work: &mut Vec<ParseWork<'a>>, value: &mut Node, outer: Parser<'a>, depth: usize) {
*self = outer;
let index = Some(Box::new(std::mem::replace(value, Node::Group(Vec::new()))));
work.push(ParseWork::SqrtArgument { index });
work.push(ParseWork::Arg { depth });
}
fn step_sqrt_argument(value: &mut Node, index: Option<Box<Node>>) {
*value = Node::Sqrt(index, Box::new(std::mem::replace(value, Node::Group(Vec::new()))));
}
fn step_fence(&mut self, value: &mut Node, open: String) {
let close = self.delimiter();
*value = Node::Fenced(open, Box::new(std::mem::replace(value, Node::Group(Vec::new()))), close);
}
fn step_until_right(work: &mut Vec<ParseWork<'a>>, value: &mut Node, depth: usize) {
if depth >= MAX_NESTING {
*value = Node::Group(Vec::new());
} else {
work.push(ParseWork::Sequence { kind: SequenceKind::Fence, depth, out: Vec::new() });
}
}
fn step_one(work: &mut Vec<ParseWork<'a>>, depth: usize) {
work.push(ParseWork::OneAtom { depth });
work.push(ParseWork::Atom { depth });
}
fn step_one_atom(work: &mut Vec<ParseWork<'a>>, value: &mut Node, depth: usize) {
let out = vec![std::mem::replace(value, Node::Group(Vec::new()))];
work.push(ParseWork::Sequence { kind: SequenceKind::One, depth, out });
}
fn step_atom(&mut self, work: &mut Vec<ParseWork<'a>>, value: &mut Node, depth: usize) {
self.skip_ws();
let Some(c) = self.peek() else {
*value = Node::Group(Vec::new());
return;
};
self.i += 1;
match c {
'{' => work.push(ParseWork::List { end: Some('}'), depth }),
'\\' => self.step_command(work, value, depth),
c if c.is_ascii_digit() || c == '.' => {
let mut s = c.to_string();
while let Some(d) = self.peek().filter(|d| d.is_ascii_digit() || *d == '.') {
s.push(d);
self.i += 1;
}
*value = Node::Sym(s, Class::Number);
}
c if c.is_alphabetic() => *value = Node::Sym(c.to_string(), Class::Ident),
'+' => *value = Node::Sym("+".into(), Class::Bin),
'-' => *value = Node::Sym("−".into(), Class::Bin),
'*' => *value = Node::Sym("∗".into(), Class::Bin),
'<'..='>' => *value = Node::Sym(c.to_string(), Class::Rel),
',' | ';' => *value = Node::Sym(c.to_string(), Class::Punct),
'\'' => *value = Node::Sym("′".into(), Class::Plain),
c => *value = Node::Sym(c.to_string(), Class::Plain),
}
}
fn step_command(&mut self, work: &mut Vec<ParseWork<'a>>, value: &mut Node, depth: usize) {
let name = self.command();
match name.as_str() {
"frac" | "dfrac" | "tfrac" => {
work.push(ParseWork::FractionNumerator { depth });
work.push(ParseWork::Arg { depth });
}
"sqrt" => self.push_sqrt(work, depth),
"left" => {
let open = self.delimiter();
work.push(ParseWork::Fence { open });
work.push(ParseWork::UntilRight { depth });
}
"text" | "mathrm" | "textrm" | "operatorname" => *value = Node::Text(self.raw_group()),
n => *value = command_atom(n),
}
}
fn push_sqrt(&mut self, work: &mut Vec<ParseWork<'a>>, depth: usize) {
self.skip_ws();
if self.peek() == Some('[') {
self.i += 1;
let start = self.i;
let mut end = start;
// Keep the existing first-']' index grammar, including EOF.
while let Some(c) = self.peek() {
self.i += 1;
if c == ']' {
break;
}
end = self.i;
}
let inner = self.s.get(start..end).unwrap_or_default();
let outer = std::mem::replace(self, Parser { s: inner, i: 0 });
work.push(ParseWork::SqrtIndex { outer, depth });
work.push(ParseWork::List { end: None, depth: depth + 1 });
} else {
work.push(ParseWork::SqrtArgument { index: None });
work.push(ParseWork::Arg { depth });
}
}
}
fn command_atom(name: &str) -> Node {
match name {
"sin" | "cos" | "tan" | "log" | "ln" | "exp" | "lim" | "max" | "min" => Node::Text(name.to_owned()),
"," => Node::Space(3.0 / 18.0),
":" | ">" => Node::Space(4.0 / 18.0),
";" => Node::Space(5.0 / 18.0),
"quad" => Node::Space(1.0),
"qquad" => Node::Space(2.0),
"!" => Node::Space(-3.0 / 18.0),
" " => Node::Space(0.25),
"{" => Node::Sym("{".into(), Class::Punct),
"}" => Node::Sym("}".into(), Class::Punct),
"%" | "$" | "#" | "&" | "_" => Node::Sym(name.to_owned(), Class::Plain),
n => match (big_op(n), symbol(n)) {
(Some(op), _) => Node::Big(op.into()),
(None, Some((s, cl))) => Node::Sym(s.into(), cl),
_ => Node::Text(format!("\\{n}")),
},
}
}
fn parse(src: &str) -> Node {
@@ -889,173 +925,185 @@ mod tests {
// Frozen pre-repair arithmetic; called only for shallow fixtures, never deep input.
fn recursive_layout_reference(f: &Fonts, n: &Node, size: f64) -> MBox {
let axis = size * 0.25;
match n {
Node::Sym(s, cl) => text_box(f, s, size, *cl == Class::Ident),
Node::Text(s) => text_box(f, s, size, false),
Node::Space(em) => MBox { w: em * size, ..Default::default() },
Node::Group(list) => {
let mut parts = Vec::new();
let mut prev: Option<Class> = None;
for (k, item) in list.iter().enumerate() {
let cl = match item {
Node::Sym(_, c) => Some(*c),
Node::Scripts { base, .. } => match &**base {
Node::Sym(_, c) => Some(*c),
_ => None,
},
_ => None,
};
// A leading + or − (or after another operator) is a sign, not a binary operator.
let cl = if cl == Some(Class::Bin) && (k == 0 || matches!(prev, Some(Class::Bin | Class::Rel | Class::Punct))) {
Some(Class::Plain)
} else {
cl
};
let gap = |c: Option<Class>| match c {
Some(Class::Bin) => size * 4.0 / 18.0,
Some(Class::Rel) => size * 5.0 / 18.0,
_ => 0.0,
};
let mut before = gap(cl).max(gap(prev));
if k == 0 {
before = 0.0;
}
if prev == Some(Class::Punct) {
before = before.max(size * 3.0 / 18.0);
}
parts.push((recursive_layout_reference(f, item, size), before));
prev = cl;
}
hbox(parts)
Node::Group(list) => recursive_group_reference(f, list, size),
Node::Frac(a, b) => recursive_fraction_reference(f, a, b, size),
Node::Sqrt(idx, a) => recursive_sqrt_reference(f, idx, a, size),
Node::Big(op) => recursive_big_reference(f, op, size),
Node::Scripts { base, sup, sub } => recursive_scripts_reference(f, base, sup, sub, size),
Node::Fenced(open, body, close) => recursive_fenced_reference(f, open, body, close, size),
}
}
fn recursive_group_reference(f: &Fonts, list: &[Node], size: f64) -> MBox {
let mut parts = Vec::new();
let mut prev: Option<Class> = None;
for (k, item) in list.iter().enumerate() {
let cl = match item {
Node::Sym(_, c) => Some(*c),
Node::Scripts { base, .. } => match &**base {
Node::Sym(_, c) => Some(*c),
_ => None,
},
_ => None,
};
// A leading + or − (or after another operator) is a sign, not a binary operator.
let cl = if cl == Some(Class::Bin) && (k == 0 || matches!(prev, Some(Class::Bin | Class::Rel | Class::Punct))) {
Some(Class::Plain)
} else {
cl
};
let gap = |c: Option<Class>| match c {
Some(Class::Bin) => size * 4.0 / 18.0,
Some(Class::Rel) => size * 5.0 / 18.0,
_ => 0.0,
};
let mut before = gap(cl).max(gap(prev));
if k == 0 {
before = 0.0;
}
Node::Frac(a, b) => {
let s2 = size * 0.85;
let (na, nb) = (recursive_layout_reference(f, a, s2), recursive_layout_reference(f, b, s2));
let w = na.w.max(nb.w) + size * 0.2;
let t = (size * 0.06).max(0.4);
let gap = size * 0.15;
let num_base = -(axis + t / 2.0 + gap + na.desc);
let den_base = -axis + t / 2.0 + gap + nb.asc;
let mut out = MBox { w, asc: -num_base + na.asc, desc: den_base + nb.desc, els: Vec::new() };
out.els.push(Elem::Rule { x: 0.0, y: -axis - t / 2.0, w, h: t });
let (wa, wb) = (na.w, nb.w);
out.els.extend(na.shifted((w - wa) / 2.0, num_base));
out.els.extend(nb.shifted((w - wb) / 2.0, den_base));
out
if prev == Some(Class::Punct) {
before = before.max(size * 3.0 / 18.0);
}
Node::Sqrt(idx, a) => {
let inner = recursive_layout_reference(f, a, size);
let t = (size * 0.05).max(0.4);
let gap = size * 0.12;
let top = inner.asc + gap + t;
let bottom = inner.desc;
let hook = size * 0.55;
let mut out = MBox { w: hook + inner.w + size * 0.1, asc: top, desc: bottom, els: Vec::new() };
// The radical: a short rise, the long stroke down, then up to the bar.
out.els.push(Elem::Line {
pts: vec![(0.0, -axis * 0.6), (hook * 0.25, -axis), (hook * 0.55, bottom), (hook, -top + t / 2.0), (out.w, -top + t / 2.0)],
w: t,
});
let iw = inner.w;
let _ = iw;
out.els.extend(inner.shifted(hook, 0.0));
if let Some(i) = idx {
let ib = recursive_layout_reference(f, i, size * 0.55);
let (ia, iw2) = (ib.asc, ib.w);
out.els.extend(ib.shifted((hook * 0.3 - iw2).max(0.0) - 0.0, -axis - size * 0.1));
out.asc = out.asc.max(axis + size * 0.1 + ia);
}
out
}
Node::Big(op) => {
let mut b = text_box(f, op, size * 1.5, false);
b.asc = size * 1.05;
b.desc = size * 0.35;
for e in &mut b.els {
if let Elem::Text { y, .. } = e {
// Centre the glyph on the math axis.
*y = size * 0.32;
}
}
b
}
Node::Scripts { base, sup, sub } => {
let big = matches!(&**base, Node::Big(_));
let b = recursive_layout_reference(f, base, size);
let s2 = size * 0.7;
let (bw, basc, bdesc) = (b.w, b.asc, b.desc);
let mut out = MBox { w: bw, asc: basc, desc: bdesc, els: b.els };
if big {
// Limits above and below.
let mut w = bw;
if let Some(sp) = sup {
let sb = recursive_layout_reference(f, sp, s2);
let y = -basc - size * 0.1 - sb.desc;
w = w.max(sb.w);
let (sw, sa) = (sb.w, sb.asc);
out.els.extend(sb.shifted((bw - sw) / 2.0, y));
out.asc = out.asc.max(-y + sa);
}
if let Some(sbn) = sub {
let sb = recursive_layout_reference(f, sbn, s2);
let y = bdesc + size * 0.1 + sb.asc;
w = w.max(sb.w);
let (sw, sd) = (sb.w, sb.desc);
out.els.extend(sb.shifted((bw - sw) / 2.0, y));
out.desc = out.desc.max(y + sd);
}
// Centre the operator over the widest limit.
let dx = (w - bw) / 2.0;
let els = std::mem::take(&mut out.els);
out = MBox { w, asc: out.asc, desc: out.desc, els: MBox { els, ..Default::default() }.shifted(dx, 0.0) };
return out;
}
let mut w = bw;
if let Some(sp) = sup {
let sb = recursive_layout_reference(f, sp, s2);
let y = -(size * 0.42).max(basc - sb.asc * 0.6);
w = w.max(bw + sb.w + size * 0.05);
let sa = sb.asc;
out.els.extend(sb.shifted(bw + size * 0.05, y));
out.asc = out.asc.max(-y + sa);
}
if let Some(sbn) = sub {
let sb = recursive_layout_reference(f, sbn, s2);
let y = (size * 0.2).max(bdesc * 0.8);
w = w.max(bw + sb.w + size * 0.05);
let sd = sb.desc;
out.els.extend(sb.shifted(bw + size * 0.05, y));
out.desc = out.desc.max(y + sd);
}
out.w = w;
out
}
Node::Fenced(open, body, close) => {
let inner = recursive_layout_reference(f, body, size);
let h = (inner.asc + inner.desc).max(size);
let k = h / size;
let ds = size * k.max(1.0);
let mk = |s: &str| -> MBox {
if s.is_empty() {
return MBox::default();
}
let mut b = text_box(f, s, ds, false);
// Scaled delimiters centred on the content.
let mid = (inner.desc - inner.asc) / 2.0;
for e in &mut b.els {
if let Elem::Text { y, .. } = e {
*y = mid + ds * 0.25;
}
}
b.asc = inner.asc.max(size * 0.72);
b.desc = inner.desc.max(size * 0.22);
b
};
let (l, r) = (mk(open), mk(close));
hbox(vec![(l, 0.0), (inner, size * 0.05), (r, size * 0.05)])
parts.push((recursive_layout_reference(f, item, size), before));
prev = cl;
}
hbox(parts)
}
fn recursive_fraction_reference(f: &Fonts, a: &Node, b: &Node, size: f64) -> MBox {
let axis = size * 0.25;
let s2 = size * 0.85;
let (na, nb) = (recursive_layout_reference(f, a, s2), recursive_layout_reference(f, b, s2));
let w = na.w.max(nb.w) + size * 0.2;
let t = (size * 0.06).max(0.4);
let gap = size * 0.15;
let num_base = -(axis + t / 2.0 + gap + na.desc);
let den_base = -axis + t / 2.0 + gap + nb.asc;
let mut out = MBox { w, asc: -num_base + na.asc, desc: den_base + nb.desc, els: Vec::new() };
out.els.push(Elem::Rule { x: 0.0, y: -axis - t / 2.0, w, h: t });
let (wa, wb) = (na.w, nb.w);
out.els.extend(na.shifted((w - wa) / 2.0, num_base));
out.els.extend(nb.shifted((w - wb) / 2.0, den_base));
out
}
fn recursive_sqrt_reference(f: &Fonts, idx: &Option<Box<Node>>, a: &Node, size: f64) -> MBox {
let axis = size * 0.25;
let inner = recursive_layout_reference(f, a, size);
let t = (size * 0.05).max(0.4);
let gap = size * 0.12;
let top = inner.asc + gap + t;
let bottom = inner.desc;
let hook = size * 0.55;
let mut out = MBox { w: hook + inner.w + size * 0.1, asc: top, desc: bottom, els: Vec::new() };
// The radical: a short rise, the long stroke down, then up to the bar.
out.els.push(Elem::Line {
pts: vec![(0.0, -axis * 0.6), (hook * 0.25, -axis), (hook * 0.55, bottom), (hook, -top + t / 2.0), (out.w, -top + t / 2.0)],
w: t,
});
let iw = inner.w;
let _ = iw;
out.els.extend(inner.shifted(hook, 0.0));
if let Some(i) = idx {
let ib = recursive_layout_reference(f, i, size * 0.55);
let (ia, iw2) = (ib.asc, ib.w);
out.els.extend(ib.shifted((hook * 0.3 - iw2).max(0.0) - 0.0, -axis - size * 0.1));
out.asc = out.asc.max(axis + size * 0.1 + ia);
}
out
}
fn recursive_big_reference(f: &Fonts, op: &str, size: f64) -> MBox {
let mut b = text_box(f, op, size * 1.5, false);
b.asc = size * 1.05;
b.desc = size * 0.35;
for e in &mut b.els {
if let Elem::Text { y, .. } = e {
// Centre the glyph on the math axis.
*y = size * 0.32;
}
}
b
}
fn recursive_scripts_reference(f: &Fonts, base: &Node, sup: &Option<Box<Node>>, sub: &Option<Box<Node>>, size: f64) -> MBox {
let big = matches!(base, Node::Big(_));
let b = recursive_layout_reference(f, base, size);
let s2 = size * 0.7;
let (bw, basc, bdesc) = (b.w, b.asc, b.desc);
let mut out = MBox { w: bw, asc: basc, desc: bdesc, els: b.els };
if big {
// Limits above and below.
let mut w = bw;
if let Some(sp) = sup {
let sb = recursive_layout_reference(f, sp, s2);
let y = -basc - size * 0.1 - sb.desc;
w = w.max(sb.w);
let (sw, sa) = (sb.w, sb.asc);
out.els.extend(sb.shifted((bw - sw) / 2.0, y));
out.asc = out.asc.max(-y + sa);
}
if let Some(sbn) = sub {
let sb = recursive_layout_reference(f, sbn, s2);
let y = bdesc + size * 0.1 + sb.asc;
w = w.max(sb.w);
let (sw, sd) = (sb.w, sb.desc);
out.els.extend(sb.shifted((bw - sw) / 2.0, y));
out.desc = out.desc.max(y + sd);
}
// Centre the operator over the widest limit.
let dx = (w - bw) / 2.0;
let els = std::mem::take(&mut out.els);
return MBox { w, asc: out.asc, desc: out.desc, els: MBox { els, ..Default::default() }.shifted(dx, 0.0) };
}
let mut w = bw;
if let Some(sp) = sup {
let sb = recursive_layout_reference(f, sp, s2);
let y = -(size * 0.42).max(basc - sb.asc * 0.6);
w = w.max(bw + sb.w + size * 0.05);
let sa = sb.asc;
out.els.extend(sb.shifted(bw + size * 0.05, y));
out.asc = out.asc.max(-y + sa);
}
if let Some(sbn) = sub {
let sb = recursive_layout_reference(f, sbn, s2);
let y = (size * 0.2).max(bdesc * 0.8);
w = w.max(bw + sb.w + size * 0.05);
let sd = sb.desc;
out.els.extend(sb.shifted(bw + size * 0.05, y));
out.desc = out.desc.max(y + sd);
}
out.w = w;
out
}
fn recursive_fenced_reference(f: &Fonts, open: &str, body: &Node, close: &str, size: f64) -> MBox {
let inner = recursive_layout_reference(f, body, size);
let h = (inner.asc + inner.desc).max(size);
let k = h / size;
let ds = size * k.max(1.0);
let mk = |s: &str| -> MBox {
if s.is_empty() {
return MBox::default();
}
let mut b = text_box(f, s, ds, false);
// Scaled delimiters centred on the content.
let mid = (inner.desc - inner.asc) / 2.0;
for e in &mut b.els {
if let Elem::Text { y, .. } = e {
*y = mid + ds * 0.25;
}
}
b.asc = inner.asc.max(size * 0.72);
b.desc = inner.desc.max(size * 0.22);
b
};
let (l, r) = (mk(open), mk(close));
hbox(vec![(l, 0.0), (inner, size * 0.05), (r, size * 0.05)])
}
#[test]
+52 -42
View File
@@ -435,58 +435,68 @@ fn ime_ranges_from_command(r: &Value) -> Option<(Range<usize>, Range<usize>)> {
/// round inward: a count that ends inside a character leaves that whole character. Invalid or
/// non-boundary selection offsets return `None` rather than guessed ranges.
pub fn ime_delete_ranges(text: &str, anchor: usize, focus: usize, before: usize, after: usize, bytes: bool) -> Option<(Range<usize>, Range<usize>)> {
let (min, max) = ime_selection_bounds(text, anchor, focus)?;
Some(if bytes { ime_byte_delete_ranges(text, min, max, before, after) } else { ime_character_delete_ranges(text, min, max, before, after) })
}
/// Validate the committed selection and return its lower and upper UTF-8 byte offsets.
fn ime_selection_bounds(text: &str, anchor: usize, focus: usize) -> Option<(usize, usize)> {
if anchor > text.len() || focus > text.len() || !text.is_char_boundary(anchor) || !text.is_char_boundary(focus) {
return None;
}
let min = anchor.min(focus);
let max = anchor.max(focus);
if bytes {
// Start at the requested byte offset and step at most three bytes to the next character
// boundary: the scan is bounded by the count, not by the caret's offset in the story.
let from = min.saturating_sub(before).min(min);
let mut before_start = from;
while before_start < min && !text.is_char_boundary(before_start) {
before_start += 1;
Some((anchor.min(focus), anchor.max(focus)))
}
/// Byte-counted ranges around the selection, rounded inward to character boundaries.
fn ime_byte_delete_ranges(text: &str, min: usize, max: usize, before: usize, after: usize) -> (Range<usize>, Range<usize>) {
// Start at the requested byte offset and step at most three bytes to the next character
// boundary: the scan is bounded by the count, not by the caret's offset in the story.
let from = min.saturating_sub(before).min(min);
let mut before_start = from;
while before_start < min && !text.is_char_boundary(before_start) {
before_start += 1;
}
let target = max.saturating_add(after).min(text.len());
let mut after_end = max;
for (i, c) in text[max..].char_indices() {
let end = max + i + c.len_utf8();
if end <= target {
after_end = end;
} else {
break;
}
let target = max.saturating_add(after).min(text.len());
let mut after_end = max;
for (i, c) in text[max..].char_indices() {
let end = max + i + c.len_utf8();
if end <= target {
after_end = end;
} else {
}
(before_start..min, max..after_end)
}
/// Character-counted ranges around the selection, using Unicode scalar values.
fn ime_character_delete_ranges(text: &str, min: usize, max: usize, before: usize, after: usize) -> (Range<usize>, Range<usize>) {
let before_start = if before == 0 {
min
} else {
let mut seen = 0usize;
let mut start = 0usize;
for (i, _) in text[..min].char_indices().rev() {
seen += 1;
start = i;
if seen == before {
break;
}
}
Some((before_start..min, max..after_end))
} else {
let before_start = if before == 0 {
min
} else {
let mut seen = 0usize;
let mut start = 0usize;
for (i, _) in text[..min].char_indices().rev() {
seen += 1;
start = i;
if seen == before {
break;
}
}
if seen < before { 0 } else { start }
};
let mut after_end = max;
if after > 0 {
let mut seen = 0usize;
for (i, c) in text[max..].char_indices() {
seen += 1;
after_end = max + i + c.len_utf8();
if seen == after {
break;
}
if seen < before { 0 } else { start }
};
let mut after_end = max;
if after > 0 {
let mut seen = 0usize;
for (i, c) in text[max..].char_indices() {
seen += 1;
after_end = max + i + c.len_utf8();
if seen == after {
break;
}
}
Some((before_start..min, max..after_end))
}
(before_start..min, max..after_end)
}
impl Session {
+18
View File
@@ -4127,6 +4127,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Records per page", ["Datensätze pro Seite", "Enregistrements par page", "Registros por página", "ページあたりのレコード", "每页记录数"]),
("Single record", ["Einzelner Datensatz", "Un seul enregistrement", "Un solo registro", "単一レコード", "单条记录"]),
("Multiple records", ["Mehrere Datensätze", "Plusieurs enregistrements", "Varios registros", "複数レコード", "多条记录"]),
("Arrangement", ["Anordnung", "Disposition", "Disposición", "配置", "排列"]),
("Rows first", ["Zeilen zuerst", "Lignes d'abord", "Filas primero", "行を優先", "先行后列"]),
("Columns first", ["Spalten zuerst", "Colonnes d'abord", "Columnas primero", "列を優先", "先列后行"]),
("Column spacing", ["Spaltenabstand", "Espacement des colonnes", "Espacio entre columnas", "列の間隔", "列间距"]),
@@ -4858,6 +4859,22 @@ mod tests {
}
}
/// The Data Merge arrangement combo takes its accessible name from `tr(lang, "Arrangement")`.
/// A row in only the shared table leaves the languages whose lookup returns directly with the
/// English name.
#[test]
fn arrangement_accessible_name_is_localized_in_every_interface_language() {
for (code, name) in LANGUAGES {
let label = tr(code, "Arrangement");
if code.is_empty() {
assert_eq!(label, "Arrangement", "{name}");
} else {
assert_ne!(label, "Arrangement", "{name} still announces the English accessible name");
assert!(!label.trim().is_empty(), "{name}");
}
}
}
#[test]
fn context_captions_come_from_their_context_or_the_plain_translation() {
for (lang, table) in CONTEXT_TABLES {
@@ -5075,6 +5092,7 @@ mod tests {
"{label}: {name} (double-click: Color Picker)",
"All",
"Align To",
"Arrangement",
"Auto Rename",
"Character Style",
"Company",
+1
View File
@@ -1848,6 +1848,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("All records", "كل السجلات"),
("Alpha Channel", "قناة ألفا"),
("Anchored Object Options", "خيارات الكائن المرسّى"),
("Arrangement", "الترتيب"),
("ArtCraft App Page", "صفحة تطبيق ArtCraft"),
("ArtCraft Website", "موقع ArtCraft"),
("Bind to frame", "ربط بالإطار"),
+1
View File
@@ -1466,6 +1466,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Records per page", "Záznamů na stránku"),
("Single record", "Jeden záznam"),
("Multiple records", "Více záznamů"),
("Arrangement", "Uspořádání"),
("Rows first", "Nejdřív řádky"),
("Columns first", "Nejdřív sloupce"),
("Column spacing", "Mezera mezi sloupci"),
+1
View File
@@ -1640,6 +1640,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Records per page", "Record per pagina"),
("Single record", "Record singolo"),
("Multiple records", "Record multipli"),
("Arrangement", "Disposizione"),
("Rows first", "Prima le righe"),
("Columns first", "Prima le colonne"),
("Column spacing", "Spaziatura colonne"),
+1
View File
@@ -1566,6 +1566,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Records per page", "ჩანაწერი გვერდზე"),
("Single record", "ერთი ჩანაწერი"),
("Multiple records", "რამდენიმე ჩანაწერი"),
("Arrangement", "განლაგება"),
("Rows first", "ჯერ სტრიქონები"),
("Columns first", "ჯერ სვეტები"),
("Column spacing", "სვეტებს შორის დაშორება"),
+1
View File
@@ -1498,6 +1498,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Records per page", "Rekordy na stronę"),
("Single record", "Pojedynczy rekord"),
("Multiple records", "Wiele rekordów"),
("Arrangement", "Rozmieszczenie"),
("Rows first", "Najpierw wiersze"),
("Columns first", "Najpierw kolumny"),
("Column spacing", "Odstęp między kolumnami"),
+1
View File
@@ -1846,6 +1846,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Records per page", "Registros por página"),
("Single record", "Registro único"),
("Multiple records", "Vários registros"),
("Arrangement", "Organização"),
("Rows first", "Linhas primeiro"),
("Columns first", "Colunas primeiro"),
("Column spacing", "Espaçamento entre colunas"),
+1
View File
@@ -1454,6 +1454,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Records per page", "Записей на страницу"),
("Single record", "Одна запись"),
("Multiple records", "Несколько записей"),
("Arrangement", "Расположение"),
("Rows first", "Сначала строки"),
("Columns first", "Сначала столбцы"),
("Column spacing", "Интервал между столбцами"),
+1
View File
@@ -1637,6 +1637,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Records per page", "Записів на сторінку"),
("Single record", "Один запис"),
("Multiple records", "Кілька записів"),
("Arrangement", "Упорядкування"),
("Rows first", "Спочатку рядки"),
("Columns first", "Спочатку стовпці"),
("Column spacing", "Інтервал між стовпцями"),
+45 -5
View File
@@ -79,10 +79,12 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
jump = Some(shown.saturating_sub(1).max(1));
}
let mut rec = shown;
if ui.add(egui::DragValue::new(&mut rec).range(1..=max_record)).changed() {
let record = ui.add(egui::DragValue::new(&mut rec).range(1..=max_record));
if record.changed() {
jump = Some(rec);
}
crate::rtl::label(ui, egui::RichText::new(tr(&lang, "Record")).color(t.text));
let label = crate::rtl::label(ui, egui::RichText::new(tr(&lang, "Record")).color(t.text));
record.labelled_by(label.id);
if ui.small_button(w(ui, &lang, "Next")).clicked() {
jump = Some(shown.saturating_add(1).min(max_record));
}
@@ -122,7 +124,8 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
}
if options.per_page == "multiple" {
let arrange_options = [("rows", "Rows first"), ("columns", "Columns first")];
if let Some(arrange) = choice(ui, "dm_arrange", &options.arrange, &arrange_options, &lang, records_name) {
let arrange_name = tr(&lang, "Arrangement");
if let Some(arrange) = choice(ui, "dm_arrange", &options.arrange, &arrange_options, &lang, arrange_name) {
let _ = app.run("data.options", json!({"arrange": arrange}));
}
let labels = ["Top", "Right", "Bottom", "Left"];
@@ -130,8 +133,8 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
let mut insets_changed = false;
for (i, label) in labels.iter().enumerate() {
ui.horizontal(|ui| {
crate::rtl::label(ui, egui::RichText::new(tr(&lang, label)).color(t.text));
if ui.add(egui::DragValue::new(&mut insets[i]).speed(1.0)).changed() {
let label = crate::rtl::label(ui, egui::RichText::new(tr(&lang, label)).color(t.text));
if ui.add(egui::DragValue::new(&mut insets[i]).speed(1.0)).labelled_by(label.id).changed() {
insets_changed = true;
}
});
@@ -239,3 +242,40 @@ fn tr<'a>(lang: &str, s: &'a str) -> &'a str {
fn w(ui: &egui::Ui, lang: &str, s: &str) -> egui::WidgetText {
crate::rtl::widget(ui, tr(lang, s))
}
#[cfg(test)]
mod tests {
use egui_kittest::{
Harness,
kittest::{NodeT, Queryable},
};
use super::*;
#[test]
fn arrangement_combo_is_accessible_and_distinct_from_the_records_combo() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
app.run("data.source.select", json!({"bytes": designcraft_engine::cmd::base64_encode(b"Name\nAda\nGrace\n"), "name": "people.csv"})).unwrap();
app.run("data.options", json!({"perPage": "multiple"})).unwrap();
let mut h = Harness::builder().with_size(vec2(420.0, 700.0)).build_ui_state(
|ui, app: &mut DesignApp| {
let key = egui::Id::unique("datamerge_test_fonts");
if ui.data(|d| d.get_temp::<bool>(key)).is_none() {
crate::theme::install_fonts(ui.ctx(), "");
ui.data_mut(|d| d.insert_temp(key, true));
return;
}
show(app, ui);
},
app,
);
h.run_steps(3);
let combos = |h: &Harness<'_, DesignApp>, label: &str| {
h.query_all_by_label(label).filter(|n| n.accesskit_node().role() == egui::accesskit::Role::ComboBox).count()
};
assert_eq!(combos(&h, "Merge options"), 1, "records selection keeps its name");
assert_eq!(combos(&h, "Arrangement"), 1, "the arrangement control has its own name");
h.check_accessibility();
}
}
+45 -16
View File
@@ -178,6 +178,7 @@ impl<'a> NumField<'a> {
pub fn show_or_clear(self, ui: &mut Ui) -> Option<Option<f64>> {
let t = Tokens::get(ui.ctx());
let name = self.name.unwrap_or(self.id);
let enabled = self.enabled && ui.is_enabled();
let key = ui.scope_id().with(self.id);
let (rect, _) = ui.allocate_exact_size(vec2(self.width, FIELD_H), Sense::hover());
let step = self.step.unwrap_or_else(|| self.kind.step());
@@ -189,7 +190,7 @@ impl<'a> NumField<'a> {
let focused = ui.memory(|m| m.has_focus(focused_id));
let (bg, fg, border) =
if focused { (Color32::WHITE, Color32::from_gray(20), Color32::from_rgb(0x45, 0xa0, 0xf5)) } else { (t.input, t.text, t.field_border) };
let fg = if self.enabled { fg } else { t.text_disabled };
let fg = if enabled { fg } else { t.text_disabled };
// Spinner segment.
if self.spinner {
let seg = Rect::from_min_size(rect.min, vec2(SEG_W, FIELD_H));
@@ -198,28 +199,24 @@ impl<'a> NumField<'a> {
let up = Rect::from_min_max(seg.min, pos2(seg.max.x, seg.center().y));
let down = Rect::from_min_max(pos2(seg.min.x, seg.center().y), seg.max);
for (r, dir) in [(up, 1.0), (down, -1.0)] {
let resp = ui.interact(r, key.with(("spin", dir as i32)), if self.enabled { Sense::click() } else { Sense::hover() });
if resp.hovered() && self.enabled {
let resp = ui.interact(r, key.with(("spin", dir as i32)), if enabled { Sense::click() } else { Sense::hover() });
if resp.hovered() && enabled {
ui.painter().rect_filled(r.shrink(1.0), 0.0, t.hover);
}
let c = r.center();
let (a, b) = if dir > 0.0 { (2.5, -1.5) } else { (-2.5, 1.5) };
ui.painter().line_segment(
[pos2(c.x - 4.0, c.y + a * 0.6), pos2(c.x, c.y + b)],
Stroke::new(1.0, if self.enabled { t.icon } else { t.text_disabled }),
Stroke::new(1.0, if enabled { t.icon } else { t.text_disabled }),
);
ui.painter().line_segment(
[pos2(c.x, c.y + b), pos2(c.x + 4.0, c.y + a * 0.6)],
Stroke::new(1.0, if self.enabled { t.icon } else { t.text_disabled }),
Stroke::new(1.0, if enabled { t.icon } else { t.text_disabled }),
);
// The two spinner halves are real inputs: name them for screen readers and strict
// accessibility checks, which would otherwise see unnamed clickable nodes.
resp.widget_info(|| {
egui::WidgetInfo::labeled(
egui::Role::Button,
self.enabled,
format!("{} {}", if dir > 0.0 { "Increase" } else { "Decrease" }, name),
)
egui::WidgetInfo::labeled(egui::Role::Button, enabled, format!("{} {}", if dir > 0.0 { "Increase" } else { "Decrease" }, name))
});
if resp.clicked() {
let mul = if ui.input(|i| i.modifiers.shift) { 10.0 } else { 1.0 };
@@ -232,10 +229,10 @@ impl<'a> NumField<'a> {
if !self.presets.is_empty() {
let seg = Rect::from_min_max(pos2(rect.max.x - SEG_W, rect.min.y), rect.max);
text_rect.max.x = seg.min.x;
let resp = ui.interact(seg, key.with("presets"), if self.enabled { Sense::click() } else { Sense::hover() });
ui.painter().rect_filled(seg, 0.0, if resp.hovered() && self.enabled { t.hover } else { t.input });
chevron_down(ui.painter(), seg.center(), if self.enabled { t.icon } else { t.text_disabled });
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, self.enabled, format!("{name} presets")));
let resp = ui.interact(seg, key.with("presets"), if enabled { Sense::click() } else { Sense::hover() });
ui.painter().rect_filled(seg, 0.0, if resp.hovered() && enabled { t.hover } else { t.input });
chevron_down(ui.painter(), seg.center(), if enabled { t.icon } else { t.text_disabled });
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, format!("{name} presets")));
preset_resp = Some(resp);
}
// Value.
@@ -256,7 +253,7 @@ impl<'a> NumField<'a> {
let value_rect = Rect::from_min_max(text_rect.min + vec2(5.0, 1.0), text_rect.max - vec2(1.0, 1.0));
let mut editor_ui = ui.new_child(egui::UiBuilder::new().max_rect(value_rect));
editor_ui.set_clip_rect(editor_ui.clip_rect().intersect(text_rect));
if !self.enabled {
if !enabled {
editor_ui.disable();
}
let r = editor_ui.put(value_rect, te).accessible_name(name);
@@ -311,7 +308,7 @@ impl<'a> NumField<'a> {
p.line_segment([pos2(rect.max.x - SEG_W, rect.min.y), pos2(rect.max.x - SEG_W, rect.max.y)], Stroke::new(1.0, t.field_border));
}
if let Some(resp) = preset_resp
&& self.enabled
&& enabled
{
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(self.width);
@@ -855,6 +852,38 @@ mod tests {
h.check_accessibility();
}
#[test]
fn numeric_controls_inherit_a_disabled_parents_state() {
use egui_kittest::kittest::{NodeT, Queryable};
const PRESETS: &[f64] = &[0.0, 50.0, 100.0];
let mut h = egui_kittest::Harness::builder().with_size(vec2(420.0, 240.0)).build_ui(|ui| {
ui.disable();
let _ = NumField::number("Opacity", Some(50.0), "%", 0).spinner().presets(PRESETS).show(ui);
});
h.ctx.all_styles_mut(|style| style.debug.show_interactive_widgets = true);
h.run_steps(2);
let metadata_enabled = |label: &str| {
h.ctx.viewport(|viewport| {
let rects = &viewport.prev_pass.widgets;
rects
.layers()
.flat_map(|(_, widgets)| widgets)
.filter_map(|widget| rects.info(widget.id))
.find(|info| info.label.as_deref() == Some(label))
.map(|info| info.enabled)
})
};
for label in ["Increase Opacity", "Decrease Opacity", "Opacity presets"] {
assert!(h.get_by_label(label).accesskit_node().is_disabled(), "{label} reports the disabled parent");
assert_eq!(metadata_enabled(label), Some(false), "{label} metadata reports the disabled parent");
}
h.get_by_label("Opacity presets").click();
h.run_steps(3);
assert!(!egui::Popup::is_any_open(&h.ctx), "a preset in a disabled parent must not open its menu");
h.check_accessibility();
}
/// Strict accessibility: every input in these fields is named and reports the right role.
#[test]
fn numeric_dropdown_and_checkbox_inputs_have_accessible_names_and_roles() {