1use std::cell::Cell;
4use std::fmt;
5use std::rc::Rc;
6
7use ravenrs_core::{Border, CursorIcon, Insets, Key, Role};
8use ravenrs_layout::{Align, Axis, LayoutStyle};
9use ravenrs_reactive::{Binding, State};
10use ravenrs_style::{Semantic, TextStyle};
11
12use crate::context::{BuildCx, NodeId, Paint, Placement, QuadPaint, Semantics};
13use crate::floating::{fuzzy_match, search_row};
14use crate::overlay::panel;
15use crate::{ButtonSize, IconButton, IconName, LookupItem, TextField, View, util};
16
17const MOST_SHOWN: usize = 50;
19
20#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
37pub struct TagBox {
38 selected: Binding<Vec<String>>,
39 options: Vec<LookupItem>,
40 placeholder: Option<String>,
41 allow_new: bool,
42 most: Option<usize>,
43 width: f32,
44 fill: bool,
45 label: Option<String>,
46 state: util::EditorState,
47}
48
49impl fmt::Debug for TagBox {
50 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51 f.debug_struct("TagBox").field("options", &self.options.len()).finish_non_exhaustive()
52 }
53}
54
55#[cfg_attr(feature = "live", ravenrs_live::expose)]
56impl TagBox {
57 pub fn new(selected: Binding<Vec<String>>, options: Vec<LookupItem>) -> Self {
59 Self {
60 selected,
61 options,
62 placeholder: None,
63 allow_new: false,
64 most: None,
65 width: 280.0,
66 fill: false,
67 label: None,
68 state: util::EditorState::default(),
69 }
70 }
71
72 pub fn placeholder(mut self, text: impl Into<String>) -> Self {
74 self.placeholder = Some(text.into());
75 self
76 }
77
78 pub fn allow_new(mut self, allow: bool) -> Self {
80 self.allow_new = allow;
81 self
82 }
83
84 pub fn most(mut self, most: usize) -> Self {
86 self.most = Some(most.max(1));
87 self
88 }
89
90 pub fn fill(mut self) -> Self {
92 self.fill = true;
93 self
94 }
95
96 pub fn width(mut self, width: f32) -> Self {
98 self.width = width;
99 self
100 }
101
102 pub fn label(mut self, label: impl Into<String>) -> Self {
104 self.label = Some(label.into());
105 self
106 }
107
108 pub fn disabled(mut self, disabled: bool) -> Self {
110 self.state.disabled = disabled;
111 self
112 }
113
114 pub fn read_only(mut self, read_only: bool) -> Self {
116 self.state.read_only = read_only;
117 self
118 }
119
120 fn caption(&self, key: &str) -> String {
121 self.options.iter().find(|o| o.key == key).map_or_else(|| key.to_owned(), |o| o.label.clone())
122 }
123}
124
125fn flip(chosen: &mut Vec<String>, key: &str, most: Option<usize>) {
127 if let Some(at) = chosen.iter().position(|k| k == key) {
128 chosen.remove(at);
129 } else if most.is_none_or(|most| chosen.len() < most) {
130 chosen.push(key.to_owned());
131 }
132}
133
134struct TagPanel<'a> {
135 field: &'a TagBox,
136 open: State<bool>,
137}
138
139impl View for TagPanel<'_> {
140 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
141 let theme = *cx.theme();
142 let query: State<String> = cx.use_state(String::new);
143 let highlight: State<usize> = cx.use_state(|| 0);
144 let q = query.get();
145 let chosen = self.field.selected.get();
146 let typed = q.trim().to_owned();
147 let mut shown: Vec<LookupItem> = self
148 .field
149 .options
150 .iter()
151 .filter(|o| fuzzy_match(&o.label, &q))
152 .take(MOST_SHOWN)
153 .cloned()
154 .collect();
155 let exists = self.field.options.iter().any(|o| o.label.eq_ignore_ascii_case(&typed))
156 || chosen.iter().any(|k| k.eq_ignore_ascii_case(&typed));
157 if self.field.allow_new && !typed.is_empty() && !exists {
158 shown.push(LookupItem::new(typed.clone(), cx.labels().tag_add(&typed)));
159 }
160 let hl = highlight.get().min(shown.len().saturating_sub(1));
161 let shown = Rc::new(shown);
162 let most = self.field.most;
163 let pick = {
164 let (selected, shown, query) = (self.field.selected.clone(), shown.clone(), query.clone());
165 Rc::new(move |i: usize| {
166 if let Some(item) = shown.get(i) {
167 selected.update(|chosen| flip(chosen, &item.key, most));
168 query.set(String::new());
169 }
170 })
171 };
172 let count = shown.len();
173 let (h, p, open, selected, empty) =
174 (highlight.clone(), pick.clone(), self.open.clone(), self.field.selected.clone(), q.is_empty());
175 let search = cx.child(
176 &TextField::new(query.binding())
177 .placeholder(cx.labels().search.clone())
178 .icon(IconName::Search)
179 .compact()
180 .autofocus()
181 .intercept_keys(Rc::new(move |key, _| {
182 match key {
183 Key::ArrowDown if count > 0 => h.update(|x| *x = (*x + 1).min(count - 1)),
184 Key::ArrowUp => h.update(|x| *x = x.saturating_sub(1)),
185 Key::Enter if count > 0 => p(h.get().min(count - 1)),
186 Key::Enter => {}
187 Key::Escape => open.set(false),
188 Key::Backspace if empty => selected.update(|chosen| {
189 chosen.pop();
190 }),
191 _ => return false,
192 }
193 true
194 })),
195 );
196 let mut rows = vec![search];
197 for (i, item) in shown.iter().enumerate() {
198 let p = pick.clone();
199 let on = chosen.contains(&item.key);
200 rows.push(search_row(cx, &item.label, on, i == hl, Rc::new(move || p(i))));
201 }
202 if shown.is_empty() {
203 let note = cx.labels().no_results.clone();
204 rows.push(util::label(cx, ¬e, TextStyle::Callout, None, theme.color(Semantic::InkMuted)));
205 }
206 let node = panel(cx, &rows, 6.0, self.field.width);
207 cx.semantics(node, Semantics::new(Role::ListBox));
208 node
209 }
210}
211
212impl View for TagBox {
213 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
214 let theme = *cx.theme();
215 let open: State<bool> = cx.use_state(|| false);
216 let state = self.state;
217 let is_open = open.get() && state.accepts_input();
218 let hovered = cx.is_hovered() && state.accepts_input();
219 let chosen = self.selected.get();
220 let ink = state.ink(theme.color(Semantic::Ink));
221 let mut tags = Vec::with_capacity(chosen.len());
222 for key in &chosen {
223 let caption = self.caption(key);
224 let text = util::label(cx, &caption, TextStyle::Callout, None, ink);
225 cx.semantics(text, Semantics::hidden());
226 let mut parts = vec![text];
227 if state.accepts_input() {
228 let (selected, key) = (self.selected.clone(), key.clone());
229 let name = format!("{} {caption}", cx.labels().tag_remove);
230 parts.push(
231 cx.keyed_child(
232 util::hash_text(&key),
233 &IconButton::new(IconName::X)
234 .label(name)
235 .size(ButtonSize::Small)
236 .on_click(move || selected.update(|chosen| chosen.retain(|k| *k != key))),
237 ),
238 );
239 }
240 let tag = cx.container(
241 LayoutStyle {
242 padding: Insets { left: 8.0, right: 2.0, top: 2.0, bottom: 2.0 },
243 spacing: 2.0,
244 align: Align::Center,
245 min_height: Some(26.0),
246 ..LayoutStyle::stack(Axis::Horizontal)
247 },
248 &parts,
249 Paint::Quad(QuadPaint {
250 corner_radius: theme.radii.sm,
251 ..QuadPaint::new(theme.color(Semantic::Ink).with_alpha(0.08))
252 }),
253 );
254 tags.push(tag);
255 }
256 if chosen.is_empty() {
257 let hint = self.placeholder.clone().unwrap_or_else(|| cx.labels().select.clone());
258 let muted = state.ink(theme.color(Semantic::InkMuted));
259 tags.push(util::label(cx, &hint, TextStyle::Body, None, muted));
260 }
261 let holder = cx.container(
262 LayoutStyle {
263 grow: 1.0,
264 min_width: Some(0.0),
265 spacing: 4.0,
266 wrap: true,
267 align: Align::Center,
268 ..LayoutStyle::stack(Axis::Horizontal)
269 },
270 &tags,
271 Paint::None,
272 );
273 let chevron = util::icon(cx, IconName::ChevronDown, 16.0, state.ink(theme.color(Semantic::InkMuted)));
274 let border = if is_open {
275 Border { width: 1.5, color: theme.color(Semantic::Accent) }
276 } else {
277 let base = theme.color(Semantic::Border);
278 Border {
279 width: 1.0,
280 color: state.ink(if hovered { base.lerp(theme.color(Semantic::Ink), 0.25) } else { base }),
281 }
282 };
283 let node = cx.container(
284 LayoutStyle {
285 width: (!self.fill).then_some(self.width),
286 min_height: Some(40.0),
287 padding: Insets { left: 6.0, right: 12.0, top: 6.0, bottom: 6.0 },
288 spacing: 8.0,
289 align: Align::Center,
290 align_self: Some(if self.fill { Align::Stretch } else { Align::Fit }),
291 ..LayoutStyle::stack(Axis::Horizontal)
292 },
293 &[holder, chevron],
294 Paint::Quad(QuadPaint {
295 corner_radius: theme.radii.md,
296 border: Some(border),
297 ..QuadPaint::new(state.fill(theme.color(Semantic::Control)))
298 }),
299 );
300 let value = chosen.iter().map(|k| self.caption(k)).collect::<Vec<_>>().join(", ");
301 let mut semantics = Semantics::new(Role::ComboBox).value(value).expanded(is_open);
302 if let Some(label) = &self.label {
303 semantics = semantics.name(label.clone());
304 }
305 cx.semantics(node, state.semantics(semantics));
306 if state.disabled {
307 return node;
308 }
309 cx.focusable(node, theme.radii.md);
310 if !state.accepts_input() {
311 return node;
312 }
313 let toggle = open.clone();
314 cx.on_click(node, Rc::new(move || toggle.update(|o| *o = !*o)));
315 let (opener, selected) = (open.clone(), self.selected.clone());
316 cx.on_key(
317 node,
318 Rc::new(move |key, _| match key {
319 Key::ArrowDown | Key::ArrowUp => {
320 opener.set(true);
321 true
322 }
323 Key::Backspace => {
324 selected.update(|chosen| {
325 chosen.pop();
326 });
327 true
328 }
329 _ => false,
330 }),
331 );
332 cx.cursor(node, CursorIcon::Pointer);
333 let was_open = cx.use_state(|| Rc::new(Cell::new(false))).get();
334 if is_open {
335 was_open.set(true);
336 let close = open.clone();
337 let list = TagPanel { field: self, open: open.clone() };
338 cx.overlay(node, Placement::Below, &list, Some(Rc::new(move || close.set(false))));
339 } else if was_open.replace(false) {
340 cx.request_focus();
341 }
342 node
343 }
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349
350 #[test]
351 fn flipping_takes_off_what_is_on_and_respects_the_limit() {
352 let mut chosen = vec!["a".to_owned()];
353 flip(&mut chosen, "b", Some(2));
354 assert_eq!(chosen, ["a", "b"]);
355 flip(&mut chosen, "c", Some(2));
356 assert_eq!(chosen, ["a", "b"], "no room");
357 flip(&mut chosen, "a", Some(2));
358 assert_eq!(chosen, ["b"]);
359 }
360}