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, TextField, View, util};
16
17const DEFAULT_LIMIT: usize = 50;
19const FIELD_MIN_WIDTH: f32 = 180.0;
21
22#[derive(Clone, Debug, PartialEq, Eq)]
24#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
25pub struct LookupItem {
26 pub key: String,
28 pub label: String,
30}
31
32#[cfg_attr(feature = "live", ravenrs_live::expose)]
33impl LookupItem {
34 pub fn new(key: impl Into<String>, label: impl Into<String>) -> Self {
36 Self { key: key.into(), label: label.into() }
37 }
38}
39
40#[derive(Clone, Debug, PartialEq, Eq)]
42#[non_exhaustive]
43#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
44pub struct LookupRequest {
45 pub query: String,
47 pub limit: usize,
49}
50
51#[derive(Clone)]
54#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
55pub struct LookupResponder {
56 answers: State<Answer>,
57 request: u64,
58}
59
60impl fmt::Debug for LookupResponder {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 f.debug_struct("LookupResponder").field("request", &self.request).finish_non_exhaustive()
63 }
64}
65
66#[cfg_attr(feature = "live", ravenrs_live::expose)]
67impl LookupResponder {
68 pub fn respond(&self, items: Vec<LookupItem>) {
70 self.deliver(Loaded::Items(Rc::new(items)));
71 }
72
73 pub fn fail(&self, message: impl Into<String>) {
75 self.deliver(Loaded::Failed(message.into()));
76 }
77
78 fn deliver(&self, loaded: Loaded) {
79 let current = self.answers.get();
80 if current.request == self.request {
81 self.answers.set(Answer { loaded, ..current });
82 }
83 }
84}
85
86#[derive(Clone, Debug, PartialEq)]
87enum Loaded {
88 Waiting,
89 Items(Rc<Vec<LookupItem>>),
90 Failed(String),
91}
92
93#[derive(Clone, Debug, PartialEq)]
94struct Answer {
95 request: u64,
96 query: String,
97 loaded: Loaded,
98}
99
100type Loader = Rc<dyn Fn(LookupRequest, LookupResponder)>;
101
102#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
120pub struct LookupField {
121 selected: Binding<Option<LookupItem>>,
122 load: Loader,
123 placeholder: Option<String>,
124 limit: usize,
125 width: Option<f32>,
126 label: Option<String>,
127 state: util::EditorState,
128}
129
130impl fmt::Debug for LookupField {
131 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132 f.debug_struct("LookupField").field("limit", &self.limit).finish_non_exhaustive()
133 }
134}
135
136#[cfg_attr(feature = "live", ravenrs_live::expose)]
137impl LookupField {
138 pub fn new(selected: Binding<Option<LookupItem>>, items: Vec<LookupItem>) -> Self {
140 Self::remote(selected, move |request, responder| {
141 let found = items.iter().filter(|i| fuzzy_match(&i.label, &request.query));
142 responder.respond(found.take(request.limit).cloned().collect());
143 })
144 }
145
146 pub fn remote(
148 selected: Binding<Option<LookupItem>>,
149 load: impl Fn(LookupRequest, LookupResponder) + 'static,
150 ) -> Self {
151 Self {
152 selected,
153 load: Rc::new(load),
154 placeholder: None,
155 limit: DEFAULT_LIMIT,
156 width: None,
157 label: None,
158 state: util::EditorState::default(),
159 }
160 }
161
162 pub fn placeholder(mut self, text: impl Into<String>) -> Self {
164 self.placeholder = Some(text.into());
165 self
166 }
167
168 pub fn limit(mut self, limit: usize) -> Self {
170 self.limit = limit.max(1);
171 self
172 }
173
174 pub fn width(mut self, width: f32) -> Self {
176 self.width = Some(width);
177 self
178 }
179
180 pub fn label(mut self, label: impl Into<String>) -> Self {
182 self.label = Some(label.into());
183 self
184 }
185
186 pub fn disabled(mut self, disabled: bool) -> Self {
188 self.state.disabled = disabled;
189 self
190 }
191
192 pub fn read_only(mut self, read_only: bool) -> Self {
194 self.state.read_only = read_only;
195 self
196 }
197}
198
199struct LookupPanel<'a> {
200 field: &'a LookupField,
201 open: State<bool>,
202}
203
204impl View for LookupPanel<'_> {
205 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
206 let theme = *cx.theme();
207 let query: State<String> = cx.use_state(String::new);
208 let highlight: State<usize> = cx.use_state(|| 0);
209 let answers: State<Answer> =
210 cx.use_state(|| Answer { request: 0, query: "\u{0}".into(), loaded: Loaded::Waiting });
211 let q = query.get();
212 if answers.peek().query != q {
213 let request = answers.peek().request + 1;
214 answers.set(Answer { request, query: q.clone(), loaded: Loaded::Waiting });
215 highlight.set_if_changed(0);
216 (self.field.load)(
217 LookupRequest { query: q, limit: self.field.limit },
218 LookupResponder { answers: answers.clone(), request },
219 );
220 }
221 let loaded = answers.get().loaded;
222 let items: Rc<Vec<LookupItem>> = match &loaded {
223 Loaded::Items(items) => items.clone(),
224 _ => Rc::new(Vec::new()),
225 };
226 let hl = highlight.get().min(items.len().saturating_sub(1));
227 let pick = {
228 let (selected, open, items) = (self.field.selected.clone(), self.open.clone(), items.clone());
229 Rc::new(move |i: usize| {
230 selected.set(items.get(i).cloned());
231 open.set(false);
232 })
233 };
234 let (h, count, p, open) = (highlight.clone(), items.len(), pick.clone(), self.open.clone());
235 let search = cx.child(
236 &TextField::new(query.binding())
237 .placeholder(cx.labels().search.clone())
238 .icon(IconName::Search)
239 .compact()
240 .autofocus()
241 .intercept_keys(Rc::new(move |key, _| {
242 match key {
243 Key::ArrowDown if count > 0 => h.update(|x| *x = (*x + 1).min(count - 1)),
244 Key::ArrowUp => h.update(|x| *x = x.saturating_sub(1)),
245 Key::Enter if count > 0 => p(h.get().min(count - 1)),
246 Key::Enter => {}
247 Key::Escape => open.set(false),
248 _ => return false,
249 }
250 true
251 })),
252 );
253 let mut rows = vec![search];
254 let current = self.field.selected.get();
255 for (i, item) in items.iter().enumerate() {
256 let p = pick.clone();
257 let selected = current.as_ref().is_some_and(|c| c.key == item.key);
258 rows.push(search_row(cx, &item.label, selected, i == hl, Rc::new(move || p(i))));
259 }
260 let note = match &loaded {
261 Loaded::Waiting => Some(cx.labels().loading.clone()),
262 Loaded::Failed(message) => Some(message.clone()),
263 Loaded::Items(items) if items.is_empty() => Some(cx.labels().no_results.clone()),
264 Loaded::Items(_) => None,
265 };
266 if let Some(note) = note {
267 let tone =
268 if matches!(loaded, Loaded::Failed(_)) { Semantic::Danger } else { Semantic::InkMuted };
269 rows.push(util::label(cx, ¬e, TextStyle::Callout, None, theme.color(tone)));
270 }
271 let node = panel(cx, &rows, 6.0, self.field.width.unwrap_or(FIELD_MIN_WIDTH));
272 cx.semantics(node, Semantics::new(Role::ListBox));
273 node
274 }
275}
276
277impl View for LookupField {
278 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
279 let theme = *cx.theme();
280 let open: State<bool> = cx.use_state(|| false);
281 let state = self.state;
282 let is_open = open.get() && state.accepts_input();
283 let hovered = cx.is_hovered() && state.accepts_input();
284 let chosen = self.selected.get();
285 let (text, color) = match &chosen {
286 Some(item) => (item.label.clone(), theme.color(Semantic::Ink)),
287 None => (
288 self.placeholder.clone().unwrap_or_else(|| cx.labels().select.clone()),
289 theme.color(Semantic::InkMuted),
290 ),
291 };
292 let label = cx.text_truncated(
293 &text,
294 &util::attrs(&theme, TextStyle::Body, None),
295 state.ink(color),
296 LayoutStyle::leaf(),
297 );
298 let label = cx.container(
299 LayoutStyle { grow: 1.0, min_width: Some(0.0), ..LayoutStyle::stack(Axis::Horizontal) },
300 &[label],
301 Paint::None,
302 );
303 let mut children = vec![label];
304 if chosen.is_some() && state.accepts_input() {
305 let selected = self.selected.clone();
306 children.push(
307 cx.child(
308 &IconButton::new(IconName::X)
309 .label(cx.labels().clear.clone())
310 .size(ButtonSize::Small)
311 .on_click(move || selected.set(None)),
312 ),
313 );
314 }
315 children.push(util::icon(
316 cx,
317 IconName::ChevronDown,
318 16.0,
319 state.ink(theme.color(Semantic::InkMuted)),
320 ));
321 let border = if is_open {
322 Border { width: 1.5, color: theme.color(Semantic::Accent) }
323 } else {
324 let base = theme.color(Semantic::Border);
325 Border {
326 width: 1.0,
327 color: state.ink(if hovered { base.lerp(theme.color(Semantic::Ink), 0.25) } else { base }),
328 }
329 };
330 let node = cx.container(
331 LayoutStyle {
332 width: self.width,
333 min_width: Some(FIELD_MIN_WIDTH),
334 height: Some(40.0),
335 padding: Insets::symmetric(0.0, 12.0),
336 spacing: 8.0,
337 align: Align::Center,
338 align_self: Some(if self.width.is_some() { Align::Fit } else { Align::Stretch }),
339 ..LayoutStyle::stack(Axis::Horizontal)
340 },
341 &children,
342 Paint::Quad(QuadPaint {
343 corner_radius: theme.radii.md,
344 border: Some(border),
345 ..QuadPaint::new(state.fill(theme.color(Semantic::Control)))
346 }),
347 );
348 cx.semantics(label, Semantics::hidden());
349 let value = chosen.as_ref().map_or_else(String::new, |item| item.label.clone());
350 let mut semantics = Semantics::new(Role::ComboBox).value(value).expanded(is_open);
351 if let Some(label) = &self.label {
352 semantics = semantics.name(label.clone());
353 }
354 cx.semantics(node, state.semantics(semantics));
355 if state.disabled {
356 return node;
357 }
358 cx.focusable(node, theme.radii.md);
359 if !state.accepts_input() {
360 return node;
361 }
362 let toggle = open.clone();
363 cx.on_click(node, Rc::new(move || toggle.update(|o| *o = !*o)));
364 let opener = open.clone();
365 cx.on_key(
366 node,
367 Rc::new(move |key, _| {
368 let opens = matches!(key, Key::ArrowDown | Key::ArrowUp);
369 if opens {
370 opener.set(true);
371 }
372 opens
373 }),
374 );
375 cx.cursor(node, CursorIcon::Pointer);
376 let was_open = cx.use_state(|| Rc::new(Cell::new(false))).get();
377 if is_open {
378 was_open.set(true);
379 let close = open.clone();
380 let list = LookupPanel { field: self, open: open.clone() };
381 cx.overlay(node, Placement::Below, &list, Some(Rc::new(move || close.set(false))));
382 } else if was_open.replace(false) {
383 cx.request_focus();
384 }
385 node
386 }
387}