Merge: say what each control is, so a screen reader can use one

NFR-A11Y-2 went from five accessible-* declarations in the whole
interface -- all five on the colour mixer's swatch row -- to
seventy-three, on twelve shared components and four screens. The one that
mattered is SliderTrack: the most-used control in the application, until
now unnamed, and now carrying a label, a formatted readout and
increment/decrement/set-value, so it is adjustable rather than merely
readable. Fixing the shared components covered library.slint's
twenty-seven buttons and eleven chips without editing that file at all.

NFR-A11Y-1 is scaffolded and one screen of nine is converted -- 38 @tr()
calls, about a tenth of the interface's strings. Slint's translate()
returns the original when no bundle is active, so a converted string and
a literal behave identically today and each remaining screen is an
independent commit.

Two accessibility defects are recorded rather than fixed, as TD-6 and
TD-7 with measurements: ink-faint reaches 4.5:1 on no surface (3.97 at
best) and rule reaches 3:1 on none. Fixing either re-derives the palette
beside a photograph, which wants a screenshot and an opinion.

Verified: fmt, clippy -D warnings, 563 tests including a new integration
test that walks the markup and fails on an unnamed control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 10:50:38 +02:00
co-authored by Claude Opus 5
9 changed files with 987 additions and 42 deletions
+146
View File
@@ -297,6 +297,152 @@ millisecond for the `point` and `all` chains, and the codegen tests still pass b
--- ---
## TD-6 — The quietest ink does not reach WCAG AA, and the rule does not reach 3:1
**Breaks:** [requirements.md](requirements.md) NFR-A11Y-2 — "non-canvas UI meets WCAG AA contrast".
### What it does
`style.yaml` sets three inks and four surfaces. Measured as WCAG 2 contrast ratios (sRGB relative
luminance, the standard formula), against the surfaces each ink is actually drawn on:
| ink | on `ground` | on `surface` | on `surface-raised` | on `hover` | on `selected` |
|---|---|---|---|---|---|
| `ink` #EDEEF0 | 16.02 | 14.69 | 13.03 | 11.39 | 9.68 |
| `ink-dim` #9EA1A6 | 7.18 | 6.58 | 5.84 | 5.10 | **4.34** |
| `ink-faint` #71747A | **3.97** | **3.64** | **3.23** | **2.82** | **2.40** |
| `warn-ink` #C9A05A | 7.67 | 7.03 | 6.24 | 5.45 | 4.64 |
| `rule` #323438 | **1.49** | **1.37** | **1.21** | **1.06** | **1.11** |
Every text size in the application is 11px, 13px, 17px or 24px, and WCAG's "large text" relief
begins at 18.66px bold or 24px regular — so all four of those thresholds are the normal-text one,
**4.5:1**, except the masthead. Bold entries fail it.
The inverted cases pass and are worth stating so nobody re-measures them: `ground` on `active`
(#FFFFFF) is 18.60, on `active-dim` 11.20, on `active-pressed` 5.95, on `selected-ring` 13.02. The
near-white fills that `Button.primary`, `FilterChip.active` and the held tool-rail entry use are
the *best*-contrasting text in the interface, not the worst.
So the failures are exactly two, and neither is where one would guess:
- **`ink-faint` reaches 4.5:1 nowhere at all.** It is the ink for `Caption`, `PanelHeading`,
`Disclosure`, `Value`'s placeholder state and `FilterChip`'s count — every hint, every section
name, every "3 photographs" under a title.
- **`rule` reaches 3:1 nowhere.** WCAG 1.4.11 asks 3:1 of the boundary of a control the user must
perceive, and `rule` is the border of every `Button`, `Field`, `Panel`, `ChoiceChip` and
`IconButton`. An unfilled secondary button is a 1.4:1 outline on a 1.2:1 background.
`ink-dim` on `selected` at 4.34 is a third case, marginal enough that a two-point lift fixes it.
### Why
Not an oversight — the direct consequence of the palette's own argument, which `style.yaml`'s
preamble makes at length and correctly. The chrome is deliberately quiet because a bright surround
biases how a photograph is judged, and hue is banned outright because an accent beside the image
shifts the perception of nearby colours. What is left to signal with is luminance, and the palette
spends its luminance range on the *photograph*, keeping the chrome inside a narrow band above the
ground.
A narrow band is precisely what a contrast ratio measures. `ink-faint` exists to be skipped by the
reader who did not stop to look; that is a real design intent, and "text you are meant to skip"
and "text everyone can read" are in genuine tension rather than one being a mistake.
### What it costs
The photographer who cannot read a caption cannot read *any* caption, on any screen — this is one
token, so it fails everywhere at once. The hints under the settings switches say what a setting
costs, the section names say what a panel is, and the counts say how big a filter is. None of it is
decorative.
### Paying it off
Two token changes, and the second is the awkward one.
`ink-faint` needs roughly #8A8D93 to clear 4.5:1 against `surface-raised`, the darkest surface it
is drawn on that matters — which puts it about where `ink-dim` sits today and collapses the
three-ink scale to two. So the real fix is to re-derive all three inks against the surfaces rather
than to nudge one: the scale wants to start higher and keep its steps, not compress.
`rule` needs about #4A4D52 for 3:1 against `surface`. That is a visibly stronger line, and the
preamble's "instrument rather than absence" reasoning applies to it as much as to the greys — this
is a look change, not a number change, and it should be looked at rather than computed.
Both are decisions about how the application appears next to a photograph, which is the one thing
this palette was designed around. They want a screenshot and an opinion, not a patch.
**Done when:** every `Theme` ink reaches 4.5:1 against every surface it is drawn on, `rule` reaches
3:1 against `surface` and `surface-raised`, and a test recomputes those ratios from `style.yaml` so
the next palette edit cannot quietly undo it. The table above is the baseline to compare against.
### Not in scope
The histogram's `plot-*` inks (2.36 for `plot-luma` on `ground`) are drawn *on* the canvas, and
NFR-A11Y-2 scopes contrast to non-canvas UI. NFR-A11Y-3 covers what those need instead, and is
already met — the readouts name the channel in words.
---
## TD-7 — Platform font scaling is not honoured
**Breaks:** [requirements.md](requirements.md) NFR-A11Y-2 — "platform font scaling is honoured
without clipping".
### What it does
Nothing at all, which is the entry. Every type size is a constant in `style.yaml` — 11, 13, 17, 24
— read as `Theme.text-sm` and friends at 65 call sites, and there is no multiplier anywhere between
the platform's font-size preference and those numbers. `scale_factor()` is read in `display_ui.rs`
and in `lib.rs`, but only to size the canvas in physical pixels for the render; it is display DPI,
which Slint already applies to logical lengths, and it is not the user's text-size setting. A
photographer who sets 130% text on GNOME or Android gets an application that ignores it.
### Why
Because honouring it is not a multiplier, and pretending it is would be worse than not doing it.
The layout is built on constants that are not derived from the type size: `control-height` 28,
`touch-target` 44, `row-height` 26, `rail-entry-height` 54, `panel-width` 360, and a dozen fixed
heights written at their call sites — `ParamSlider`'s 46px, the folder picker's 220px box, the
readout column's 30px. Scaling the type alone clips against every one of them, silently, because
Slint elides rather than errors. `SwatchSlider` is the sharpest case: 12 hue bands × 3 channels in
a 360px column, sized so that a track and a swatch and a three-character readout fit on one line.
And the failure is invisible to the person shipping it. The requirement's own phrase is "without
clipping", and clipping is exactly what a screenshot at 100% cannot show — the memory note on
verifying Slint changes exists because these files have a history of compiling, rendering and being
wrong.
### What it costs
The user for whom this matters most is not the screen-reader user the rest of this branch serves —
it is the one with usable but poor sight, who reads the interface and needs it larger. The
application is unusable to them at any setting, and there is no partial credit: text scaling is a
system-wide preference, so an app that ignores it is the one thing on the desktop that did.
### Paying it off
In the order the pieces depend on each other:
1. **A scale token.** `Theme.text-sm` and the rest become `base × Theme.type-scale`, with the
scale an `in-out` property Rust writes at startup from the platform. `build.rs` already emits
`in-out` tokens under `live-style`, so the codegen half of this exists and is proven — that
feature is the mechanism, one line from being general.
2. **A source for the number.** GNOME publishes `text-scaling-factor` over the settings portal;
Android has `Configuration.fontScale` through JNI, beside the calls `lib.rs` already makes for
`ACTION_VIEW`. Both want a default of 1.0 and a sane clamp — 0.8 to 2.0 — because a user who has
set 300% for a phone launcher has not asked for a 72px slider readout.
3. **The constants that are not type.** Every fixed height a *label* sits inside has to follow the
scale; every touch target must not shrink and need not grow. That is the work, and it is where
the 46px and 220px literals get read one at a time.
4. **Evidence.** Screenshots at 1.0, 1.3 and 2.0 of the develop column, the settings page and the
colour mixer — the three densest layouts — because "without clipping" is a claim about the
worst case and nothing else will show it.
**Done when:** the develop column, the settings page and the colour mixer render at a 2.0 scale
with no elided label and no touch target under 44 logical pixels.
---
## Related, and deliberately not here ## Related, and deliberately not here
The window-move rule, the grid's ordering index and the whole-library readout cache were *fixed* The window-move rule, the grid's ordering index and the whole-library readout cache were *fixed*
+81 -1
View File
@@ -12,6 +12,48 @@
//! `OUT_DIR` as an include path makes the generated file answer every //! `OUT_DIR` as an include path makes the generated file answer every
//! existing `import { Theme } from "theme.slint"` unchanged. This only works //! existing `import { Theme } from "theme.slint"` unchanged. This only works
//! while no `ui/theme.slint` exists to shadow it — see the guard below. //! while no `ui/theme.slint` exists to shadow it — see the guard below.
//!
//! # Translations (NFR-A11Y-1)
//!
//! A string written `@tr("Sign in")` in the markup is **already correct in a
//! build with no translation at all**: with neither the `gettext` nor the
//! `bundle-translations` path active, Slint's `translate()` formats the
//! original and returns it. That is the property that makes converting the
//! interface a string at a time possible rather than a flag day — the
//! alternative, wiring the machinery first and converting after, means every
//! intermediate commit ships an interface half of which cannot be translated
//! and none of which can be extracted.
//!
//! **Extracting.** `slint-tr-extractor` walks the `.slint` files and writes a
//! `.pot`:
//!
//! ```text
//! cargo install slint-tr-extractor
//! find ui/dr-ui/ui -name '*.slint' | xargs slint-tr-extractor -o dr-ui.pot
//! ```
//!
//! Do **not** pass `--no-default-translation-context`. Slint's default
//! context is the enclosing component's name, which is what keeps the two
//! senses of a word like "or" apart when the same word is a conjunction on one
//! screen and a search operator on another — and the extractor and the
//! compiler have to agree about it or every lookup misses silently.
//!
//! **Delivering.** Two mechanisms exist and this one picks bundling: a `.po`
//! per language under `lang/<lang>/LC_MESSAGES/dr-ui.po`, compiled into the
//! binary by the block in `main`. The alternative is Slint's `gettext`
//! feature, which reads `.mo` files off disk at runtime through the C gettext
//! library. Bundling wins here for one reason that outranks the rest:
//! **Android**, where there is no filesystem path a `.mo` could sit at that
//! the app can reach under ARCH §6.9's storage model, and no C library to
//! link. A build with no `lang/` directory takes neither path and keeps every
//! original string, which is exactly the state of this crate today.
//!
//! **What this does not reach.** `@tr()` is markup. The operation and
//! parameter labels NFR-A11Y-1 names explicitly are resolved in Rust, by
//! `labels.rs`, from the `LocalizedKey`s the core publishes — the core cannot
//! depend on a localisation library (ARCH §6.5a), which is the constraint that
//! put the catalogue in the UI crate in the first place. Translating those
//! needs a second mechanism on the Rust side, and it is not built.
use std::collections::BTreeSet; use std::collections::BTreeSet;
use std::fmt::Write as _; use std::fmt::Write as _;
@@ -21,6 +63,8 @@ use serde_norway::Value;
const STYLE_YAML: &str = "style.yaml"; const STYLE_YAML: &str = "style.yaml";
const GENERATED: &str = "theme.slint"; const GENERATED: &str = "theme.slint";
/// Where a `.po` goes, relative to this crate: `lang/<lang>/LC_MESSAGES/`.
const LANG_DIR: &str = "lang";
fn main() { fn main() {
println!("cargo:rerun-if-changed={STYLE_YAML}"); println!("cargo:rerun-if-changed={STYLE_YAML}");
@@ -72,12 +116,48 @@ fn main() {
// The failure is silent either way: a missing image loads as empty. // The failure is silent either way: a missing image loads as empty.
// Embedding costs the size of ui/app-icon.png, the only asset this // Embedding costs the size of ui/app-icon.png, the only asset this
// reaches, since every UI glyph is a Path rather than a file. // reaches, since every UI glyph is a Path rather than a file.
let config = slint_build::CompilerConfiguration::new() let mut config = slint_build::CompilerConfiguration::new()
.with_include_paths(vec![out_dir.clone(), manifest_dir.join("ui")]) .with_include_paths(vec![out_dir.clone(), manifest_dir.join("ui")])
.embed_resources(slint_build::EmbedResourcesKind::EmbedFiles); .embed_resources(slint_build::EmbedResourcesKind::EmbedFiles);
// Bundling is asked for only once a translation exists to bundle.
//
// Enabling it unconditionally would make an empty `lang/` — or a
// missing one, which is every checkout today — into a build failure for
// everyone, in service of a feature nobody is yet using. Asking the
// directory instead means the mechanism is wired and inert: the first
// `lang/fr/LC_MESSAGES/dr-ui.po` someone commits turns it on with no
// build-system change, which is the point at which a translator can
// actually verify their work.
if let Some(lang) = translations(&manifest_dir) {
println!("cargo:rerun-if-changed={}", lang.display());
config = config.with_bundled_translations(lang);
}
slint_build::compile_with_config(entry(&out_dir, live), config).expect("compiling app.slint"); slint_build::compile_with_config(entry(&out_dir, live), config).expect("compiling app.slint");
} }
/// The translation root, if any language has a catalogue in it.
///
/// The domain is the crate name — slint-build takes it from `CARGO_PKG_NAME`
/// and this reads the same variable, so a rename does not leave the two
/// halves looking for different files. Checking for a `.po` rather than
/// merely for the directory is deliberate: an empty `lang/` left behind by a
/// half-finished translation would otherwise switch bundling on and hand every
/// string to a lookup with nothing behind it.
fn translations(manifest_dir: &Path) -> Option<PathBuf> {
let root = manifest_dir.join(LANG_DIR);
let catalogue = format!("{}.po", env!("CARGO_PKG_NAME"));
let entries = std::fs::read_dir(&root).ok()?;
for entry in entries.flatten() {
if entry.path().join("LC_MESSAGES").join(&catalogue).is_file() {
return Some(root);
}
}
None
}
/// The file handed to the Slint compiler. /// The file handed to the Slint compiler.
/// ///
/// Normally `ui/app.slint` itself. Under `live-style` it is a generated /// Normally `ui/app.slint` itself. Under `live-style` it is a generated
@@ -0,0 +1,325 @@
// TRACES: NFR-A11Y-2
//! Every shared control says what it is, and every slider says what it edits.
//!
//! NFR-A11Y-2 asks that controls expose names, roles and values to AT-SPI and
//! TalkBack. Almost none of that is Rust: it is `accessible-*` properties in
//! Slint markup, which no unit test can observe by running the interface —
//! there is no accessibility tree without a window, and CI has no display.
//!
//! So this reads the markup, the way `darkroom-android`'s manifest test reads
//! the XML that aapt2 owns and rustc never sees. The property being defended
//! is not "the screen reader announces the right words", which needs a device
//! and a person; it is the thing that silently regresses instead — **a control
//! losing its role or its name in an ordinary refactor**, which compiles,
//! renders identically, passes every other test, and is invisible to anyone
//! not using a screen reader. That failure has already happened once here: the
//! whole application had five `accessible-*` lines in 14,482 lines of markup,
//! all of them on one row of the colour mixer, and nothing said so.
//!
//! ## What is checked, and what deliberately is not
//!
//! Two properties, both structural:
//!
//! 1. **Named components declare the roles they promise.** A `Button` that
//! stops declaring `accessible-role` stops existing for a screen reader
//! entirely, because Slint rejects every other `accessible-*` property that
//! is not accompanied by a role — so the role is the load-bearing one and
//! the rest fail with it.
//!
//! 2. **Every `SliderTrack` instantiation supplies a `label`.** This is the
//! one that catches a *new* control rather than a broken old one. The track
//! cannot name itself — it has no idea what number it is dragging — so an
//! unnamed one announces "slider, 0.35" and is the single most-used control
//! in the application. A track added without a label is the exact mistake
//! this file exists to make loud.
//!
//! Not checked: whether the words are good, whether they are translated,
//! whether contrast passes, or whether Android exposes any of it — spike S13
//! is still unrun and no test on this side of it can answer that.
use std::fs;
use std::path::{Path, PathBuf};
/// A component that must carry particular `accessible-*` properties, and the
/// properties it must carry.
///
/// Only the ones whose absence is a behavioural loss are listed. `Button` must
/// declare `accessible-action-default` because without it a screen reader can
/// read the button and not press it, which is a control that exists and cannot
/// be used; it need not declare `accessible-description`, which is a nicety.
struct Promise {
file: &'static str,
component: &'static str,
properties: &'static [&'static str],
}
const PROMISES: &[Promise] = &[
Promise {
file: "widgets.slint",
component: "Button",
properties: &["accessible-role", "accessible-label", "accessible-action-default"],
},
Promise {
file: "widgets.slint",
component: "IconButton",
properties: &["accessible-role", "accessible-label", "accessible-action-default"],
},
Promise {
file: "widgets.slint",
component: "FilterChip",
properties: &[
"accessible-role",
"accessible-label",
"accessible-checked",
"accessible-action-default",
],
},
Promise {
file: "widgets.slint",
component: "Section",
properties: &["accessible-role", "accessible-label", "accessible-expanded"],
},
Promise {
file: "widgets.slint",
component: "ProgressBar",
properties: &["accessible-role", "accessible-label", "accessible-value"],
},
// The label goes on the `TextInput` inside, not on the box around it —
// Slint gives that element its role, so the name belongs beside it.
Promise {
file: "widgets.slint",
component: "Field",
properties: &["accessible-label", "accessible-placeholder-text"],
},
Promise {
file: "controls.slint",
component: "SliderTrack",
properties: &[
"accessible-role",
"accessible-label",
"accessible-value",
"accessible-value-minimum",
"accessible-value-maximum",
"accessible-action-increment",
"accessible-action-decrement",
"accessible-action-set-value",
],
},
Promise {
file: "controls.slint",
component: "Check",
properties: &[
"accessible-role",
"accessible-label",
"accessible-checked",
"accessible-action-default",
],
},
Promise {
file: "controls.slint",
component: "ChoiceChip",
properties: &[
"accessible-role",
"accessible-label",
"accessible-checked",
"accessible-action-default",
],
},
];
fn ui_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("ui")
}
fn read(path: &Path) -> String {
fs::read_to_string(path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()))
}
/// The body of one top-level component declaration, or `None` if the file
/// declares no such component.
///
/// Every component in these files is declared at column zero, so the block
/// runs from its header to the next header or to the end of the file. A
/// looser rule than brace matching and a stricter one than "somewhere in the
/// file": the point is that a property found for `Button` really is inside
/// `Button` and not inside the component below it.
fn component_body<'a>(source: &'a str, name: &str) -> Option<&'a str> {
let header_starts = |line: &str| {
line.starts_with("component ") || line.starts_with("export component ")
};
let mut start = None;
for (offset, line) in line_offsets(source) {
if !header_starts(line) {
continue;
}
// `component Foo inherits Bar {` — the word after `component`.
let declared = line
.trim_start_matches("export ")
.trim_start_matches("component ")
.split_whitespace()
.next();
match start {
None if declared == Some(name) => start = Some(offset),
Some(from) => return Some(&source[from..offset]),
None => {}
}
}
start.map(|from| &source[from..])
}
/// Each line of `source` with its byte offset.
fn line_offsets(source: &str) -> impl Iterator<Item = (usize, &str)> {
let mut offset = 0;
source.lines().map(move |line| {
let at = offset;
offset += line.len() + 1;
(at, line)
})
}
/// Whether a block sets a property — the name at the start of a line, followed
/// by what Slint puts after a name it is binding to.
///
/// Three forms, and the third is easy to forget: `name:` for a property,
/// `name =>` for a callback, and `name(arg) =>` for a callback that takes one.
/// `accessible-action-set-value` is the only member of the third group here and
/// leaving it out made this test report the slider as missing the very action
/// it declares.
///
/// Anchored at the start of the trimmed line so that a *mention* in the prose
/// above a component does not count. These files carry more comment than code,
/// and several of those comments name the very properties asserted here; a
/// substring search would pass on the strength of an explanation of the thing
/// that had been deleted, which is the failure `ui_names_no_operation.rs`
/// documents at length and the android manifest test strips comments to avoid.
fn sets(block: &str, property: &str) -> bool {
block.lines().any(|line| {
let line = line.trim_start();
line.strip_prefix(property).is_some_and(|rest| {
let rest = rest.trim_start();
rest.starts_with(':') || rest.starts_with("=>") || rest.starts_with('(')
})
})
}
#[test]
fn shared_controls_declare_their_roles() {
let ui = ui_dir();
let mut failures = Vec::new();
for promise in PROMISES {
let path = ui.join(promise.file);
let source = read(&path);
let Some(body) = component_body(&source, promise.component) else {
failures.push(format!(
" {} declares no component `{}` — it was renamed or removed, and \
this test then checks nothing",
promise.file, promise.component
));
continue;
};
for property in promise.properties {
if !sets(body, property) {
failures.push(format!(
" {}: `{}` does not set `{property}`",
promise.file, promise.component
));
}
}
}
assert!(
failures.is_empty(),
"\n\nNFR-A11Y-2: controls expose names, roles and values to the platform \
accessibility layer.\n\n{}\n\n\
Slint rejects every `accessible-*` property that is not accompanied by an \
`accessible-role`, so the role is what makes the control exist for AT-SPI \
and TalkBack at all, and the actions are what make it usable rather than \
merely readable.\n\n\
These live on the shared components rather than at the call sites \
deliberately: a control annotated where it is used is a control unnamed \
everywhere it is used next. See the preamble to widgets.slint.\n",
failures.join("\n")
);
}
#[test]
fn every_slider_track_is_named() {
let ui = ui_dir();
let mut files: Vec<PathBuf> = fs::read_dir(&ui)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", ui.display()))
.map(|entry| entry.expect("read dir entry").path())
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("slint"))
.collect();
files.sort();
let mut instantiations = 0usize;
let mut unnamed = Vec::new();
for path in &files {
let source = read(path);
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
// The declaration itself, in controls.slint, is not an instantiation.
for (offset, line) in line_offsets(&source) {
if line.trim_start() != "SliderTrack {" {
continue;
}
instantiations += 1;
let block = &source[offset..offset + block_len(&source[offset..])];
if !sets(block, "label") {
let number = source[..offset].lines().count() + 1;
unnamed.push(format!(" {name}:{number}"));
}
}
}
// A scan that found nothing passes for the wrong reason. Four tracks are
// instantiated today — the develop panel's three shapes and the settings
// page's row — and the floor sits below that and well above zero, so
// removing one is a code change and not a silent scan failure.
assert!(
instantiations >= 3,
"found only {instantiations} `SliderTrack` instantiations — the scan is \
matching nothing, and a scan over nothing passes"
);
assert!(
unnamed.is_empty(),
"\n\nNFR-A11Y-2: a slider that does not say what it adjusts.\n\n{}\n\n\
`SliderTrack` cannot name itself — it is handed four numbers and knows \
nothing about what they mean — so a track without a `label` announces as \
\"slider, 0.35\". The develop column holds thirty-six of them in the \
colour mixer alone, which is thirty-six controls a screen reader cannot \
tell apart.\n\n\
Pass the same string the row is already drawing above the track.\n",
unnamed.join("\n")
);
}
/// The length of the brace-delimited block beginning at the start of `rest`,
/// including both braces.
///
/// Braces inside string literals are not handled, and do not occur inside a
/// `SliderTrack` instantiation — the only strings there are labels and units.
fn block_len(rest: &str) -> usize {
let mut depth = 0usize;
for (i, c) in rest.char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return i + 1;
}
}
_ => {}
}
}
rest.len()
}
+56 -10
View File
@@ -140,6 +140,18 @@ component GroupHeading inherits Rectangle {
width: 34px; width: 34px;
visible: root.has-reset; visible: root.has-reset;
// A `Caption` is a `Text`, which Slint announces as text — so
// without this the reset reads as the word "reset" sitting beside
// the heading rather than as something that can be pressed.
accessible-role: button;
accessible-label: "Reset";
accessible-enabled: root.has-reset;
accessible-action-default => {
if (root.has-reset) {
root.reset();
}
}
reset-touch := TouchArea { reset-touch := TouchArea {
width: 100%; width: 100%;
height: max(parent.height, Theme.touch-target); height: max(parent.height, Theme.touch-target);
@@ -202,6 +214,18 @@ component ParamSlider inherits Rectangle {
modified: root.data.value != root.data.default-value; modified: root.data.value != root.data.default-value;
SliderTrack { SliderTrack {
// The same two strings `ControlRow` is drawing above the track.
// Drawn text and announced text are separate channels — Slint
// associates neither with the control on its own — so the label a
// sighted user reads and the label a screen reader hears come from
// one expression each, rather than the second being left empty.
label: root.data.param-label;
readout: Readout.of(root.data);
// The descriptor's declared precision, as a step: a parameter in
// whole units nudges by one and one in stops by a hundredth,
// which is the same quantum the readout is rounded to.
step: root.data.precision == 0 ? 1.0 : 0.01;
value: root.data.value; value: root.data.value;
default-value: root.data.default-value; default-value: root.data.default-value;
minimum: root.data.minimum; minimum: root.data.minimum;
@@ -245,12 +269,18 @@ component FacetHeading inherits Rectangle {
// most of a screen of scrolling with the band name absent from every row of it // most of a screen of scrolling with the band name absent from every row of it
// anyway. // anyway.
// //
// **The name is not thrown away, it moves.** It is the row's accessible label, // **The name is not thrown away, it moves.** It becomes the track's accessible
// so a screen reader says "Orange" where the eye reads the colour, and the // label, so a screen reader says "Orange" where the eye reads the colour, and
// catalogue in `labels.rs` is where the mapping is written down for anyone who // the catalogue in `labels.rs` is where the mapping is written down for anyone
// cannot separate two squares by eye. A row identified by colour *alone* // who cannot separate two squares by eye. A row identified by colour *alone*
// would be a control some photographers could not use, which is why the // would be a control some photographers could not use, which is why the
// spoken name is part of the design and not an afterthought. // spoken name is part of the design and not an afterthought.
//
// It used to be announced on this row, with the track below it silent. Now
// that every `SliderTrack` names itself (NFR-A11Y-2) the two would nest — a
// slider inside a slider, the outer one carrying the value and the inner one
// carrying the actions that can change it — so the row stands down and hands
// the same three strings to the control that owns the gesture.
component SwatchSlider inherits Rectangle { component SwatchSlider inherits Rectangle {
in property <ParamRow> data; in property <ParamRow> data;
callback changed(float); callback changed(float);
@@ -264,12 +294,6 @@ component SwatchSlider inherits Rectangle {
// and the gestures behind it (FR-UI-3). // and the gestures behind it (FR-UI-3).
height: Theme.touch-target / 2 + 4px; height: Theme.touch-target / 2 + 4px;
accessible-role: slider;
accessible-label: root.data.param-label;
accessible-value: Readout.of(root.data);
accessible-value-minimum: root.data.minimum;
accessible-value-maximum: root.data.maximum;
HorizontalLayout { HorizontalLayout {
padding-left: Theme.gap-sm; padding-left: Theme.gap-sm;
spacing: Theme.gap-sm; spacing: Theme.gap-sm;
@@ -284,6 +308,8 @@ component SwatchSlider inherits Rectangle {
SliderTrack { SliderTrack {
horizontal-stretch: 1; horizontal-stretch: 1;
label: root.data.param-label;
readout: Readout.of(root.data);
value: root.data.value; value: root.data.value;
default-value: root.data.default-value; default-value: root.data.default-value;
minimum: root.data.minimum; minimum: root.data.minimum;
@@ -346,6 +372,10 @@ component PlainSlider inherits Rectangle {
modified: root.value != root.default-value; modified: root.value != root.default-value;
SliderTrack { SliderTrack {
label: root.label;
readout: (Math.round(root.value * 10) / 10) + root.unit;
step: 0.1;
value: root.value; value: root.value;
default-value: root.default-value; default-value: root.default-value;
minimum: root.minimum; minimum: root.minimum;
@@ -575,16 +605,26 @@ export component GeometryPanel inherits Rectangle {
// Rotation and flips. Icons rather than labels: four controls // Rotation and flips. Icons rather than labels: four controls
// named in words would wrap the 280px column, and each of // named in words would wrap the 280px column, and each of
// these shows its own result. // these shows its own result.
//
// The words are still written, once each, as `label` — the width
// argument is about the column, and a screen reader has no column.
// The two flips also declare themselves checkable, which
// `IconButton` cannot do on its own: `active` says a toggle is on
// and says nothing at all about whether an inactive control is a
// toggle that is off, so only these call sites know that the two
// rotations are not toggles and these two are.
HorizontalLayout { HorizontalLayout {
spacing: Theme.gap-sm; spacing: Theme.gap-sm;
IconButton { IconButton {
icon: "rotate-ccw"; icon: "rotate-ccw";
label: "Rotate left";
enabled: root.enabled; enabled: root.enabled;
clicked => { root.rotate(-1); } clicked => { root.rotate(-1); }
} }
IconButton { IconButton {
icon: "rotate-cw"; icon: "rotate-cw";
label: "Rotate right";
enabled: root.enabled; enabled: root.enabled;
clicked => { root.rotate(1); } clicked => { root.rotate(1); }
} }
@@ -593,13 +633,19 @@ export component GeometryPanel inherits Rectangle {
IconButton { IconButton {
icon: "flip-h"; icon: "flip-h";
label: "Flip horizontally";
active: root.flip-h; active: root.flip-h;
accessible-checkable: true;
accessible-checked: root.flip-h;
enabled: root.enabled; enabled: root.enabled;
clicked => { root.flip-h-toggled(); } clicked => { root.flip-h-toggled(); }
} }
IconButton { IconButton {
icon: "flip-v"; icon: "flip-v";
label: "Flip vertically";
active: root.flip-v; active: root.flip-v;
accessible-checkable: true;
accessible-checked: root.flip-v;
enabled: root.enabled; enabled: root.enabled;
clicked => { root.flip-v-toggled(); } clicked => { root.flip-v-toggled(); }
} }
+129
View File
@@ -22,6 +22,16 @@
// primitives take plain numbers and strings, and the ParamRow-shaped wrappers // primitives take plain numbers and strings, and the ParamRow-shaped wrappers
// stay in the panel that owns the model. This is the constraint that makes the // stay in the panel that owns the model. This is the constraint that makes the
// file reusable, so it is worth stating rather than merely observing. // file reusable, so it is worth stating rather than merely observing.
//
// **Accessibility follows the same rule as behaviour** (NFR-A11Y-2, and see
// widgets.slint's preamble for the two Slint constraints that shape it): a
// control's role, and the actions assistive technology can invoke on it, are
// written once here. Its *name* is the one thing that cannot be — a track has
// no idea what number it is dragging — so every primitive below takes a
// `label`, and the wrappers that do know pass it down. An unnamed control is
// the failure mode that matters: a screen reader announcing "slider, 0.35" for
// each of the thirty-six controls in the colour mixer has told the user
// nothing at all.
import { Theme } from "theme.slint"; import { Theme } from "theme.slint";
import { Icon, Label, Value, Caption, Field } from "widgets.slint"; import { Icon, Label, Value, Caption, Field } from "widgets.slint";
@@ -49,6 +59,30 @@ export component SliderTrack inherits Rectangle {
in property <float> minimum; in property <float> minimum;
in property <float> maximum; in property <float> maximum;
/// What this track adjusts. The track's accessible name.
///
/// Every wrapper already draws this word somewhere — `ControlRow` puts it
/// above, `FieldRow` puts it above and to the left — and none of those
/// placements associates it with the control as far as the platform is
/// concerned. `SwatchSlider` is the case that makes the point: it draws no
/// word at all, only a coloured square, and its name has *always* had to
/// travel this way.
in property <string> label;
/// The value as the user should hear it, already formatted.
///
/// Empty falls back to the raw number, which is right for a track whose
/// caller has nothing better; a caller with a declared precision and a
/// unit hands over what it is drawing, so "+1.25 EV" is announced rather
/// than "1.2500000298".
///
/// A string rather than a float for the reason `ControlRow.readout` gives:
/// precision belongs to whoever owns the value, and a control that rounded
/// on its own would announce a parameter one way and draw it another.
in property <string> readout;
/// How far one assistive-technology nudge moves the value. Zero takes a
/// hundredth of the range, which is the resolution a drag has anyway.
in property <float> step: 0;
/// Live, once per movement. For anything that should follow the drag: a /// Live, once per movement. For anything that should follow the drag: a
/// readout, a preview, the image itself. /// readout, a preview, the image itself.
callback changed(float); callback changed(float);
@@ -76,6 +110,47 @@ export component SliderTrack inherits Rectangle {
// by nothing and put every position at infinity. // by nothing and put every position at infinity.
property <float> span: max(0.000001, root.maximum - root.minimum); property <float> span: max(0.000001, root.maximum - root.minimum);
property <float> nudge: root.step > 0 ? root.step : root.span / 100;
// **One nudge is a whole gesture, so it commits.**
//
// The two callbacks exist because a drag is many movements and one
// decision (see `committed` above). An arrow key pressed once is both at
// the same time: there is no stream to debounce and no release to wait
// for, so a nudge that only fired `changed` would move the photograph and
// never be saved by any caller that listens for the end of a drag — which
// is every settings-shaped caller in the application.
function move-to(v: float) {
root.changed(clamp(v, root.minimum, root.maximum));
root.committed(clamp(v, root.minimum, root.maximum));
}
// **The only route to this control that is not a pointer.** ui-navigation
// D-N2 rules out hover as the sole affordance; a control reachable only by
// dragging it is the same objection with the pointer itself as the
// modifier. These three actions are what AT-SPI and TalkBack drive a
// slider with, and they are what makes the track adjustable rather than
// merely readable.
accessible-role: slider;
accessible-label: root.label;
// Both arms of the ternary must be strings — the empty concatenation is
// what makes the fallback one.
accessible-value: root.readout != "" ? root.readout : (root.value + "");
accessible-value-minimum: root.minimum;
accessible-value-maximum: root.maximum;
accessible-value-step: root.nudge;
accessible-action-increment => { root.move-to(root.value + root.nudge); }
accessible-action-decrement => { root.move-to(root.value - root.nudge); }
accessible-action-set-value(v) => {
// `is-float()` for the reason `NumberField` gives at length:
// `to-float()` answers 0 for a string it could not parse, and a
// screen reader handing over "abc" would silently set the exposure to
// zero rather than reject the entry.
if (v.is-float()) {
root.move-to(v.to-float());
}
}
// **Why hover, and not the drag itself.** // **Why hover, and not the drag itself.**
// //
// A Flickable does not merely compete for a gesture, it *withholds* the // A Flickable does not merely compete for a gesture, it *withholds* the
@@ -258,6 +333,9 @@ export component NumberField inherits Rectangle {
in property <float> value; in property <float> value;
in property <float> minimum; in property <float> minimum;
in property <float> maximum; in property <float> maximum;
/// What the number means. Handed straight to the entry, which is where the
/// accessibility tree wants it — see `Field.label`.
in property <string> label;
/// Decimal places shown, and the precision an entry is held to. Zero for a /// Decimal places shown, and the precision an entry is held to. Zero for a
/// count, two for a value in stops — the same figure a parameter /// count, two for a value in stops — the same figure a parameter
/// descriptor declares. /// descriptor declares.
@@ -303,6 +381,7 @@ export component NumberField inherits Rectangle {
field := Field { field := Field {
width: 100%; width: 100%;
height: 100%; height: 100%;
label: root.label;
text <=> root.text; text <=> root.text;
// Committed on Enter *and* on losing focus, matching `TextRow`: Enter // Committed on Enter *and* on losing focus, matching `TextRow`: Enter
// alone loses the edit the moment the user clicks the next control, // alone loses the edit the moment the user clicks the next control,
@@ -344,6 +423,21 @@ export component Check inherits Rectangle {
callback toggled(bool); callback toggled(bool);
// The hint becomes the description rather than part of the name. It exists
// to say what a setting *costs* — "location is stripped", "upscaling is
// off" — which is the second thing a reader wants and never the first, and
// a name that carried it would read the whole sentence back on every pass
// through the page.
accessible-role: checkbox;
accessible-label: root.label;
accessible-description: root.hint;
accessible-checkable: true;
accessible-checked: root.checked;
accessible-action-default => {
root.checked = !root.checked;
root.toggled(root.checked);
}
height: max(row.preferred-height, Theme.control-height); height: max(row.preferred-height, Theme.control-height);
touch := TouchArea { touch := TouchArea {
@@ -408,6 +502,31 @@ export component ChoiceChip inherits Rectangle {
callback clicked(); callback clicked();
// `radio-button` and not `button`, because single-selection over a fixed
// list is what a radio button *is* — and the difference is audible: a
// reader announcing "radio button, selected" has told the user that
// picking another one will unpick this, which "button, pressed" has not.
// It is the same distinction the prose above draws against `FilterChip`,
// said in the vocabulary the platform already has a word for.
//
// **No `radio-group` around them.** The role exists, and the natural home
// for it — `Segmented` — is a `VerticalLayout` rather than the plain root
// Slint's own `RadioGroupBase` carries it on, and `Segmented` delegates to
// `ChipGrid` for a wrapped set, so the group would either sit on a layout
// element or be declared twice and nest. The group's name still reaches a
// reader: `FieldRow` draws it as a `Text`, which Slint exposes on its own,
// immediately before the chips in traversal order.
accessible-role: radio-button;
accessible-label: root.label;
accessible-enabled: root.enabled;
accessible-checkable: true;
accessible-checked: root.selected;
accessible-action-default => {
if (root.enabled) {
root.clicked();
}
}
height: Theme.control-height; height: Theme.control-height;
// Wide enough that a one-word label is still a comfortable target, which // Wide enough that a one-word label is still a comfortable target, which
// is what `control-min-width` exists for — but chips sit several to a row, // is what `control-min-width` exists for — but chips sit several to a row,
@@ -612,6 +731,7 @@ export component TextRow inherits VerticalLayout {
field := Field { field := Field {
width: 100%; width: 100%;
label: root.label;
text <=> root.text; text <=> root.text;
placeholder: root.placeholder; placeholder: root.placeholder;
// Committed on Enter *and* on losing focus. Enter alone loses // Committed on Enter *and* on losing focus. Enter alone loses
@@ -755,6 +875,14 @@ export component SliderRow inherits VerticalLayout {
// tall where the track is half of one. // tall where the track is half of one.
y: (parent.height - self.height) / 2; y: (parent.height - self.height) / 2;
label: root.label;
// A declared precision is a declared step — the argument above,
// reused. The nudge an assistive technology makes is therefore the
// same quantum a drag snaps to, so arrowing to a value and
// dragging to it produce the same number rather than two that
// differ in the last place.
step: 1.0 / root.step-factor;
value: root.live; value: root.live;
default-value: root.default-value; default-value: root.default-value;
minimum: root.minimum; minimum: root.minimum;
@@ -768,6 +896,7 @@ export component SliderRow inherits VerticalLayout {
NumberField { NumberField {
// Follows the drag, so the number and the handle never disagree. // Follows the drag, so the number and the handle never disagree.
value: root.live; value: root.live;
label: root.label;
minimum: root.minimum; minimum: root.minimum;
maximum: root.maximum; maximum: root.maximum;
precision: root.precision; precision: root.precision;
+8
View File
@@ -155,12 +155,19 @@ component FaceCell inherits Rectangle {
// judgement, and the two are never conflated. A // judgement, and the two are never conflated. A
// rejection is remembered, so the face is not suggested // rejection is remembered, so the face is not suggested
// for that person again. // for that person again.
// The gesture note above is the whole label: a tick and a cross
// are only "confirm" and "reject" to someone who can see the
// suggestion they sit beside, and `IconButton`'s fallback would
// announce them as "check" and "cross" — two icon names that say
// nothing about which person is being ruled on.
if !face.confirmed: IconButton { if !face.confirmed: IconButton {
icon: "check"; icon: "check";
label: "Confirm this face";
clicked => { root.confirm(); } clicked => { root.confirm(); }
} }
if !face.confirmed: IconButton { if !face.confirmed: IconButton {
icon: "cross"; icon: "cross";
label: "Reject this face";
clicked => { root.reject(); } clicked => { root.reject(); }
} }
if face.confirmed: Text { if face.confirmed: Text {
@@ -750,6 +757,7 @@ export component IdentityScreen inherits Rectangle {
Rectangle { } Rectangle { }
IconButton { IconButton {
icon: "rotate-cw"; icon: "rotate-cw";
label: "Recheck coverage";
clicked => { root.check-coverage(); } clicked => { root.check-coverage(); }
} }
} }
+75 -31
View File
@@ -7,6 +7,30 @@ import { Check } from "controls.slint";
// Deliberately separate from AppWindow. It is the first thing a user sees // Deliberately separate from AppWindow. It is the first thing a user sees
// with no library configured, and the place they return to in order to sign // with no library configured, and the place they return to in order to sign
// out or switch account (FR-NC-1, FR-NC-4). // out or switch account (FR-NC-1, FR-NC-4).
//
// **The first screen converted to `@tr()`** (NFR-A11Y-1), and this one first
// because it is the one a user cannot get past: an interface they cannot read
// is unusable here in a way it is not in a preferences page they could ignore.
// The mechanism, the extraction command and the reason a build with no
// translation behaves identically are in `build.rs`.
//
// Four kinds of string are deliberately *not* wrapped, and the distinction is
// worth stating because "wrap every literal" is the obvious rule and the wrong
// one:
//
// - **"DarkRoom".** A product name, the same in every language. Translating
// it invites a translator to answer the question, and there is no answer.
// - **Example values** — `https://cloud.example.com`, `/home/you/Pictures`,
// the app-password mask. These are shapes rather than sentences; a
// translated hostname would teach the wrong format.
// - **`".."`**, the row that leads out of a folder. A filesystem convention,
// not a word.
// - **`"/"`**, the path separator.
//
// The headings are wrapped and carry their own capitals — `PanelHeading` draws
// what it is given — so a translator supplies "SERVEUR" rather than "Serveur".
// That is a real cost of styling in the string, and it is recorded here rather
// than discovered by the first person to translate the screen.
// This screen's buttons are form actions in a single stacked column, not // This screen's buttons are form actions in a single stacked column, not
// chrome beside a photograph — they are given the full `touch-target` height // chrome beside a photograph — they are given the full `touch-target` height
@@ -17,11 +41,21 @@ component FormButton inherits Button {
} }
// One folder in the picker. The whole row is the target, not just the text. // One folder in the picker. The whole row is the target, not just the text.
//
// A `Rectangle` with a `TouchArea` over it is a button as far as the user is
// concerned and a decorated box as far as the platform is, so the name reaches
// a screen reader — the `Value` below is a `Text` — while the fact that it can
// be entered does not. Saying so is what makes the picker navigable rather
// than merely readable (NFR-A11Y-2).
component FolderRow inherits Rectangle { component FolderRow inherits Rectangle {
in property <string> label; in property <string> label;
in property <bool> is-parent: false; in property <bool> is-parent: false;
callback clicked(); callback clicked();
accessible-role: button;
accessible-label: root.label;
accessible-action-default => { root.clicked(); }
height: Theme.touch-target; height: Theme.touch-target;
background: touch.has-hover ? Theme.surface-raised : transparent; background: touch.has-hover ? Theme.surface-raised : transparent;
border-radius: 3px; border-radius: 3px;
@@ -141,8 +175,8 @@ export component LaunchScreen inherits Rectangle {
} }
Label { Label {
text: root.signed-in text: root.signed-in
? "Connected" ? @tr("Connected")
: "Connect a Nextcloud account, or open a folder"; : @tr("Connect a Nextcloud account, or open a folder");
body: true; body: true;
} }
} }
@@ -153,29 +187,36 @@ export component LaunchScreen inherits Rectangle {
if !root.signed-in && root.login-url == "": VerticalLayout { if !root.signed-in && root.login-url == "": VerticalLayout {
spacing: Theme.gap; spacing: Theme.gap;
PanelHeading { text: "SERVER"; } PanelHeading { text: @tr("SERVER"); }
server-input := Field { server-input := Field {
// The `PanelHeading` above each of these entries names
// it on screen and names nothing at all as far as the
// accessibility tree is concerned — Slint associates a
// heading with the control below it only if something
// says so. `Field.label` is that something, so the word
// is written twice on purpose.
label: @tr("Server address");
text: root.server-url; text: root.server-url;
placeholder: "https://cloud.example.com"; placeholder: "https://cloud.example.com";
accepted(url) => { root.sign-in(url); } accepted(url) => { root.sign-in(url); }
} }
if !root.can-remember: Caption { if !root.can-remember: Caption {
text: "No system keyring found — you will need to sign in each time."; text: @tr("No system keyring found — you will need to sign in each time.");
warn: true; warn: true;
wrap: word-wrap; wrap: word-wrap;
} }
FormButton { FormButton {
text: root.busy ? "Connecting…" : "Sign in"; text: root.busy ? @tr("Connecting…") : @tr("Sign in");
primary: true; primary: true;
enabled: !root.busy && server-input.text != ""; enabled: !root.busy && server-input.text != "";
clicked => { root.sign-in(server-input.text); } clicked => { root.sign-in(server-input.text); }
} }
Caption { Caption {
text: "Sign-in happens in your browser. DarkRoom never sees your password."; text: @tr("Sign-in happens in your browser. DarkRoom never sees your password.");
wrap: word-wrap; wrap: word-wrap;
} }
@@ -193,7 +234,7 @@ export component LaunchScreen inherits Rectangle {
background: Theme.rule; background: Theme.rule;
horizontal-stretch: 1; horizontal-stretch: 1;
} }
Caption { text: "or"; } Caption { text: @tr("or"); }
Rectangle { Rectangle {
height: 1px; height: 1px;
background: Theme.rule; background: Theme.rule;
@@ -201,14 +242,16 @@ export component LaunchScreen inherits Rectangle {
} }
} }
PanelHeading { text: "USERNAME"; } PanelHeading { text: @tr("USERNAME"); }
user-input := Field { user-input := Field {
label: @tr("Username");
text: ""; text: "";
placeholder: "your Nextcloud username"; placeholder: @tr("your Nextcloud username");
} }
PanelHeading { text: "APP PASSWORD"; } PanelHeading { text: @tr("APP PASSWORD"); }
pass-input := Field { pass-input := Field {
label: @tr("App password");
text: ""; text: "";
placeholder: "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"; placeholder: "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx";
secret: true; secret: true;
@@ -218,7 +261,7 @@ export component LaunchScreen inherits Rectangle {
} }
FormButton { FormButton {
text: root.busy ? "Connecting…" : "Connect directly"; text: root.busy ? @tr("Connecting…") : @tr("Connect directly");
enabled: !root.busy enabled: !root.busy
&& server-input.text != "" && server-input.text != ""
&& user-input.text != "" && user-input.text != ""
@@ -233,7 +276,7 @@ export component LaunchScreen inherits Rectangle {
} }
Caption { Caption {
text: "Create one in Nextcloud under Settings › Security › Devices & sessions. It is device-scoped and can be revoked on its own."; text: @tr("Create one in Nextcloud under Settings › Security › Devices & sessions. It is device-scoped and can be revoked on its own.");
wrap: word-wrap; wrap: word-wrap;
} }
@@ -251,7 +294,7 @@ export component LaunchScreen inherits Rectangle {
background: Theme.rule; background: Theme.rule;
horizontal-stretch: 1; horizontal-stretch: 1;
} }
Caption { text: "or"; } Caption { text: @tr("or"); }
Rectangle { Rectangle {
height: 1px; height: 1px;
background: Theme.rule; background: Theme.rule;
@@ -259,21 +302,22 @@ export component LaunchScreen inherits Rectangle {
} }
} }
PanelHeading { text: "FOLDER"; } PanelHeading { text: @tr("FOLDER"); }
folder-input := Field { folder-input := Field {
label: @tr("Library folder");
text: root.folder-path; text: root.folder-path;
placeholder: "/home/you/Pictures"; placeholder: "/home/you/Pictures";
accepted(path) => { root.use-folder(path); } accepted(path) => { root.use-folder(path); }
} }
FormButton { FormButton {
text: "Open folder"; text: @tr("Open folder");
enabled: !root.busy && folder-input.text != ""; enabled: !root.busy && folder-input.text != "";
clicked => { root.use-folder(folder-input.text); } clicked => { root.use-folder(folder-input.text); }
} }
Caption { Caption {
text: "Any folder this machine can read: a local disk, a network mount, or one your Nextcloud client already syncs. Nothing is uploaded and no password is needed."; text: @tr("Any folder this machine can read: a local disk, a network mount, or one your Nextcloud client already syncs. Nothing is uploaded and no password is needed.");
wrap: word-wrap; wrap: word-wrap;
} }
} }
@@ -282,7 +326,7 @@ export component LaunchScreen inherits Rectangle {
if root.login-url != "": VerticalLayout { if root.login-url != "": VerticalLayout {
spacing: Theme.gap; spacing: Theme.gap;
PanelHeading { text: "APPROVE IN YOUR BROWSER"; } PanelHeading { text: @tr("APPROVE IN YOUR BROWSER"); }
Panel { Panel {
Label { Label {
@@ -294,18 +338,18 @@ export component LaunchScreen inherits Rectangle {
} }
FormButton { FormButton {
text: "Copy link"; text: @tr("Copy link");
clicked => { root.copy-login-url(); } clicked => { root.copy-login-url(); }
} }
Caption { text: "Waiting for approval…"; } Caption { text: @tr("Waiting for approval…"); }
} }
// --- folder picker --- // --- folder picker ---
if root.signed-in && root.browsing: VerticalLayout { if root.signed-in && root.browsing: VerticalLayout {
spacing: Theme.gap; spacing: Theme.gap;
PanelHeading { text: "CHOOSE LIBRARY FOLDER"; } PanelHeading { text: @tr("CHOOSE LIBRARY FOLDER"); }
// Current location, so it is always clear what // Current location, so it is always clear what
// "Use this folder" would select. // "Use this folder" would select.
@@ -326,7 +370,7 @@ export component LaunchScreen inherits Rectangle {
border-color: Theme.rule; border-color: Theme.rule;
if root.browse-loading: Caption { if root.browse-loading: Caption {
text: "Loading…"; text: @tr("Loading…");
horizontal-alignment: center; horizontal-alignment: center;
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -357,7 +401,7 @@ export component LaunchScreen inherits Rectangle {
if !root.browse-loading && root.browse-entries.length == 0 if !root.browse-loading && root.browse-entries.length == 0
&& root.browse-path != "": Caption { && root.browse-path != "": Caption {
text: "No subfolders here"; text: @tr("No subfolders here");
horizontal-alignment: center; horizontal-alignment: center;
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -367,12 +411,12 @@ export component LaunchScreen inherits Rectangle {
HorizontalLayout { HorizontalLayout {
spacing: Theme.gap; spacing: Theme.gap;
FormButton { FormButton {
text: "Cancel"; text: @tr("Cancel");
horizontal-stretch: 1; horizontal-stretch: 1;
clicked => { root.browse-cancel(); } clicked => { root.browse-cancel(); }
} }
FormButton { FormButton {
text: "Use this folder"; text: @tr("Use this folder");
primary: true; primary: true;
horizontal-stretch: 1; horizontal-stretch: 1;
clicked => { root.browse-confirm(); } clicked => { root.browse-confirm(); }
@@ -384,22 +428,22 @@ export component LaunchScreen inherits Rectangle {
if root.signed-in && !root.browsing: VerticalLayout { if root.signed-in && !root.browsing: VerticalLayout {
spacing: Theme.gap; spacing: Theme.gap;
PanelHeading { text: "ACCOUNT"; } PanelHeading { text: @tr("ACCOUNT"); }
Value { text: root.account; } Value { text: root.account; }
Rectangle { height: Theme.gap-sm; } Rectangle { height: Theme.gap-sm; }
PanelHeading { text: "LIBRARY FOLDER"; } PanelHeading { text: @tr("LIBRARY FOLDER"); }
HorizontalLayout { HorizontalLayout {
spacing: Theme.gap; spacing: Theme.gap;
Value { Value {
text: root.library-root == "" ? "(not chosen)" : root.library-root; text: root.library-root == "" ? @tr("(not chosen)") : root.library-root;
placeholder: root.library-root == ""; placeholder: root.library-root == "";
horizontal-stretch: 1; horizontal-stretch: 1;
overflow: elide; overflow: elide;
} }
FormButton { FormButton {
text: "Choose…"; text: @tr("Choose…");
width: 110px; width: 110px;
clicked => { root.choose-folder(); } clicked => { root.choose-folder(); }
} }
@@ -407,7 +451,7 @@ export component LaunchScreen inherits Rectangle {
Rectangle { height: Theme.gap-sm; } Rectangle { height: Theme.gap-sm; }
PanelHeading { text: "SCAN FOR"; } PanelHeading { text: @tr("SCAN FOR"); }
for label[i] in root.format-labels: Check { for label[i] in root.format-labels: Check {
label: label; label: label;
@@ -418,13 +462,13 @@ export component LaunchScreen inherits Rectangle {
Rectangle { height: Theme.gap; } Rectangle { height: Theme.gap; }
FormButton { FormButton {
text: root.busy ? "Scanning…" : "Open library"; text: root.busy ? @tr("Scanning…") : @tr("Open library");
primary: true; primary: true;
enabled: !root.busy && root.library-root != ""; enabled: !root.busy && root.library-root != "";
clicked => { root.open-library(); } clicked => { root.open-library(); }
} }
FormButton { FormButton {
text: "Sign out"; text: @tr("Sign out");
clicked => { root.sign-out(); } clicked => { root.sign-out(); }
} }
} }
+20
View File
@@ -121,6 +121,26 @@ export component ToolRail inherits Rectangle {
root.picked(entry.on ? ViewMode.photo : tool.mode); root.picked(entry.on ? ViewMode.photo : tool.mode);
} }
// **The word below is drawn; this is what makes it a control.**
// Without these the rail reaches a screen reader as four pieces of
// static text — the labels get through, because a `Text` announces
// itself, and nothing says any of them can be pressed. That is the
// develop view's primary navigation reduced to a caption.
//
// `checkable` unconditionally, unlike `Button`'s: a rail entry is
// always a held-or-not state, so an unheld one should say "not
// pressed" rather than pass for an ordinary button. The action
// repeats the click handler rather than calling it, because a
// `TouchArea`'s `clicked` is raised by the pointer and cannot be
// raised from here.
accessible-role: button;
accessible-label: tool.label;
accessible-checkable: true;
accessible-checked: entry.on;
accessible-action-default => {
root.picked(entry.on ? ViewMode.photo : tool.mode);
}
// The lit tile, and the only marker there is. Inset from the // The lit tile, and the only marker there is. Inset from the
// rail's edges so the run of four reads as four things rather than // rail's edges so the run of four reads as four things rather than
// as one striped column. // as one striped column.
+147
View File
@@ -13,6 +13,21 @@
// `surface` is; only this file says what a *panel heading* is — and until it // `surface` is; only this file says what a *panel heading* is — and until it
// did, four screens each re-derived one, which is exactly how a single accent // did, four screens each re-derived one, which is exactly how a single accent
// colour reached forty call sites with no single place to change it. // colour reached forty call sites with no single place to change it.
//
// **The same argument decides where accessibility lives** (NFR-A11Y-2). What
// AT-SPI and TalkBack are handed — a role, a name, a value, and an action they
// can invoke — is a property of *what a control is*, not of where it happens
// to be used, so it is declared once here and at the call site only where the
// call site knows something the component cannot. A button annotated in forty
// places is a button unnamed in thirty-nine of them, which is the failure this
// file was written to stop, one layer down.
//
// Two Slint rules shape what that can look like. `accessible-role` must be a
// *constant* — a ternary over a runtime property is a compile error — so a
// component that would need two roles is two components. And every other
// `accessible-*` property is rejected unless a role is set beside it or on the
// element it inherits from; that inheritance is what lets a call site add
// `accessible-checkable` to a `Button` whose role was set here.
import { Theme } from "theme.slint"; import { Theme } from "theme.slint";
import { Icon } from "icons.slint"; import { Icon } from "icons.slint";
@@ -42,10 +57,31 @@ export component Button inherits Rectangle {
/// Sustained state — a toggle that is currently on, not a press. The same /// Sustained state — a toggle that is currently on, not a press. The same
/// meaning [`IconButton`] gives it, so a labelled toggle and an icon /// meaning [`IconButton`] gives it, so a labelled toggle and an icon
/// toggle read alike. /// toggle read alike.
///
/// **Deliberately not exposed to assistive technology from here.** Only
/// the call site knows whether a button that is currently *not* active is
/// a toggle that is off or an ordinary button that has no such state, and
/// announcing every button in the application as an unpressed toggle is
/// worse than announcing none of them. A call site that means a toggle
/// says so — `accessible-checkable: true; accessible-checked: <state>;` —
/// which Slint permits because the role below is inherited.
in property <bool> active: false; in property <bool> active: false;
callback clicked(); callback clicked();
accessible-role: button;
accessible-label: root.text;
accessible-enabled: root.enabled;
// The action a screen reader invokes, and it goes around the TouchArea
// rather than through it — so the `enabled` gate the TouchArea applies to
// a pointer has to be applied again here, or a disabled button would be
// pressable by exactly the users who cannot see that it is greyed out.
accessible-action-default => {
if (root.enabled) {
root.clicked();
}
}
height: Theme.control-height; height: Theme.control-height;
// A minimum rather than a fixed width: callers that set `width` or hand // A minimum rather than a fixed width: callers that set `width` or hand
// this to a stretching layout still win, and a long label is not clipped. // this to a stretching layout still win, and a long label is not clipped.
@@ -108,12 +144,40 @@ export component Button inherits Rectangle {
export component IconButton inherits Rectangle { export component IconButton inherits Rectangle {
/// A name from the [`Icon`] vocabulary. /// A name from the [`Icon`] vocabulary.
in property <string> icon; in property <string> icon;
/// What the drawing means, in words.
///
/// A `Button` gets its accessible name for nothing, out of the text it was
/// already drawing. This control draws no text at all, so the name has to
/// be given — and an icon button without one is not a degraded experience
/// for a screen-reader user, it is an unusable one.
///
/// Left empty it falls back to the icon's own name, which is a bad label
/// ("chevron-right") and still a better answer than silence. That is the
/// bargain `labels::resolve` already strikes for a key nobody has
/// catalogued, and it is struck here for the same reason: a control added
/// today should be reachable before someone has written its word.
in property <string> label;
in property <bool> enabled: true; in property <bool> enabled: true;
/// Sustained state — a toggle that is currently on, not a press. /// Sustained state — a toggle that is currently on, not a press.
///
/// Not announced from here, for the reason [`Button`]'s copy of this
/// property gives: the component cannot tell an off toggle from a button
/// with no state, so the call site that means a toggle sets
/// `accessible-checkable: true` and `accessible-checked` beside its
/// `active`.
in property <bool> active: false; in property <bool> active: false;
callback clicked(); callback clicked();
accessible-role: button;
accessible-label: root.label != "" ? root.label : root.icon;
accessible-enabled: root.enabled;
accessible-action-default => {
if (root.enabled) {
root.clicked();
}
}
width: Theme.control-height; width: Theme.control-height;
height: Theme.control-height; height: Theme.control-height;
horizontal-stretch: 0; horizontal-stretch: 0;
@@ -178,6 +242,21 @@ export component FilterChip inherits Rectangle {
callback clicked(); callback clicked();
// Unlike [`Button`], this one *can* say it is a toggle without asking the
// call site, because being one is the whole of what distinguishes it —
// "it is *state*, not an action", two paragraphs up. So `checkable` is
// unconditional and an inactive chip announces as unpressed rather than
// as an ordinary button.
//
// The count is not folded into the name. It is drawn as a `Text`, which
// Slint already exposes as its own node, so a reader reaches it by moving
// one step further rather than by hearing a bare number glued to a word.
accessible-role: button;
accessible-label: root.label;
accessible-checkable: true;
accessible-checked: root.active;
accessible-action-default => { root.clicked(); }
height: Theme.control-height - 4px; height: Theme.control-height - 4px;
// A floor on a content-sized chip, expressed as one property: Slint rejects // A floor on a content-sized chip, expressed as one property: Slint rejects
// `width` and `min-width` together, and the floor is what keeps a chip // `width` and `min-width` together, and the floor is what keeps a chip
@@ -304,6 +383,15 @@ export component Section inherits Rectangle {
/// Whether this section's contents can be reset at all. /// Whether this section's contents can be reset at all.
in property <bool> has-reset: true; in property <bool> has-reset: true;
accessible-role: groupbox;
accessible-label: root.title;
accessible-expandable: true;
accessible-expanded: root.expanded;
accessible-action-expand => {
root.expanded = !root.expanded;
root.toggled(root.expanded);
}
background: transparent; background: transparent;
// Own height comes from the layout below, so a collapsed section shrinks // Own height comes from the layout below, so a collapsed section shrinks
// to its header. // to its header.
@@ -372,6 +460,22 @@ export component Section inherits Rectangle {
Rectangle { Rectangle {
width: 28px; width: 28px;
// The reset is drawn only under the pointer, which
// ui-navigation.md D-N2 rules out as the *only* route to
// a control. Announcing it whenever it is live gives a
// screen reader the route the ink withholds — so this is
// not a translation of the visual affordance so much as
// the honest version of it, and the gate is `has-reset &&
// modified` rather than the hover the `Text` below adds.
accessible-role: button;
accessible-label: "Reset";
accessible-enabled: root.has-reset && root.modified;
accessible-action-default => {
if (root.has-reset && root.modified) {
root.op-reset();
}
}
reset-touch := TouchArea { reset-touch := TouchArea {
// Sits after `header-touch` in the tree, so it takes // Sits after `header-touch` in the tree, so it takes
// the press first and the section does not toggle out // the press first and the section does not toggle out
@@ -598,6 +702,20 @@ export component Panel inherits Rectangle {
// token is for. // token is for.
export component Field inherits Rectangle { export component Field inherits Rectangle {
in-out property <string> text; in-out property <string> text;
/// What this entry is for, in words.
///
/// The visible caption belongs to whatever row wraps the field — `TextRow`
/// draws one above, the launch screen draws one beside — and none of those
/// is a thing Slint associates with the entry on its own. So the name is
/// carried a second time, here, where the accessibility tree can attach it
/// to the control the user is actually typing into.
///
/// That second copy is not redundant even where the caption renders. It is
/// the *only* copy where the caption does not: `TextRow`'s label draws
/// behind its own field on the settings page and has done since 0.9.0, so
/// a sighted user reading that page today has less to go on than a screen
/// reader does.
in property <string> label;
in property <string> placeholder; in property <string> placeholder;
/// Masks the entry, for a credential that should not be readable over the /// Masks the entry, for a credential that should not be readable over the
/// user's shoulder. The placeholder still shows while the field is empty. /// user's shoulder. The placeholder still shows while the field is empty.
@@ -655,6 +773,13 @@ export component Field inherits Rectangle {
input := TextInput { input := TextInput {
text <=> root.text; text <=> root.text;
edited => { root.edited(self.text); } edited => { root.edited(self.text); }
// Slint gives a TextInput its role, its value, its enabled state and
// its set-value action for free; the name and the placeholder are the
// two it cannot guess. They go on the entry rather than on the box
// around it so there is one node in the tree and not a nameless
// rectangle wrapping a nameless input.
accessible-label: root.label;
accessible-placeholder-text: root.placeholder;
color: Theme.ink; color: Theme.ink;
font-size: Theme.text; font-size: Theme.text;
vertical-alignment: center; vertical-alignment: center;
@@ -677,6 +802,12 @@ export component Field inherits Rectangle {
x: Theme.gap; x: Theme.gap;
height: 100%; height: 100%;
visible: input.text == ""; visible: input.text == "";
// Drawn text, not content. The same words already reach the tree as
// the entry's `accessible-placeholder-text`, where a reader can
// announce them as a prompt rather than as a value the field holds —
// which is what a second text node beside an empty entry would look
// like. `lineedit-base.slint` in Slint's own widgets does exactly this.
accessible-role: none;
} }
} }
@@ -693,6 +824,22 @@ export component Field inherits Rectangle {
export component ProgressBar inherits Rectangle { export component ProgressBar inherits Rectangle {
in property <float> fraction: 0; in property <float> fraction: 0;
in property <bool> indeterminate: false; in property <bool> indeterminate: false;
/// What is progressing. A bar with no name announces as "progress
/// indicator, 40%", which says how far along an unnamed something is.
in property <string> label;
// An indeterminate bar reports no value at all rather than 0%. It has one
// — the sweep — but it is not a position, and a reader that announced 0%
// for a directory walk that is half done would be stating a falsehood in
// the one place the interface was careful not to (see the two modes
// above). Silence is the honest answer to "how far".
accessible-role: progress-indicator;
accessible-label: root.label;
accessible-value: root.indeterminate
? ""
: Math.round(clamp(root.fraction, 0, 1) * 100) + "%";
accessible-value-minimum: 0;
accessible-value-maximum: 100;
height: 3px; height: 3px;
background: Theme.rule; background: Theme.rule;