Make custom overlay keyboard targets and controls distinct

This commit is contained in:
2026-09-12 17:42:34 -07:00
parent 05b613ea19
commit a357b22778
3 changed files with 335 additions and 3 deletions
+149 -1
View File
@@ -737,6 +737,25 @@ impl<Message: Clone> overlay::Overlay<Message, Theme, Renderer> for MenuOverlay<
},
Color::from_rgb8(218, 238, 235),
);
if row.width > 2. && row.height > 2. {
renderer.fill_quad(
renderer::Quad {
bounds: Rectangle {
x: row.x + 1.,
y: row.y + 1.,
width: row.width - 2.,
height: row.height - 2.,
},
border: Border {
color: style::TEAL,
width: 1.5,
radius: 4.into(),
},
..Default::default()
},
Color::TRANSPARENT,
);
}
}
let color = if message.is_some() {
style::INK
@@ -792,7 +811,7 @@ impl<Message: Clone> overlay::Overlay<Message, Theme, Renderer> for MenuOverlay<
},
..Default::default()
},
style::LINE,
style::BOUNDARY,
);
}
});
@@ -1103,6 +1122,135 @@ impl<'a, Message: 'a> From<Area<'a, Message>> for Element<'a, Message> {
mod tests {
use super::*;
fn pixel_contrast_on_white(pixel: &[u8]) -> f32 {
let linear = |byte: u8| {
let value = f32::from(byte) / 255.;
if value <= 0.04045 {
value / 12.92
} else {
((value + 0.055) / 1.055).powf(2.4)
}
};
1.05 / (0.2126 * linear(pixel[0])
+ 0.7152 * linear(pixel[1])
+ 0.0722 * linear(pixel[2])
+ 0.05)
}
fn target_rule_coverage(pixels: &[u8], width: usize, row: Rectangle) -> f32 {
// Only the lower padding, away from text, corners and scroll thumb.
// A pale fill or scattered text pixels cannot satisfy a continuous cue.
let left = (row.x + 14.).ceil() as usize;
let right = (row.x + row.width - 20.).floor() as usize;
let count = (left..right)
.filter(|x| {
((row.y + row.height - 4.).ceil() as usize
..(row.y + row.height - 1.).ceil() as usize)
.any(|y| {
let index = (y * width + x) * 4;
pixel_contrast_on_white(&pixels[index..index + 4]) >= 3.
})
})
.count();
count as f32 / (right - left) as f32
}
#[tokio::test]
async fn mounted_keyboard_menu_paints_target_and_scroll_thumb_then_invokes_that_action() {
use iced::advanced::{overlay::Overlay as _, renderer::Headless};
use iced_runtime::user_interface::{Cache, UserInterface};
let mut renderer = <Renderer as Headless>::new(iced::Font::DEFAULT, 13.into(), None)
.await
.expect("custom menu paint test requires WGPU headless rendering");
let mut menu = menu();
menu.anchor = Point::new(20., 20.);
menu.entries
.extend((30..70).map(|id| Entry::action(format!("Other action {id}"), id)));
let size = Size::new(480., 420.);
let mut measured_state = State::default();
let mut measured = MenuOverlay {
menu: &menu,
state: &mut measured_state,
};
let node = measured.layout(&renderer, size);
let geometry = measured.geometry(Layout::new(&node));
let row = |index: usize| {
geometry.row(
menu.entries[..index].iter().map(Entry::height).sum(),
menu.entries[index].height(),
0.,
)
};
let thumb = geometry.scroll_thumb(0.).expect("fixture must overflow");
let mut ui = UserInterface::build(
Popup::new(
iced::widget::Space::new()
.width(Length::Fill)
.height(Length::Fill),
Some(menu.clone()),
),
size,
Cache::new(),
&mut renderer,
);
let cursor = mouse::Cursor::Unavailable;
let mut messages = vec![];
let theme = style::theme();
let paint_style = renderer::Style {
text_color: style::INK,
};
for (movement, active, inactive) in
[(None, 1, 4), (Some(keyboard::key::Named::ArrowDown), 4, 1)]
{
if let Some(movement) = movement {
let _ = ui.update(
&[key(movement)],
cursor,
&mut renderer,
&mut iced::advanced::clipboard::Null,
&mut messages,
);
assert!(
messages.is_empty(),
"moving the target must not invoke an action"
);
}
ui.draw(&mut renderer, &theme, &paint_style, cursor);
let pixels = Headless::screenshot(&mut renderer, Size::new(480, 420), 1., Color::WHITE);
assert!(
target_rule_coverage(&pixels, 480, row(active)) > 0.8,
"the keyboard target needs a continuous high-contrast shape cue"
);
for index in [inactive, 2, 5] {
assert!(
target_rule_coverage(&pixels, 480, row(index)) < 0.2,
"ordinary and disabled rows must not acquire the target cue"
);
}
let point = thumb.center();
let index = (point.y.floor() as usize * 480 + point.x.floor() as usize) * 4;
assert!(
pixel_contrast_on_white(&pixels[index..index + 4]) >= 3.,
"the actual overflow thumb must be visible without hover"
);
}
let _ = ui.update(
&[
key(keyboard::key::Named::Enter),
key(keyboard::key::Named::Enter),
],
cursor,
&mut renderer,
&mut iced::advanced::clipboard::Null,
&mut messages,
);
assert_eq!(
messages,
[22],
"Enter invokes exactly the painted Properties target once"
);
}
#[test]
fn actions_area_does_not_swallow_first_independent_click_after_overlay_consumed_its_release() {
let release = Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left));