From 6bc2e257923c191ae37fc37c49fc575d8e94f96a Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Sun, 25 Oct 2026 08:31:14 +0100 Subject: [PATCH] M0.37: fix accessible controls and simplify reviewed engine paths --- crates/engine/src/cmd/preflight.rs | 1 + crates/engine/src/math.rs | 742 +++++++++++++------------ crates/engine/src/tooling.rs | 94 ++-- crates/ui-egui/src/i18n.rs | 18 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/panels/datamerge.rs | 50 +- crates/ui-egui/src/widgets.rs | 61 +- 14 files changed, 564 insertions(+), 410 deletions(-) diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index cd5c4204e..99d21c801 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -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(); diff --git a/crates/engine/src/math.rs b/crates/engine/src/math.rs index d4aaf2600..c22755799 100644 --- a/crates/engine/src/math.rs +++ b/crates/engine/src/math.rs @@ -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>, value: &mut Node, end: Option, 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>, value: &mut Node, kind: SequenceKind, depth: usize, mut out: Vec) { + 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>, value: &mut Node, kind: SequenceKind, depth: usize, mut out: Vec, 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>, 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>, value: &mut Node, kind: SequenceKind, depth: usize, mut out: Vec, 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>, 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>, 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>) { + *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>, 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>, depth: usize) { + work.push(ParseWork::OneAtom { depth }); + work.push(ParseWork::Atom { depth }); + } + + fn step_one_atom(work: &mut Vec>, 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>, 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>, 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>, 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 = 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| 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 = 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| 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>, 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>, sub: &Option>, 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] diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 1ff7fbe5f..f26e50714 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -435,58 +435,68 @@ fn ime_ranges_from_command(r: &Value) -> Option<(Range, Range)> { /// 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, Range)> { + 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, Range) { + // 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, Range) { + 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 { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 19e9d911d..2267ed265 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -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", diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index beafe4b84..9249d2357 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -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", "ربط بالإطار"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 2f12068a1..33408302a 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -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"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index d81a34df9..df15c1f22 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -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"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 61196d196..c79d737cd 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -1566,6 +1566,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Records per page", "ჩანაწერი გვერდზე"), ("Single record", "ერთი ჩანაწერი"), ("Multiple records", "რამდენიმე ჩანაწერი"), + ("Arrangement", "განლაგება"), ("Rows first", "ჯერ სტრიქონები"), ("Columns first", "ჯერ სვეტები"), ("Column spacing", "სვეტებს შორის დაშორება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 4dffa9de3..0977bd1df 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -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"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 4628baffa..dd032c213 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -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"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index f13694bb9..ee3ae38c7 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -1454,6 +1454,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Records per page", "Записей на страницу"), ("Single record", "Одна запись"), ("Multiple records", "Несколько записей"), + ("Arrangement", "Расположение"), ("Rows first", "Сначала строки"), ("Columns first", "Сначала столбцы"), ("Column spacing", "Интервал между столбцами"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 5dca5b717..ee077da37 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1637,6 +1637,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Records per page", "Записів на сторінку"), ("Single record", "Один запис"), ("Multiple records", "Кілька записів"), + ("Arrangement", "Упорядкування"), ("Rows first", "Спочатку рядки"), ("Columns first", "Спочатку стовпці"), ("Column spacing", "Інтервал між стовпцями"), diff --git a/crates/ui-egui/src/panels/datamerge.rs b/crates/ui-egui/src/panels/datamerge.rs index edf7b3261..c482637ff 100644 --- a/crates/ui-egui/src/panels/datamerge.rs +++ b/crates/ui-egui/src/panels/datamerge.rs @@ -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::(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(); + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 01877304c..83cc1b5eb 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -178,6 +178,7 @@ impl<'a> NumField<'a> { pub fn show_or_clear(self, ui: &mut Ui) -> Option> { 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() {