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
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
//! Does a disabled surface read as **receded**, on both appearances?
//!
//! # The defect this pins
//!
//! Several controls dim a disabled surface by blending it toward a **fixed white**
//! (`base_bg.blend(&Color::WHITE, 0.35)`). On a dark appearance that moves the surface *up* in
//! luminance, i.e. away from the window behind it — so the disabled control is painted **more**
//! prominently than the enabled one, which is the opposite of the state it is meant to express.
//! The pattern was measured at `1.13:1` enabled versus `3.63:1` disabled against the window, i.e.
//! three times the prominence, while on the light appearance it barely moved at all.
//!
//! The crate's own shared weight and derivation already fix this: step the surface toward its
//! **own contrast colour** (`BaseWidget::disabled_ink_on`), which recedes on both appearances.
//!
//! # The assertion
//!
//! Prominence is measured as the surface's contrast **against the window fill** it sits on: a
//! disabled control must be *no more* prominent than the same control enabled, on both
//! appearances. A control with no separate surface is skipped.
//!
//! Run with:
//! cargo test --no-default-features --features desktop --test disabled_surface_probe -- --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;
use rust_widgets::widget::WidgetFactory;
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)
}
/// The control's own surface, as painted.
///
/// # Why this needs a tolerance
///
/// A control's surface is not always its whole rectangle: `search_bar` reserves a band for its
/// button, so the field is narrower than the control. The match is therefore "a full-height,
/// full-width-or-nearly fill", and the widest candidate wins. Matching the exact box alone
/// silently reported "no surface" for that control -- which reads as a pass, and is the kind of
/// silent skip this probe exists to avoid.
fn surface_of(svg: &str, width: u32, height: u32) -> Option<Color> {
let full_height = format!("height=\"{height}\"");
let mut best: Option<(u32, Color)> = None;
for line in svg.lines() {
if !line.contains("fill=\"") || !line.contains(&full_height) {
continue;
}
let Some(rest) = line.split("fill=\"").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 || p[3] != "1.00" {
continue;
}
let (Ok(r), Ok(g), Ok(b)) = (p[0].parse(), p[1].parse(), p[2].parse()) else {
continue;
};
let c = Color::rgb(r, g, b);
// The renderer's canvas plate is the same box and is written first.
if c.r == 0 && c.g == 0 && c.b == 0 {
continue;
}
let w: u32 = line
.split("width=\"")
.nth(1)
.and_then(|s| s.split('"').next())
.and_then(|s| s.parse().ok())
.unwrap_or(0);
// At least three quarters of the control's width: wide enough to be the field rather than
// a glyph run or an icon plate.
if w * 4 < width * 3 {
continue;
}
if best.is_none_or(|(bw, _)| w > bw) {
best = Some((w, c));
}
}
best.map(|(_, c)| c)
}
fn render(
name: &str,
geometry: Rect,
disabled: bool,
appearance: AppearanceMode,
) -> Option<(String, Color)> {
rust_widgets::theme::global_theme_manager().set_appearance(appearance);
// The window fill is read **here**, immediately after the appearance is set, so it belongs to
// the same appearance as the render below. Reading it in the caller after both renders had
// already run returned the *other* appearance's window -- which silently inverted every
// contrast figure and made the assertion compare a surface against the wrong page.
let window = rust_widgets::style::theme_manager()
.current_theme()
.map(|active| active.colors.background)
.unwrap_or(Color::BLACK);
let factory = WidgetFactory::new_with_defaults();
let mut w = factory.create(name, geometry, "x")?;
if disabled {
factory.write_property(w.as_mut(), "enabled", CapabilityValue::Bool(false)).ok()?;
}
let drawable = draw_of(w.as_mut()).expect("draw bridge");
Some((render_widget_to_svg_on(drawable, geometry, Color::BLACK), window))
}
#[test]
fn probe_a_disabled_surface_recedes_on_both_appearances() {
let _guard = rust_widgets::theme::theme_test_guard();
// # Which controls this can measure
//
// Only the ones whose field is painted without extra input. The four media shells
// (`animated_image`, `lottie_widget`, `rive_widget`, `hero_animation`) paint that surface
// **inside** their loaded-content branch, so with no frames loaded they draw their empty state
// instead and the surface this probe is meant to check is never painted. They are listed
// separately and asserted **unmeasured**, so a silent skip cannot read as a pass -- which is
// how the empty-state plate was mistaken for the shell on the first run.
let cases: &[(&str, u32, u32)] = &[
("search_bar", 240, 40),
("masked_edit", 240, 40),
("search_box", 240, 40),
("tag_input", 240, 40),
("navigation_drawer", 200, 160),
// `hero_animation` paints its shell unconditionally, so its disabled branch is reachable
// without supplying content -- confirmed by the surface differing between the two states.
("hero_animation", 200, 120),
];
let needs_content: &[(&str, u32, u32)] =
&[("animated_image", 200, 120), ("lottie_widget", 200, 120), ("rive_widget", 200, 120)];
for (name, w, h) in cases {
let geometry = Rect::new(0, 0, *w, *h);
for appearance in [AppearanceMode::Dark, AppearanceMode::Light] {
let (Some((on_svg, window)), Some((off_svg, window_off))) = (
render(name, geometry, false, appearance),
render(name, geometry, true, appearance),
) else {
continue;
};
assert_eq!(
window, window_off,
"the appearance must not change between the two renders"
);
// The surface is the fill whose rectangle **is** the field. Reported as a list so the
// reader can confirm which one was matched -- a guess was wrong three times here.
let on = surface_of(&on_svg, *w, *h);
let off = surface_of(&off_svg, *w, *h);
println!("[{name}/{appearance:?}] window rgba({},{},{})", window.r, window.g, window.b);
for (tag, c) in [("enabled ", on), ("disabled", off)] {
match c {
Some(c) => println!(
" {tag} surface rgba({},{},{}) -> vs window {:.2}:1",
c.r,
c.g,
c.b,
contrast(c, window)
),
None => println!(" {tag} no surface matched"),
}
}
let (Some(on), Some(off)) = (on, off) else {
panic!(
"{name} painted no surface this probe can identify, so its disabled \
recede is **unmeasured** rather than verified"
);
};
let (p_on, p_off) = (contrast(on, window), contrast(off, window));
assert!(
p_off <= p_on + 0.01,
"{name}'s disabled surface is MORE prominent than its enabled one on the \
{appearance:?} appearance ({p_off:.2}:1 disabled vs {p_on:.2}:1 enabled). A \
disabled fill must recede toward the page; this is the \
`base_bg.blend(&Color::WHITE, 0.35)` defect, where a dark surface is moved up \
toward white and away from the window it sits in."
);
}
}
// The shells: asserted to be unmeasurable *here*, with the reason, rather than quietly
// omitted. Their disabled surface needs loaded content, so a probe that cannot supply frames,
// a Lottie document or a Rive document cannot reach the branch.
for (name, w, h) in needs_content {
let geometry = Rect::new(0, 0, *w, *h);
let (Some((on_svg, _)), Some((off_svg, _))) = (
render(name, geometry, false, AppearanceMode::Dark),
render(name, geometry, true, AppearanceMode::Dark),
) else {
continue;
};
assert_eq!(
surface_of(&on_svg, *w, *h),
surface_of(&off_svg, *w, *h),
"{name} has no loaded content, so it paints its empty state in both states: its \
disabled shell is **not** exercised and must not be reported as passing"
);
}
}