use std::ops::Range;
use omp_core::Str;
use smallvec::SmallVec;
use super::{
layout::{grid_measure, place_grid_row, solve_columns},
table::TableCell,
};
use crate::{
component::{
Cached, Component, EventCtx, Flow, Hit, HitTag, IntoChildren, PaintCtx, Slot, next_slot,
},
context::{Theme, UiContext},
frame::{Rect, Style},
input::{Key, Mouse, UiEvent, sanitize_paste, word_rubout_start},
props::{Prop, PropValue, Props},
rich::cell_width,
};
pub struct SelectOption {
props: Props,
label: Str,
preview: Vec<Cached>,
cells: SmallVec<Cached, 8>,
}
impl SelectOption {
pub fn new() -> Self {
Self {
props: Props::new(),
label: Str::default(),
preview: Vec::new(),
cells: SmallVec::new(),
}
}
pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
self.props.set(prop, value);
self
}
pub fn with_str(mut self, prop: Prop, value: &str) -> Self {
self.props.set(prop, value);
self
}
pub fn label(mut self, label: impl Into<Str>) -> Self {
let label = label.into();
if self.label.is_empty() {
self.label = label;
} else {
self.label = Str::from(format!("{}{}", self.label, label));
}
self
}
pub fn cell(mut self, cell: TableCell) -> Self {
self.cells.push(Cached::new(Box::new(cell)));
self
}
pub fn child(mut self, child: impl IntoChildren) -> Self {
child.extend_children(&mut self.preview);
self
}
}
impl Default for SelectOption {
fn default() -> Self {
Self::new()
}
}
struct OptionData {
label: Str,
value: Str,
desc: Option<Str>,
recommended: bool,
preview: Range<usize>,
cells: Range<usize>,
custom: bool,
}
#[derive(Clone, Copy, Default)]
struct OptionLayout {
top: u16,
height: u16,
}
#[derive(Default)]
struct SelectState {
options: Vec<OptionData>,
layouts: SmallVec<OptionLayout, 8>,
multi: bool,
filter: bool,
cursor: u16,
chosen: smol_bitmap::SmolBitmap,
custom_text: String,
editing: bool,
filter_q: String,
searching: bool,
scroll: u16,
header_rows: u16,
page: u16,
}
impl SelectState {
fn visible(&self) -> SmallVec<u16, 16> {
if self.filter_q.is_empty() {
return (0..self.options.len() as u16).collect();
}
let mut scored: SmallVec<(i32, u16), 16> = (0..self.options.len() as u16)
.filter_map(|index| {
fuzzy_score(&self.options[usize::from(index)].label, &self.filter_q)
.map(|score| (-score, index))
})
.collect();
scored.sort_unstable();
scored.into_iter().map(|(_, index)| index).collect()
}
const fn types_to_filter(&self) -> bool {
self.filter && !self.multi
}
}
pub struct Select {
props: Props,
slot: Slot,
state: SelectState,
children: Vec<Cached>,
}
impl Select {
const GUTTER: u16 = 2;
pub fn new() -> Self {
Self {
props: Props::new(),
slot: next_slot(),
state: SelectState::default(),
children: Vec::new(),
}
}
#[allow(dead_code, reason = "acceptance-suite probe")]
pub(crate) fn visible_len(&self) -> usize {
self.state.visible().len()
}
pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
self.props.set(prop, value);
self.sync_prop(prop);
self
}
pub fn with_str(mut self, prop: Prop, value: &str) -> Self {
self.props.set(prop, value);
self.sync_prop(prop);
self
}
pub fn option(mut self, option: SelectOption) -> Self {
let cells_start = self.children.len();
self.children.extend(option.cells);
let cells = cells_start..self.children.len();
let preview_start = self.children.len();
self.children.extend(option.preview);
let preview = preview_start..self.children.len();
let label = if option.label.is_empty() {
option
.props
.str_of(Prop::Label)
.cloned()
.unwrap_or_default()
} else {
option.label
};
let data = OptionData {
value: option
.props
.str_of(Prop::Value)
.cloned()
.unwrap_or_else(|| label.clone()),
desc: option.props.str_of(Prop::Desc).cloned(),
recommended: option.props.flag(Prop::Recommended),
custom: false,
label,
preview,
cells,
};
let at = self
.state
.options
.iter()
.position(|candidate| candidate.custom)
.unwrap_or(self.state.options.len());
self.insert_option(at, data);
self
}
fn sync_prop(&mut self, prop: Prop) {
match prop {
Prop::Multi => {
self.state.multi = self.props.flag(Prop::Multi);
if self.state.multi {
self.state.chosen = smol_bitmap::SmolBitmap::new();
} else {
self.choose_recommended();
}
},
Prop::Filter => match self.props.get(Prop::Filter) {
Some(PropValue::Bool(enabled)) => self.state.filter = *enabled,
Some(PropValue::Str(seed)) => {
self.state.filter = true;
self.state.filter_q = seed.as_str().to_owned();
},
_ => self.state.filter = false,
},
Prop::Custom => self.set_custom(self.props.flag(Prop::Custom)),
_ => {},
}
}
fn insert_option(&mut self, at: usize, option: OptionData) {
let mut chosen = smol_bitmap::SmolBitmap::new();
for index in &self.state.chosen {
chosen.set(if index >= at { index + 1 } else { index }, true);
}
let recommended = option.recommended;
self.state.options.insert(at, option);
self.state.layouts.insert(at, OptionLayout::default());
self.state.chosen = chosen;
if recommended && !self.state.multi && self.state.chosen.iter().next().is_none() {
self.state.chosen.set(at, true);
}
}
fn remove_option(&mut self, at: usize) {
self.state.options.remove(at);
self.state.layouts.remove(at);
let mut chosen = smol_bitmap::SmolBitmap::new();
for index in &self.state.chosen {
if index < at {
chosen.set(index, true);
} else if index > at {
chosen.set(index - 1, true);
}
}
self.state.chosen = chosen;
self.state.cursor = self
.state
.cursor
.min(self.state.options.len().saturating_sub(1) as u16);
}
fn set_custom(&mut self, enabled: bool) {
let current = self.state.options.iter().position(|option| option.custom);
match (enabled, current) {
(true, None) => {
let end = self.children.len();
self.insert_option(self.state.options.len(), OptionData {
label: Str::from("Other (type your own)"),
value: Str::default(),
desc: None,
recommended: false,
preview: end..end,
cells: end..end,
custom: true,
});
},
(false, Some(index)) => self.remove_option(index),
_ => {},
}
}
fn choose_recommended(&mut self) {
if self.state.chosen.iter().next().is_some() {
return;
}
if let Some(index) = self
.state
.options
.iter()
.position(|option| option.recommended)
{
self.state.chosen.set(index, true);
}
}
fn header_rows(&self) -> u16 {
u16::from(self.props.str_of(Prop::Label).is_some()) + u16::from(self.state.filter)
}
fn cell_gap(&self) -> u16 {
if self.props.get(Prop::Gap).is_some() {
self.props.gap()
} else {
2
}
}
fn cell_spans(&self) -> SmallVec<Range<usize>, 16> {
self
.state
.options
.iter()
.filter(|option| !option.cells.is_empty())
.map(|option| option.cells.clone())
.collect()
}
fn solve_cells(&mut self, ctx: &UiContext, width: u16) -> SmallVec<u16, 8> {
let spans = self.cell_spans();
if spans.is_empty() {
return SmallVec::new();
}
let gap = self.cell_gap();
solve_columns(ctx, &mut self.children, &spans, width.saturating_sub(Self::GUTTER), gap)
}
fn option_height(&mut self, ctx: &UiContext, width: u16, index: usize, columns: &[u16]) -> u16 {
let desc_rows = self.state.options[index]
.desc
.as_ref()
.map_or(0, |desc| desc_lines(desc, width.saturating_sub(6)).len() as u16);
let cells = self.state.options[index].cells.clone();
let row = if cells.is_empty() {
1
} else {
cells
.enumerate()
.map(|(column, cell)| {
let cell_width = columns.get(column).copied().unwrap_or(1).max(1);
self.children[cell].height(ctx, cell_width)
})
.max()
.unwrap_or(1)
.max(1)
};
let preview = self.state.options[index].preview.clone();
let preview_h = self.children[preview]
.iter_mut()
.filter(|child| child.visible)
.fold(0u16, |height, child| {
height.saturating_add(child.height(ctx, width.saturating_sub(8)))
});
row.saturating_add(desc_rows).saturating_add(preview_h)
}
fn cursor_value(&self) -> Option<Str> {
let visible = self.state.visible();
let &index = visible.get(usize::from(self.state.cursor))?;
let option = &self.state.options[usize::from(index)];
Some(if option.custom {
Str::from(self.state.custom_text.as_str())
} else {
option.value.clone()
})
}
fn highlight_flow(&self) -> Flow {
match (self.props.id(), self.cursor_value()) {
(Some(id), Some(value)) => Flow::Event(UiEvent::Highlighted { id: id.clone(), value }),
_ => Flow::Consumed,
}
}
fn filter_flow(&mut self) -> Flow {
let count = self.state.visible().len();
self.state.cursor = self.state.cursor.min(count.saturating_sub(1) as u16);
match self.props.id() {
Some(id) => Flow::Event(UiEvent::Filtered {
id: id.clone(),
query: Str::from(self.state.filter_q.as_str()),
value: self.cursor_value(),
}),
None => Flow::Consumed,
}
}
fn move_cursor(&mut self, delta: i64, wrap: bool) -> bool {
let count = self.state.visible().len() as i64;
if count == 0 {
return false;
}
let at = i64::from(self.state.cursor);
let next = if wrap {
(at + delta).rem_euclid(count)
} else {
(at + delta).clamp(0, count - 1)
};
if next == at {
return false;
}
self.state.cursor = next as u16;
true
}
fn dispatch(&mut self, key: Key) -> Flow {
let visible = self.state.visible();
if self.state.editing {
match key {
Key::Enter => self.state.editing = false,
Key::Esc => {
self.state.editing = false;
self.state.custom_text.clear();
},
Key::Backspace => {
self.state.custom_text.pop();
},
Key::Space => self.state.custom_text.push(' '),
Key::Char(character) => self.state.custom_text.push(character),
Key::Ctrl('u') => self.state.custom_text.clear(),
Key::Ctrl('w') => {
let end = self.state.custom_text.len();
self
.state
.custom_text
.truncate(word_rubout_start(&self.state.custom_text, end));
},
_ => {},
}
return Flow::Consumed;
}
let typing = self.state.types_to_filter() || self.state.searching;
if typing && !matches!(key, Key::Up | Key::Down) {
match key {
Key::Char(character) => {
self.state.filter_q.push(character);
return self.filter_flow();
},
Key::Space if self.state.types_to_filter() => {
self.state.filter_q.push(' ');
return self.filter_flow();
},
Key::Backspace => {
if self.state.filter_q.pop().is_none() {
self.state.searching = false;
return Flow::Consumed;
}
return self.filter_flow();
},
Key::Ctrl('u') if !self.state.filter_q.is_empty() => {
self.state.filter_q.clear();
return self.filter_flow();
},
Key::Ctrl('w') if !self.state.filter_q.is_empty() => {
let end = self.state.filter_q.len();
self
.state
.filter_q
.truncate(word_rubout_start(&self.state.filter_q, end));
return self.filter_flow();
},
Key::Esc if !self.state.filter_q.is_empty() => {
self.state.filter_q.clear();
self.state.searching = false;
return self.filter_flow();
},
Key::Esc | Key::Enter if self.state.searching => {
self.state.searching = false;
return Flow::Consumed;
},
_ => {},
}
}
match key {
Key::Up if !visible.is_empty() => {
if self.move_cursor(-1, self.state.filter) {
self.highlight_flow()
} else {
Flow::Skip
}
},
Key::Down if !visible.is_empty() => {
if self.move_cursor(1, self.state.filter) {
self.highlight_flow()
} else {
Flow::Skip
}
},
Key::PageUp | Key::PageDown if !visible.is_empty() => {
let stride = i64::from(self.state.page.max(1));
let delta = if key == Key::PageUp { -stride } else { stride };
if self.move_cursor(delta, false) {
self.highlight_flow()
} else {
Flow::Consumed
}
},
Key::Home | Key::End if !visible.is_empty() => {
let delta = i64::from(u16::MAX);
let delta = if key == Key::Home { -delta } else { delta };
if self.move_cursor(delta, false) {
self.highlight_flow()
} else {
Flow::Consumed
}
},
Key::Enter if !visible.is_empty() => {
let position = usize::from(self.state.cursor.min(visible.len() as u16 - 1));
self.commit(visible[position])
},
Key::Space if !visible.is_empty() && !self.state.types_to_filter() => {
let position = usize::from(self.state.cursor.min(visible.len() as u16 - 1));
self.commit(visible[position])
},
Key::Char('/') if self.state.filter && !self.state.types_to_filter() => {
self.state.searching = true;
Flow::Consumed
},
Key::Esc if !self.state.filter_q.is_empty() => {
self.state.filter_q.clear();
self.filter_flow()
},
_ => Flow::Skip,
}
}
fn activate(&mut self, index: u16) {
let index = usize::from(index);
if self.state.multi {
let current = self.state.chosen.get(index);
self.state.chosen.set(index, !current);
} else {
self.state.chosen = smol_bitmap::SmolBitmap::new();
self.state.chosen.set(index, true);
}
if self.state.options[index].custom && self.state.chosen.get(index) {
self.state.editing = true;
}
}
fn commit(&mut self, index: u16) -> Flow {
self.activate(index);
if self.state.editing {
return Flow::Consumed;
}
match self.props.id() {
Some(id) => {
let option = &self.state.options[usize::from(index)];
let value = if option.custom {
Str::from(self.state.custom_text.as_str())
} else {
option.value.clone()
};
Flow::Event(UiEvent::Changed { id: id.clone(), value })
},
None => Flow::Consumed,
}
}
fn paint_option_tail(
&mut self,
pc: &mut PaintCtx<'_>,
rect: Rect,
index: usize,
layout: OptionLayout,
) {
let option = &self.state.options[index];
if let Some(desc) = &option.desc {
let preview_h: u16 = self.children[option.preview.clone()]
.iter()
.filter(|child| child.visible)
.map(|child| child.rect.height)
.sum();
let base = layout
.height
.saturating_sub(preview_h)
.saturating_sub(desc_lines(desc, rect.width.saturating_sub(6)).len() as u16)
.max(1);
for (line_index, line) in desc_lines(desc, rect.width.saturating_sub(6))
.iter()
.enumerate()
{
let line_y = layout.top.saturating_add(base + line_index as u16);
if line_y < pc.clip {
pc.frame
.put(rect.x.saturating_add(6), line_y, line, dim(&pc.ctx.theme));
}
}
}
let preview = self.state.options[index].preview.clone();
for child in &mut self.children[preview] {
if !child.visible {
continue;
}
let stem_x = rect.x.saturating_add(6);
for line_y in child.rect.y..child.rect.y.saturating_add(child.rect.height) {
if line_y < pc.clip {
pc.frame.put(
stem_x,
line_y,
pc.ctx.charset.icon(crate::Icon::PreviewRail),
dim(&pc.ctx.theme),
);
}
}
child.paint(pc);
}
}
}
impl Default for Select {
fn default() -> Self {
Self::new()
}
}
impl Component for Select {
fn props(&self) -> &Props {
&self.props
}
fn props_mut(&mut self) -> &mut Props {
&mut self.props
}
fn slot(&self) -> Slot {
self.slot
}
fn children(&self) -> &[Cached] {
&self.children
}
fn children_mut(&mut self) -> &mut [Cached] {
&mut self.children
}
fn measure(&mut self, ctx: &UiContext) -> (u16, u16) {
let mut natural = self
.props
.str_of(Prop::Label)
.map_or(0, |label| cell_width(label));
for option in &self.state.options {
if option.cells.is_empty() {
natural = natural.max(cell_width(&option.label).saturating_add(18));
}
if let Some(desc) = &option.desc {
natural = natural.max(cell_width(desc).min(52).saturating_add(6));
}
}
let spans = self.cell_spans();
let gap = self.cell_gap();
if !spans.is_empty() {
let (_, grid) = grid_measure(ctx, &mut self.children, &spans, gap);
natural = natural.max(grid.saturating_add(Self::GUTTER));
}
let preview: SmallVec<Range<usize>, 16> = self
.state
.options
.iter()
.map(|option| option.preview.clone())
.collect();
for range in preview {
for child in &mut self.children[range] {
if child.visible {
natural = natural.max(child.measure(ctx).1.saturating_add(8));
}
}
}
(24, natural.max(30))
}
fn height(&mut self, ctx: &UiContext, width: u16) -> u16 {
let header = self.header_rows();
self.state.header_rows = header;
let columns = self.solve_cells(ctx, width);
let visible = self.state.visible();
self.state.cursor = self
.state
.cursor
.min(visible.len().saturating_sub(1) as u16);
let used = visible.iter().fold(0u16, |height, &index| {
height.saturating_add(self.option_height(ctx, width, usize::from(index), &columns))
});
header.saturating_add(used)
}
fn place(&mut self, ctx: &UiContext, content: Rect) {
self.state.layouts.fill(OptionLayout::default());
let header = self.header_rows();
self.state.header_rows = header;
let columns = self.solve_cells(ctx, content.width);
let gap = self.cell_gap();
let visible = self.state.visible();
self.state.cursor = self
.state
.cursor
.min(visible.len().saturating_sub(1) as u16);
let cursor_at = usize::from(self.state.cursor);
let mut scroll = usize::from(self.state.scroll).min(visible.len().saturating_sub(1));
if cursor_at < scroll {
scroll = cursor_at;
}
self.state.scroll = scroll as u16;
let cap = content.height.saturating_sub(header).max(1);
let mut y = content.y.saturating_add(header);
let mut used = 0u16;
let mut shown = 0u16;
for (position, &index) in visible.iter().enumerate().skip(scroll) {
if used >= cap {
break;
}
let index = usize::from(index);
let desc_rows = self.state.options[index]
.desc
.as_ref()
.map_or(0, |desc| desc_lines(desc, content.width.saturating_sub(6)).len() as u16);
let cells = self.state.options[index].cells.clone();
let row = if cells.is_empty() {
1
} else {
place_grid_row(
ctx,
&mut self.children,
cells,
&columns,
content.x.saturating_add(Self::GUTTER),
y,
gap,
)
};
let preview = self.state.options[index].preview.clone();
let mut block = row.saturating_add(desc_rows);
for child in &mut self.children[preview] {
if !child.visible {
continue;
}
let height = child.height(ctx, content.width.saturating_sub(8));
child.place(
ctx,
Rect::new(
content.x.saturating_add(8),
y.saturating_add(block),
content.width.saturating_sub(8),
height,
),
);
block = block.saturating_add(height);
}
self.state.layouts[index] = OptionLayout { top: y, height: block };
y = y.saturating_add(block);
used = used.saturating_add(block);
shown = shown.saturating_add(1);
if used >= cap && cursor_at > position {
self.state.scroll = self.state.scroll.saturating_add(1);
}
}
self.state.page = shown.max(1);
}
fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
let focused = pc.focus == Some(self.slot);
let hover_row = match pc.hover {
Some((slot, HitTag::Row(index))) if slot == self.slot => Some(index),
_ => None,
};
let mut y = rect.y;
if let Some(label) = self.props.str_of(Prop::Label) {
if y < pc.clip {
pc.frame.put(rect.x, y, label, base(&pc.ctx.theme).bold());
}
y = y.saturating_add(1);
}
if self.state.filter {
let always_on = self.state.types_to_filter();
if y < pc.clip && always_on {
let mut x = pc.frame.put(
rect.x,
y,
pc.ctx.charset.icon(crate::Icon::Search),
Style::new().fg(pc.ctx.theme.accent),
);
x = pc.frame.put(x, y, " ", base(&pc.ctx.theme));
x = pc
.frame
.put(x, y, &self.state.filter_q, base(&pc.ctx.theme));
if focused {
pc.frame.set_cursor(x, y);
}
let count = format!("{}/{}", self.state.visible().len(), self.state.options.len());
let count_x = rect
.x
.saturating_add(rect.width.saturating_sub(cell_width(&count)));
if count_x > x {
pc.frame.put(count_x, y, &count, dim(&pc.ctx.theme));
}
} else if y < pc.clip && (self.state.searching || !self.state.filter_q.is_empty()) {
let mut x = pc
.frame
.put(rect.x, y, "/ ", Style::new().fg(pc.ctx.theme.accent).bold());
x = pc
.frame
.put(x, y, &self.state.filter_q, base(&pc.ctx.theme));
if self.state.searching {
if focused {
pc.frame.set_cursor(x, y);
}
x = pc
.frame
.put(x, y, pc.ctx.charset.beam(), Style::new().fg(pc.ctx.theme.accent));
}
let count = format!("{}/{}", self.state.visible().len(), self.state.options.len());
let count_x = rect
.x
.saturating_add(rect.width.saturating_sub(cell_width(&count)));
if count_x > x {
pc.frame.put(count_x, y, &count, dim(&pc.ctx.theme));
}
} else if y < pc.clip && focused {
pc.frame.put(rect.x, y, "/ to search", dim(&pc.ctx.theme));
}
}
let visible = self.state.visible();
for (position, &raw_index) in visible.iter().enumerate() {
let index = usize::from(raw_index);
let layout = self.state.layouts[index];
if layout.height == 0 {
continue;
}
pc.hits.push(Hit {
rect: Rect::new(rect.x, layout.top, rect.width, layout.height),
slot: self.slot,
tag: HitTag::Row(raw_index),
});
if layout.top >= pc.clip {
continue;
}
let option = &self.state.options[index];
let here = position as u16 == self.state.cursor;
let hovered = hover_row == Some(raw_index);
if !option.cells.is_empty() {
let cells = option.cells.clone();
let glyph = if here && focused {
pc.ctx.charset.cursor()
} else {
" "
};
pc.frame
.put(rect.x, layout.top, glyph, Style::new().fg(pc.ctx.theme.accent));
for child in &mut self.children[cells] {
if child.visible {
child.paint(pc);
}
}
if hovered {
pc.frame
.underlay(Rect::new(rect.x, layout.top, rect.width, 1), pc.ctx.theme.hover);
}
self.paint_option_tail(pc, rect, index, layout);
continue;
}
let row_bg = hovered.then_some(pc.ctx.theme.hover);
if let Some(background) = row_bg {
pc.frame
.fill(Rect::new(rect.x, layout.top, rect.width, 1), Style::new().bg(background));
}
let tint = |style: Style| row_bg.map_or(style, |background| style.bg(background));
let mut x = pc.frame.put(
rect.x,
layout.top,
if here && focused {
pc.ctx.charset.cursor()
} else {
" "
},
tint(Style::new().fg(pc.ctx.theme.accent)),
);
let checked = self.state.chosen.get(index);
let mark = if self.state.multi {
pc.ctx.charset.checkbox(checked)
} else {
pc.ctx.charset.radio(checked)
};
x = pc.frame.put(
x,
layout.top,
mark,
tint(Style::new().fg(if checked {
pc.ctx.theme.ok
} else {
pc.ctx.theme.muted
})),
);
x = pc.frame.put(x, layout.top, " ", tint(base(&pc.ctx.theme)));
let label_style = if here {
tint(Style::new().fg(pc.ctx.theme.accent).bold())
} else {
tint(base(&pc.ctx.theme))
};
x = pc.frame.put(x, layout.top, &option.label, label_style);
if option.recommended {
x = pc
.frame
.put(x, layout.top, " (Recommended)", tint(dim(&pc.ctx.theme)));
}
if option.custom && (self.state.editing || !self.state.custom_text.is_empty()) {
x = pc.frame.put(x, layout.top, ": ", tint(dim(&pc.ctx.theme)));
x = pc.frame.put(
x,
layout.top,
&self.state.custom_text,
tint(Style::new().fg(pc.ctx.theme.info)),
);
if self.state.editing {
pc.frame.put(
x,
layout.top,
pc.ctx.charset.beam(),
tint(Style::new().fg(pc.ctx.theme.accent)),
);
}
}
self.paint_option_tail(pc, rect, index, layout);
}
}
fn focusable(&self) -> bool {
true
}
fn enter(&mut self, forward: bool) {
let visible = self.state.visible();
if visible.is_empty() {
return;
}
if !self.state.multi
&& let Some(chosen) = self.state.chosen.iter().next()
&& let Some(position) = visible
.iter()
.position(|&index| usize::from(index) == chosen)
{
self.state.cursor = position as u16;
return;
}
self.state.cursor = if forward { 0 } else { visible.len() as u16 - 1 };
}
fn key(&mut self, _ec: &mut EventCtx<'_>, key: Key) -> Flow {
self.dispatch(key)
}
fn mouse(
&mut self,
_ec: &mut EventCtx<'_>,
tag: HitTag,
_at: (u16, u16),
_rect: Rect,
mouse: Mouse,
) -> Flow {
match (mouse, tag) {
(Mouse::Click, HitTag::Row(index)) if usize::from(index) < self.state.options.len() => {
let visible = self.state.visible();
if let Some(position) = visible.iter().position(|&candidate| candidate == index) {
self.state.cursor = position as u16;
}
self.commit(index)
},
(Mouse::WheelUp | Mouse::WheelDown, _) => {
let delta = if mouse == Mouse::WheelUp { -1 } else { 1 };
if self.move_cursor(delta, false) {
self.highlight_flow()
} else if self.state.visible().is_empty() {
Flow::Skip
} else {
Flow::Consumed
}
},
(
Mouse::Click
| Mouse::RightClick
| Mouse::MiddleClick
| Mouse::Move
| Mouse::Drag
| Mouse::Release
| Mouse::WheelLeft
| Mouse::WheelRight,
_,
) => Flow::Skip,
}
}
fn paste(&mut self, _ec: &mut EventCtx<'_>, text: &str) -> Flow {
let sanitized = sanitize_paste(text);
if sanitized.is_empty() {
return Flow::Skip;
}
let single_line = sanitized.replace(['\n', '\t'], " ");
if self.state.editing {
self.state.custom_text.push_str(&single_line);
Flow::Consumed
} else if self.state.types_to_filter() || self.state.searching {
self.state.filter_q.push_str(&single_line);
self.filter_flow()
} else {
Flow::Skip
}
}
fn value(&self, out: &mut serde_json::Map<String, serde_json::Value>) {
let Some(id) = self.props.id() else {
return;
};
let value = if self.state.multi {
serde_json::Value::Array(
self
.state
.chosen
.iter()
.map(|index| option_value(&self.state, index))
.collect(),
)
} else {
self
.state
.chosen
.iter()
.next()
.map_or(serde_json::Value::Null, |index| option_value(&self.state, index))
};
out.insert(id.to_string(), value);
}
}
fn option_value(state: &SelectState, index: usize) -> serde_json::Value {
let option = &state.options[index];
if option.custom {
serde_json::Value::String(state.custom_text.clone())
} else {
serde_json::Value::String(option.value.to_string())
}
}
fn fuzzy_score(hay: &str, needle: &str) -> Option<i32> {
let hay: SmallVec<char, 64> = hay.chars().flat_map(char::to_lowercase).collect();
let mut score = 0_i32;
let mut position = 0_usize;
let mut previous: Option<usize> = None;
for ch in needle.chars().flat_map(char::to_lowercase) {
let found = hay[position..]
.iter()
.position(|&candidate| candidate == ch)?;
let at = position + found;
score += match previous {
Some(prev) if at == prev + 1 => 10,
_ => 10 - i32::try_from(found.min(8)).expect("bounded gap"),
};
previous = Some(at);
position = at + 1;
}
Some(score - i32::try_from(hay.len().min(64)).expect("bounded length"))
}
fn desc_lines(desc: &Str, width: u16) -> SmallVec<Str, 2> {
let width = width.max(8);
let mut lines = SmallVec::new();
let mut start = None;
let mut end = 0usize;
let mut line_width = 0u16;
for (offset, word) in desc
.split_whitespace()
.map(|word| (word.as_ptr() as usize - desc.as_str().as_ptr() as usize, word))
{
let word_width = cell_width(word);
match start {
Some(previous) if line_width.saturating_add(1).saturating_add(word_width) > width => {
lines.push(desc.slice(previous..end));
start = Some(offset);
end = offset + word.len();
line_width = word_width;
},
Some(_) => {
end = offset + word.len();
line_width = line_width.saturating_add(1).saturating_add(word_width);
},
None => {
start = Some(offset);
end = offset + word.len();
line_width = word_width;
},
}
if lines.len() == 2 {
break;
}
}
if let Some(start) = start
&& lines.len() < 2
{
lines.push(desc.slice(start..end));
}
lines
}
const fn base(theme: &Theme) -> Style {
Style::new().fg(theme.fg)
}
const fn dim(theme: &Theme) -> Style {
Style::new().fg(theme.muted)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Frame, Size, test_support::frame_row_text};
fn event_ctx(ctx: &UiContext) -> EventCtx<'_> {
EventCtx::new(ctx, 40, 8)
}
#[test]
fn navigate_and_activate_changes_value() {
let mut select = Select::new()
.with(Prop::Id, "pick")
.option(SelectOption::new().label("one").with(Prop::Value, "1"))
.option(SelectOption::new().label("two").with(Prop::Value, "2"));
let ctx = UiContext::default();
assert_eq!(
select.key(&mut event_ctx(&ctx), Key::Down),
Flow::Event(UiEvent::Highlighted { id: "pick".into(), value: "2".into() }),
"cursor moves surface the highlighted option"
);
assert_eq!(
select.key(&mut event_ctx(&ctx), Key::Enter),
Flow::Event(UiEvent::Changed { id: "pick".into(), value: "2".into() }),
"activation surfaces the committed option"
);
let mut values = serde_json::Map::new();
select.value(&mut values);
assert_eq!(values["pick"], serde_json::json!("2"));
assert_eq!(select.key(&mut event_ctx(&ctx), Key::Down), Flow::Skip);
}
#[test]
fn paint_places_rows_and_registers_hits() {
let mut select = Select::new().option(SelectOption::new().label("Alpha"));
let ctx = UiContext::default();
let height = select.height(&ctx, 32);
let rect = Rect::new(0, 0, 32, height);
select.place(&ctx, rect);
let mut frame = Frame::new(Size::new(32, height));
let mut hits = Vec::new();
let mut wakes = Vec::new();
let mut pc = PaintCtx::new(&mut frame, &ctx, &mut hits, &mut wakes);
pc.focus = Some(select.slot());
select.paint(&mut pc, rect);
assert!(frame_row_text(&frame, 0).contains("Alpha"));
assert_eq!(hits.len(), 1);
}
}