Skip to main content

omp_tui/components/
select.rs

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
21/// Declarative option data backing the `<option>` markup tag.
22pub struct SelectOption {
23	props:   Props,
24	label:   Str,
25	preview: Vec<Cached>,
26	cells:   SmallVec<Cached, 8>,
27}
28
29impl SelectOption {
30	/// Creates an empty option.
31	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	/// Sets one option property.
41	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
42		self.props.set(prop, value);
43		self
44	}
45
46	/// Sets one option property from a string.
47	pub fn with_str(mut self, prop: Prop, value: &str) -> Self {
48		self.props.set(prop, value);
49		self
50	}
51
52	/// Appends label text.
53	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	/// Appends one grid cell; cell options render as aligned columns
64	/// (shared across every option) instead of a flat label.
65	pub fn cell(mut self, cell: TableCell) -> Self {
66		self.cells.push(Cached::new(Box::new(cell)));
67		self
68	}
69
70	/// Appends preview content shown below this option.
71	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	/// Grid cells rendered as this option's row; empty for label options.
90	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	/// Option rows that fit the last placed viewport; page-jump stride.
115	page:        u16,
116}
117
118impl SelectState {
119	/// Indices of options matching the query, best fuzzy score first
120	/// (stable by declaration order); an empty query keeps every option.
121	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	/// Whether typing edits the query directly (pi-style browsers): a
136	/// filterable single select needs no `/` search mode, while multi
137	/// selects keep Space for toggling.
138	const fn types_to_filter(&self) -> bool {
139		self.filter && !self.multi
140	}
141}
142
143/// A filterable choice list backing the `<select>` markup tag.
144pub struct Select {
145	props:    Props,
146	slot:     Slot,
147	state:    SelectState,
148	children: Vec<Cached>,
149}
150
151impl Select {
152	/// Column gutter reserved for the cursor glyph ahead of cell rows.
153	const GUTTER: u16 = 2;
154
155	/// Creates an empty select.
156	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	/// Sets one select property.
171	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	/// Sets one select property from a string.
178	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	/// Appends an option and adopts its cell and preview subtrees.
185	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			// A bare `filter` flag enables filtering; a string value also
235			// seeds the initial query (rebuilding hosts keep continuity).
236			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	/// Spacing between cell columns: the `gap` prop, defaulting to two.
320	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	/// Cell ranges of every cell option, in declaration order. Columns are
329	/// solved over the full catalog — not the filtered subset — so widths
330	/// hold steady while the user types.
331	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	/// Solves the shared cell columns for `width` content cells.
342	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	/// Value of the option under the cursor, `None` when nothing matches.
381	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	/// Wraps a cursor move into an event for identified selects.
393	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	/// Wraps a query edit into an event for identified selects, clamping
401	/// the cursor instead of resetting it (pi `#applyQuery`).
402	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	/// Moves the cursor by `delta`: wrapping single steps for filterable
416	/// browsers, clamping jumps and form selects. `false` leaves the
417	/// cursor untouched (the edge of a clamping select).
418	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	/// Routes one key: custom-option editing, then query editing (always-on
437	/// for filterable single selects, `/`-armed otherwise), then list
438	/// navigation. Identified selects surface cursor, query, and commit
439	/// changes as [`UiEvent`]s.
440	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				// The cancel ladder: a first Esc clears the query, a second
497				// bubbles out of the component (dismissing an overlay host).
498				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	/// Activates `index` and surfaces the commit for identified selects.
578	fn commit(&mut self, index: u16) -> Flow {
579		self.activate(index);
580		if self.state.editing {
581			// The custom option opened its inline editor; the commit event
582			// waits for the typed value.
583			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	/// Paints the description lines and preview subtree under one option.
600	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				// pi-style query line: search glyph, live query, and the
818				// real terminal cursor at the insertion point.
819				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					// Behind-the-glyphs tint: cells that named no
900					// background keep it, so arbitrary row content
901					// survives the hover highlight.
902					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	/// Positions the cursor when focus enters: a single select rests on
978	/// its chosen option (pi preselects the active model), otherwise the
979	/// entry edge.
980	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
1092/// Case-insensitive subsequence match, scoring contiguous runs and early
1093/// matches higher — pi's `fuzzyRank` shape. `None` when `needle` is not a
1094/// subsequence of `hay`.
1095fn 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}