1use alloc::string::String;
4use alloc::vec::Vec;
5
6use denise::Pen;
7use denise::{ElementState, InputEvent, KeyCode, Point, Radius, Rect, Role};
8use denise_text::TextStyle;
9
10use crate::widget::{
11 Event, EventCtx, Handled, MeasureCtx, Measured, Offer, PaintCtx, VisualState, Widget,
12};
13use crate::widgets::describe::{
14 Describe, DynDescribe, Group, Mismatch, Payload, Property, PropertyKind, ROLES, Value,
15};
16use crate::widgets::style::{Align, draw_aligned, focus_ring, interactive_pair, muted};
17
18#[derive(Clone, Debug)]
70pub struct Select<M> {
71 options: Vec<String>,
72 selected: Option<usize>,
73 placeholder: String,
74 message: Option<M>,
75 role: Role,
76 style: TextStyle,
77}
78
79impl<M> Select<M> {
80 pub fn new(options: impl IntoIterator<Item = impl Into<String>>, message: M) -> Self {
82 Self {
83 options: options.into_iter().map(Into::into).collect(),
84 selected: None,
85 placeholder: String::from("—"),
86 message: Some(message),
87 role: Role::Base100,
88 style: TextStyle::built_in(16),
89 }
90 }
91
92 pub fn inert(options: impl IntoIterator<Item = impl Into<String>>) -> Self {
105 Self {
106 options: options.into_iter().map(Into::into).collect(),
107 selected: None,
108 placeholder: String::from("—"),
109 message: None,
110 role: Role::Base100,
111 style: TextStyle::built_in(16),
112 }
113 }
114
115 pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
117 self.placeholder = placeholder.into();
118 self
119 }
120
121 pub fn with_selected(mut self, index: Option<usize>) -> Self {
123 self.set_selected(index);
124 self
125 }
126
127 pub fn with_role(mut self, role: Role) -> Self {
129 self.role = role;
130 self
131 }
132
133 pub fn with_style(mut self, style: TextStyle) -> Self {
135 self.style = style;
136 self
137 }
138
139 #[inline]
141 pub const fn selected(&self) -> Option<usize> {
142 self.selected
143 }
144
145 #[inline]
147 pub fn selected_option(&self) -> Option<&str> {
148 self.options.get(self.selected?).map(String::as_str)
149 }
150
151 pub fn set_selected(&mut self, index: Option<usize>) {
155 self.selected = index.filter(|index| *index < self.options.len());
156 }
157
158 #[inline]
160 pub fn options(&self) -> &[String] {
161 &self.options
162 }
163
164 pub fn set_options(&mut self, options: impl IntoIterator<Item = impl Into<String>>) {
166 self.options = options.into_iter().map(Into::into).collect();
167 self.set_selected(self.selected);
168 }
169
170 pub fn set_style(&mut self, style: TextStyle) {
172 self.style = style;
173 }
174
175 #[inline]
177 pub const fn style(&self) -> TextStyle {
178 self.style
179 }
180
181 fn shown(&self) -> &str {
183 self.selected_option().unwrap_or(&self.placeholder)
184 }
185}
186
187#[inline]
189const fn padding(size_px: u16) -> i32 {
190 let half = size_px as i32 / 2;
191 if half < 4 { 4 } else { half }
192}
193
194fn chevron_box(bounds: Rect, pad: i32) -> Rect {
196 let side = (bounds.height / 3).clamp(1, bounds.width.max(1));
197 Rect::new(
198 bounds.right() - pad - side,
199 bounds.y + (bounds.height - side / 2) / 2,
200 side,
201 side / 2,
202 )
203}
204
205fn draw_chevron(canvas: &mut Pen<'_>, box_of: Rect, thickness: i32, color: denise::Color) {
211 if box_of.is_empty() {
212 return;
213 }
214 let tip = Point::new(box_of.x + box_of.width / 2, box_of.bottom());
215 let left = Point::new(box_of.x, box_of.y);
216 let right = Point::new(box_of.right(), box_of.y);
217 for offset in 0..thickness.max(1) {
218 let dy = offset;
219 canvas.draw_line(
220 Point::new(left.x, left.y + dy),
221 Point::new(tip.x, tip.y + dy),
222 color,
223 );
224 canvas.draw_line(
225 Point::new(tip.x, tip.y + dy),
226 Point::new(right.x, right.y + dy),
227 color,
228 );
229 }
230}
231
232impl<M: Clone + 'static> Widget<M> for Select<M> {
233 fn describe(&self) -> Option<&dyn DynDescribe> {
234 Some(self)
235 }
236
237 fn describe_mut(&mut self) -> Option<&mut dyn DynDescribe> {
238 Some(self)
239 }
240 fn measure(&self, ctx: &mut MeasureCtx<'_>, _offered: Offer) -> Measured {
241 let pad = padding(self.style.size_px);
244 let mut widest = ctx.text.measure_line(self.style, &self.placeholder);
247 for option in self.options() {
248 widest = widest.max(ctx.text.measure_line(self.style, option));
249 }
250 Measured::both(widest + pad * 4, ctx.theme.metrics.size_field.max(1))
251 }
252
253 fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
254 let bounds = ctx.bounds;
255 if bounds.is_empty() {
256 return;
257 }
258 let radius = ctx.theme.radius(Radius::Field);
259 let (surface, content) = interactive_pair(ctx.theme, self.role, ctx.state);
260 canvas.fill_rounded_rect(bounds, radius, surface);
261 canvas.stroke_rounded_rect(
262 bounds,
263 radius,
264 ctx.theme.metrics.border,
265 ctx.theme.color(Role::Base300),
266 );
267 if ctx.state.contains(VisualState::FOCUSED) {
268 focus_ring(ctx.theme, bounds, radius, canvas);
269 }
270
271 let pad = padding(self.style.size_px);
272 let chevron = chevron_box(bounds, pad);
273 draw_chevron(canvas, chevron, ctx.theme.metrics.border, content);
274
275 let colour = if self.selected.is_some() {
280 content
281 } else {
282 muted(surface, content)
283 };
284 let text = Rect::from_edges(
285 bounds.x + pad,
286 bounds.y,
287 (chevron.x - pad).max(bounds.x + pad),
288 bounds.bottom(),
289 );
290 if !text.is_empty() {
291 draw_aligned(
292 canvas,
293 ctx.text,
294 self.style,
295 text,
296 (Align::Start, Align::Center),
297 self.shown(),
298 colour,
299 );
300 }
301 }
302
303 fn on_event(&mut self, event: &Event<'_>, ctx: &mut EventCtx<'_, M>) -> Handled {
304 let opened = match event {
305 Event::Input(InputEvent::PointerButton {
306 state: ElementState::Up,
307 position,
308 ..
309 })
310 | Event::Input(InputEvent::TouchUp {
311 position,
312 cancelled: false,
313 ..
314 }) => ctx.bounds.contains(*position),
315 Event::Input(InputEvent::Key {
319 code: KeyCode::Enter | KeyCode::NumpadEnter | KeyCode::Space | KeyCode::ArrowDown,
320 state: ElementState::Down,
321 repeat: false,
322 ..
323 }) => ctx.state.contains(VisualState::FOCUSED),
324 _ => return Handled::No,
325 };
326 if !opened || self.options.is_empty() {
327 return Handled::No;
328 }
329 if let Some(message) = self.message.clone() {
330 ctx.emit(message);
331 }
332 Handled::Yes
333 }
334
335 fn accepts_pointer(&self) -> bool {
336 true
337 }
338
339 fn focusable(&self) -> bool {
341 !self.options.is_empty()
342 }
343}
344
345impl<M> Describe for Select<M> {
346 const KIND: &'static str = "select";
347 const DOC: &'static str = "One choice out of many, picked from a dropdown list.";
348 const GROUP: Group = Group::Input;
349 const ICON: &'static denise::icon::Icon = &super::icons::SELECT;
350
351 const PROPERTIES: &'static [Property] = &[
352 Property::new(
353 "option",
354 PropertyKind::List,
355 "The choices, as `option` child nodes. A dropdown's are usually the real ones.",
356 ),
357 Property::new(
358 "selected",
359 PropertyKind::Int {
360 min: 0,
361 max: i32::MAX,
362 },
363 "The chosen option. Without one, nothing is chosen and the placeholder shows.",
364 ),
365 Property::new(
366 "placeholder",
367 PropertyKind::Text,
368 "Shown while nothing is chosen.",
369 ),
370 Property::new(
371 "on-change",
372 PropertyKind::Message(Payload::None),
376 "Emitted when a choice is made; the application reads `selected` afterwards.",
377 ),
378 Property::new(
379 "role",
380 PropertyKind::Enum(ROLES),
381 "Colour role of the control's own surface.",
382 ),
383 Property::new(
384 "size",
385 PropertyKind::Int { min: 6, max: 96 },
386 "Text size in logical pixels.",
387 )
388 .in_pixels(),
389 ];
390
391 fn get(&self, name: &str) -> Option<Value> {
392 Some(match name {
393 "selected" => Value::Int(i32::try_from(self.selected?).unwrap_or(i32::MAX)),
396 "placeholder" => Value::text(self.placeholder.as_str()),
397 "role" => Value::role(self.role),
398 "size" => Value::Int(i32::from(self.style.size_px)),
399 _ => return None,
400 })
401 }
402
403 fn apply(&mut self, name: &str, value: Value) -> Result<(), Mismatch> {
404 match name {
405 "selected" => self.set_selected(Some(value.as_index()?)),
408 "placeholder" => self.placeholder = value.as_text()?,
409 "on-change" | "option" => return Err(Mismatch::Supplied),
413 "role" => self.role = value.as_role()?,
414 "size" => self.style.size_px = value.as_size()?,
415 _ => return Err(Mismatch::Unknown),
416 }
417 Ok(())
418 }
419}
420
421pub fn open_select<M: Clone + 'static>(
448 ui: &mut crate::Ui<M>,
449 select: crate::NodeId,
450 message: fn(usize) -> M,
451) -> Option<crate::NodeId> {
452 let widget = ui.widget::<Select<M>>(select)?;
453 let options: Vec<String> = widget.options().to_vec();
454 if options.is_empty() {
455 return None;
456 }
457 let style = widget.style();
458 let chosen = widget.selected();
459
460 let anchor = ui.bounds(select)?;
461 let row = ui.theme().metrics.size_field;
462 let widest = options
463 .iter()
464 .map(|option| ui.text_mut().measure_line(style, option))
465 .max()
466 .unwrap_or(0);
467
468 let pad = padding(style.size_px);
471 let width = anchor.width.max(widest + pad * 2);
472 let content = row * options.len() as i32;
473 let surface = ui.bounds(ui.root())?;
477 let room = (surface.bottom() - anchor.bottom()).max(anchor.y - surface.y) - POPUP_MARGIN;
478 let height = content.min((room / row).max(1) * row);
479
480 let container = ui.push_popup(
481 select,
482 denise::Size::new(width as u32, height as u32),
483 crate::Side::Below,
484 )?;
485 let viewport = ui.add(
489 container,
490 super::Panel::default(),
491 Rect::new(0, 0, width, height),
492 )?;
493 ui.set_scrollable(viewport, true);
494 let list = super::List::inert(options)
499 .on_activate(message)
500 .with_row_height(row)
501 .with_style(style)
502 .activate_on_click()
503 .with_selected(chosen);
504 let list = ui.add(viewport, list, Rect::new(0, 0, width, content))?;
505 ui.focus(Some(list));
508 if let Some(chosen) = chosen {
511 let y = row * chosen as i32 - (height - row) / 2;
512 ui.set_scroll(viewport, Point::new(0, y));
513 }
514 Some(container)
515}
516
517const POPUP_MARGIN: i32 = 8;
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524
525 fn select() -> Select<u8> {
526 Select::new(["Auto", "Manuell", "Av"], 1u8)
527 }
528
529 #[test]
537 fn an_inert_select_shows_a_choice_and_cannot_be_opened() {
538 let mut inert: Select<u8> = Select::inert(["Auto", "Manuell", "Av"]);
539 assert_eq!(inert.options().len(), 3);
540 assert_eq!(inert.selected(), None);
541
542 inert.set_selected(Some(2));
543 assert_eq!(inert.selected(), Some(2));
544 assert!(inert.focusable(), "an inert select is still readable");
545
546 assert!(select().message.is_some());
549 assert!(inert.message.is_none());
550 }
551
552 #[test]
554 fn it_shows_the_placeholder_until_something_is_chosen() {
555 let mut select = select().with_placeholder("Velg modus");
556 assert_eq!(select.shown(), "Velg modus");
557 assert_eq!(select.selected(), None);
558
559 select.set_selected(Some(1));
560 assert_eq!(select.shown(), "Manuell");
561 assert_eq!(select.selected_option(), Some("Manuell"));
562 }
563
564 #[test]
567 fn an_index_that_does_not_exist_chooses_nothing() {
568 let mut select = select();
569 select.set_selected(Some(9));
570 assert_eq!(select.selected(), None);
571
572 select.set_selected(Some(2));
573 select.set_options(["Bare én"]);
574 assert_eq!(select.selected(), None, "a shorter list drops it");
575 }
576
577 #[test]
579 fn an_empty_select_is_not_a_tab_stop() {
580 let empty: Select<u8> = Select::new(Vec::<String>::new(), 1u8);
581 assert!(!Widget::<u8>::focusable(&empty));
582 assert!(Widget::<u8>::focusable(&select()));
583 }
584
585 #[test]
587 fn the_chevron_stays_inside_the_control() {
588 for bounds in [
589 Rect::new(0, 0, 200, 36),
590 Rect::new(10, 10, 40, 20),
591 Rect::new(0, 0, 8, 8),
592 Rect::new(0, 0, 1, 1),
593 ] {
594 let box_of = chevron_box(bounds, 8);
595 assert!(box_of.width >= 0 && box_of.height >= 0, "{bounds:?}");
596 assert!(
597 box_of.right() <= bounds.right(),
598 "{bounds:?}: chevron {box_of:?} escaped right"
599 );
600 assert!(
601 box_of.y >= bounds.y && box_of.bottom() <= bounds.bottom(),
602 "{bounds:?}: chevron {box_of:?} escaped vertically"
603 );
604 }
605 }
606
607 #[test]
610 fn the_text_column_stops_before_the_chevron() {
611 let bounds = Rect::new(0, 0, 200, 36);
612 let pad = padding(16);
613 let chevron = chevron_box(bounds, pad);
614 let text = Rect::from_edges(
615 bounds.x + pad,
616 bounds.y,
617 (chevron.x - pad).max(bounds.x + pad),
618 bounds.bottom(),
619 );
620 assert!(text.width > 0);
621 assert!(
622 text.right() <= chevron.x,
623 "the text runs into the chevron: {text:?} {chevron:?}"
624 );
625 }
626
627 #[test]
630 fn the_placeholder_is_muted_but_still_readable() {
631 use denise::Theme;
632 use denise::theme::{AA_LARGE, contrast_x100};
633
634 for theme in Theme::BUILT_IN {
635 for state in [VisualState::NONE, VisualState::DISABLED] {
636 let (surface, content) = interactive_pair(&theme, Role::Base100, state);
637 let placeholder = muted(surface, content);
638 let ratio = contrast_x100(surface, placeholder);
639 assert!(
640 ratio >= AA_LARGE,
641 "{} {state:?}: placeholder is {ratio}, floor is {AA_LARGE}",
642 theme.name
643 );
644 }
645 let (surface, content) = interactive_pair(&theme, Role::Base100, VisualState::NONE);
647 assert_ne!(muted(surface, content), content, "{}", theme.name);
648 }
649 }
650}