mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 04:36:47 -06:00
M0.37: fix accessible controls and simplify reviewed engine paths
This commit is contained in:
@@ -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
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", "ربط بالإطار"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -1566,6 +1566,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Records per page", "ჩანაწერი გვერდზე"),
|
||||
("Single record", "ერთი ჩანაწერი"),
|
||||
("Multiple records", "რამდენიმე ჩანაწერი"),
|
||||
("Arrangement", "განლაგება"),
|
||||
("Rows first", "ჯერ სტრიქონები"),
|
||||
("Columns first", "ჯერ სვეტები"),
|
||||
("Column spacing", "სვეტებს შორის დაშორება"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -1454,6 +1454,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Records per page", "Записей на страницу"),
|
||||
("Single record", "Одна запись"),
|
||||
("Multiple records", "Несколько записей"),
|
||||
("Arrangement", "Расположение"),
|
||||
("Rows first", "Сначала строки"),
|
||||
("Columns first", "Сначала столбцы"),
|
||||
("Column spacing", "Интервал между столбцами"),
|
||||
|
||||
@@ -1637,6 +1637,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Records per page", "Записів на сторінку"),
|
||||
("Single record", "Один запис"),
|
||||
("Multiple records", "Кілька записів"),
|
||||
("Arrangement", "Упорядкування"),
|
||||
("Rows first", "Спочатку рядки"),
|
||||
("Columns first", "Спочатку стовпці"),
|
||||
("Column spacing", "Інтервал між стовпцями"),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user