use crate::compat::{String, ToString, Vec};
use crate::core::{Color, HorizontalAlignment, Point, Rect, Size};
use crate::event::{Event, EventHandler};
use crate::impl_widget_property_hooks;
use crate::property_names_of;
use crate::render::RenderContext;
use crate::signal::{EventSignalRef, GenericSignal, Signal1};
use crate::widget::capability::coercion::{
expect_bool, expect_string, expect_u32, expect_usize, normalize_key,
};
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::input_widgets::popup_support::{
expect_rect, expect_strings, rect_value, row_height_for, strings_value,
};
use crate::widget::input_widgets::text_match;
use crate::widget::metrics::{effective_font, estimate_text_width};
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
const DEFAULT_MAX_VISIBLE: usize = 8;
const ROW_INSET: i32 = 8;
const ROW_GAP: u32 = 6;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PopupItem {
pub label: String,
pub icon: Option<String>,
pub enabled: bool,
pub hint: String,
}
impl PopupItem {
pub fn new(label: impl Into<String>) -> Self {
Self { label: label.into(), icon: None, enabled: true, hint: String::new() }
}
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = hint.into();
self
}
pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
self.icon = Some(icon.into());
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MatchMode {
#[default]
Prefix,
Contains,
}
impl MatchMode {
fn as_name(self) -> &'static str {
match self {
Self::Prefix => "prefix",
Self::Contains => "contains",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct RowDetail {
icon: Option<String>,
hint: String,
disabled: bool,
}
impl RowDetail {
fn from_item(item: &PopupItem) -> Self {
Self { icon: item.icon.clone(), hint: item.hint.clone(), disabled: !item.enabled }
}
}
pub struct SuggestionPopup {
base: BaseWidget,
items: Vec<String>,
details: Vec<RowDetail>,
visible: Vec<usize>,
anchor: Option<Rect>,
viewport: Option<Rect>,
flip_allowed: bool,
max_visible: usize,
row_height: Option<u32>,
selected_index: Option<usize>,
scroll_offset: usize,
filter: String,
match_mode: MatchMode,
empty_text: String,
alignment: HorizontalAlignment,
shown: bool,
pub suggestion_highlighted: Signal1<usize>,
pub suggestion_committed: Signal1<usize>,
pub highlight_changed: Signal1<usize>,
pub item_selected: Signal1<PopupItem>,
pub dismissed: GenericSignal,
}
impl SuggestionPopup {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::Panel, geometry, "SuggestionPopup"),
items: Vec::new(),
details: Vec::new(),
visible: Vec::new(),
anchor: None,
viewport: None,
flip_allowed: true,
max_visible: DEFAULT_MAX_VISIBLE,
row_height: None,
selected_index: None,
scroll_offset: 0,
filter: String::new(),
match_mode: MatchMode::Prefix,
empty_text: String::from("(No matches)"),
alignment: HorizontalAlignment::Left,
shown: false,
suggestion_highlighted: Signal1::new(),
suggestion_committed: Signal1::new(),
highlight_changed: Signal1::new(),
item_selected: Signal1::new(),
dismissed: GenericSignal::new(),
}
}
pub fn set_items(&mut self, items: Vec<String>) {
self.set_popup_items(items.into_iter().map(PopupItem::new).collect());
}
pub fn set_popup_items(&mut self, items: Vec<PopupItem>) {
self.items = items.iter().map(|item| item.label.clone()).collect();
self.details = items.iter().map(RowDetail::from_item).collect();
self.refresh_visible();
self.base.request_redraw();
}
pub fn items(&self) -> &[String] {
&self.items
}
pub fn set_anchor(&mut self, anchor: Rect) {
if self.anchor == Some(anchor) {
return;
}
self.anchor = Some(anchor);
self.base.request_redraw();
}
pub fn anchor(&self) -> Option<Rect> {
self.anchor
}
pub fn set_viewport(&mut self, viewport: Option<Rect>) {
if self.viewport == viewport {
return;
}
self.viewport = viewport;
self.base.request_redraw();
}
pub fn viewport(&self) -> Option<Rect> {
self.viewport
}
pub fn set_flip_allowed(&mut self, allowed: bool) {
if self.flip_allowed == allowed {
return;
}
self.flip_allowed = allowed;
self.base.request_redraw();
}
pub fn flip_allowed(&self) -> bool {
self.flip_allowed
}
pub fn set_max_visible(&mut self, max_visible: usize) {
let max_visible = max_visible.max(1);
if self.max_visible == max_visible {
return;
}
self.max_visible = max_visible;
self.scroll_to_highlight();
self.base.request_redraw();
}
pub fn max_visible(&self) -> usize {
self.max_visible
}
pub fn set_metrics(&mut self, max_visible: usize, row_height: u32) {
self.max_visible = max_visible.max(1);
self.row_height = Some(row_height.max(1));
self.scroll_to_highlight();
self.base.request_redraw();
}
pub fn set_selected_index(&mut self, index: Option<usize>) {
self.set_highlight(index);
}
pub fn selected_index(&self) -> Option<usize> {
self.selected_index
}
pub fn highlight_at(&mut self, index: usize) {
self.set_highlight(Some(index));
}
pub fn filter(&self) -> &str {
&self.filter
}
pub fn apply_filter(&mut self, text: &str) {
if self.filter == text {
return;
}
self.filter = text.to_string();
self.scroll_offset = 0;
self.refresh_visible();
self.selected_index = self.visible.first().map(|_| 0);
self.scroll_to_highlight();
self.base.request_redraw();
}
pub fn match_mode(&self) -> MatchMode {
self.match_mode
}
pub fn set_match_mode(&mut self, mode: MatchMode) {
if self.match_mode == mode {
return;
}
self.match_mode = mode;
self.refresh_visible();
self.base.request_redraw();
}
pub fn visible_items(&self) -> Vec<PopupItem> {
self.visible.iter().filter_map(|index| self.item(*index)).collect()
}
pub fn visible_count(&self) -> usize {
self.visible.len()
}
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
pub fn move_highlight(&mut self, delta: i32) -> bool {
let rows = self.visible.len();
if rows == 0 {
return false;
}
let last = rows - 1;
let next = match self.selected_index {
Some(index) => (index as i32 + delta).clamp(0, last as i32) as usize,
None => {
if delta < 0 {
last
} else {
0
}
}
};
self.set_highlight(Some(next))
}
pub fn scroll_to_highlight(&mut self) {
let Some(highlight) = self.selected_index else {
self.scroll_offset = 0;
return;
};
if highlight < self.scroll_offset {
self.scroll_offset = highlight;
} else if highlight >= self.scroll_offset + self.max_visible {
self.scroll_offset = highlight + 1 - self.max_visible;
}
let max_offset = self.visible.len().saturating_sub(self.max_visible);
self.scroll_offset = self.scroll_offset.min(max_offset);
}
pub fn commit_highlighted(&mut self) -> bool {
self.commit_selected().is_some()
}
pub fn select_highlighted(&mut self) -> Option<PopupItem> {
self.commit_selected().map(|(_, item)| item)
}
pub fn show(&mut self) {
if self.shown {
return;
}
self.shown = true;
if self.selected_index.is_none() && !self.visible.is_empty() {
self.set_highlight(Some(0));
}
self.base.request_redraw();
}
pub fn hide(&mut self) {
if !self.shown {
return;
}
self.shown = false;
self.base.request_redraw();
}
pub fn is_shown(&self) -> bool {
self.shown
}
pub fn dismiss(&mut self) {
if self.shown {
self.hide();
self.dismissed.emit();
}
}
pub fn set_row_height(&mut self, row_height: u32) {
let next = Some(row_height.max(1));
if self.row_height == next {
return;
}
self.row_height = next;
self.base.request_redraw();
}
pub fn set_empty_text(&mut self, text: &str) {
if self.empty_text == text {
return;
}
self.empty_text = text.to_string();
self.base.request_redraw();
}
pub fn empty_text(&self) -> &str {
&self.empty_text
}
pub fn alignment(&self) -> HorizontalAlignment {
self.alignment
}
pub fn set_alignment(&mut self, alignment: HorizontalAlignment) {
if self.alignment == alignment {
return;
}
self.alignment = alignment;
self.base.request_redraw();
}
pub fn row_height(&self) -> u32 {
self.row_height.unwrap_or_else(|| row_height_for(effective_font(self.base.style())))
}
pub fn popup_rect(&self) -> Rect {
let rows = self.window_count().max(1) as u32;
let height = rows.saturating_mul(self.row_height());
match self.anchor {
Some(anchor) => {
let below = anchor.y + anchor.height as i32;
let fits_below = !self.flip_allowed
|| self.viewport.is_none_or(|viewport| {
below + height as i32 <= viewport.y + viewport.height as i32
});
let y = if fits_below { below } else { anchor.y - height as i32 };
Rect::new(anchor.x, y, anchor.width, height)
}
None => {
let geometry = self.geometry();
Rect::new(geometry.x, geometry.y, geometry.width, height)
}
}
}
pub fn row_rect(&self, row: usize) -> Option<Rect> {
if row < self.scroll_offset || row >= self.scroll_offset + self.window_count() {
return None;
}
let popup = self.popup_rect();
let offset = (row - self.scroll_offset) as i32 * self.row_height() as i32;
Some(Rect::new(popup.x, popup.y + offset, popup.width, self.row_height()))
}
pub fn row_at(&self, pos: Point) -> Option<usize> {
if !self.shown {
return None;
}
let popup = self.popup_rect();
if !popup.contains_point(pos) {
return None;
}
let row_height = self.row_height().max(1) as i32;
let window_row = ((pos.y - popup.y) / row_height) as usize;
if window_row >= self.window_count() {
return None;
}
let row = self.scroll_offset + window_row;
(row < self.visible.len()).then_some(row)
}
fn window_count(&self) -> usize {
self.visible.len().min(self.max_visible)
}
fn item(&self, index: usize) -> Option<PopupItem> {
let label = self.items.get(index)?;
let detail = self.details.get(index);
Some(PopupItem {
label: label.clone(),
icon: detail.and_then(|detail| detail.icon.clone()),
hint: detail.map(|detail| detail.hint.clone()).unwrap_or_default(),
enabled: detail.is_none_or(|detail| !detail.disabled),
})
}
fn item_at(&self, visible_index: usize) -> Option<PopupItem> {
self.item(*self.visible.get(visible_index)?)
}
fn selected_item(&self) -> Option<PopupItem> {
self.selected_index.and_then(|index| self.item_at(index)).filter(|item| item.enabled)
}
fn set_highlight(&mut self, index: Option<usize>) -> bool {
let next = index.filter(|index| *index < self.visible.len());
if self.selected_index == next {
return false;
}
self.selected_index = next;
self.scroll_to_highlight();
if let Some(index) = next {
self.suggestion_highlighted.emit(index);
self.highlight_changed.emit(index);
}
self.base.request_redraw();
true
}
fn commit_selected(&mut self) -> Option<(usize, PopupItem)> {
if !self.shown {
return None;
}
let index = self.selected_index?;
let item = self.selected_item()?;
self.suggestion_committed.emit(index);
self.item_selected.emit(item.clone());
self.hide();
Some((index, item))
}
fn refresh_visible(&mut self) {
let needle = text_match::fold(&self.filter);
self.visible.clear();
for (index, label) in self.items.iter().enumerate() {
let matched = if needle.is_empty() {
true
} else {
match self.match_mode {
MatchMode::Prefix => text_match::starts_with(label, &needle),
MatchMode::Contains => text_match::contains(label, &needle),
}
};
if matched {
self.visible.push(index);
}
}
self.selected_index = match self.selected_index {
Some(_) if self.visible.is_empty() => None,
Some(index) => Some(index.min(self.visible.len() - 1)),
None => None,
};
self.scroll_to_highlight();
}
}
impl EventHandler for SuggestionPopup {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() || !self.shown {
return;
}
match event {
Event::KeyPress { key, .. } => match *key {
38 => {
self.move_highlight(-1);
}
40 => {
self.move_highlight(1);
}
36 => {
self.set_highlight(self.visible.first().map(|_| 0));
}
35 => {
self.set_highlight(self.visible.len().checked_sub(1));
}
13 => {
self.commit_highlighted();
}
27 => self.dismiss(),
_ => {}
},
Event::MousePress { pos, button: 1, .. } => {
if let Some(row) = self.row_at(*pos) {
self.set_highlight(Some(row));
self.commit_highlighted();
} else if !self.popup_rect().contains_point(*pos) {
self.dismiss();
}
}
Event::FocusLost => self.dismiss(),
_ => {}
}
}
}
impl Draw for SuggestionPopup {
fn draw(&mut self, context: &mut RenderContext) {
if !self.shown {
return;
}
let popup = self.popup_rect();
if popup.width == 0 || popup.height == 0 {
return;
}
let style = self.style();
let font = effective_font(style);
let surface = style.background_color.unwrap_or(Color::WHITE);
let ink = style.text_color.unwrap_or_else(|| surface.contrast_color());
let border = style.border_color.unwrap_or_else(|| surface.blend(&ink, 0.25));
let accent = crate::style::resolved_theme_style_shared("suggestion_popup")
.and_then(|resolved| resolved.background_color)
.or_else(|| {
let manager = crate::style::theme_manager();
manager.current_theme().map(|theme| theme.colors.primary)
})
.unwrap_or(Color::PRIMARY);
context.fill_rounded_rect(popup, 2, surface);
context.draw_rounded_rect_stroke(popup, 2, border, 1);
if self.visible.is_empty() {
let row = Rect::new(popup.x, popup.y, popup.width, self.row_height());
let line = context.text_line(row, font);
context.draw_text_fitted_cow(
Rect::new(
row.x + ROW_INSET,
line.y,
row.width.saturating_sub(ROW_INSET as u32 * 2),
line.height.max(1),
),
&self.empty_text,
font,
ink.with_alpha(120),
self.alignment,
);
return;
}
for window_row in 0..self.window_count() {
let row = self.scroll_offset + window_row;
let Some(rect) = self.row_rect(row) else { continue };
let Some(&item_index) = self.visible.get(row) else { continue };
let Some(label) = self.items.get(item_index) else { continue };
let detail = self.details.get(item_index);
let enabled = detail.is_none_or(|detail| !detail.disabled);
let hint = detail.map_or("", |detail| detail.hint.as_str());
let highlighted = self.selected_index == Some(row);
if highlighted {
context.fill_rect(rect, accent.with_alpha(40));
}
let line = context.text_line(rect, font);
let text_width = rect.width.saturating_sub(ROW_INSET as u32 * 2);
let hint_width = if hint.is_empty() {
0
} else {
estimate_text_width(hint, font, 1.0).min(text_width / 2)
};
let label_width = if hint_width == 0 {
text_width
} else {
text_width.saturating_sub(hint_width + ROW_GAP)
};
let label_ink = if !enabled {
ink.with_alpha(90)
} else if highlighted {
ink
} else {
ink.with_alpha(230)
};
context.draw_text_fitted_cow(
Rect::new(rect.x + ROW_INSET, line.y, label_width.max(1), line.height.max(1)),
label,
font,
label_ink,
self.alignment,
);
if hint_width > 0 {
context.draw_text_fitted_cow(
Rect::new(rect.x + ROW_INSET, line.y, text_width, line.height.max(1)),
hint,
font,
ink.with_alpha(120),
HorizontalAlignment::Right,
);
}
}
}
}
impl Widget for SuggestionPopup {
fn base(&self) -> &BaseWidget {
&self.base
}
fn set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> Size {
Size::new(200, self.row_height().saturating_mul(self.window_count().max(1) as u32))
}
impl_draw_bridge!();
impl_widget_property_hooks!();
fn event_signal_dyn(&self, name: &str) -> Option<EventSignalRef> {
match name {
"suggestion_highlighted" => Some(EventSignalRef::mapped(
"suggestion_highlighted",
&self.suggestion_highlighted,
|value| CapabilityValue::UInt(*value as u64),
)),
"suggestion_committed" => Some(EventSignalRef::mapped(
"suggestion_committed",
&self.suggestion_committed,
|value| CapabilityValue::UInt(*value as u64),
)),
"highlight_changed" => Some(EventSignalRef::mapped(
"highlight_changed",
&self.highlight_changed,
|value| CapabilityValue::UInt(*value as u64),
)),
"item_selected" => {
Some(EventSignalRef::mapped("item_selected", &self.item_selected, |item| {
CapabilityValue::String(item.label.clone())
}))
}
"dismissed" => Some(EventSignalRef::unit("dismissed", &self.dismissed)),
_ => None,
}
}
}
impl WidgetProperties for SuggestionPopup {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"items" => Ok(strings_value(&self.items)),
"anchor" => match self.anchor {
Some(anchor) => Ok(rect_value(anchor)),
None => Ok(CapabilityValue::Null),
},
"max_visible" => Ok(CapabilityValue::UInt(self.max_visible as u64)),
"row_height" => Ok(CapabilityValue::UInt(u64::from(self.row_height()))),
"selected_index" => match self.selected_index {
Some(index) => Ok(CapabilityValue::UInt(index as u64)),
None => Ok(CapabilityValue::Null),
},
"empty_text" => Ok(CapabilityValue::String(self.empty_text.clone())),
"shown" => Ok(CapabilityValue::Bool(self.shown)),
"filter" => Ok(CapabilityValue::String(self.filter.clone())),
"match_mode" => Ok(CapabilityValue::String(self.match_mode.as_name().to_string())),
"viewport" => match self.viewport {
Some(viewport) => Ok(rect_value(viewport)),
None => Ok(CapabilityValue::Null),
},
"flip_allowed" => Ok(CapabilityValue::Bool(self.flip_allowed)),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"items" => {
self.set_items(expect_strings(value)?);
Ok(())
}
"anchor" => match value {
CapabilityValue::Null => {
self.anchor = None;
self.base.request_redraw();
Ok(())
}
other => {
self.set_anchor(expect_rect(other)?);
Ok(())
}
},
"max_visible" => {
self.set_max_visible(expect_usize(value)?);
Ok(())
}
"row_height" => {
self.set_row_height(expect_u32(value)?);
Ok(())
}
"selected_index" => match value {
CapabilityValue::Null => {
self.set_selected_index(None);
Ok(())
}
other => {
self.set_selected_index(Some(expect_usize(other)?));
Ok(())
}
},
"empty_text" => {
let text = expect_string(value)?;
self.set_empty_text(&text);
Ok(())
}
"shown" => {
let shown = expect_bool(value)?;
if shown {
self.show();
} else {
self.dismiss();
}
Ok(())
}
"filter" => {
let text = expect_string(value)?;
self.apply_filter(&text);
Ok(())
}
"match_mode" => {
let token = normalize_key(&expect_string(value)?);
let mode = match token.as_str() {
"prefix" => MatchMode::Prefix,
"contains" => MatchMode::Contains,
_ => return Err(CapabilityAccessError::TypeMismatch),
};
self.set_match_mode(mode);
Ok(())
}
"viewport" => match value {
CapabilityValue::Null => {
self.set_viewport(None);
Ok(())
}
other => {
self.set_viewport(Some(expect_rect(other)?));
Ok(())
}
},
"flip_allowed" => {
self.set_flip_allowed(expect_bool(value)?);
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"items",
"anchor",
"max_visible",
"row_height",
"selected_index",
"empty_text",
"shown",
"filter",
"match_mode",
"viewport",
"flip_allowed",
BASE_PROPERTY_NAMES
]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compat::{lock, Arc, Mutex};
use core::sync::atomic::{AtomicUsize, Ordering};
fn popup() -> SuggestionPopup {
SuggestionPopup::new(Rect::new(0, 0, 200, 1))
}
fn labels(items: &[&str]) -> Vec<String> {
items.iter().map(|item| item.to_string()).collect()
}
#[test]
fn arrows_move_the_mark_and_only_enter_commits() {
let mut popup = popup();
popup.set_items(vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()]);
popup.show();
assert_eq!(popup.selected_index(), Some(0));
assert!(popup.move_highlight(1));
assert_eq!(popup.selected_index(), Some(1));
assert!(popup.commit_highlighted());
assert!(!popup.is_shown());
assert!(!popup.commit_highlighted());
}
#[test]
fn escape_dismisses_without_committing() {
let mut popup = popup();
popup.set_items(vec!["alpha".to_string()]);
popup.show();
popup.handle_event(&Event::key_press(27, 0));
assert!(!popup.is_shown());
}
#[test]
fn the_empty_state_row_is_painted_and_configurable() {
let mut popup = popup();
popup.show();
assert_eq!(popup.empty_text(), "(No matches)");
popup.set_empty_text("Nothing here");
assert_eq!(popup.empty_text(), "Nothing here");
}
#[test]
fn a_row_is_hit_tested_from_the_anchor_band() {
let mut popup = popup();
popup.set_items(vec!["a".to_string(), "b".to_string()]);
popup.set_anchor(Rect::new(10, 20, 120, 24));
popup.show();
let height = popup.row_height() as i32;
assert_eq!(popup.row_at(Point::new(20, 44 + height / 2)), Some(0));
assert_eq!(popup.row_at(Point::new(20, 44 + height + height / 2)), Some(1));
assert_eq!(popup.row_at(Point::new(20, 44 + height * 2)), None);
}
#[test]
fn the_property_contract_round_trips_the_rows() {
let mut popup = popup();
popup
.set("items", CapabilityValue::Tuple(vec![CapabilityValue::String("x".to_string())]))
.unwrap();
assert_eq!(popup.items(), ["x".to_string()]);
assert_eq!(popup.get("selected_index").unwrap(), CapabilityValue::Null);
}
#[test]
fn a_filter_matches_by_prefix_or_by_substring() {
let mut popup = popup();
popup.set_items(labels(&["alpha", "beta", "alphabet"]));
popup.apply_filter("ALP");
assert_eq!(popup.match_mode(), MatchMode::Prefix);
assert_eq!(popup.visible_count(), 2);
assert_eq!(
popup.visible_items().iter().map(|item| item.label.as_str()).collect::<Vec<_>>(),
vec!["alpha", "alphabet"]
);
assert_eq!(popup.items().len(), 3);
popup.set_match_mode(MatchMode::Contains);
popup.apply_filter("PHA");
assert_eq!(popup.visible_count(), 2);
popup.apply_filter("eta");
assert_eq!(popup.visible_count(), 1);
assert_eq!(popup.visible_items()[0].label, "beta");
popup.apply_filter("");
assert_eq!(popup.visible_count(), 3);
}
#[test]
fn the_popup_flips_above_the_anchor_when_the_viewport_has_no_room_below() {
let mut popup = popup();
popup.set_items(labels(&["a", "b"]));
popup.set_row_height(20);
popup.set_anchor(Rect::new(10, 20, 120, 24));
popup.set_viewport(Some(Rect::new(0, 0, 300, 44)));
assert_eq!(popup.popup_rect().y, 20 - 40);
popup.set_viewport(Some(Rect::new(0, 0, 300, 200)));
assert_eq!(popup.popup_rect().y, 44);
popup.set_viewport(Some(Rect::new(0, 0, 300, 44)));
popup.set_flip_allowed(false);
assert_eq!(popup.popup_rect().y, 44);
}
#[test]
fn the_scroll_window_is_shared_by_the_paint_order_and_the_hit_test() {
let mut popup = popup();
popup.set_items(labels(&["r0", "r1", "r2", "r3", "r4"]));
popup.set_max_visible(2);
popup.set_row_height(10);
popup.set_anchor(Rect::new(0, 0, 100, 10));
popup.show();
assert_eq!(popup.scroll_offset(), 0);
popup.set_selected_index(Some(3));
assert_eq!(popup.scroll_offset(), 2);
assert_eq!(popup.popup_rect(), Rect::new(0, 10, 100, 20));
assert_eq!(popup.row_rect(2), Some(Rect::new(0, 10, 100, 10)));
assert_eq!(popup.row_rect(3), Some(Rect::new(0, 20, 100, 10)));
assert_eq!(popup.row_rect(0), None);
assert_eq!(popup.row_rect(4), None);
assert_eq!(popup.row_at(Point::new(5, 15)), Some(2));
assert_eq!(popup.row_at(Point::new(5, 25)), Some(3));
assert_eq!(popup.row_at(Point::new(5, 35)), None);
}
#[test]
fn an_outside_press_dismisses_but_a_press_inside_the_empty_state_does_not() {
let mut popup = popup();
popup.set_anchor(Rect::new(10, 20, 120, 24));
popup.show();
let dismissals = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&dismissals);
popup.dismissed.connect(move || {
counter.fetch_add(1, Ordering::SeqCst);
});
popup.handle_event(&Event::mouse_press(20, 50, 1));
assert!(popup.is_shown());
assert_eq!(dismissals.load(Ordering::SeqCst), 0);
popup.handle_event(&Event::mouse_press(5, 5, 1));
assert!(!popup.is_shown());
assert_eq!(dismissals.load(Ordering::SeqCst), 1);
popup.handle_event(&Event::mouse_press(5, 5, 1));
assert_eq!(dismissals.load(Ordering::SeqCst), 1);
}
#[test]
fn a_press_on_a_row_commits_it_and_a_disabled_row_refuses() {
let mut popup = popup();
popup.set_popup_items(vec![
PopupItem::new("alpha"),
PopupItem::new("beta").with_hint("second"),
]);
popup.set_anchor(Rect::new(10, 20, 120, 24));
popup.show();
let row_height = popup.row_height() as i32;
popup.handle_event(&Event::mouse_press(20, 44 + row_height + 2, 1));
assert!(!popup.is_shown());
let mut disabled = PopupItem::new("locked");
disabled.enabled = false;
popup.set_popup_items(vec![disabled]);
popup.show();
assert!(!popup.commit_highlighted());
assert!(popup.is_shown());
}
#[test]
fn both_signal_vocabularies_report_one_highlight_and_one_commit() {
let mut popup = popup();
popup.set_popup_items(vec![PopupItem::new("alpha").with_hint("first")]);
let legacy = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&legacy);
popup.suggestion_highlighted.connect_ref(move |_| {
counter.fetch_add(1, Ordering::SeqCst);
});
let changed = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&changed);
popup.highlight_changed.connect_ref(move |_| {
counter.fetch_add(1, Ordering::SeqCst);
});
let picked: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let sink = Arc::clone(&picked);
popup.item_selected.connect_ref(move |item: &PopupItem| {
*lock(&sink) = Some(item.label.clone());
});
popup.show();
assert_eq!(legacy.load(Ordering::SeqCst), 1);
assert_eq!(changed.load(Ordering::SeqCst), 1);
let item = popup.select_highlighted().unwrap();
assert_eq!(item.label, "alpha");
assert_eq!(*lock(&picked), Some("alpha".to_string()));
assert!(!popup.is_shown());
}
}