use alloc::string::String;
use alloc::vec::Vec;
use denise::{ElementState, InputEvent, KeyCode, Point, Radius, Rect, Role};
use denise_render::Canvas;
use denise_text::TextStyle;
use crate::widget::{Event, EventCtx, Handled, PaintCtx, VisualState, Widget};
use crate::widgets::style::{Align, draw_aligned, focus_ring, interactive_pair, muted};
#[derive(Clone, Debug)]
pub struct Select<M> {
options: Vec<String>,
selected: Option<usize>,
placeholder: String,
message: Option<M>,
role: Role,
style: TextStyle,
}
impl<M> Select<M> {
pub fn new(options: impl IntoIterator<Item = impl Into<String>>, message: M) -> Self {
Self {
options: options.into_iter().map(Into::into).collect(),
selected: None,
placeholder: String::from("—"),
message: Some(message),
role: Role::Base100,
style: TextStyle::built_in(16),
}
}
pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
self.placeholder = placeholder.into();
self
}
pub fn with_selected(mut self, index: Option<usize>) -> Self {
self.set_selected(index);
self
}
pub fn with_role(mut self, role: Role) -> Self {
self.role = role;
self
}
pub fn with_style(mut self, style: TextStyle) -> Self {
self.style = style;
self
}
#[inline]
pub const fn selected(&self) -> Option<usize> {
self.selected
}
#[inline]
pub fn selected_option(&self) -> Option<&str> {
self.options.get(self.selected?).map(String::as_str)
}
pub fn set_selected(&mut self, index: Option<usize>) {
self.selected = index.filter(|index| *index < self.options.len());
}
#[inline]
pub fn options(&self) -> &[String] {
&self.options
}
pub fn set_options(&mut self, options: impl IntoIterator<Item = impl Into<String>>) {
self.options = options.into_iter().map(Into::into).collect();
self.set_selected(self.selected);
}
pub fn set_style(&mut self, style: TextStyle) {
self.style = style;
}
#[inline]
pub const fn style(&self) -> TextStyle {
self.style
}
fn shown(&self) -> &str {
self.selected_option().unwrap_or(&self.placeholder)
}
}
#[inline]
const fn padding(size_px: u16) -> i32 {
let half = size_px as i32 / 2;
if half < 4 { 4 } else { half }
}
fn chevron_box(bounds: Rect, pad: i32) -> Rect {
let side = (bounds.height / 3).clamp(1, bounds.width.max(1));
Rect::new(
bounds.right() - pad - side,
bounds.y + (bounds.height - side / 2) / 2,
side,
side / 2,
)
}
fn draw_chevron(canvas: &mut Canvas<'_>, box_of: Rect, thickness: i32, color: denise::Color) {
if box_of.is_empty() {
return;
}
let tip = Point::new(box_of.x + box_of.width / 2, box_of.bottom());
let left = Point::new(box_of.x, box_of.y);
let right = Point::new(box_of.right(), box_of.y);
for offset in 0..thickness.max(1) {
let dy = offset;
canvas.draw_line(
Point::new(left.x, left.y + dy),
Point::new(tip.x, tip.y + dy),
color,
);
canvas.draw_line(
Point::new(tip.x, tip.y + dy),
Point::new(right.x, right.y + dy),
color,
);
}
}
impl<M: Clone + 'static> Widget<M> for Select<M> {
fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Canvas<'_>) {
let bounds = ctx.bounds;
if bounds.is_empty() {
return;
}
let radius = ctx.theme.radius(Radius::Field);
let (surface, content) = interactive_pair(ctx.theme, self.role, ctx.state);
canvas.fill_rounded_rect(bounds, radius, surface);
canvas.stroke_rounded_rect(
bounds,
radius,
ctx.theme.metrics.border,
ctx.theme.color(Role::Base300),
);
if ctx.state.contains(VisualState::FOCUSED) {
focus_ring(ctx.theme, bounds, radius, canvas);
}
let pad = padding(self.style.size_px);
let chevron = chevron_box(bounds, pad);
draw_chevron(canvas, chevron, ctx.theme.metrics.border, content);
let colour = if self.selected.is_some() {
content
} else {
muted(surface, content)
};
let text = Rect::from_edges(
bounds.x + pad,
bounds.y,
(chevron.x - pad).max(bounds.x + pad),
bounds.bottom(),
);
if !text.is_empty() {
draw_aligned(
canvas,
ctx.text,
self.style,
text,
(Align::Start, Align::Center),
self.shown(),
colour,
);
}
}
fn on_event(&mut self, event: &Event<'_>, ctx: &mut EventCtx<'_, M>) -> Handled {
let opened = match event {
Event::Input(InputEvent::PointerButton {
state: ElementState::Up,
position,
..
})
| Event::Input(InputEvent::TouchUp {
position,
cancelled: false,
..
}) => ctx.bounds.contains(*position),
Event::Input(InputEvent::Key {
code: KeyCode::Enter | KeyCode::NumpadEnter | KeyCode::Space | KeyCode::ArrowDown,
state: ElementState::Down,
repeat: false,
..
}) => ctx.state.contains(VisualState::FOCUSED),
_ => return Handled::No,
};
if !opened || self.options.is_empty() {
return Handled::No;
}
if let Some(message) = self.message.clone() {
ctx.emit(message);
}
Handled::Yes
}
fn accepts_pointer(&self) -> bool {
true
}
fn focusable(&self) -> bool {
!self.options.is_empty()
}
}
pub fn open_select<M: Clone + 'static>(
ui: &mut crate::Ui<M>,
select: crate::NodeId,
message: fn(usize) -> M,
) -> Option<crate::NodeId> {
let widget = ui.widget::<Select<M>>(select)?;
let options: Vec<String> = widget.options().to_vec();
if options.is_empty() {
return None;
}
let style = widget.style();
let chosen = widget.selected();
let anchor = ui.bounds(select)?;
let row = ui.theme().metrics.size_field;
let widest = options
.iter()
.map(|option| ui.text_mut().measure_line(style, option))
.max()
.unwrap_or(0);
let pad = padding(style.size_px);
let width = anchor.width.max(widest + pad * 2);
let height = row * options.len() as i32;
let container = ui.push_popup(
select,
denise::Size::new(width as u32, height as u32),
crate::Side::Below,
)?;
ui.add(
container,
super::Panel::default(),
Rect::new(0, 0, width, height),
)?;
let list = super::List::inert(options)
.on_activate(message)
.with_row_height(row)
.with_style(style)
.activate_on_click()
.with_selected(chosen);
let list = ui.add(container, list, Rect::new(0, 0, width, height))?;
ui.focus(Some(list));
Some(container)
}
#[cfg(test)]
mod tests {
use super::*;
fn select() -> Select<u8> {
Select::new(["Auto", "Manuell", "Av"], 1u8)
}
#[test]
fn it_shows_the_placeholder_until_something_is_chosen() {
let mut select = select().with_placeholder("Velg modus");
assert_eq!(select.shown(), "Velg modus");
assert_eq!(select.selected(), None);
select.set_selected(Some(1));
assert_eq!(select.shown(), "Manuell");
assert_eq!(select.selected_option(), Some("Manuell"));
}
#[test]
fn an_index_that_does_not_exist_chooses_nothing() {
let mut select = select();
select.set_selected(Some(9));
assert_eq!(select.selected(), None);
select.set_selected(Some(2));
select.set_options(["Bare én"]);
assert_eq!(select.selected(), None, "a shorter list drops it");
}
#[test]
fn an_empty_select_is_not_a_tab_stop() {
let empty: Select<u8> = Select::new(Vec::<String>::new(), 1u8);
assert!(!Widget::<u8>::focusable(&empty));
assert!(Widget::<u8>::focusable(&select()));
}
#[test]
fn the_chevron_stays_inside_the_control() {
for bounds in [
Rect::new(0, 0, 200, 36),
Rect::new(10, 10, 40, 20),
Rect::new(0, 0, 8, 8),
Rect::new(0, 0, 1, 1),
] {
let box_of = chevron_box(bounds, 8);
assert!(box_of.width >= 0 && box_of.height >= 0, "{bounds:?}");
assert!(
box_of.right() <= bounds.right(),
"{bounds:?}: chevron {box_of:?} escaped right"
);
assert!(
box_of.y >= bounds.y && box_of.bottom() <= bounds.bottom(),
"{bounds:?}: chevron {box_of:?} escaped vertically"
);
}
}
#[test]
fn the_text_column_stops_before_the_chevron() {
let bounds = Rect::new(0, 0, 200, 36);
let pad = padding(16);
let chevron = chevron_box(bounds, pad);
let text = Rect::from_edges(
bounds.x + pad,
bounds.y,
(chevron.x - pad).max(bounds.x + pad),
bounds.bottom(),
);
assert!(text.width > 0);
assert!(
text.right() <= chevron.x,
"the text runs into the chevron: {text:?} {chevron:?}"
);
}
#[test]
fn the_placeholder_is_muted_but_still_readable() {
use denise::Theme;
use denise::theme::{AA_LARGE, contrast_x100};
for theme in Theme::BUILT_IN {
for state in [VisualState::NONE, VisualState::DISABLED] {
let (surface, content) = interactive_pair(&theme, Role::Base100, state);
let placeholder = muted(surface, content);
let ratio = contrast_x100(surface, placeholder);
assert!(
ratio >= AA_LARGE,
"{} {state:?}: placeholder is {ratio}, floor is {AA_LARGE}",
theme.name
);
}
let (surface, content) = interactive_pair(&theme, Role::Base100, VisualState::NONE);
assert_ne!(muted(surface, content), content, "{}", theme.name);
}
}
}