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
@@ -295,7 +295,7 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Field<'_, Mes
color: if focus || cursor.is_over(layout.bounds()) {
style::TEAL
} else {
style::LINE
style::BOUNDARY
},
width: if focus { 2. } else { 1. },
radius: 5.into(),
@@ -708,7 +708,7 @@ impl<Message: Clone, R: text::Renderer<Font = Font>> overlay::Overlay<Message, T
renderer::Quad {
bounds: geometry.popup,
border: Border {
color: style::LINE,
color: style::BOUNDARY,
width: 1.,
radius: 8.into(),
},
@@ -751,6 +751,22 @@ impl<Message: Clone, R: text::Renderer<Font = Font>> overlay::Overlay<Message, T
},
Color::from_rgb8(218, 238, 235),
);
// Labels begin at row.x. Keep the target cue below their
// two text lines without changing measured or hit bounds.
if row.width > 4. && row.height > 3. {
renderer.fill_quad(
renderer::Quad {
bounds: Rectangle {
x: row.x + 2.,
y: row.y + row.height - 3.,
width: row.width - 4.,
height: 2.,
},
..Default::default()
},
style::TEAL,
);
}
}
let alias_width = (row.width * 0.42).min(
measure::<R>(
@@ -992,3 +1008,171 @@ fn raw_label<R: text::Renderer<Font = Font>>(
bounds,
);
}
#[cfg(test)]
mod paint_tests {
use super::super::{Candidate, Context, Current, Outcome};
use super::*;
use iced::advanced::renderer::Headless;
use iced_runtime::user_interface::{Cache, UserInterface};
use oa_core::Id;
fn named(key: keyboard::key::Named) -> Event {
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(key),
modified_key: keyboard::Key::Named(key),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Space),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
})
}
fn view(chooser: &Chooser, anchor: Rectangle) -> Element<'_, ChoiceEvent> {
Popup::new(
iced::widget::Space::new()
.width(Length::Fill)
.height(Length::Fill),
Some(chooser),
anchor,
std::convert::identity,
)
.into()
}
fn lower_rule_coverage(pixels: &[u8], row: Rectangle) -> 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)
}
};
let left = (row.x + 14.).ceil() as usize;
let right = (row.x + row.width - 14.).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 * 480 + x) * 4;
let pixel = &pixels[index..index + 4];
let luminance = 0.2126 * linear(pixel[0])
+ 0.7152 * linear(pixel[1])
+ 0.0722 * linear(pixel[2]);
1.05 / (luminance + 0.05) >= 3.
})
})
.count();
count as f32 / (right - left) as f32
}
#[tokio::test]
async fn mounted_chooser_keyboard_target_is_visible_and_enter_keeps_exact_identity() {
let mut renderer = <iced::Renderer as Headless>::new(Font::DEFAULT, 13.into(), None)
.await
.expect("custom chooser paint test requires WGPU headless rendering");
let context = Context::live(
Id::from_u128(1),
7,
Id::from_u128(2),
Id::from_u128(3),
"/body/SequenceUse/sequence",
false,
)
.unwrap();
let mut chooser = Chooser::open(
context.clone(),
[(13, "Gamma"), (12, "Beta"), (11, "Alpha")]
.into_iter()
.map(|(id, name)| Candidate {
id: Id::from_u128(id),
name: name.into(),
alias: format!("candidate_{id}"),
kind: "Function".into(),
scope: "SA".into(),
containment: "System / supply topology".into(),
})
.collect(),
Current::Unassigned,
"SA · compatible functions",
)
.unwrap();
let anchor = Rectangle {
x: 20.,
y: 20.,
width: 300.,
height: 30.,
};
let size = Size::new(480., 420.);
let geometry = Geometry::new(anchor, size);
let cursor = mouse::Cursor::Unavailable;
let theme = style::theme();
let paint_style = renderer::Style {
text_color: style::INK,
};
let mut cache = Cache::new();
let mut messages = vec![];
for active in 0..2 {
let mut ui = UserInterface::build(view(&chooser, anchor), size, cache, &mut renderer);
ui.draw(&mut renderer, &theme, &paint_style, cursor);
let pixels = Headless::screenshot(&mut renderer, Size::new(480, 420), 1., Color::WHITE);
for index in 0..3 {
let coverage = lower_rule_coverage(&pixels, geometry.row(index));
if index == active {
assert!(
coverage > 0.8,
"the actual keyboard candidate needs a continuous shape cue"
);
} else {
assert!(
coverage < 0.2,
"the previous and ordinary candidates must not retain the cue"
);
}
}
let events = if active == 0 {
vec![named(keyboard::key::Named::ArrowDown)]
} else {
vec![
named(keyboard::key::Named::Enter),
named(keyboard::key::Named::Enter),
]
};
let _ = ui.update(
&events,
cursor,
&mut renderer,
&mut iced::advanced::clipboard::Null,
&mut messages,
);
cache = ui.into_cache();
assert_eq!(
messages.len(),
1,
"one movement or one exact choice, with no repeated Enter"
);
let outcome = chooser.update(messages.pop().unwrap());
if active == 0 {
assert_eq!(outcome, Outcome::Browsed);
assert_eq!(chooser.highlighted().unwrap().id, Id::from_u128(12));
assert_eq!(
chooser.current,
Current::Unassigned,
"navigation cannot write a reference"
);
} else {
assert_eq!(
outcome,
Outcome::Write {
context: context.clone(),
value: Some(Id::from_u128(12))
}
);
assert!(!chooser.is_open());
}
}
}
}