use alloc::string::String;
use alloc::vec::Vec;
use denise::Pen;
use denise::{ElementState, InputEvent, KeyCode, Point, Radius, Rect, Role};
use denise_text::TextStyle;
use crate::widget::{
Event, EventCtx, Handled, MeasureCtx, Measured, Offer, PaintCtx, VisualState, Widget,
};
use crate::widgets::describe::{
Describe, DynDescribe, Group, Mismatch, Payload, Property, PropertyKind, ROLES, Value,
};
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 inert(options: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self {
options: options.into_iter().map(Into::into).collect(),
selected: None,
placeholder: String::from("—"),
message: None,
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 Pen<'_>, 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 describe(&self) -> Option<&dyn DynDescribe> {
Some(self)
}
fn describe_mut(&mut self) -> Option<&mut dyn DynDescribe> {
Some(self)
}
fn measure(&self, ctx: &mut MeasureCtx<'_>, _offered: Offer) -> Measured {
let pad = padding(self.style.size_px);
let mut widest = ctx.text.measure_line(self.style, &self.placeholder);
for option in self.options() {
widest = widest.max(ctx.text.measure_line(self.style, option));
}
Measured::both(widest + pad * 4, ctx.theme.metrics.size_field.max(1))
}
fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
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()
}
}
impl<M> Describe for Select<M> {
const KIND: &'static str = "select";
const DOC: &'static str = "One choice out of many, picked from a dropdown list.";
const GROUP: Group = Group::Input;
const ICON: &'static denise::icon::Icon = &super::icons::SELECT;
const PROPERTIES: &'static [Property] = &[
Property::new(
"option",
PropertyKind::List,
"The choices, as `option` child nodes. A dropdown's are usually the real ones.",
),
Property::new(
"selected",
PropertyKind::Int {
min: 0,
max: i32::MAX,
},
"The chosen option. Without one, nothing is chosen and the placeholder shows.",
),
Property::new(
"placeholder",
PropertyKind::Text,
"Shown while nothing is chosen.",
),
Property::new(
"on-change",
PropertyKind::Message(Payload::None),
"Emitted when a choice is made; the application reads `selected` afterwards.",
),
Property::new(
"role",
PropertyKind::Enum(ROLES),
"Colour role of the control's own surface.",
),
Property::new(
"size",
PropertyKind::Int { min: 6, max: 96 },
"Text size in logical pixels.",
)
.in_pixels(),
];
fn get(&self, name: &str) -> Option<Value> {
Some(match name {
"selected" => Value::Int(i32::try_from(self.selected?).unwrap_or(i32::MAX)),
"placeholder" => Value::text(self.placeholder.as_str()),
"role" => Value::role(self.role),
"size" => Value::Int(i32::from(self.style.size_px)),
_ => return None,
})
}
fn apply(&mut self, name: &str, value: Value) -> Result<(), Mismatch> {
match name {
"selected" => self.set_selected(Some(value.as_index()?)),
"placeholder" => self.placeholder = value.as_text()?,
"on-change" | "option" => return Err(Mismatch::Supplied),
"role" => self.role = value.as_role()?,
"size" => self.style.size_px = value.as_size()?,
_ => return Err(Mismatch::Unknown),
}
Ok(())
}
}
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 content = row * options.len() as i32;
let surface = ui.bounds(ui.root())?;
let room = (surface.bottom() - anchor.bottom()).max(anchor.y - surface.y) - POPUP_MARGIN;
let height = content.min((room / row).max(1) * row);
let container = ui.push_popup(
select,
denise::Size::new(width as u32, height as u32),
crate::Side::Below,
)?;
let viewport = ui.add(
container,
super::Panel::default(),
Rect::new(0, 0, width, height),
)?;
ui.set_scrollable(viewport, true);
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(viewport, list, Rect::new(0, 0, width, content))?;
ui.focus(Some(list));
if let Some(chosen) = chosen {
let y = row * chosen as i32 - (height - row) / 2;
ui.set_scroll(viewport, Point::new(0, y));
}
Some(container)
}
const POPUP_MARGIN: i32 = 8;
#[cfg(test)]
mod tests {
use super::*;
fn select() -> Select<u8> {
Select::new(["Auto", "Manuell", "Av"], 1u8)
}
#[test]
fn an_inert_select_shows_a_choice_and_cannot_be_opened() {
let mut inert: Select<u8> = Select::inert(["Auto", "Manuell", "Av"]);
assert_eq!(inert.options().len(), 3);
assert_eq!(inert.selected(), None);
inert.set_selected(Some(2));
assert_eq!(inert.selected(), Some(2));
assert!(inert.focusable(), "an inert select is still readable");
assert!(select().message.is_some());
assert!(inert.message.is_none());
}
#[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);
}
}
}