1use std::ops::Range;
2
3use omp_core::Str;
4use smallvec::SmallVec;
5
6use super::{
7 layout::{grid_measure, place_grid_row, solve_columns},
8 table::TableCell,
9};
10use crate::{
11 component::{
12 Cached, Component, EventCtx, Flow, Hit, HitTag, IntoChildren, PaintCtx, Slot, next_slot,
13 },
14 context::{Theme, UiContext},
15 frame::{Rect, Style},
16 input::{Key, Mouse, UiEvent, sanitize_paste, word_rubout_start},
17 props::{Prop, PropValue, Props},
18 rich::cell_width,
19};
20
21pub struct SelectOption {
23 props: Props,
24 label: Str,
25 preview: Vec<Cached>,
26 cells: SmallVec<Cached, 8>,
27}
28
29impl SelectOption {
30 pub fn new() -> Self {
32 Self {
33 props: Props::new(),
34 label: Str::default(),
35 preview: Vec::new(),
36 cells: SmallVec::new(),
37 }
38 }
39
40 pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
42 self.props.set(prop, value);
43 self
44 }
45
46 pub fn with_str(mut self, prop: Prop, value: &str) -> Self {
48 self.props.set(prop, value);
49 self
50 }
51
52 pub fn label(mut self, label: impl Into<Str>) -> Self {
54 let label = label.into();
55 if self.label.is_empty() {
56 self.label = label;
57 } else {
58 self.label = Str::from(format!("{}{}", self.label, label));
59 }
60 self
61 }
62
63 pub fn cell(mut self, cell: TableCell) -> Self {
66 self.cells.push(Cached::new(Box::new(cell)));
67 self
68 }
69
70 pub fn child(mut self, child: impl IntoChildren) -> Self {
72 child.extend_children(&mut self.preview);
73 self
74 }
75}
76
77impl Default for SelectOption {
78 fn default() -> Self {
79 Self::new()
80 }
81}
82
83struct OptionData {
84 label: Str,
85 value: Str,
86 desc: Option<Str>,
87 recommended: bool,
88 preview: Range<usize>,
89 cells: Range<usize>,
91 custom: bool,
92}
93
94#[derive(Clone, Copy, Default)]
95struct OptionLayout {
96 top: u16,
97 height: u16,
98}
99
100#[derive(Default)]
101struct SelectState {
102 options: Vec<OptionData>,
103 layouts: SmallVec<OptionLayout, 8>,
104 multi: bool,
105 filter: bool,
106 cursor: u16,
107 chosen: smol_bitmap::SmolBitmap,
108 custom_text: String,
109 editing: bool,
110 filter_q: String,
111 searching: bool,
112 scroll: u16,
113 header_rows: u16,
114 page: u16,
116}
117
118impl SelectState {
119 fn visible(&self) -> SmallVec<u16, 16> {
122 if self.filter_q.is_empty() {
123 return (0..self.options.len() as u16).collect();
124 }
125 let mut scored: SmallVec<(i32, u16), 16> = (0..self.options.len() as u16)
126 .filter_map(|index| {
127 fuzzy_score(&self.options[usize::from(index)].label, &self.filter_q)
128 .map(|score| (-score, index))
129 })
130 .collect();
131 scored.sort_unstable();
132 scored.into_iter().map(|(_, index)| index).collect()
133 }
134
135 const fn types_to_filter(&self) -> bool {
139 self.filter && !self.multi
140 }
141}
142
143pub struct Select {
145 props: Props,
146 slot: Slot,
147 state: SelectState,
148 children: Vec<Cached>,
149}
150
151impl Select {
152 const GUTTER: u16 = 2;
154
155 pub fn new() -> Self {
157 Self {
158 props: Props::new(),
159 slot: next_slot(),
160 state: SelectState::default(),
161 children: Vec::new(),
162 }
163 }
164
165 #[allow(dead_code, reason = "acceptance-suite probe")]
166 pub(crate) fn visible_len(&self) -> usize {
167 self.state.visible().len()
168 }
169
170 pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
172 self.props.set(prop, value);
173 self.sync_prop(prop);
174 self
175 }
176
177 pub fn with_str(mut self, prop: Prop, value: &str) -> Self {
179 self.props.set(prop, value);
180 self.sync_prop(prop);
181 self
182 }
183
184 pub fn option(mut self, option: SelectOption) -> Self {
186 let cells_start = self.children.len();
187 self.children.extend(option.cells);
188 let cells = cells_start..self.children.len();
189 let preview_start = self.children.len();
190 self.children.extend(option.preview);
191 let preview = preview_start..self.children.len();
192 let label = if option.label.is_empty() {
193 option
194 .props
195 .str_of(Prop::Label)
196 .cloned()
197 .unwrap_or_default()
198 } else {
199 option.label
200 };
201 let data = OptionData {
202 value: option
203 .props
204 .str_of(Prop::Value)
205 .cloned()
206 .unwrap_or_else(|| label.clone()),
207 desc: option.props.str_of(Prop::Desc).cloned(),
208 recommended: option.props.flag(Prop::Recommended),
209 custom: false,
210 label,
211 preview,
212 cells,
213 };
214 let at = self
215 .state
216 .options
217 .iter()
218 .position(|candidate| candidate.custom)
219 .unwrap_or(self.state.options.len());
220 self.insert_option(at, data);
221 self
222 }
223
224 fn sync_prop(&mut self, prop: Prop) {
225 match prop {
226 Prop::Multi => {
227 self.state.multi = self.props.flag(Prop::Multi);
228 if self.state.multi {
229 self.state.chosen = smol_bitmap::SmolBitmap::new();
230 } else {
231 self.choose_recommended();
232 }
233 },
234 Prop::Filter => match self.props.get(Prop::Filter) {
237 Some(PropValue::Bool(enabled)) => self.state.filter = *enabled,
238 Some(PropValue::Str(seed)) => {
239 self.state.filter = true;
240 self.state.filter_q = seed.as_str().to_owned();
241 },
242 _ => self.state.filter = false,
243 },
244 Prop::Custom => self.set_custom(self.props.flag(Prop::Custom)),
245 _ => {},
246 }
247 }
248
249 fn insert_option(&mut self, at: usize, option: OptionData) {
250 let mut chosen = smol_bitmap::SmolBitmap::new();
251 for index in &self.state.chosen {
252 chosen.set(if index >= at { index + 1 } else { index }, true);
253 }
254 let recommended = option.recommended;
255 self.state.options.insert(at, option);
256 self.state.layouts.insert(at, OptionLayout::default());
257 self.state.chosen = chosen;
258 if recommended && !self.state.multi && self.state.chosen.iter().next().is_none() {
259 self.state.chosen.set(at, true);
260 }
261 }
262
263 fn remove_option(&mut self, at: usize) {
264 self.state.options.remove(at);
265 self.state.layouts.remove(at);
266 let mut chosen = smol_bitmap::SmolBitmap::new();
267 for index in &self.state.chosen {
268 if index < at {
269 chosen.set(index, true);
270 } else if index > at {
271 chosen.set(index - 1, true);
272 }
273 }
274 self.state.chosen = chosen;
275 self.state.cursor = self
276 .state
277 .cursor
278 .min(self.state.options.len().saturating_sub(1) as u16);
279 }
280
281 fn set_custom(&mut self, enabled: bool) {
282 let current = self.state.options.iter().position(|option| option.custom);
283 match (enabled, current) {
284 (true, None) => {
285 let end = self.children.len();
286 self.insert_option(self.state.options.len(), OptionData {
287 label: Str::from("Other (type your own)"),
288 value: Str::default(),
289 desc: None,
290 recommended: false,
291 preview: end..end,
292 cells: end..end,
293 custom: true,
294 });
295 },
296 (false, Some(index)) => self.remove_option(index),
297 _ => {},
298 }
299 }
300
301 fn choose_recommended(&mut self) {
302 if self.state.chosen.iter().next().is_some() {
303 return;
304 }
305 if let Some(index) = self
306 .state
307 .options
308 .iter()
309 .position(|option| option.recommended)
310 {
311 self.state.chosen.set(index, true);
312 }
313 }
314
315 fn header_rows(&self) -> u16 {
316 u16::from(self.props.str_of(Prop::Label).is_some()) + u16::from(self.state.filter)
317 }
318
319 fn cell_gap(&self) -> u16 {
321 if self.props.get(Prop::Gap).is_some() {
322 self.props.gap()
323 } else {
324 2
325 }
326 }
327
328 fn cell_spans(&self) -> SmallVec<Range<usize>, 16> {
332 self
333 .state
334 .options
335 .iter()
336 .filter(|option| !option.cells.is_empty())
337 .map(|option| option.cells.clone())
338 .collect()
339 }
340
341 fn solve_cells(&mut self, ctx: &UiContext, width: u16) -> SmallVec<u16, 8> {
343 let spans = self.cell_spans();
344 if spans.is_empty() {
345 return SmallVec::new();
346 }
347 let gap = self.cell_gap();
348 solve_columns(ctx, &mut self.children, &spans, width.saturating_sub(Self::GUTTER), gap)
349 }
350
351 fn option_height(&mut self, ctx: &UiContext, width: u16, index: usize, columns: &[u16]) -> u16 {
352 let desc_rows = self.state.options[index]
353 .desc
354 .as_ref()
355 .map_or(0, |desc| desc_lines(desc, width.saturating_sub(6)).len() as u16);
356 let cells = self.state.options[index].cells.clone();
357 let row = if cells.is_empty() {
358 1
359 } else {
360 cells
361 .enumerate()
362 .map(|(column, cell)| {
363 let cell_width = columns.get(column).copied().unwrap_or(1).max(1);
364 self.children[cell].height(ctx, cell_width)
365 })
366 .max()
367 .unwrap_or(1)
368 .max(1)
369 };
370 let preview = self.state.options[index].preview.clone();
371 let preview_h = self.children[preview]
372 .iter_mut()
373 .filter(|child| child.visible)
374 .fold(0u16, |height, child| {
375 height.saturating_add(child.height(ctx, width.saturating_sub(8)))
376 });
377 row.saturating_add(desc_rows).saturating_add(preview_h)
378 }
379
380 fn cursor_value(&self) -> Option<Str> {
382 let visible = self.state.visible();
383 let &index = visible.get(usize::from(self.state.cursor))?;
384 let option = &self.state.options[usize::from(index)];
385 Some(if option.custom {
386 Str::from(self.state.custom_text.as_str())
387 } else {
388 option.value.clone()
389 })
390 }
391
392 fn highlight_flow(&self) -> Flow {
394 match (self.props.id(), self.cursor_value()) {
395 (Some(id), Some(value)) => Flow::Event(UiEvent::Highlighted { id: id.clone(), value }),
396 _ => Flow::Consumed,
397 }
398 }
399
400 fn filter_flow(&mut self) -> Flow {
403 let count = self.state.visible().len();
404 self.state.cursor = self.state.cursor.min(count.saturating_sub(1) as u16);
405 match self.props.id() {
406 Some(id) => Flow::Event(UiEvent::Filtered {
407 id: id.clone(),
408 query: Str::from(self.state.filter_q.as_str()),
409 value: self.cursor_value(),
410 }),
411 None => Flow::Consumed,
412 }
413 }
414
415 fn move_cursor(&mut self, delta: i64, wrap: bool) -> bool {
419 let count = self.state.visible().len() as i64;
420 if count == 0 {
421 return false;
422 }
423 let at = i64::from(self.state.cursor);
424 let next = if wrap {
425 (at + delta).rem_euclid(count)
426 } else {
427 (at + delta).clamp(0, count - 1)
428 };
429 if next == at {
430 return false;
431 }
432 self.state.cursor = next as u16;
433 true
434 }
435
436 fn dispatch(&mut self, key: Key) -> Flow {
441 let visible = self.state.visible();
442 if self.state.editing {
443 match key {
444 Key::Enter => self.state.editing = false,
445 Key::Esc => {
446 self.state.editing = false;
447 self.state.custom_text.clear();
448 },
449 Key::Backspace => {
450 self.state.custom_text.pop();
451 },
452 Key::Space => self.state.custom_text.push(' '),
453 Key::Char(character) => self.state.custom_text.push(character),
454 Key::Ctrl('u') => self.state.custom_text.clear(),
455 Key::Ctrl('w') => {
456 let end = self.state.custom_text.len();
457 self
458 .state
459 .custom_text
460 .truncate(word_rubout_start(&self.state.custom_text, end));
461 },
462 _ => {},
463 }
464 return Flow::Consumed;
465 }
466 let typing = self.state.types_to_filter() || self.state.searching;
467 if typing && !matches!(key, Key::Up | Key::Down) {
468 match key {
469 Key::Char(character) => {
470 self.state.filter_q.push(character);
471 return self.filter_flow();
472 },
473 Key::Space if self.state.types_to_filter() => {
474 self.state.filter_q.push(' ');
475 return self.filter_flow();
476 },
477 Key::Backspace => {
478 if self.state.filter_q.pop().is_none() {
479 self.state.searching = false;
480 return Flow::Consumed;
481 }
482 return self.filter_flow();
483 },
484 Key::Ctrl('u') if !self.state.filter_q.is_empty() => {
485 self.state.filter_q.clear();
486 return self.filter_flow();
487 },
488 Key::Ctrl('w') if !self.state.filter_q.is_empty() => {
489 let end = self.state.filter_q.len();
490 self
491 .state
492 .filter_q
493 .truncate(word_rubout_start(&self.state.filter_q, end));
494 return self.filter_flow();
495 },
496 Key::Esc if !self.state.filter_q.is_empty() => {
499 self.state.filter_q.clear();
500 self.state.searching = false;
501 return self.filter_flow();
502 },
503 Key::Esc | Key::Enter if self.state.searching => {
504 self.state.searching = false;
505 return Flow::Consumed;
506 },
507 _ => {},
508 }
509 }
510 match key {
511 Key::Up if !visible.is_empty() => {
512 if self.move_cursor(-1, self.state.filter) {
513 self.highlight_flow()
514 } else {
515 Flow::Skip
516 }
517 },
518 Key::Down if !visible.is_empty() => {
519 if self.move_cursor(1, self.state.filter) {
520 self.highlight_flow()
521 } else {
522 Flow::Skip
523 }
524 },
525 Key::PageUp | Key::PageDown if !visible.is_empty() => {
526 let stride = i64::from(self.state.page.max(1));
527 let delta = if key == Key::PageUp { -stride } else { stride };
528 if self.move_cursor(delta, false) {
529 self.highlight_flow()
530 } else {
531 Flow::Consumed
532 }
533 },
534 Key::Home | Key::End if !visible.is_empty() => {
535 let delta = i64::from(u16::MAX);
536 let delta = if key == Key::Home { -delta } else { delta };
537 if self.move_cursor(delta, false) {
538 self.highlight_flow()
539 } else {
540 Flow::Consumed
541 }
542 },
543 Key::Enter if !visible.is_empty() => {
544 let position = usize::from(self.state.cursor.min(visible.len() as u16 - 1));
545 self.commit(visible[position])
546 },
547 Key::Space if !visible.is_empty() && !self.state.types_to_filter() => {
548 let position = usize::from(self.state.cursor.min(visible.len() as u16 - 1));
549 self.commit(visible[position])
550 },
551 Key::Char('/') if self.state.filter && !self.state.types_to_filter() => {
552 self.state.searching = true;
553 Flow::Consumed
554 },
555 Key::Esc if !self.state.filter_q.is_empty() => {
556 self.state.filter_q.clear();
557 self.filter_flow()
558 },
559 _ => Flow::Skip,
560 }
561 }
562
563 fn activate(&mut self, index: u16) {
564 let index = usize::from(index);
565 if self.state.multi {
566 let current = self.state.chosen.get(index);
567 self.state.chosen.set(index, !current);
568 } else {
569 self.state.chosen = smol_bitmap::SmolBitmap::new();
570 self.state.chosen.set(index, true);
571 }
572 if self.state.options[index].custom && self.state.chosen.get(index) {
573 self.state.editing = true;
574 }
575 }
576
577 fn commit(&mut self, index: u16) -> Flow {
579 self.activate(index);
580 if self.state.editing {
581 return Flow::Consumed;
584 }
585 match self.props.id() {
586 Some(id) => {
587 let option = &self.state.options[usize::from(index)];
588 let value = if option.custom {
589 Str::from(self.state.custom_text.as_str())
590 } else {
591 option.value.clone()
592 };
593 Flow::Event(UiEvent::Changed { id: id.clone(), value })
594 },
595 None => Flow::Consumed,
596 }
597 }
598
599 fn paint_option_tail(
601 &mut self,
602 pc: &mut PaintCtx<'_>,
603 rect: Rect,
604 index: usize,
605 layout: OptionLayout,
606 ) {
607 let option = &self.state.options[index];
608 if let Some(desc) = &option.desc {
609 let preview_h: u16 = self.children[option.preview.clone()]
610 .iter()
611 .filter(|child| child.visible)
612 .map(|child| child.rect.height)
613 .sum();
614 let base = layout
615 .height
616 .saturating_sub(preview_h)
617 .saturating_sub(desc_lines(desc, rect.width.saturating_sub(6)).len() as u16)
618 .max(1);
619 for (line_index, line) in desc_lines(desc, rect.width.saturating_sub(6))
620 .iter()
621 .enumerate()
622 {
623 let line_y = layout.top.saturating_add(base + line_index as u16);
624 if line_y < pc.clip {
625 pc.frame
626 .put(rect.x.saturating_add(6), line_y, line, dim(&pc.ctx.theme));
627 }
628 }
629 }
630 let preview = self.state.options[index].preview.clone();
631 for child in &mut self.children[preview] {
632 if !child.visible {
633 continue;
634 }
635 let stem_x = rect.x.saturating_add(6);
636 for line_y in child.rect.y..child.rect.y.saturating_add(child.rect.height) {
637 if line_y < pc.clip {
638 pc.frame.put(
639 stem_x,
640 line_y,
641 pc.ctx.charset.icon(crate::Icon::PreviewRail),
642 dim(&pc.ctx.theme),
643 );
644 }
645 }
646 child.paint(pc);
647 }
648 }
649}
650
651impl Default for Select {
652 fn default() -> Self {
653 Self::new()
654 }
655}
656
657impl Component for Select {
658 fn props(&self) -> &Props {
659 &self.props
660 }
661
662 fn props_mut(&mut self) -> &mut Props {
663 &mut self.props
664 }
665
666 fn slot(&self) -> Slot {
667 self.slot
668 }
669
670 fn children(&self) -> &[Cached] {
671 &self.children
672 }
673
674 fn children_mut(&mut self) -> &mut [Cached] {
675 &mut self.children
676 }
677
678 fn measure(&mut self, ctx: &UiContext) -> (u16, u16) {
679 let mut natural = self
680 .props
681 .str_of(Prop::Label)
682 .map_or(0, |label| cell_width(label));
683 for option in &self.state.options {
684 if option.cells.is_empty() {
685 natural = natural.max(cell_width(&option.label).saturating_add(18));
686 }
687 if let Some(desc) = &option.desc {
688 natural = natural.max(cell_width(desc).min(52).saturating_add(6));
689 }
690 }
691 let spans = self.cell_spans();
692 let gap = self.cell_gap();
693 if !spans.is_empty() {
694 let (_, grid) = grid_measure(ctx, &mut self.children, &spans, gap);
695 natural = natural.max(grid.saturating_add(Self::GUTTER));
696 }
697 let preview: SmallVec<Range<usize>, 16> = self
698 .state
699 .options
700 .iter()
701 .map(|option| option.preview.clone())
702 .collect();
703 for range in preview {
704 for child in &mut self.children[range] {
705 if child.visible {
706 natural = natural.max(child.measure(ctx).1.saturating_add(8));
707 }
708 }
709 }
710 (24, natural.max(30))
711 }
712
713 fn height(&mut self, ctx: &UiContext, width: u16) -> u16 {
714 let header = self.header_rows();
715 self.state.header_rows = header;
716 let columns = self.solve_cells(ctx, width);
717 let visible = self.state.visible();
718 self.state.cursor = self
719 .state
720 .cursor
721 .min(visible.len().saturating_sub(1) as u16);
722 let used = visible.iter().fold(0u16, |height, &index| {
723 height.saturating_add(self.option_height(ctx, width, usize::from(index), &columns))
724 });
725 header.saturating_add(used)
726 }
727
728 fn place(&mut self, ctx: &UiContext, content: Rect) {
729 self.state.layouts.fill(OptionLayout::default());
730 let header = self.header_rows();
731 self.state.header_rows = header;
732 let columns = self.solve_cells(ctx, content.width);
733 let gap = self.cell_gap();
734 let visible = self.state.visible();
735 self.state.cursor = self
736 .state
737 .cursor
738 .min(visible.len().saturating_sub(1) as u16);
739 let cursor_at = usize::from(self.state.cursor);
740 let mut scroll = usize::from(self.state.scroll).min(visible.len().saturating_sub(1));
741 if cursor_at < scroll {
742 scroll = cursor_at;
743 }
744 self.state.scroll = scroll as u16;
745 let cap = content.height.saturating_sub(header).max(1);
746 let mut y = content.y.saturating_add(header);
747 let mut used = 0u16;
748 let mut shown = 0u16;
749 for (position, &index) in visible.iter().enumerate().skip(scroll) {
750 if used >= cap {
751 break;
752 }
753 let index = usize::from(index);
754 let desc_rows = self.state.options[index]
755 .desc
756 .as_ref()
757 .map_or(0, |desc| desc_lines(desc, content.width.saturating_sub(6)).len() as u16);
758 let cells = self.state.options[index].cells.clone();
759 let row = if cells.is_empty() {
760 1
761 } else {
762 place_grid_row(
763 ctx,
764 &mut self.children,
765 cells,
766 &columns,
767 content.x.saturating_add(Self::GUTTER),
768 y,
769 gap,
770 )
771 };
772 let preview = self.state.options[index].preview.clone();
773 let mut block = row.saturating_add(desc_rows);
774 for child in &mut self.children[preview] {
775 if !child.visible {
776 continue;
777 }
778 let height = child.height(ctx, content.width.saturating_sub(8));
779 child.place(
780 ctx,
781 Rect::new(
782 content.x.saturating_add(8),
783 y.saturating_add(block),
784 content.width.saturating_sub(8),
785 height,
786 ),
787 );
788 block = block.saturating_add(height);
789 }
790 self.state.layouts[index] = OptionLayout { top: y, height: block };
791 y = y.saturating_add(block);
792 used = used.saturating_add(block);
793 shown = shown.saturating_add(1);
794 if used >= cap && cursor_at > position {
795 self.state.scroll = self.state.scroll.saturating_add(1);
796 }
797 }
798 self.state.page = shown.max(1);
799 }
800
801 fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
802 let focused = pc.focus == Some(self.slot);
803 let hover_row = match pc.hover {
804 Some((slot, HitTag::Row(index))) if slot == self.slot => Some(index),
805 _ => None,
806 };
807 let mut y = rect.y;
808 if let Some(label) = self.props.str_of(Prop::Label) {
809 if y < pc.clip {
810 pc.frame.put(rect.x, y, label, base(&pc.ctx.theme).bold());
811 }
812 y = y.saturating_add(1);
813 }
814 if self.state.filter {
815 let always_on = self.state.types_to_filter();
816 if y < pc.clip && always_on {
817 let mut x = pc.frame.put(
820 rect.x,
821 y,
822 pc.ctx.charset.icon(crate::Icon::Search),
823 Style::new().fg(pc.ctx.theme.accent),
824 );
825 x = pc.frame.put(x, y, " ", base(&pc.ctx.theme));
826 x = pc
827 .frame
828 .put(x, y, &self.state.filter_q, base(&pc.ctx.theme));
829 if focused {
830 pc.frame.set_cursor(x, y);
831 }
832 let count = format!("{}/{}", self.state.visible().len(), self.state.options.len());
833 let count_x = rect
834 .x
835 .saturating_add(rect.width.saturating_sub(cell_width(&count)));
836 if count_x > x {
837 pc.frame.put(count_x, y, &count, dim(&pc.ctx.theme));
838 }
839 } else if y < pc.clip && (self.state.searching || !self.state.filter_q.is_empty()) {
840 let mut x = pc
841 .frame
842 .put(rect.x, y, "/ ", Style::new().fg(pc.ctx.theme.accent).bold());
843 x = pc
844 .frame
845 .put(x, y, &self.state.filter_q, base(&pc.ctx.theme));
846 if self.state.searching {
847 if focused {
848 pc.frame.set_cursor(x, y);
849 }
850 x = pc
851 .frame
852 .put(x, y, pc.ctx.charset.beam(), Style::new().fg(pc.ctx.theme.accent));
853 }
854 let count = format!("{}/{}", self.state.visible().len(), self.state.options.len());
855 let count_x = rect
856 .x
857 .saturating_add(rect.width.saturating_sub(cell_width(&count)));
858 if count_x > x {
859 pc.frame.put(count_x, y, &count, dim(&pc.ctx.theme));
860 }
861 } else if y < pc.clip && focused {
862 pc.frame.put(rect.x, y, "/ to search", dim(&pc.ctx.theme));
863 }
864 }
865
866 let visible = self.state.visible();
867 for (position, &raw_index) in visible.iter().enumerate() {
868 let index = usize::from(raw_index);
869 let layout = self.state.layouts[index];
870 if layout.height == 0 {
871 continue;
872 }
873 pc.hits.push(Hit {
874 rect: Rect::new(rect.x, layout.top, rect.width, layout.height),
875 slot: self.slot,
876 tag: HitTag::Row(raw_index),
877 });
878 if layout.top >= pc.clip {
879 continue;
880 }
881 let option = &self.state.options[index];
882 let here = position as u16 == self.state.cursor;
883 let hovered = hover_row == Some(raw_index);
884 if !option.cells.is_empty() {
885 let cells = option.cells.clone();
886 let glyph = if here && focused {
887 pc.ctx.charset.cursor()
888 } else {
889 " "
890 };
891 pc.frame
892 .put(rect.x, layout.top, glyph, Style::new().fg(pc.ctx.theme.accent));
893 for child in &mut self.children[cells] {
894 if child.visible {
895 child.paint(pc);
896 }
897 }
898 if hovered {
899 pc.frame
903 .underlay(Rect::new(rect.x, layout.top, rect.width, 1), pc.ctx.theme.hover);
904 }
905 self.paint_option_tail(pc, rect, index, layout);
906 continue;
907 }
908 let row_bg = hovered.then_some(pc.ctx.theme.hover);
909 if let Some(background) = row_bg {
910 pc.frame
911 .fill(Rect::new(rect.x, layout.top, rect.width, 1), Style::new().bg(background));
912 }
913 let tint = |style: Style| row_bg.map_or(style, |background| style.bg(background));
914 let mut x = pc.frame.put(
915 rect.x,
916 layout.top,
917 if here && focused {
918 pc.ctx.charset.cursor()
919 } else {
920 " "
921 },
922 tint(Style::new().fg(pc.ctx.theme.accent)),
923 );
924 let checked = self.state.chosen.get(index);
925 let mark = if self.state.multi {
926 pc.ctx.charset.checkbox(checked)
927 } else {
928 pc.ctx.charset.radio(checked)
929 };
930 x = pc.frame.put(
931 x,
932 layout.top,
933 mark,
934 tint(Style::new().fg(if checked {
935 pc.ctx.theme.ok
936 } else {
937 pc.ctx.theme.muted
938 })),
939 );
940 x = pc.frame.put(x, layout.top, " ", tint(base(&pc.ctx.theme)));
941 let label_style = if here {
942 tint(Style::new().fg(pc.ctx.theme.accent).bold())
943 } else {
944 tint(base(&pc.ctx.theme))
945 };
946 x = pc.frame.put(x, layout.top, &option.label, label_style);
947 if option.recommended {
948 x = pc
949 .frame
950 .put(x, layout.top, " (Recommended)", tint(dim(&pc.ctx.theme)));
951 }
952 if option.custom && (self.state.editing || !self.state.custom_text.is_empty()) {
953 x = pc.frame.put(x, layout.top, ": ", tint(dim(&pc.ctx.theme)));
954 x = pc.frame.put(
955 x,
956 layout.top,
957 &self.state.custom_text,
958 tint(Style::new().fg(pc.ctx.theme.info)),
959 );
960 if self.state.editing {
961 pc.frame.put(
962 x,
963 layout.top,
964 pc.ctx.charset.beam(),
965 tint(Style::new().fg(pc.ctx.theme.accent)),
966 );
967 }
968 }
969 self.paint_option_tail(pc, rect, index, layout);
970 }
971 }
972
973 fn focusable(&self) -> bool {
974 true
975 }
976
977 fn enter(&mut self, forward: bool) {
981 let visible = self.state.visible();
982 if visible.is_empty() {
983 return;
984 }
985 if !self.state.multi
986 && let Some(chosen) = self.state.chosen.iter().next()
987 && let Some(position) = visible
988 .iter()
989 .position(|&index| usize::from(index) == chosen)
990 {
991 self.state.cursor = position as u16;
992 return;
993 }
994 self.state.cursor = if forward { 0 } else { visible.len() as u16 - 1 };
995 }
996
997 fn key(&mut self, _ec: &mut EventCtx<'_>, key: Key) -> Flow {
998 self.dispatch(key)
999 }
1000
1001 fn mouse(
1002 &mut self,
1003 _ec: &mut EventCtx<'_>,
1004 tag: HitTag,
1005 _at: (u16, u16),
1006 _rect: Rect,
1007 mouse: Mouse,
1008 ) -> Flow {
1009 match (mouse, tag) {
1010 (Mouse::Click, HitTag::Row(index)) if usize::from(index) < self.state.options.len() => {
1011 let visible = self.state.visible();
1012 if let Some(position) = visible.iter().position(|&candidate| candidate == index) {
1013 self.state.cursor = position as u16;
1014 }
1015 self.commit(index)
1016 },
1017 (Mouse::WheelUp | Mouse::WheelDown, _) => {
1018 let delta = if mouse == Mouse::WheelUp { -1 } else { 1 };
1019 if self.move_cursor(delta, false) {
1020 self.highlight_flow()
1021 } else if self.state.visible().is_empty() {
1022 Flow::Skip
1023 } else {
1024 Flow::Consumed
1025 }
1026 },
1027 (
1028 Mouse::Click
1029 | Mouse::RightClick
1030 | Mouse::MiddleClick
1031 | Mouse::Move
1032 | Mouse::Drag
1033 | Mouse::Release
1034 | Mouse::WheelLeft
1035 | Mouse::WheelRight,
1036 _,
1037 ) => Flow::Skip,
1038 }
1039 }
1040
1041 fn paste(&mut self, _ec: &mut EventCtx<'_>, text: &str) -> Flow {
1042 let sanitized = sanitize_paste(text);
1043 if sanitized.is_empty() {
1044 return Flow::Skip;
1045 }
1046 let single_line = sanitized.replace(['\n', '\t'], " ");
1047 if self.state.editing {
1048 self.state.custom_text.push_str(&single_line);
1049 Flow::Consumed
1050 } else if self.state.types_to_filter() || self.state.searching {
1051 self.state.filter_q.push_str(&single_line);
1052 self.filter_flow()
1053 } else {
1054 Flow::Skip
1055 }
1056 }
1057
1058 fn value(&self, out: &mut serde_json::Map<String, serde_json::Value>) {
1059 let Some(id) = self.props.id() else {
1060 return;
1061 };
1062 let value = if self.state.multi {
1063 serde_json::Value::Array(
1064 self
1065 .state
1066 .chosen
1067 .iter()
1068 .map(|index| option_value(&self.state, index))
1069 .collect(),
1070 )
1071 } else {
1072 self
1073 .state
1074 .chosen
1075 .iter()
1076 .next()
1077 .map_or(serde_json::Value::Null, |index| option_value(&self.state, index))
1078 };
1079 out.insert(id.to_string(), value);
1080 }
1081}
1082
1083fn option_value(state: &SelectState, index: usize) -> serde_json::Value {
1084 let option = &state.options[index];
1085 if option.custom {
1086 serde_json::Value::String(state.custom_text.clone())
1087 } else {
1088 serde_json::Value::String(option.value.to_string())
1089 }
1090}
1091
1092fn fuzzy_score(hay: &str, needle: &str) -> Option<i32> {
1096 let hay: SmallVec<char, 64> = hay.chars().flat_map(char::to_lowercase).collect();
1097 let mut score = 0_i32;
1098 let mut position = 0_usize;
1099 let mut previous: Option<usize> = None;
1100 for ch in needle.chars().flat_map(char::to_lowercase) {
1101 let found = hay[position..]
1102 .iter()
1103 .position(|&candidate| candidate == ch)?;
1104 let at = position + found;
1105 score += match previous {
1106 Some(prev) if at == prev + 1 => 10,
1107 _ => 10 - i32::try_from(found.min(8)).expect("bounded gap"),
1108 };
1109 previous = Some(at);
1110 position = at + 1;
1111 }
1112 Some(score - i32::try_from(hay.len().min(64)).expect("bounded length"))
1113}
1114
1115fn desc_lines(desc: &Str, width: u16) -> SmallVec<Str, 2> {
1116 let width = width.max(8);
1117 let mut lines = SmallVec::new();
1118 let mut start = None;
1119 let mut end = 0usize;
1120 let mut line_width = 0u16;
1121 for (offset, word) in desc
1122 .split_whitespace()
1123 .map(|word| (word.as_ptr() as usize - desc.as_str().as_ptr() as usize, word))
1124 {
1125 let word_width = cell_width(word);
1126 match start {
1127 Some(previous) if line_width.saturating_add(1).saturating_add(word_width) > width => {
1128 lines.push(desc.slice(previous..end));
1129 start = Some(offset);
1130 end = offset + word.len();
1131 line_width = word_width;
1132 },
1133 Some(_) => {
1134 end = offset + word.len();
1135 line_width = line_width.saturating_add(1).saturating_add(word_width);
1136 },
1137 None => {
1138 start = Some(offset);
1139 end = offset + word.len();
1140 line_width = word_width;
1141 },
1142 }
1143 if lines.len() == 2 {
1144 break;
1145 }
1146 }
1147 if let Some(start) = start
1148 && lines.len() < 2
1149 {
1150 lines.push(desc.slice(start..end));
1151 }
1152 lines
1153}
1154
1155const fn base(theme: &Theme) -> Style {
1156 Style::new().fg(theme.fg)
1157}
1158const fn dim(theme: &Theme) -> Style {
1159 Style::new().fg(theme.muted)
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164 use super::*;
1165 use crate::{Frame, Size, test_support::frame_row_text};
1166
1167 fn event_ctx(ctx: &UiContext) -> EventCtx<'_> {
1168 EventCtx::new(ctx, 40, 8)
1169 }
1170
1171 #[test]
1172 fn navigate_and_activate_changes_value() {
1173 let mut select = Select::new()
1174 .with(Prop::Id, "pick")
1175 .option(SelectOption::new().label("one").with(Prop::Value, "1"))
1176 .option(SelectOption::new().label("two").with(Prop::Value, "2"));
1177 let ctx = UiContext::default();
1178 assert_eq!(
1179 select.key(&mut event_ctx(&ctx), Key::Down),
1180 Flow::Event(UiEvent::Highlighted { id: "pick".into(), value: "2".into() }),
1181 "cursor moves surface the highlighted option"
1182 );
1183 assert_eq!(
1184 select.key(&mut event_ctx(&ctx), Key::Enter),
1185 Flow::Event(UiEvent::Changed { id: "pick".into(), value: "2".into() }),
1186 "activation surfaces the committed option"
1187 );
1188 let mut values = serde_json::Map::new();
1189 select.value(&mut values);
1190 assert_eq!(values["pick"], serde_json::json!("2"));
1191 assert_eq!(select.key(&mut event_ctx(&ctx), Key::Down), Flow::Skip);
1192 }
1193
1194 #[test]
1195 fn paint_places_rows_and_registers_hits() {
1196 let mut select = Select::new().option(SelectOption::new().label("Alpha"));
1197 let ctx = UiContext::default();
1198 let height = select.height(&ctx, 32);
1199 let rect = Rect::new(0, 0, 32, height);
1200 select.place(&ctx, rect);
1201 let mut frame = Frame::new(Size::new(32, height));
1202 let mut hits = Vec::new();
1203 let mut wakes = Vec::new();
1204 let mut pc = PaintCtx::new(&mut frame, &ctx, &mut hits, &mut wakes);
1205 pc.focus = Some(select.slot());
1206 select.paint(&mut pc, rect);
1207 assert!(frame_row_text(&frame, 0).contains("Alpha"));
1208 assert_eq!(hits.len(), 1);
1209 }
1210}