1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
//! Ground-truth measurement of disabled **text** contrast, on real pixels.
//!
//! The ranking script is a *model*; this is a *measurement*. It exists because the model has
//! already been wrong once (an inverted blend direction reported an already-fixed control as
//! broken). Every site is confirmed here before it is changed.
//!
//! Run with:
//! cargo test --no-default-features --features desktop --test disabled_text_contrast -- --nocapture
// This probe needs the full widget registry and the theme layer; the reduced
// `mini`/`embedded` profiles compile both out, so the file is gated rather than
// rewritten to avoid APIs those profiles do not have.
#![cfg(all(full_widgets, feature = "desktop"))]
use rust_widgets::core::{Color, Rect};
use rust_widgets::theme::AppearanceMode;
use rust_widgets::widget::capability::types::CapabilityValue;
use rust_widgets::widget::draw_bridge::draw_of;
use rust_widgets::widget::svg::render_widget_to_svg_on;
fn luminance(c: Color) -> f32 {
let f = |v: u8| {
let s = v as f32 / 255.0;
if s <= 0.039_28 {
s / 12.92
} else {
((s + 0.055) / 1.055).powf(2.4)
}
};
0.2126 * f(c.r) + 0.7152 * f(c.g) + 0.0722 * f(c.b)
}
fn contrast(a: Color, b: Color) -> f32 {
let (la, lb) = (luminance(a), luminance(b));
let (hi, lo) = if la > lb { (la, lb) } else { (lb, la) };
(hi + 0.05) / (lo + 0.05)
}
fn fills(svg: &str) -> Vec<Color> {
let mut out = Vec::new();
for line in svg.lines() {
for attr in ["fill=\"", "stroke=\""] {
let Some(rest) = line.split(attr).nth(1) else { continue };
let Some(v) = rest.split('"').next() else { continue };
let Some(inner) = v.strip_prefix("rgba(").and_then(|x| x.strip_suffix(')')) else {
continue;
};
let p: Vec<&str> = inner.split(',').map(|s| s.trim()).collect();
if p.len() != 4 {
continue;
}
let (Ok(r), Ok(g), Ok(b)) = (p[0].parse(), p[1].parse(), p[2].parse()) else {
continue;
};
let a: f32 = p[3].parse().unwrap_or(1.0);
let alpha = if a <= 1.0 { (a * 255.0).round() as u8 } else { a.round() as u8 };
out.push(Color::rgba(r, g, b, alpha));
}
}
out
}
/// Renders a control at the given appearance, optionally disabled, and reports every painted
/// colour's contrast against the most likely panel (the largest opaque non-canvas fill).
fn survey(
name: &str,
geometry: Rect,
disabled: bool,
appearance: AppearanceMode,
) -> Vec<(String, f32)> {
rust_widgets::theme::global_theme_manager().set_appearance(appearance);
let factory = rust_widgets::widget::WidgetFactory::new_with_defaults();
let Some(mut w) = factory.create(name, geometry, "x") else {
println!(" {name}: not constructible");
return Vec::new();
};
if disabled {
// Through the factory's public write path, which is what a host manifest applies.
factory
.write_property(w.as_mut(), "enabled", CapabilityValue::Bool(false))
.expect("enabled is writable");
}
let drawable = draw_of(w.as_mut()).expect("draw bridge");
let svg = render_widget_to_svg_on(drawable, geometry, Color::BLACK);
let painted = fills(&svg);
// The panel: the first opaque, non-black, non-canvas colour (the control's own face).
// # Against which colour is an ink measured?
//
// A control can paint **several** regions (a header band over a content area, a field inside a
// panel), and an ink is only meaningful against the region it is actually drawn on. Comparing
// every colour against one "panel" reported a header ink as 1.21:1 when the header it sits on
// gives it 6.08:1 -- a probe artefact, not a defect.
//
// So each colour is measured against every *other* colour in the picture, and reports the best
// contrast it has against any of them. An ink legible on some surface the control draws is
// legible; one legible on none of the others is the defect. The `c != other` guard is what
// keeps a colour from being compared with itself (which would read 1.00:1 and swamp the report).
let mut out = Vec::new();
for c in &painted {
if (c.r == 0 && c.g == 0 && c.b == 0) || c.a < 200 {
continue;
}
let best = painted
.iter()
.filter(|other| **other != *c && other.a >= 200)
.map(|other| contrast(*c, *other))
.fold(0.0_f32, f32::max);
out.push((format!("rgba({},{},{},{})", c.r, c.g, c.b, c.a), best));
}
out
}
#[test]
fn probe_disabled_text_contrast_on_real_pixels() {
let _guard = rust_widgets::theme::theme_test_guard();
let cases: &[(&str, u32, u32)] = &[
("collapsible_pane", 240, 120),
("app_bar", 320, 64),
("action", 160, 40),
("sparkline", 200, 60),
("progress_circle", 64, 64),
];
// # The controls whose *disabled text* was below the body-text floor
//
// Only these owe 4.5:1. A chart series or a progress arc is a **graphic on the window**, not
// text on a panel: `sparkline`'s disabled line is the fixed `DISABLED_FOREGROUND` and measures
// 7.37:1 against either window fill, which is why the fixed grey is not universally wrong --
// it fails specifically as *panel text*, where it cannot follow the appearance.
let text_controls = ["collapsible_pane", "app_bar"];
for (name, w, h) in cases {
let geometry = Rect::new(0, 0, *w, *h);
for appearance in [AppearanceMode::Dark, AppearanceMode::Light] {
let rows = survey(name, geometry, true, appearance);
let worst = rows.iter().map(|(_, r)| *r).fold(f32::INFINITY, f32::min);
let worst_text = if worst.is_finite() {
format!("{worst:.2}:1")
} else {
"(no distinct ink painted)".to_string()
};
println!("[{name} / {appearance:?} disabled] worst = {worst_text}");
for (c, r) in &rows {
println!(" {c} -> {r:.2}:1");
}
if text_controls.contains(name) {
assert!(
worst >= 4.5,
"{name}'s disabled text measured {worst:.2}:1 on the {appearance:?} \
appearance, under the 4.5:1 body-text floor. This is the defect the probe \
pins: `collapsible_pane` blended its panel half-way toward its own ink \
(1.03:1) and `app_bar` used the appearance-blind `DISABLED_FOREGROUND` \
(2.48:1 on light)"
);
}
}
}
}