The picker's own label counts the ready nodes, so the GPU list under a chosen model was clutter; it stays only for a model no node can serve, where it names why. A closed ComboBox reset its text field to the tail of the label after set_text; the cursor now sits at the start. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1896 lines
65 KiB
Rust
1896 lines
65 KiB
Rust
//! ComboBox — an editable, *filtering* selector over a **closed set** of items.
|
|
//!
|
|
//! This is a selector over a closed set, not a free-text field: half-typed
|
|
//! filter text is never itself a value. The control only ever commits ITEMS
|
|
//! from `labels` — typing filters the list, Enter commits the highlighted
|
|
//! match, Esc or blur restores the previous selection and the filter text
|
|
//! evaporates. No action is ever emitted carrying free text.
|
|
//!
|
|
//! # Interaction contract
|
|
//!
|
|
//! Conventions followed: the W3C **WAI-ARIA APG combobox pattern**, variant
|
|
//! *"Editable Combobox with List Autocomplete"* (`aria-autocomplete="list"`)
|
|
//! for the keyboard map and the activedescendant highlight model; **macOS
|
|
//! AppKit `NSComboBox`** for the disclosure-arrow and click-outside rules;
|
|
//! **VS Code's suggest widget / quick pick** for top-match preselection,
|
|
//! match highlighting and the revert-on-blur rule.
|
|
//!
|
|
//! ## Keyboard (key focus lives in the embedded `TextInput` at all times —
|
|
//! the popup highlight is a purely visual "activedescendant")
|
|
//!
|
|
//! | Key | Popup closed | Popup open |
|
|
//! |---|---|---|
|
|
//! | printable | opens popup, filters, highlights top match | filters, highlights top match |
|
|
//! | Backspace to empty | (as above) shows all items | shows all items, highlights the committed item |
|
|
//! | Down | opens popup, highlights **first** item | moves highlight down (wraps) |
|
|
//! | Alt+Down | opens popup, highlight unchanged | — |
|
|
//! | Up | opens popup, highlights **last** item | moves highlight up (wraps) |
|
|
//! | Alt+Up | — | closes popup, keeps the committed value |
|
|
//! | PageDown / PageUp | (caret: text start/end, `TextInput`) | moves highlight by one page |
|
|
//! | Home / End | caret to start / end of text | caret to start / end of text (APG: never a list jump) |
|
|
//! | Enter | nothing | commits the highlighted item and closes; **no match ⇒ nothing happens**, filter stays editable |
|
|
//! | Esc | restores the committed label | closes popup **and** restores the committed label |
|
|
//! | Tab | moves focus on | commits the highlighted item, then moves focus on |
|
|
//!
|
|
//! ## Pointer
|
|
//!
|
|
//! | Gesture | Result |
|
|
//! |---|---|
|
|
//! | click the arrow / field padding | toggles the popup showing the **full** list, focuses and selects the text |
|
|
//! | click the text of an unfocused box | focuses, selects all, opens the full list (keeps plain-dropdown ergonomics) |
|
|
//! | click the text of a focused box | places the caret; closes the popup if it was open (macOS: the field counts as "outside") |
|
|
//! | hover a row | paints a weak hover highlight — it does **not** move the keyboard highlight |
|
|
//! | click a row | commits that item and closes |
|
|
//! | click outside | closes and restores the committed label |
|
|
//! | wheel / scrollbar drag | scrolls the list |
|
|
//!
|
|
//! ## Deliberate divergences (macOS-native / closed-set semantics win)
|
|
//!
|
|
//! * `NSComboBox` accepts typed text as a value; we never do — blur and
|
|
//! click-outside **revert** to the last committed item (VS Code model).
|
|
//! * APG's second Escape *clears* the textbox; an empty box is not a member of
|
|
//! a closed set, so a second Escape re-asserts the committed label instead.
|
|
//! * APG's list-autocomplete variant highlights nothing until an arrow key is
|
|
//! pressed; Enter-commits-the-top-match needs a target, so the top match is
|
|
//! preselected as you type (VS Code `editor.suggestSelection: "first"`).
|
|
//! * No inline ("ghost text") completion — APG's list variant omits it and
|
|
//! completed-but-uncommitted text reads as a phantom value.
|
|
|
|
use crate::{
|
|
animator::{Animate, Animator, AnimatorAction, AnimatorImpl, Play},
|
|
drop_down::PopupAnchorTransform,
|
|
makepad_derive_widget::*,
|
|
makepad_draw::*,
|
|
scroll_bar::{ScrollAxis, ScrollBar, ScrollBarAction},
|
|
text_input::{TextInput, TextInputAction},
|
|
widget::*,
|
|
};
|
|
|
|
script_mod! {
|
|
use mod.prelude.widgets_internal.*
|
|
use mod.widgets.*
|
|
|
|
mod.widgets.DrawComboItemTextBase = #(DrawComboItemText::script_component(vm))
|
|
set_type_default() do #(DrawComboItemText::script_shader(vm)){
|
|
..mod.draw.DrawText
|
|
}
|
|
mod.widgets.DrawComboItemBgBase = #(DrawComboItemBg::script_component(vm))
|
|
set_type_default() do #(DrawComboItemBg::script_shader(vm)){
|
|
..mod.draw.DrawQuad
|
|
}
|
|
mod.widgets.ComboBoxBase = #(ComboBox::register_widget(vm))
|
|
|
|
mod.widgets.ComboBoxFlat = set_type_default() do mod.widgets.ComboBoxBase{
|
|
width: Fit
|
|
height: Fit
|
|
flow: Right
|
|
align: Align{y: 0.5}
|
|
padding: theme.mspace_1{left: theme.space_2, right: 22.5}
|
|
margin: theme.mspace_v_1{}
|
|
|
|
item_height: 22.0
|
|
max_visible_items: 12
|
|
popup_margin: 8.0
|
|
popup_gap: 2.0
|
|
popup_padding: 3.0
|
|
no_match_text: "No matches"
|
|
|
|
input: TextInput{
|
|
width: Fill
|
|
height: Fit
|
|
margin: 0.
|
|
padding: 0.
|
|
empty_text: ""
|
|
label_align: Align{y: 0.5}
|
|
|
|
draw_text +: {
|
|
color: theme.color_label_inner
|
|
color_hover: theme.color_label_inner_hover
|
|
color_focus: theme.color_label_inner_focus
|
|
color_down: theme.color_label_inner_down
|
|
color_disabled: theme.color_label_inner_disabled
|
|
color_empty: theme.color_text_placeholder
|
|
color_empty_hover: theme.color_text_placeholder_hover
|
|
color_empty_focus: theme.color_text_focus
|
|
text_style: theme.font_regular{ font_size: theme.font_size_p }
|
|
}
|
|
|
|
draw_bg +: {
|
|
border_radius: 0.
|
|
border_size: 0.
|
|
color: theme.color_u_hidden
|
|
color_hover: theme.color_u_hidden
|
|
color_focus: theme.color_u_hidden
|
|
color_disabled: theme.color_u_hidden
|
|
color_empty: theme.color_u_hidden
|
|
border_color: theme.color_u_hidden
|
|
border_color_hover: theme.color_u_hidden
|
|
border_color_empty: theme.color_u_hidden
|
|
border_color_disabled: theme.color_u_hidden
|
|
border_color_focus: theme.color_u_hidden
|
|
}
|
|
}
|
|
|
|
scroll_bar: mod.widgets.ScrollBar{}
|
|
|
|
draw_bg +: {
|
|
hover: instance(0.0)
|
|
focus: instance(0.0)
|
|
down: instance(0.0)
|
|
disabled: instance(0.0)
|
|
border_size: uniform(theme.beveling)
|
|
border_radius: uniform(theme.corner_radius)
|
|
color: uniform(theme.color_outset)
|
|
color_hover: uniform(theme.color_outset_hover)
|
|
color_focus: uniform(theme.color_outset_focus)
|
|
color_down: uniform(theme.color_outset_down)
|
|
color_disabled: uniform(theme.color_outset_disabled)
|
|
border_color: uniform(theme.color_bevel)
|
|
border_color_hover: uniform(theme.color_bevel_hover)
|
|
border_color_focus: uniform(theme.color_bevel_focus)
|
|
border_color_down: uniform(theme.color_bevel_down)
|
|
arrow_color: uniform(theme.color_label_inner)
|
|
arrow_color_hover: uniform(theme.color_label_inner_hover)
|
|
pixel: fn() {
|
|
let sdf = Sdf2d.viewport(self.pos * self.rect_size)
|
|
sdf.box(
|
|
self.border_size
|
|
self.border_size
|
|
self.rect_size.x - self.border_size * 2.
|
|
self.rect_size.y - self.border_size * 2.
|
|
self.border_radius
|
|
)
|
|
let fill = self.color
|
|
.mix(self.color_focus, self.focus)
|
|
.mix(self.color_hover, self.hover)
|
|
.mix(self.color_down, self.down * self.hover)
|
|
.mix(self.color_disabled, self.disabled)
|
|
sdf.fill_keep(fill)
|
|
sdf.stroke(
|
|
self.border_color
|
|
.mix(self.border_color_focus, self.focus)
|
|
.mix(self.border_color_hover, self.hover)
|
|
.mix(self.border_color_down, self.down * self.hover)
|
|
self.border_size
|
|
)
|
|
let c = vec2(self.rect_size.x - 10.0, self.rect_size.y * 0.5)
|
|
let sz = 2.5
|
|
sdf.move_to(c.x - sz, c.y - sz * 0.5)
|
|
sdf.line_to(c.x + sz, c.y - sz * 0.5)
|
|
sdf.line_to(c.x, c.y + sz)
|
|
sdf.close_path()
|
|
sdf.fill(self.arrow_color.mix(self.arrow_color_hover, self.hover))
|
|
sdf.result
|
|
}
|
|
}
|
|
|
|
draw_popup_bg +: {
|
|
border_size: uniform(theme.beveling)
|
|
border_radius: uniform(theme.corner_radius)
|
|
color: uniform(theme.color_fg_app)
|
|
border_color: uniform(theme.color_bevel)
|
|
pixel: fn() {
|
|
let sdf = Sdf2d.viewport(self.pos * self.rect_size)
|
|
sdf.box(
|
|
self.border_size
|
|
self.border_size
|
|
self.rect_size.x - self.border_size * 2.
|
|
self.rect_size.y - self.border_size * 2.
|
|
self.border_radius
|
|
)
|
|
sdf.fill_keep(self.color)
|
|
sdf.stroke(self.border_color, self.border_size)
|
|
sdf.result
|
|
}
|
|
}
|
|
|
|
draw_item +: {
|
|
hover: 0.0
|
|
active: 0.0
|
|
color: uniform(theme.color_u_hidden)
|
|
color_hover: uniform(theme.color_outset_hover)
|
|
color_active: uniform(theme.color_outset_active)
|
|
pixel: fn() {
|
|
let sdf = Sdf2d.viewport(self.pos * self.rect_size)
|
|
sdf.rect(0., 0., self.rect_size.x, self.rect_size.y)
|
|
sdf.fill(
|
|
self.color
|
|
.mix(self.color_hover, self.hover)
|
|
.mix(self.color_active, self.active)
|
|
)
|
|
sdf.result
|
|
}
|
|
}
|
|
|
|
draw_item_text +: {
|
|
hover: 0.0
|
|
active: 0.0
|
|
matched: 0.0
|
|
dim: 0.0
|
|
color: theme.color_label_inner
|
|
color_hover: uniform(theme.color_label_inner_hover)
|
|
color_active: uniform(theme.color_label_inner_active)
|
|
color_match: uniform(theme.color_label_inner_focus)
|
|
color_dim: uniform(theme.color_label_inner_disabled)
|
|
text_style: theme.font_regular{ font_size: theme.font_size_p }
|
|
get_color: fn() {
|
|
self.color
|
|
.mix(self.color_hover, self.hover)
|
|
.mix(self.color_active, self.active)
|
|
.mix(self.color_match, self.matched)
|
|
.mix(self.color_dim, self.dim)
|
|
}
|
|
}
|
|
|
|
selected_item: 0
|
|
|
|
animator: Animator{
|
|
disabled: {
|
|
default: @off
|
|
off: AnimatorState{
|
|
from: {all: Forward{duration: 0.}}
|
|
apply: { draw_bg: {disabled: 0.0} }
|
|
}
|
|
on: AnimatorState{
|
|
from: {all: Forward{duration: 0.2}}
|
|
apply: { draw_bg: {disabled: 1.0} }
|
|
}
|
|
}
|
|
hover: {
|
|
default: @off
|
|
off: AnimatorState{
|
|
from: {all: Forward{duration: 0.1}}
|
|
apply: { draw_bg: {down: 0.0, hover: 0.0} }
|
|
}
|
|
on: AnimatorState{
|
|
from: { all: Forward{duration: 0.1} down: Forward{duration: 0.01} }
|
|
apply: { draw_bg: {down: 0.0, hover: [{time: 0.0, value: 1.0}]} }
|
|
}
|
|
down: AnimatorState{
|
|
from: {all: Forward{duration: 0.2}}
|
|
apply: { draw_bg: {down: [{time: 0.0, value: 1.0}], hover: 1.0} }
|
|
}
|
|
}
|
|
focus: {
|
|
default: @off
|
|
off: AnimatorState{
|
|
from: {all: Forward{duration: 0.2}}
|
|
apply: { draw_bg: {focus: 0.0} }
|
|
}
|
|
on: AnimatorState{
|
|
from: {all: Forward{duration: 0.0}}
|
|
apply: { draw_bg: {focus: 1.0} }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
mod.widgets.ComboBox = set_type_default() do mod.widgets.ComboBoxFlat{}
|
|
}
|
|
|
|
/// Horizontal space kept free for the scrollbar when it is visible.
|
|
const SCROLL_BAR_RESERVE: f64 = 12.0;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pure filter model — the closed-set rules live here so they can be tested
|
|
// without a `Cx`. `labels` and the committed index stay on the widget; this
|
|
// only owns the *view* state derived from them.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Case-insensitive substring filter over `labels`. An empty filter matches
|
|
/// everything; a filter that matches nothing yields an empty set (the
|
|
/// "no match" state, in which nothing can be committed).
|
|
pub fn filter_indices(labels: &[String], filter: &str) -> Vec<usize> {
|
|
if filter.trim().is_empty() {
|
|
return (0..labels.len()).collect();
|
|
}
|
|
let needle = filter.to_lowercase();
|
|
labels
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, l)| l.to_lowercase().contains(&needle))
|
|
.map(|(i, _)| i)
|
|
.collect()
|
|
}
|
|
|
|
/// Byte range of the first case-insensitive occurrence of `filter` in `label`,
|
|
/// used to paint the matched run in the match colour. `None` when the filter is
|
|
/// empty or does not occur.
|
|
pub fn match_range(label: &str, filter: &str) -> Option<(usize, usize)> {
|
|
if filter.trim().is_empty() {
|
|
return None;
|
|
}
|
|
let hay = label.to_lowercase();
|
|
let needle = filter.to_lowercase();
|
|
let start = hay.find(&needle)?;
|
|
// Lowercasing can change byte lengths (e.g. 'İ'), so only trust the byte
|
|
// offsets when they still land on char boundaries of the original.
|
|
let end = start + needle.len();
|
|
if label.is_char_boundary(start) && label.is_char_boundary(end) {
|
|
Some((start, end))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Scrolls just enough to bring `row` fully inside a `view_h` tall viewport.
|
|
pub fn scroll_row_into_view(scroll: f64, view_h: f64, item_h: f64, row: usize) -> f64 {
|
|
let top = row as f64 * item_h;
|
|
let bot = top + item_h;
|
|
let max = (0.0f64).max(top);
|
|
if top < scroll {
|
|
top
|
|
} else if bot > scroll + view_h {
|
|
(bot - view_h).min(max)
|
|
} else {
|
|
scroll
|
|
}
|
|
}
|
|
|
|
/// The filter/highlight view state of a [`ComboBox`].
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct ComboFilter {
|
|
/// Live filter text. Empty means "no filter" — every item is listed.
|
|
pub filter: String,
|
|
/// Indices into the widget's `labels`, in list order.
|
|
pub filtered: Vec<usize>,
|
|
/// Position **within `filtered`** of the visually highlighted row, i.e. the
|
|
/// item Enter would commit. `None` in the no-match state.
|
|
pub highlight: Option<usize>,
|
|
/// True while the text field holds filter text rather than the committed
|
|
/// label. Cleared on commit, revert and blur.
|
|
pub editing: bool,
|
|
}
|
|
|
|
impl ComboFilter {
|
|
/// Drops any filter and lists everything, highlighting the committed item.
|
|
pub fn reset(&mut self, label_count: usize, selected: usize) {
|
|
self.filter.clear();
|
|
self.editing = false;
|
|
self.filtered = (0..label_count).collect();
|
|
self.highlight = if self.filtered.is_empty() {
|
|
None
|
|
} else {
|
|
Some(selected.min(self.filtered.len() - 1))
|
|
};
|
|
}
|
|
|
|
/// Applies a new filter string, preselecting the **top match** (VS Code
|
|
/// model) unless the committed item is itself still in the filtered set,
|
|
/// in which case the highlight stays on it.
|
|
pub fn set_filter(&mut self, labels: &[String], filter: &str, selected: usize) {
|
|
self.filter = filter.to_string();
|
|
self.editing = true;
|
|
self.filtered = filter_indices(labels, filter);
|
|
self.highlight = if self.filtered.is_empty() {
|
|
None
|
|
} else if let Some(pos) = self.filtered.iter().position(|i| *i == selected) {
|
|
Some(pos)
|
|
} else {
|
|
Some(0)
|
|
};
|
|
}
|
|
|
|
/// The index into `labels` that Enter would commit, or `None` when nothing
|
|
/// matches — in which case Enter must do nothing at all.
|
|
pub fn highlighted_label_index(&self) -> Option<usize> {
|
|
self.filtered.get(self.highlight?).copied()
|
|
}
|
|
|
|
pub fn has_matches(&self) -> bool {
|
|
!self.filtered.is_empty()
|
|
}
|
|
|
|
pub fn len(&self) -> usize {
|
|
self.filtered.len()
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.filtered.is_empty()
|
|
}
|
|
|
|
/// Moves the highlight by `delta` rows, wrapping at both ends (APG).
|
|
pub fn move_highlight(&mut self, delta: isize) {
|
|
let n = self.filtered.len();
|
|
if n == 0 {
|
|
self.highlight = None;
|
|
return;
|
|
}
|
|
let n_i = n as isize;
|
|
let cur = self.highlight.map(|h| h as isize);
|
|
let next = match cur {
|
|
Some(c) => (c + delta).rem_euclid(n_i),
|
|
None if delta >= 0 => 0,
|
|
None => n_i - 1,
|
|
};
|
|
self.highlight = Some(next as usize);
|
|
}
|
|
|
|
/// Moves the highlight by `rows` without wrapping (PageUp/PageDown).
|
|
pub fn page_highlight(&mut self, rows: isize) {
|
|
let n = self.filtered.len();
|
|
if n == 0 {
|
|
self.highlight = None;
|
|
return;
|
|
}
|
|
let cur = self.highlight.unwrap_or(0) as isize;
|
|
let next = (cur + rows).clamp(0, n as isize - 1);
|
|
self.highlight = Some(next as usize);
|
|
}
|
|
|
|
pub fn highlight_first(&mut self) {
|
|
self.highlight = if self.filtered.is_empty() {
|
|
None
|
|
} else {
|
|
Some(0)
|
|
};
|
|
}
|
|
|
|
pub fn highlight_last(&mut self) {
|
|
self.highlight = if self.filtered.is_empty() {
|
|
None
|
|
} else {
|
|
Some(self.filtered.len() - 1)
|
|
};
|
|
}
|
|
|
|
/// Position of the committed item inside the filtered set, if visible.
|
|
pub fn position_of(&self, label_index: usize) -> Option<usize> {
|
|
self.filtered.iter().position(|i| *i == label_index)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Popup geometry
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Where the popup lands, and how tall its scrolling viewport is. The list
|
|
/// drops **below** the field (macOS combo box) and flips above when there is
|
|
/// not enough room; the height caps at `max_visible_items` rows.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct ComboPopupGeom {
|
|
pub x: f64,
|
|
pub y: f64,
|
|
pub width: f64,
|
|
pub height: f64,
|
|
/// Top of the scrolling viewport (below the popup's top padding).
|
|
pub list_y: f64,
|
|
/// Height of the scrolling viewport.
|
|
pub list_h: f64,
|
|
pub item_h: f64,
|
|
pub pad: f64,
|
|
/// Total height of all rows.
|
|
pub content_h: f64,
|
|
/// True when the popup was flipped above the field.
|
|
pub above: bool,
|
|
}
|
|
|
|
impl ComboPopupGeom {
|
|
pub fn max_scroll(&self) -> f64 {
|
|
(self.content_h - self.list_h).max(0.0)
|
|
}
|
|
|
|
pub fn needs_scroll_bar(&self) -> bool {
|
|
self.max_scroll() > 0.5
|
|
}
|
|
|
|
pub fn popup_rect(&self) -> Rect {
|
|
Rect {
|
|
pos: dvec2(self.x, self.y),
|
|
size: dvec2(self.width, self.height),
|
|
}
|
|
}
|
|
|
|
pub fn list_rect(&self) -> Rect {
|
|
Rect {
|
|
pos: dvec2(self.x + self.pad, self.list_y),
|
|
size: dvec2((self.width - self.pad * 2.0).max(0.0), self.list_h),
|
|
}
|
|
}
|
|
|
|
/// Row index (into the *filtered* set) under an absolute point.
|
|
pub fn row_at(&self, abs: Vec2d, scroll: f64, count: usize) -> Option<usize> {
|
|
let list = self.list_rect();
|
|
if !list.contains(abs) {
|
|
return None;
|
|
}
|
|
let local = abs.y - list.pos.y + scroll;
|
|
if local < 0.0 {
|
|
return None;
|
|
}
|
|
let i = (local / self.item_h.max(1.0)).floor() as usize;
|
|
if i < count {
|
|
Some(i)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Lays the popup out under (or over) `trigger`, capped to `visible_rows` rows
|
|
/// and to the pass, never overflowing the screen margins.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn layout_combo_popup(
|
|
pass: Vec2d,
|
|
trigger: Rect,
|
|
row_count: usize,
|
|
visible_rows: usize,
|
|
item_h: f64,
|
|
content_w: f64,
|
|
pad: f64,
|
|
margin: f64,
|
|
gap: f64,
|
|
) -> ComboPopupGeom {
|
|
let item_h = item_h.max(1.0);
|
|
let pad = pad.max(0.0);
|
|
let margin = margin.max(0.0);
|
|
let gap = gap.max(0.0);
|
|
let rows = row_count.max(1);
|
|
let visible_rows = visible_rows.max(1);
|
|
|
|
let content_h = rows as f64 * item_h;
|
|
let capped_h = pad * 2.0 + content_h.min(visible_rows as f64 * item_h);
|
|
|
|
let width = content_w
|
|
.max(trigger.size.x)
|
|
.min((pass.x - margin * 2.0).max(40.0))
|
|
.max(40.0);
|
|
let x = trigger
|
|
.pos
|
|
.x
|
|
.clamp(margin, (pass.x - margin - width).max(margin));
|
|
|
|
let below_top = trigger.pos.y + trigger.size.y + gap;
|
|
let below_space = (pass.y - margin - below_top).max(0.0);
|
|
let above_bottom = trigger.pos.y - gap;
|
|
let above_space = (above_bottom - margin).max(0.0);
|
|
|
|
let (height, y, above) = if capped_h <= below_space {
|
|
(capped_h, below_top, false)
|
|
} else if capped_h <= above_space {
|
|
(capped_h, above_bottom - capped_h, true)
|
|
} else if below_space >= above_space {
|
|
(below_space.max(item_h + pad * 2.0), below_top, false)
|
|
} else {
|
|
let h = above_space.max(item_h + pad * 2.0);
|
|
(h, (above_bottom - h).max(margin), true)
|
|
};
|
|
let y = y.clamp(margin, (pass.y - margin - height).max(margin));
|
|
|
|
let list_h = (height - pad * 2.0).max(item_h);
|
|
ComboPopupGeom {
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
list_y: y + pad,
|
|
list_h,
|
|
item_h,
|
|
pad,
|
|
content_h,
|
|
above,
|
|
}
|
|
}
|
|
|
|
/// Rough width needed to show the longest label without eliding.
|
|
pub fn estimate_popup_width(labels: &[String], rows: &[usize], font_px: f64) -> f64 {
|
|
let n = rows
|
|
.iter()
|
|
.filter_map(|i| labels.get(*i))
|
|
.map(|s| s.chars().count())
|
|
.max()
|
|
.unwrap_or(1) as f64;
|
|
n * font_px.max(6.0) * 0.62 + 28.0
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Draw shaders
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Script, ScriptHook)]
|
|
#[repr(C)]
|
|
struct DrawComboItemText {
|
|
#[deref]
|
|
draw_super: DrawText,
|
|
#[live]
|
|
hover: f32,
|
|
#[live]
|
|
active: f32,
|
|
#[live]
|
|
matched: f32,
|
|
#[live]
|
|
dim: f32,
|
|
}
|
|
|
|
#[derive(Script, ScriptHook)]
|
|
#[repr(C)]
|
|
struct DrawComboItemBg {
|
|
#[deref]
|
|
draw_super: DrawQuad,
|
|
#[live]
|
|
hover: f32,
|
|
#[live]
|
|
active: f32,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Widget
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Script, ScriptHook, Widget, Animator)]
|
|
pub struct ComboBox {
|
|
#[uid]
|
|
uid: WidgetUid,
|
|
#[source]
|
|
source: ScriptObjectRef,
|
|
#[apply_default]
|
|
animator: Animator,
|
|
|
|
#[redraw]
|
|
#[live]
|
|
draw_bg: DrawQuad,
|
|
#[live]
|
|
draw_popup_bg: DrawQuad,
|
|
#[live]
|
|
draw_item: DrawComboItemBg,
|
|
#[live]
|
|
draw_item_text: DrawComboItemText,
|
|
#[live]
|
|
draw_list: DrawList2d,
|
|
|
|
/// The closed control: a real text field, so selection, clipboard and IME
|
|
/// all work. Its content is either the committed label or filter text.
|
|
#[live]
|
|
input: TextInput,
|
|
#[live]
|
|
scroll_bar: ScrollBar,
|
|
|
|
#[walk]
|
|
walk: Walk,
|
|
#[layout]
|
|
layout: Layout,
|
|
|
|
#[live]
|
|
labels: Vec<String>,
|
|
#[live]
|
|
selected_item: usize,
|
|
#[live(22.0)]
|
|
item_height: f64,
|
|
/// Rows shown before the list starts scrolling.
|
|
#[live(12)]
|
|
max_visible_items: usize,
|
|
#[live(8.0)]
|
|
popup_margin: f64,
|
|
#[live(2.0)]
|
|
popup_gap: f64,
|
|
#[live(3.0)]
|
|
popup_padding: f64,
|
|
#[live(9.0)]
|
|
popup_font_px: f64,
|
|
#[live]
|
|
no_match_text: String,
|
|
|
|
#[rust]
|
|
state: ComboFilter,
|
|
#[rust]
|
|
is_open: bool,
|
|
#[rust]
|
|
hover_row: Option<usize>,
|
|
#[rust]
|
|
scroll: f64,
|
|
/// Set when the popup opens: the highlighted row must be scrolled into
|
|
/// view on the next draw, when the viewport height is finally known.
|
|
#[rust]
|
|
scroll_to_highlight: bool,
|
|
#[rust]
|
|
geom: Option<ComboPopupGeom>,
|
|
/// The field's rect as last seen BETWEEN draws (final, aligned). During a
|
|
/// draw the field's own area still sits at its pre-alignment position when
|
|
/// it follows a Fill sibling, so the popup cannot be placed from it.
|
|
#[rust]
|
|
aligned_rect: Option<Rect>,
|
|
/// Optional mapping from a transformed parent draw list into the
|
|
/// window-space overlay where the popup is drawn and hit-tested.
|
|
#[rust]
|
|
popup_anchor_transform: Option<PopupAnchorTransform>,
|
|
#[action_data]
|
|
#[rust]
|
|
action_data: WidgetActionData,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub enum ComboBoxAction {
|
|
/// An ITEM was committed. Carries the index into `labels` — never text.
|
|
Select(usize),
|
|
#[default]
|
|
None,
|
|
}
|
|
|
|
impl ComboBox {
|
|
fn clamp_selected(&mut self) {
|
|
if self.labels.is_empty() {
|
|
self.selected_item = 0;
|
|
} else {
|
|
self.selected_item = self.selected_item.min(self.labels.len() - 1);
|
|
}
|
|
}
|
|
|
|
fn selected_label(&self) -> &str {
|
|
self.labels
|
|
.get(self.selected_item)
|
|
.map(String::as_str)
|
|
.unwrap_or("")
|
|
}
|
|
|
|
/// Pushes the committed label into the text field. Only ever called when
|
|
/// the field is not holding filter text.
|
|
fn sync_text(&mut self, cx: &mut Cx) {
|
|
let label = self.selected_label().to_string();
|
|
if self.input.text() != label {
|
|
self.input.set_text(cx, &label);
|
|
// A closed control shows the START of a long label, not the tail
|
|
// the text field would scroll to after a set_text.
|
|
self.input.set_cursor(
|
|
cx,
|
|
crate::makepad_draw::text::selection::Cursor {
|
|
index: 0,
|
|
prefer_next_row: false,
|
|
},
|
|
false,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Drops the full list open without touching key focus. Mirrors
|
|
/// `DropDown2::set_active` so headless captures can pose the popup.
|
|
pub fn set_active(&mut self, cx: &mut Cx) {
|
|
self.open_popup(cx, false);
|
|
}
|
|
|
|
/// Closes the list, leaving the committed value alone.
|
|
pub fn set_closed(&mut self, cx: &mut Cx) {
|
|
self.close_popup(cx);
|
|
}
|
|
|
|
fn open_popup(&mut self, cx: &mut Cx, keep_filter: bool) {
|
|
self.clamp_selected();
|
|
if !keep_filter {
|
|
self.state.reset(self.labels.len(), self.selected_item);
|
|
self.sync_text(cx);
|
|
}
|
|
if !self.is_open {
|
|
self.is_open = true;
|
|
cx.sweep_lock(self.draw_bg.area());
|
|
}
|
|
self.hover_row = None;
|
|
self.scroll = 0.0;
|
|
self.scroll_to_highlight = true;
|
|
self.geom = None;
|
|
self.draw_bg.redraw(cx);
|
|
self.draw_list.redraw(cx);
|
|
}
|
|
|
|
fn close_popup(&mut self, cx: &mut Cx) {
|
|
if self.is_open {
|
|
self.is_open = false;
|
|
cx.sweep_unlock(self.draw_bg.area());
|
|
}
|
|
self.hover_row = None;
|
|
self.geom = None;
|
|
self.draw_bg.redraw(cx);
|
|
self.draw_list.redraw(cx);
|
|
}
|
|
|
|
/// Esc / blur / click-outside: the half-typed filter evaporates and the
|
|
/// previously committed item comes back. Never emits an action.
|
|
fn revert(&mut self, cx: &mut Cx) {
|
|
self.state.reset(self.labels.len(), self.selected_item);
|
|
self.sync_text(cx);
|
|
self.close_popup(cx);
|
|
}
|
|
|
|
/// Commits the highlighted ITEM. Returns false in the no-match state, in
|
|
/// which case nothing happens at all and the filter stays editable.
|
|
fn commit_highlighted(&mut self, cx: &mut Cx) -> bool {
|
|
let Some(index) = self.state.highlighted_label_index() else {
|
|
return false;
|
|
};
|
|
self.commit_index(cx, index);
|
|
true
|
|
}
|
|
|
|
fn commit_index(&mut self, cx: &mut Cx, index: usize) {
|
|
if self.labels.is_empty() {
|
|
return;
|
|
}
|
|
let index = index.min(self.labels.len() - 1);
|
|
let changed = index != self.selected_item;
|
|
self.selected_item = index;
|
|
self.state.reset(self.labels.len(), self.selected_item);
|
|
self.sync_text(cx);
|
|
self.close_popup(cx);
|
|
if changed {
|
|
cx.widget_action_with_data(
|
|
&self.action_data,
|
|
self.uid,
|
|
ComboBoxAction::Select(self.selected_item),
|
|
);
|
|
}
|
|
self.draw_bg.redraw(cx);
|
|
}
|
|
|
|
fn visible_rows(&self) -> usize {
|
|
self.max_visible_items.max(1)
|
|
}
|
|
|
|
fn set_scroll(&mut self, cx: &mut Cx, scroll: f64) {
|
|
let max = self.geom.map(|g| g.max_scroll()).unwrap_or(0.0);
|
|
let next = scroll.clamp(0.0, max);
|
|
if (next - self.scroll).abs() > f64::EPSILON {
|
|
self.scroll = next;
|
|
self.scroll_bar.set_scroll_pos_no_action(cx, next);
|
|
self.draw_list.redraw(cx);
|
|
}
|
|
}
|
|
|
|
fn scroll_highlight_into_view(&mut self, cx: &mut Cx) {
|
|
let Some(g) = self.geom else { return };
|
|
let Some(row) = self.state.highlight else {
|
|
return;
|
|
};
|
|
let next = scroll_row_into_view(self.scroll, g.list_h, g.item_h, row);
|
|
self.set_scroll(cx, next);
|
|
}
|
|
|
|
/// Re-runs the filter after the text field changed and keeps the popup in
|
|
/// sync. Typing always opens the popup (APG: printable characters filter
|
|
/// the listbox).
|
|
fn on_text_changed(&mut self, cx: &mut Cx, text: &str) {
|
|
self.state.set_filter(&self.labels, text, self.selected_item);
|
|
if !self.is_open {
|
|
self.is_open = true;
|
|
cx.sweep_lock(self.draw_bg.area());
|
|
}
|
|
self.hover_row = None;
|
|
self.scroll = 0.0;
|
|
self.scroll_to_highlight = true;
|
|
self.draw_bg.redraw(cx);
|
|
self.draw_list.redraw(cx);
|
|
}
|
|
|
|
/// Keys the popup owns. Returns true when the key was consumed and must not
|
|
/// reach the text field.
|
|
fn handle_nav_key(&mut self, cx: &mut Cx, ke: &KeyEvent) -> bool {
|
|
match ke.key_code {
|
|
KeyCode::ArrowDown => {
|
|
if !self.is_open {
|
|
self.open_popup(cx, self.state.editing);
|
|
if !ke.modifiers.alt {
|
|
self.state.highlight_first();
|
|
}
|
|
} else {
|
|
self.state.move_highlight(1);
|
|
}
|
|
self.scroll_highlight_into_view(cx);
|
|
self.draw_list.redraw(cx);
|
|
true
|
|
}
|
|
KeyCode::ArrowUp => {
|
|
if ke.modifiers.alt {
|
|
if self.is_open {
|
|
self.close_popup(cx);
|
|
}
|
|
return true;
|
|
}
|
|
if !self.is_open {
|
|
self.open_popup(cx, self.state.editing);
|
|
self.state.highlight_last();
|
|
} else {
|
|
self.state.move_highlight(-1);
|
|
}
|
|
self.scroll_highlight_into_view(cx);
|
|
self.draw_list.redraw(cx);
|
|
true
|
|
}
|
|
KeyCode::PageDown if self.is_open => {
|
|
self.state.page_highlight(self.visible_rows() as isize);
|
|
self.scroll_highlight_into_view(cx);
|
|
self.draw_list.redraw(cx);
|
|
true
|
|
}
|
|
KeyCode::PageUp if self.is_open => {
|
|
self.state.page_highlight(-(self.visible_rows() as isize));
|
|
self.scroll_highlight_into_view(cx);
|
|
self.draw_list.redraw(cx);
|
|
true
|
|
}
|
|
KeyCode::ReturnKey => {
|
|
if self.is_open {
|
|
// No match: Enter does nothing, the filter stays editable.
|
|
self.commit_highlighted(cx);
|
|
}
|
|
true
|
|
}
|
|
KeyCode::Escape => {
|
|
self.revert(cx);
|
|
true
|
|
}
|
|
KeyCode::Tab => {
|
|
// Commit what is highlighted, then let focus move on.
|
|
if self.is_open && !self.commit_highlighted(cx) {
|
|
self.revert(cx);
|
|
} else if self.state.editing {
|
|
self.revert(cx);
|
|
}
|
|
false
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn handle_popup_pointer(&mut self, cx: &mut Cx, event: &Event) {
|
|
let Some(g) = self.geom else { return };
|
|
match event {
|
|
Event::Scroll(e) => {
|
|
if g.popup_rect().contains(e.abs) && !e.handled_y.get() {
|
|
let next = self.scroll + e.scroll.y;
|
|
self.set_scroll(cx, next);
|
|
e.handled_y.set(true);
|
|
}
|
|
}
|
|
Event::MouseMove(e) => {
|
|
let row = g.row_at(e.abs, self.scroll, self.state.len());
|
|
if self.hover_row != row {
|
|
self.hover_row = row;
|
|
self.draw_list.redraw(cx);
|
|
}
|
|
}
|
|
Event::MouseUp(e) => {
|
|
if let Some(row) = g.row_at(e.abs, self.scroll, self.state.len()) {
|
|
if let Some(index) = self.state.filtered.get(row).copied() {
|
|
self.commit_index(cx, index);
|
|
}
|
|
}
|
|
}
|
|
_ => (),
|
|
}
|
|
}
|
|
|
|
fn draw_field(&mut self, cx: &mut Cx2d, walk: Walk) {
|
|
self.draw_bg.begin(cx, walk, self.layout);
|
|
let input_walk = self.input.walk(cx);
|
|
let _ = self.input.draw_walk(cx, &mut Scope::empty(), input_walk);
|
|
self.draw_bg.end(cx);
|
|
}
|
|
|
|
fn draw_row_text(&mut self, cx: &mut Cx2d, label: &str) {
|
|
let range = if self.state.editing {
|
|
match_range(label, &self.state.filter)
|
|
} else {
|
|
None
|
|
};
|
|
match range {
|
|
Some((s, e)) => {
|
|
if s > 0 {
|
|
self.draw_item_text.matched = 0.0;
|
|
self.draw_item_text
|
|
.draw_walk(cx, Walk::fit(), Align { x: 0.0, y: 0.5 }, &label[..s]);
|
|
}
|
|
self.draw_item_text.matched = 1.0;
|
|
self.draw_item_text
|
|
.draw_walk(cx, Walk::fit(), Align { x: 0.0, y: 0.5 }, &label[s..e]);
|
|
self.draw_item_text.matched = 0.0;
|
|
if e < label.len() {
|
|
self.draw_item_text
|
|
.draw_walk(cx, Walk::fit(), Align { x: 0.0, y: 0.5 }, &label[e..]);
|
|
}
|
|
}
|
|
None => {
|
|
self.draw_item_text.matched = 0.0;
|
|
self.draw_item_text
|
|
.draw_walk(cx, Walk::fit(), Align { x: 0.0, y: 0.5 }, label);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn draw_popup(&mut self, cx: &mut Cx2d, trigger: Rect) {
|
|
let pass = cx.current_pass_size();
|
|
let rows = self.state.len();
|
|
let content_w = estimate_popup_width(&self.labels, &self.state.filtered, self.popup_font_px);
|
|
let geom = layout_combo_popup(
|
|
pass,
|
|
trigger,
|
|
rows,
|
|
self.visible_rows(),
|
|
self.item_height,
|
|
content_w,
|
|
self.popup_padding,
|
|
self.popup_margin,
|
|
self.popup_gap,
|
|
);
|
|
self.geom = Some(geom);
|
|
if self.scroll_to_highlight {
|
|
self.scroll_to_highlight = false;
|
|
if let Some(row) = self.state.highlight {
|
|
self.scroll = scroll_row_into_view(self.scroll, geom.list_h, geom.item_h, row);
|
|
}
|
|
}
|
|
self.scroll = self.scroll.clamp(0.0, geom.max_scroll());
|
|
|
|
self.draw_list.begin_overlay_reuse(cx);
|
|
cx.begin_root_turtle(pass, Layout::flow_overlay());
|
|
|
|
let popup_walk = Walk::fixed(geom.width, geom.height).with_abs_pos(dvec2(geom.x, geom.y));
|
|
self.draw_popup_bg.begin(
|
|
cx,
|
|
popup_walk,
|
|
Layout::flow_down().with_padding(Inset {
|
|
left: geom.pad,
|
|
right: geom.pad,
|
|
top: geom.pad,
|
|
bottom: geom.pad,
|
|
}),
|
|
);
|
|
|
|
cx.begin_turtle(
|
|
Walk::new(Size::fill(), Size::Fixed(geom.list_h)),
|
|
Layout::flow_down(),
|
|
);
|
|
let list_rect = cx.turtle().rect();
|
|
let bar = if geom.needs_scroll_bar() {
|
|
SCROLL_BAR_RESERVE
|
|
} else {
|
|
0.0
|
|
};
|
|
let row_w = (list_rect.size.x - bar).max(1.0);
|
|
|
|
if rows == 0 {
|
|
// No-match state: one dim, non-selectable row.
|
|
let text = if self.no_match_text.is_empty() {
|
|
"No matches".to_string()
|
|
} else {
|
|
self.no_match_text.clone()
|
|
};
|
|
self.draw_item.hover = 0.0;
|
|
self.draw_item.active = 0.0;
|
|
self.draw_item.begin(
|
|
cx,
|
|
Walk::fixed(row_w, geom.item_h).with_abs_pos(list_rect.pos),
|
|
row_layout(),
|
|
);
|
|
self.draw_item_text.hover = 0.0;
|
|
self.draw_item_text.active = 0.0;
|
|
self.draw_item_text.dim = 1.0;
|
|
self.draw_item_text.matched = 0.0;
|
|
self.draw_item_text
|
|
.draw_walk(cx, Walk::fit(), Align { x: 0.0, y: 0.5 }, &text);
|
|
self.draw_item_text.dim = 0.0;
|
|
self.draw_item.end(cx);
|
|
} else {
|
|
let first = (self.scroll / geom.item_h).floor().max(0.0) as usize;
|
|
let last = (((self.scroll + geom.list_h) / geom.item_h).ceil() as usize).min(rows);
|
|
let highlight = self.state.highlight;
|
|
let hover = self.hover_row;
|
|
for row in first..last {
|
|
let Some(label) = self
|
|
.state
|
|
.filtered
|
|
.get(row)
|
|
.and_then(|i| self.labels.get(*i))
|
|
.cloned()
|
|
else {
|
|
continue;
|
|
};
|
|
let y = list_rect.pos.y + row as f64 * geom.item_h - self.scroll;
|
|
let is_active = highlight == Some(row);
|
|
let is_hover = hover == Some(row) && !is_active;
|
|
self.draw_item.hover = if is_hover { 1.0 } else { 0.0 };
|
|
self.draw_item.active = if is_active { 1.0 } else { 0.0 };
|
|
self.draw_item.begin(
|
|
cx,
|
|
Walk::fixed(row_w, geom.item_h).with_abs_pos(dvec2(list_rect.pos.x, y)),
|
|
row_layout(),
|
|
);
|
|
self.draw_item_text.hover = self.draw_item.hover;
|
|
self.draw_item_text.active = self.draw_item.active;
|
|
self.draw_item_text.dim = 0.0;
|
|
self.draw_row_text(cx, &label);
|
|
self.draw_item.end(cx);
|
|
}
|
|
}
|
|
|
|
if geom.needs_scroll_bar() {
|
|
self.scroll_bar
|
|
.set_scroll_pos_no_action(cx, self.scroll);
|
|
self.scroll_bar.draw_scroll_bar(
|
|
cx,
|
|
ScrollAxis::Vertical,
|
|
list_rect,
|
|
dvec2(list_rect.size.x, geom.content_h),
|
|
);
|
|
}
|
|
cx.end_turtle();
|
|
|
|
self.draw_popup_bg.end(cx);
|
|
cx.end_pass_sized_turtle();
|
|
self.draw_list.end(cx);
|
|
}
|
|
}
|
|
|
|
fn row_layout() -> Layout {
|
|
Layout {
|
|
padding: Inset {
|
|
left: 10.0,
|
|
right: 8.0,
|
|
top: 0.0,
|
|
bottom: 0.0,
|
|
},
|
|
align: Align { x: 0.0, y: 0.5 },
|
|
..Layout::flow_right()
|
|
}
|
|
}
|
|
|
|
impl Widget for ComboBox {
|
|
fn set_disabled(&mut self, cx: &mut Cx, disabled: bool) {
|
|
self.animator_toggle(
|
|
cx,
|
|
disabled,
|
|
Animate::Yes,
|
|
ids!(disabled.on),
|
|
ids!(disabled.off),
|
|
);
|
|
self.input.set_is_read_only(cx, disabled);
|
|
}
|
|
|
|
fn disabled(&self, cx: &Cx) -> bool {
|
|
self.animator_in_state(cx, ids!(disabled.on))
|
|
}
|
|
|
|
fn text(&self) -> String {
|
|
self.selected_label().to_string()
|
|
}
|
|
|
|
fn set_text(&mut self, cx: &mut Cx, v: &str) {
|
|
if let Some(i) = self.labels.iter().position(|l| l == v) {
|
|
self.selected_item = i;
|
|
self.state.reset(self.labels.len(), self.selected_item);
|
|
self.sync_text(cx);
|
|
}
|
|
}
|
|
|
|
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
|
|
self.animator_handle_event(cx, event);
|
|
// Between draws every deferred alignment has been applied, so this is
|
|
// the field's true on-screen rect (see `aligned_rect`).
|
|
let rect = self.draw_bg.area().rect(cx);
|
|
if rect.size.x > 0.0 && rect.size.y > 0.0 {
|
|
self.aligned_rect = Some(rect);
|
|
}
|
|
|
|
// 1. Keys the combobox owns, before the text field can eat them.
|
|
if let Event::KeyDown(ke) = event {
|
|
if self.input.key_focus(cx) && self.handle_nav_key(cx, ke) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 2. The open popup owns the pointer inside its own rect. The sweep
|
|
// lock keeps every other widget (including our own text field) from
|
|
// seeing these, so the geometry hit test below is authoritative.
|
|
let mut dismissed = false;
|
|
if self.is_open {
|
|
let popup_event = self
|
|
.popup_anchor_transform
|
|
.and_then(|transform| transform_combo_popup_event(event, transform));
|
|
let popup_event = popup_event.as_ref().unwrap_or(event);
|
|
let inside = self
|
|
.geom
|
|
.is_some_and(|g| pointer_pos(popup_event).is_some_and(|p| g.popup_rect().contains(p)));
|
|
if inside {
|
|
// The scrollbar hit-tests with its own area and would be
|
|
// refused by our sweep lock; lend it the lock for one dispatch.
|
|
if self.geom.is_some_and(|g| g.needs_scroll_bar()) {
|
|
cx.sweep_unlock(self.draw_bg.area());
|
|
let mut scrolled = None;
|
|
self.scroll_bar
|
|
.handle_event_with(cx, popup_event, &mut |_cx, action| {
|
|
if let ScrollBarAction::Scroll { scroll_pos, .. } = action {
|
|
scrolled = Some(scroll_pos);
|
|
}
|
|
});
|
|
cx.sweep_lock(self.draw_bg.area());
|
|
if let Some(pos) = scrolled {
|
|
if (pos - self.scroll).abs() > f64::EPSILON {
|
|
self.scroll = pos;
|
|
self.draw_list.redraw(cx);
|
|
}
|
|
return;
|
|
}
|
|
if self.scroll_bar.is_area_captured(cx) {
|
|
return;
|
|
}
|
|
}
|
|
self.handle_popup_pointer(cx, popup_event);
|
|
return;
|
|
}
|
|
// Anything pressed outside the popup dismisses it and restores the
|
|
// committed label (macOS: the text field itself counts as
|
|
// "outside"). `dismissed` keeps the press from re-opening the list
|
|
// further down — a click on the arrow must toggle, not cycle.
|
|
if matches!(popup_event, Event::MouseDown(_) | Event::TouchUpdate(_)) {
|
|
self.revert(cx);
|
|
dismissed = true;
|
|
} else {
|
|
self.handle_popup_pointer(cx, popup_event);
|
|
}
|
|
}
|
|
|
|
// 3. The text field. It owns clicks on the text, so it must be
|
|
// dispatched before the field-wide hit test below, or the arrow's
|
|
// hit area would swallow every press.
|
|
let input_area = self.input.area();
|
|
let input_rect = input_area.rect(cx);
|
|
let was_focused = self.input.key_focus(cx);
|
|
let clicked_text = matches!(event, Event::MouseDown(e) if input_rect.contains(e.abs));
|
|
// A mouse-up on our own chrome (the arrow, the padding) makes a focused
|
|
// TextInput blur itself — "the press landed outside me" — which would
|
|
// throw the filter away every time the arrow is clicked. Keep those
|
|
// ups away from it unless it is mid-drag and owns the finger.
|
|
let chrome_up = matches!(event, Event::MouseUp(e)
|
|
if self.aligned_rect.is_some_and(|r| r.contains(e.abs))
|
|
&& !input_rect.contains(e.abs)
|
|
&& !cx.fingers.is_area_captured(input_area));
|
|
if !chrome_up {
|
|
for action in cx.capture_actions(|cx| self.input.handle_event(cx, event, scope)) {
|
|
match action.as_widget_action().cast() {
|
|
TextInputAction::KeyFocus => {
|
|
self.animator_play(cx, ids!(focus.on));
|
|
}
|
|
TextInputAction::KeyFocusLost => {
|
|
// The field also reports a blur when its area is
|
|
// rebuilt between frames; only a real focus move may
|
|
// drop the filter.
|
|
if !self.input.key_focus(cx) {
|
|
self.animator_play(cx, ids!(focus.off));
|
|
self.revert(cx);
|
|
}
|
|
}
|
|
TextInputAction::Changed(text) => {
|
|
self.on_text_changed(cx, &text);
|
|
}
|
|
TextInputAction::Returned(_, _) => {
|
|
if self.is_open {
|
|
self.commit_highlighted(cx);
|
|
}
|
|
}
|
|
TextInputAction::Escaped => {
|
|
self.revert(cx);
|
|
}
|
|
_ => (),
|
|
}
|
|
}
|
|
}
|
|
if clicked_text && !was_focused && !dismissed {
|
|
// First click into an unfocused box behaves like the plain
|
|
// dropdown it replaces: select all and drop the full list.
|
|
self.input.select_all(cx);
|
|
self.open_popup(cx, false);
|
|
}
|
|
|
|
// 4. The rest of the field — the arrow and the padding around the text.
|
|
match event.hits_with_sweep_area(cx, self.draw_bg.area(), self.draw_bg.area()) {
|
|
Hit::FingerDown(fe) if fe.is_primary_hit() => {
|
|
if self.animator_in_state(cx, ids!(disabled.off)) {
|
|
self.animator_play(cx, ids!(hover.down));
|
|
if self.is_open {
|
|
self.close_popup(cx);
|
|
} else if !dismissed {
|
|
self.input.take_key_focus(cx);
|
|
self.input.select_all(cx);
|
|
self.open_popup(cx, false);
|
|
}
|
|
}
|
|
}
|
|
Hit::FingerHoverIn(_) => {
|
|
cx.set_cursor(MouseCursor::Hand);
|
|
self.animator_play(cx, ids!(hover.on));
|
|
}
|
|
Hit::FingerHoverOut(_) => {
|
|
self.animator_play(cx, ids!(hover.off));
|
|
}
|
|
Hit::FingerUp(fe) if fe.is_primary_hit() => {
|
|
if fe.is_over && fe.device.has_hovers() {
|
|
self.animator_play(cx, ids!(hover.on));
|
|
} else {
|
|
self.animator_play(cx, ids!(hover.off));
|
|
}
|
|
}
|
|
_ => (),
|
|
}
|
|
}
|
|
|
|
fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep {
|
|
self.clamp_selected();
|
|
if !self.state.editing {
|
|
// The field shows the committed label whenever it is not holding
|
|
// filter text — this also picks up `labels` arriving from the DSL.
|
|
self.sync_text(cx);
|
|
}
|
|
self.draw_field(cx, walk);
|
|
if self.is_open {
|
|
// Turtle alignment is deferred: a field laid out after a Fill
|
|
// sibling is recorded at its pre-shift position and only moved when
|
|
// the ancestor turtle ends, so the rect visible during THIS draw
|
|
// would put the popup at the row's pre-alignment origin. Place it
|
|
// from the rect captured between draws instead.
|
|
let trigger = self
|
|
.aligned_rect
|
|
.unwrap_or_else(|| self.draw_bg.area().rect(cx));
|
|
let trigger = self
|
|
.popup_anchor_transform
|
|
.map(|transform| transform.rect(trigger))
|
|
.unwrap_or(trigger);
|
|
self.draw_popup(cx, trigger);
|
|
}
|
|
DrawStep::done()
|
|
}
|
|
}
|
|
|
|
impl ComboBoxRef {
|
|
/// Replaces the closed set. The committed index is clamped and the text
|
|
/// field re-shows the (possibly new) committed label.
|
|
pub fn set_labels(&self, cx: &mut Cx, labels: Vec<String>) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
inner.labels = labels;
|
|
inner.clamp_selected();
|
|
let n = inner.labels.len();
|
|
let selected = inner.selected_item;
|
|
inner.state.reset(n, selected);
|
|
inner.sync_text(cx);
|
|
inner.draw_bg.redraw(cx);
|
|
if inner.is_open {
|
|
inner.draw_list.redraw(cx);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn labels(&self) -> Vec<String> {
|
|
self.borrow().map(|i| i.labels.clone()).unwrap_or_default()
|
|
}
|
|
|
|
/// The index committed by this action batch, if any. Never carries text.
|
|
pub fn selected(&self, actions: &Actions) -> Option<usize> {
|
|
if let Some(item) = actions.find_widget_action(self.widget_uid()) {
|
|
if let ComboBoxAction::Select(id) = item.cast() {
|
|
return Some(id);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub fn changed(&self, actions: &Actions) -> Option<usize> {
|
|
self.selected(actions)
|
|
}
|
|
|
|
/// The label committed by this action batch, if any.
|
|
pub fn changed_label(&self, actions: &Actions) -> Option<String> {
|
|
let index = self.selected(actions)?;
|
|
self.borrow()?.labels.get(index).cloned()
|
|
}
|
|
|
|
pub fn set_selected_item(&self, cx: &mut Cx, item: usize) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
let new_selected = if inner.labels.is_empty() {
|
|
0
|
|
} else {
|
|
item.min(inner.labels.len() - 1)
|
|
};
|
|
if new_selected != inner.selected_item {
|
|
inner.selected_item = new_selected;
|
|
let n = inner.labels.len();
|
|
let selected = inner.selected_item;
|
|
inner.state.reset(n, selected);
|
|
inner.sync_text(cx);
|
|
inner.draw_bg.redraw(cx);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn selected_item(&self) -> usize {
|
|
self.borrow().map(|inner| inner.selected_item).unwrap_or(0)
|
|
}
|
|
|
|
pub fn selected_label(&self) -> String {
|
|
self.borrow()
|
|
.map(|inner| inner.selected_label().to_string())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Selects the first item whose complete label equals `label`.
|
|
pub fn set_selected_by_label(&self, label: &str, cx: &mut Cx) {
|
|
let index = self
|
|
.borrow()
|
|
.and_then(|inner| inner.labels.iter().position(|item| item == label));
|
|
if let Some(index) = index {
|
|
self.set_selected_item(cx, index);
|
|
}
|
|
}
|
|
|
|
pub fn set_popup_anchor_transform(
|
|
&self,
|
|
cx: &mut Cx,
|
|
transform: Option<PopupAnchorTransform>,
|
|
) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
if inner.popup_anchor_transform != transform {
|
|
inner.popup_anchor_transform = transform;
|
|
inner.draw_bg.redraw(cx);
|
|
inner.draw_list.redraw(cx);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Drops the full list open (headless captures, programmatic reveal).
|
|
pub fn open_list(&self, cx: &mut Cx) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
inner.set_active(cx);
|
|
}
|
|
}
|
|
|
|
pub fn close_list(&self, cx: &mut Cx) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
inner.set_closed(cx);
|
|
}
|
|
}
|
|
|
|
pub fn is_open(&self) -> bool {
|
|
self.borrow().map(|inner| inner.is_open).unwrap_or(false)
|
|
}
|
|
|
|
/// Rows shown before the popup starts scrolling.
|
|
pub fn set_max_visible_items(&self, cx: &mut Cx, rows: usize) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
inner.max_visible_items = rows.max(1);
|
|
inner.draw_list.redraw(cx);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn pointer_pos(event: &Event) -> Option<Vec2d> {
|
|
match event {
|
|
Event::MouseDown(e) => Some(e.abs),
|
|
Event::MouseUp(e) => Some(e.abs),
|
|
Event::MouseMove(e) => Some(e.abs),
|
|
Event::Scroll(e) => Some(e.abs),
|
|
Event::TouchUpdate(e) => e.touches.first().map(|t| t.abs),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn transform_combo_popup_event(event: &Event, transform: PopupAnchorTransform) -> Option<Event> {
|
|
let point = |point: DVec2| transform.rect(Rect { pos: point, size: dvec2(0.0, 0.0) }).pos;
|
|
Some(match event {
|
|
Event::MouseDown(e) => {
|
|
let mut e = e.clone();
|
|
e.abs = point(e.abs);
|
|
Event::MouseDown(e)
|
|
}
|
|
Event::MouseMove(e) => {
|
|
let mut e = e.clone();
|
|
e.abs = point(e.abs);
|
|
e.lock_delta *= transform.scale;
|
|
Event::MouseMove(e)
|
|
}
|
|
Event::MouseUp(e) => {
|
|
let mut e = e.clone();
|
|
e.abs = point(e.abs);
|
|
Event::MouseUp(e)
|
|
}
|
|
Event::MouseLeave(e) => {
|
|
let mut e = e.clone();
|
|
e.abs = point(e.abs);
|
|
Event::MouseLeave(e)
|
|
}
|
|
Event::Scroll(e) => {
|
|
let mut e = e.clone();
|
|
e.abs = point(e.abs);
|
|
e.scroll *= transform.scale;
|
|
Event::Scroll(e)
|
|
}
|
|
Event::LongPress(e) => {
|
|
let mut e = e.clone();
|
|
e.abs = point(e.abs);
|
|
Event::LongPress(e)
|
|
}
|
|
Event::TouchUpdate(e) => {
|
|
let mut e = e.clone();
|
|
for touch in &mut e.touches {
|
|
touch.abs = point(touch.abs);
|
|
touch.radius *= transform.scale;
|
|
}
|
|
Event::TouchUpdate(e)
|
|
}
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::cell::Cell;
|
|
|
|
fn combo_ref(cx: &mut Cx) -> ComboBoxRef {
|
|
let combo = cx.with_vm(ComboBox::script_new_with_default);
|
|
WidgetRef::new_with_inner(Box::new(combo)).as_combo_box()
|
|
}
|
|
|
|
fn labels() -> Vec<String> {
|
|
[
|
|
"image", "music", "music-hd", "mesh", "matte", "video", "world",
|
|
]
|
|
.iter()
|
|
.map(|s| s.to_string())
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn ref_selects_an_item_by_its_complete_label() {
|
|
let mut cx = Cx::new(Box::new(|_, _| {}));
|
|
cx.with_vm(crate::script_mod);
|
|
let combo = combo_ref(&mut cx);
|
|
combo.set_labels(&mut cx, labels());
|
|
|
|
combo.set_selected_by_label("music-hd", &mut cx);
|
|
assert_eq!(combo.selected_item(), 2);
|
|
assert_eq!(combo.selected_label(), "music-hd");
|
|
|
|
combo.set_selected_by_label("missing", &mut cx);
|
|
assert_eq!(combo.selected_item(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn ref_reports_the_label_selected_by_an_action() {
|
|
let mut cx = Cx::new(Box::new(|_, _| {}));
|
|
cx.with_vm(crate::script_mod);
|
|
let combo = combo_ref(&mut cx);
|
|
combo.set_labels(&mut cx, labels());
|
|
let actions: ActionsBuf = vec![Box::new(WidgetAction {
|
|
data: None,
|
|
action: Box::new(ComboBoxAction::Select(4)),
|
|
widget_uid: combo.widget_uid(),
|
|
group: None,
|
|
})];
|
|
|
|
assert_eq!(combo.changed_label(&actions).as_deref(), Some("matte"));
|
|
}
|
|
|
|
fn trigger(x: f64, y: f64, w: f64, h: f64) -> Rect {
|
|
Rect {
|
|
pos: dvec2(x, y),
|
|
size: dvec2(w, h),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn popup_anchor_transform_maps_canvas_geometry_and_pointer_input() {
|
|
let transform = PopupAnchorTransform {
|
|
scale: 0.5,
|
|
translation: dvec2(20.0, -5.0),
|
|
};
|
|
assert_eq!(
|
|
transform.rect(trigger(100.0, 80.0, 200.0, 26.0)),
|
|
trigger(70.0, 35.0, 100.0, 13.0)
|
|
);
|
|
|
|
let event = Event::MouseDown(MouseDownEvent {
|
|
abs: dvec2(120.0, 100.0),
|
|
button: MouseButton::PRIMARY,
|
|
window_id: WindowId(1, 1),
|
|
modifiers: KeyModifiers::default(),
|
|
handled: Cell::new(Area::Empty),
|
|
time: 0.0,
|
|
});
|
|
assert!(matches!(
|
|
transform_combo_popup_event(&event, transform),
|
|
Some(Event::MouseDown(event)) if event.abs == dvec2(80.0, 45.0)
|
|
));
|
|
}
|
|
|
|
// -- filtering -----------------------------------------------------------
|
|
|
|
#[test]
|
|
fn empty_filter_lists_everything() {
|
|
let l = labels();
|
|
assert_eq!(filter_indices(&l, ""), (0..l.len()).collect::<Vec<_>>());
|
|
assert_eq!(filter_indices(&l, " "), (0..l.len()).collect::<Vec<_>>());
|
|
}
|
|
|
|
#[test]
|
|
fn filter_is_case_insensitive_substring() {
|
|
let l = labels();
|
|
assert_eq!(filter_indices(&l, "mus"), vec![1, 2]);
|
|
assert_eq!(filter_indices(&l, "MUS"), vec![1, 2]);
|
|
// substring, not prefix
|
|
assert_eq!(filter_indices(&l, "hd"), vec![2]);
|
|
assert_eq!(filter_indices(&l, "e"), vec![0, 3, 4, 5]);
|
|
}
|
|
|
|
#[test]
|
|
fn no_match_yields_empty_set() {
|
|
let l = labels();
|
|
assert!(filter_indices(&l, "zzz").is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn match_range_marks_the_typed_run() {
|
|
assert_eq!(match_range("music-hd", "mus"), Some((0, 3)));
|
|
assert_eq!(match_range("music-hd", "IC"), Some((3, 5)));
|
|
assert_eq!(match_range("music-hd", ""), None);
|
|
assert_eq!(match_range("music-hd", "zz"), None);
|
|
}
|
|
|
|
// -- commit / restore semantics -----------------------------------------
|
|
|
|
#[test]
|
|
fn typing_preselects_the_top_match_and_enter_commits_it() {
|
|
let l = labels();
|
|
let mut selected = 0usize;
|
|
let mut f = ComboFilter::default();
|
|
f.reset(l.len(), selected);
|
|
|
|
f.set_filter(&l, "mus", selected);
|
|
assert_eq!(f.filtered, vec![1, 2]);
|
|
assert_eq!(f.highlight, Some(0));
|
|
// Enter commits the ITEM under the highlight, never the typed text.
|
|
let committed = f.highlighted_label_index().expect("top match");
|
|
assert_eq!(l[committed], "music");
|
|
selected = committed;
|
|
f.reset(l.len(), selected);
|
|
assert!(!f.editing);
|
|
assert_eq!(f.highlight, Some(1));
|
|
}
|
|
|
|
#[test]
|
|
fn no_match_commits_nothing_and_esc_restores() {
|
|
let l = labels();
|
|
let selected = 5usize; // "video"
|
|
let mut f = ComboFilter::default();
|
|
f.reset(l.len(), selected);
|
|
|
|
f.set_filter(&l, "zzz", selected);
|
|
assert!(!f.has_matches());
|
|
assert_eq!(f.highlight, None);
|
|
// Enter: nothing to commit -> the widget leaves `selected` alone.
|
|
assert_eq!(f.highlighted_label_index(), None);
|
|
assert!(f.editing, "the filter stays editable in the no-match state");
|
|
|
|
// Esc / blur: the filter evaporates, the committed item comes back.
|
|
f.reset(l.len(), selected);
|
|
assert_eq!(f.filter, "");
|
|
assert!(!f.editing);
|
|
assert_eq!(f.highlighted_label_index(), Some(selected));
|
|
assert_eq!(l[selected], "video");
|
|
}
|
|
|
|
#[test]
|
|
fn filter_keeps_the_committed_item_highlighted_when_it_still_matches() {
|
|
let l = labels();
|
|
let selected = 2usize; // "music-hd"
|
|
let mut f = ComboFilter::default();
|
|
f.set_filter(&l, "mus", selected);
|
|
assert_eq!(f.filtered, vec![1, 2]);
|
|
assert_eq!(f.highlight, Some(1));
|
|
assert_eq!(f.highlighted_label_index(), Some(2));
|
|
}
|
|
|
|
#[test]
|
|
fn clearing_the_filter_shows_all_items_again() {
|
|
let l = labels();
|
|
let selected = 3usize;
|
|
let mut f = ComboFilter::default();
|
|
f.set_filter(&l, "mus", selected);
|
|
f.set_filter(&l, "", selected);
|
|
assert_eq!(f.len(), l.len());
|
|
assert_eq!(f.highlighted_label_index(), Some(selected));
|
|
}
|
|
|
|
#[test]
|
|
fn arrows_wrap_and_pages_clamp() {
|
|
let l = labels();
|
|
let mut f = ComboFilter::default();
|
|
f.reset(l.len(), 0);
|
|
f.move_highlight(-1);
|
|
assert_eq!(f.highlight, Some(l.len() - 1));
|
|
f.move_highlight(1);
|
|
assert_eq!(f.highlight, Some(0));
|
|
f.page_highlight(12);
|
|
assert_eq!(f.highlight, Some(l.len() - 1));
|
|
f.page_highlight(-12);
|
|
assert_eq!(f.highlight, Some(0));
|
|
|
|
f.set_filter(&l, "zzz", 0);
|
|
f.move_highlight(1);
|
|
assert_eq!(f.highlight, None, "no match: nothing to move onto");
|
|
}
|
|
|
|
#[test]
|
|
fn empty_label_set_has_no_highlight() {
|
|
let empty: Vec<String> = vec![];
|
|
let mut f = ComboFilter::default();
|
|
f.reset(empty.len(), 0);
|
|
assert_eq!(f.highlight, None);
|
|
assert_eq!(f.highlighted_label_index(), None);
|
|
}
|
|
|
|
// -- scroll-into-view ----------------------------------------------------
|
|
|
|
#[test]
|
|
fn scroll_into_view_pulls_rows_above_and_below() {
|
|
let item_h = 22.0;
|
|
let view_h = 22.0 * 5.0;
|
|
// already visible -> unchanged
|
|
assert_eq!(scroll_row_into_view(0.0, view_h, item_h, 2), 0.0);
|
|
// below the fold -> scroll just enough
|
|
assert_eq!(
|
|
scroll_row_into_view(0.0, view_h, item_h, 5),
|
|
22.0 * 6.0 - view_h
|
|
);
|
|
// above the fold -> top-align it
|
|
assert_eq!(scroll_row_into_view(220.0, view_h, item_h, 3), 66.0);
|
|
// last row of a long list
|
|
let s = scroll_row_into_view(0.0, view_h, item_h, 40);
|
|
assert_eq!(s, 41.0 * item_h - view_h);
|
|
}
|
|
|
|
#[test]
|
|
fn keyboard_walk_keeps_the_highlight_on_screen() {
|
|
let item_h = 22.0;
|
|
let view_h = item_h * 12.0;
|
|
let mut scroll = 0.0;
|
|
for row in 0..40 {
|
|
scroll = scroll_row_into_view(scroll, view_h, item_h, row);
|
|
let top = row as f64 * item_h;
|
|
assert!(top >= scroll - 0.001, "row {row} above viewport");
|
|
assert!(top + item_h <= scroll + view_h + 0.001, "row {row} below");
|
|
}
|
|
}
|
|
|
|
// -- popup geometry ------------------------------------------------------
|
|
|
|
#[test]
|
|
fn short_list_drops_below_the_field() {
|
|
let g = layout_combo_popup(
|
|
dvec2(800.0, 600.0),
|
|
trigger(40.0, 40.0, 220.0, 28.0),
|
|
5,
|
|
12,
|
|
22.0,
|
|
220.0,
|
|
3.0,
|
|
8.0,
|
|
2.0,
|
|
);
|
|
assert!(!g.above);
|
|
assert!(g.y >= 40.0 + 28.0);
|
|
assert!(g.y + g.height <= 592.0);
|
|
assert!(!g.needs_scroll_bar());
|
|
assert_eq!(g.max_scroll(), 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn long_list_caps_at_max_visible_rows_and_scrolls() {
|
|
let g = layout_combo_popup(
|
|
dvec2(800.0, 900.0),
|
|
trigger(40.0, 30.0, 220.0, 28.0),
|
|
60,
|
|
12,
|
|
22.0,
|
|
220.0,
|
|
3.0,
|
|
8.0,
|
|
2.0,
|
|
);
|
|
assert_eq!(g.height, 3.0 * 2.0 + 12.0 * 22.0);
|
|
assert!(g.needs_scroll_bar());
|
|
assert_eq!(g.max_scroll(), 60.0 * 22.0 - g.list_h);
|
|
}
|
|
|
|
#[test]
|
|
fn popup_flips_above_when_there_is_no_room_below() {
|
|
let g = layout_combo_popup(
|
|
dvec2(800.0, 600.0),
|
|
trigger(40.0, 540.0, 220.0, 28.0),
|
|
12,
|
|
12,
|
|
22.0,
|
|
220.0,
|
|
3.0,
|
|
8.0,
|
|
2.0,
|
|
);
|
|
assert!(g.above, "{g:?}");
|
|
assert!(g.y + g.height <= 540.0);
|
|
assert!(g.y >= 8.0);
|
|
}
|
|
|
|
#[test]
|
|
fn never_taller_than_the_pass() {
|
|
let g = layout_combo_popup(
|
|
dvec2(400.0, 300.0),
|
|
trigger(10.0, 150.0, 180.0, 24.0),
|
|
80,
|
|
12,
|
|
22.0,
|
|
180.0,
|
|
3.0,
|
|
8.0,
|
|
2.0,
|
|
);
|
|
assert!(g.height <= 300.0 - 16.0);
|
|
assert!(g.y >= 8.0);
|
|
assert!(g.y + g.height <= 292.0);
|
|
assert!(g.needs_scroll_bar());
|
|
}
|
|
|
|
#[test]
|
|
fn popup_stays_inside_the_horizontal_margins() {
|
|
let g = layout_combo_popup(
|
|
dvec2(400.0, 600.0),
|
|
trigger(360.0, 100.0, 200.0, 24.0),
|
|
4,
|
|
12,
|
|
22.0,
|
|
300.0,
|
|
3.0,
|
|
8.0,
|
|
2.0,
|
|
);
|
|
assert!(g.x >= 8.0);
|
|
assert!(g.x + g.width <= 392.0);
|
|
}
|
|
|
|
#[test]
|
|
fn row_at_maps_points_through_the_scroll_offset() {
|
|
let g = layout_combo_popup(
|
|
dvec2(800.0, 900.0),
|
|
trigger(40.0, 30.0, 220.0, 28.0),
|
|
60,
|
|
12,
|
|
22.0,
|
|
220.0,
|
|
3.0,
|
|
8.0,
|
|
2.0,
|
|
);
|
|
let list = g.list_rect();
|
|
let p = |dy: f64| dvec2(list.pos.x + 4.0, list.pos.y + dy);
|
|
assert_eq!(g.row_at(p(1.0), 0.0, 60), Some(0));
|
|
assert_eq!(g.row_at(p(23.0), 0.0, 60), Some(1));
|
|
// scrolled down by 5 rows
|
|
assert_eq!(g.row_at(p(1.0), 110.0, 60), Some(5));
|
|
// outside the list
|
|
assert_eq!(g.row_at(dvec2(0.0, 0.0), 0.0, 60), None);
|
|
// past the end of a short set
|
|
assert_eq!(g.row_at(p(23.0), 0.0, 1), None);
|
|
}
|
|
}
|