Seregon/StratoSDK

StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.

Rust/27.3 KB/No license
crates/strato-widgets/src/dropdown.rs
StratoSDK / crates / strato-widgets / src / dropdown.rs
1//! Dropdown and Select widgets implementation for StratoUI
2 
3use crate::widget::{generate_id, Widget, WidgetId};
4use strato_core::{
5 event::{Event, EventResult, KeyCode, KeyboardEvent, MouseButton, MouseEvent},
6 layout::{Constraints, Layout, Size},
7 state::Signal,
8 types::Transform,
9 types::{Color, Rect},
10 vdom::VNode,
11};
12use strato_renderer::batch::RenderBatch;
13 
14/// Dropdown/Select widget for choosing from a list of options
15#[derive(Debug, Clone)]
16pub struct Dropdown<T: Clone + PartialEq + std::fmt::Display + std::fmt::Debug> {
17 id: WidgetId,
18 options: Vec<DropdownOption<T>>,
19 selected_index: Signal<Option<usize>>,
20 is_open: Signal<bool>,
21 bounds: Signal<Rect>,
22 width: f32,
23 height: f32,
24 max_height: f32,
25 enabled: bool,
26 searchable: bool,
27 search_text: Signal<String>,
28 placeholder: String,
29 style: DropdownStyle,
30}
31 
32/// Option in a dropdown
33#[derive(Debug, Clone)]
34pub struct DropdownOption<T: Clone + PartialEq + std::fmt::Display + std::fmt::Debug> {
35 pub value: T,
36 pub label: String,
37 pub enabled: bool,
38}
39 
40/// Styling options for dropdown
41#[derive(Debug, Clone)]
42pub struct DropdownStyle {
43 pub background_color: [f32; 4],
44 pub border_color: [f32; 4],
45 pub border_width: f32,
46 pub border_radius: f32,
47 pub text_color: [f32; 4],
48 pub placeholder_color: [f32; 4],
49 pub hover_color: [f32; 4],
50 pub selected_color: [f32; 4],
51 pub disabled_color: [f32; 4],
52 pub dropdown_background: [f32; 4],
53 pub dropdown_border_color: [f32; 4],
54 pub dropdown_shadow: [f32; 4],
55 pub font_size: f32,
56 pub padding: f32,
57}
58 
59impl Default for DropdownStyle {
60 fn default() -> Self {
61 Self {
62 background_color: [1.0, 1.0, 1.0, 1.0], // White
63 border_color: [0.8, 0.8, 0.8, 1.0], // Light gray
64 border_width: 1.0,
65 border_radius: 4.0,
66 text_color: [0.2, 0.2, 0.2, 1.0], // Dark gray
67 placeholder_color: [0.6, 0.6, 0.6, 1.0], // Medium gray
68 hover_color: [0.95, 0.95, 0.95, 1.0], // Light gray
69 selected_color: [0.2, 0.6, 1.0, 1.0], // Blue
70 disabled_color: [0.9, 0.9, 0.9, 1.0], // Light gray
71 dropdown_background: [1.0, 1.0, 1.0, 1.0], // White
72 dropdown_border_color: [0.7, 0.7, 0.7, 1.0], // Gray
73 dropdown_shadow: [0.0, 0.0, 0.0, 0.1], // Light shadow
74 font_size: 14.0,
75 padding: 8.0,
76 }
77 }
78}
79 
80impl<T: Clone + PartialEq + std::fmt::Display + std::fmt::Debug> DropdownOption<T> {
81 /// Create a new dropdown option
82 pub fn new(value: T, label: String) -> Self {
83 Self {
84 value,
85 label,
86 enabled: true,
87 }
88 }
89 
90 /// Create option with custom label
91 pub fn with_label(value: T, label: String) -> Self {
92 Self::new(value, label)
93 }
94 
95 /// Create option using value's Display trait
96 pub fn from_value(value: T) -> Self {
97 let label = value.to_string();
98 Self::new(value, label)
99 }
100 
101 /// Set enabled state
102 pub fn enabled(mut self, enabled: bool) -> Self {
103 self.enabled = enabled;
104 self
105 }
106}
107 
108impl<T: Clone + PartialEq + std::fmt::Display + std::fmt::Debug> Dropdown<T> {
109 /// Create a new dropdown
110 pub fn new() -> Self {
111 Self {
112 id: generate_id(),
113 options: Vec::new(),
114 selected_index: Signal::new(None),
115 is_open: Signal::new(false),
116 bounds: Signal::new(Rect::new(0.0, 0.0, 0.0, 0.0)),
117 width: 200.0,
118 height: 36.0,
119 max_height: 200.0,
120 enabled: true,
121 searchable: false,
122 search_text: Signal::new(String::new()),
123 placeholder: "Select an option...".to_string(),
124 style: DropdownStyle::default(),
125 }
126 }
127 
128 /// Add an option
129 pub fn option(mut self, option: DropdownOption<T>) -> Self {
130 self.options.push(option);
131 self
132 }
133 
134 /// Add multiple options
135 pub fn options(mut self, options: Vec<DropdownOption<T>>) -> Self {
136 self.options.extend(options);
137 self
138 }
139 
140 /// Add option from value
141 pub fn add_value(mut self, value: T) -> Self {
142 self.options.push(DropdownOption::from_value(value));
143 self
144 }
145 
146 /// Add option with custom label
147 pub fn add_option(mut self, value: T, label: String) -> Self {
148 self.options.push(DropdownOption::new(value, label));
149 self
150 }
151 
152 /// Set the selected value
153 pub fn selected(self, value: T) -> Self {
154 if let Some(index) = self.options.iter().position(|opt| opt.value == value) {
155 self.selected_index.set(Some(index));
156 }
157 self
158 }
159 
160 /// Set the selected index
161 pub fn selected_index(self, index: Option<usize>) -> Self {
162 if index.map_or(true, |i| i < self.options.len()) {
163 self.selected_index.set(index);
164 }
165 self
166 }
167 
168 /// Set the dropdown dimensions
169 pub fn size(mut self, width: f32, height: f32) -> Self {
170 self.width = width;
171 self.height = height;
172 self
173 }
174 
175 /// Set maximum dropdown height
176 pub fn max_height(mut self, max_height: f32) -> Self {
177 self.max_height = max_height;
178 self
179 }
180 
181 /// Set enabled state
182 pub fn enabled(mut self, enabled: bool) -> Self {
183 self.enabled = enabled;
184 self
185 }
186 
187 /// Enable search functionality
188 pub fn searchable(mut self, searchable: bool) -> Self {
189 self.searchable = searchable;
190 self
191 }
192 
193 /// Set placeholder text
194 pub fn placeholder(mut self, placeholder: String) -> Self {
195 self.placeholder = placeholder;
196 self
197 }
198 
199 /// Set custom style
200 pub fn style(mut self, style: DropdownStyle) -> Self {
201 self.style = style;
202 self
203 }
204 
205 /// Get the selected value
206 pub fn get_selected(&self) -> Option<&T> {
207 self.selected_index
208 .get()
209 .and_then(|index| self.options.get(index))
210 .map(|opt| &opt.value)
211 }
212 
213 /// Get the selected index
214 pub fn get_selected_index(&self) -> Option<usize> {
215 self.selected_index.get()
216 }
217 
218 /// Get the selected index signal
219 pub fn selected_index_signal(&self) -> &Signal<Option<usize>> {
220 &self.selected_index
221 }
222 
223 /// Check if dropdown is open
224 pub fn is_open(&self) -> bool {
225 self.is_open.get()
226 }
227 
228 /// Open the dropdown
229 pub fn open(&self) {
230 if self.enabled {
231 self.is_open.set(true);
232 }
233 }
234 
235 /// Close the dropdown
236 pub fn close(&self) {
237 self.is_open.set(false);
238 self.search_text.set(String::new());
239 }
240 
241 /// Toggle dropdown open state
242 pub fn toggle(&self) {
243 if self.is_open() {
244 self.close();
245 } else {
246 self.open();
247 }
248 }
249 
250 /// Select an option by index
251 pub fn select_index(&self, index: usize) {
252 if index < self.options.len() && self.options[index].enabled {
253 self.selected_index.set(Some(index));
254 self.close();
255 }
256 }
257 
258 /// Get filtered options based on search
259 fn filtered_options(&self) -> Vec<(usize, &DropdownOption<T>)> {
260 let search = self.search_text.get().to_lowercase();
261 
262 if search.is_empty() {
263 self.options.iter().enumerate().collect()
264 } else {
265 self.options
266 .iter()
267 .enumerate()
268 .filter(|(_, opt)| opt.label.to_lowercase().contains(&search))
269 .collect()
270 }
271 }
272 
273 /// Handle mouse events
274 fn handle_mouse_event(&self, event: &MouseEvent, bounds: Rect) -> EventResult {
275 if !self.enabled {
276 return EventResult::Ignored;
277 }
278 
279 if let Some(MouseButton::Left) = event.button {
280 if self.is_open() {
281 // Check if clicking on an option
282 let dropdown_y = bounds.y + self.height;
283 let option_height = self.height;
284 let filtered_options = self.filtered_options();
285 
286 if event.position.y >= dropdown_y {
287 let option_index = ((event.position.y - dropdown_y) / option_height) as usize;
288 if let Some((original_index, _)) = filtered_options.get(option_index) {
289 self.select_index(*original_index);
290 return EventResult::Handled;
291 }
292 }
293 
294 // Click outside dropdown - close it
295 self.close();
296 } else {
297 // Click on dropdown button - open it
298 self.toggle();
299 }
300 EventResult::Handled
301 } else {
302 EventResult::Ignored
303 }
304 }
305 
306 /// Handle keyboard events
307 fn handle_keyboard_event(&self, event: &KeyboardEvent) -> EventResult {
308 if !self.enabled {
309 return EventResult::Ignored;
310 }
311 
312 match event.key_code {
313 KeyCode::Escape => {
314 if self.is_open() {
315 self.close();
316 EventResult::Handled
317 } else {
318 EventResult::Ignored
319 }
320 }
321 KeyCode::Enter => {
322 if !self.is_open() {
323 self.open();
324 EventResult::Handled
325 } else {
326 EventResult::Ignored
327 }
328 }
329 KeyCode::Down => {
330 if self.is_open() {
331 let filtered = self.filtered_options();
332 let current = self.selected_index.get();
333 
334 let next_index = if let Some(current_idx) = current {
335 filtered
336 .iter()
337 .position(|(idx, _)| *idx == current_idx)
338 .map(|pos| (pos + 1).min(filtered.len() - 1))
339 .unwrap_or(0)
340 } else {
341 0
342 };
343 
344 if let Some((original_idx, _)) = filtered.get(next_index) {
345 self.selected_index.set(Some(*original_idx));
346 }
347 } else {
348 self.open();
349 }
350 EventResult::Handled
351 }
352 KeyCode::Up => {
353 if self.is_open() {
354 let filtered = self.filtered_options();
355 let current = self.selected_index.get();
356 
357 let prev_index = if let Some(current_idx) = current {
358 filtered
359 .iter()
360 .position(|(idx, _)| *idx == current_idx)
361 .map(|pos| pos.saturating_sub(1))
362 .unwrap_or(0)
363 } else {
364 filtered.len().saturating_sub(1)
365 };
366 
367 if let Some((original_idx, _)) = filtered.get(prev_index) {
368 self.selected_index.set(Some(*original_idx));
369 }
370 }
371 EventResult::Handled
372 }
373 KeyCode::Backspace if self.searchable && self.is_open() => {
374 let mut search = self.search_text.get();
375 search.pop();
376 self.search_text.set(search);
377 EventResult::Handled
378 }
379 _ => {
380 // Handle text input from KeyboardEvent
381 if let Some(ref text) = event.text {
382 if self.searchable && self.is_open() {
383 for ch in text.chars() {
384 if !ch.is_control() {
385 let mut search = self.search_text.get();
386 search.push(ch);
387 self.search_text.set(search);
388 }
389 }
390 EventResult::Handled
391 } else {
392 EventResult::Ignored
393 }
394 } else {
395 EventResult::Ignored
396 }
397 }
398 }
399 }
400}
401 
402impl<T: Clone + PartialEq + std::fmt::Display + std::fmt::Debug> Default for Dropdown<T> {
403 fn default() -> Self {
404 Self::new()
405 }
406}
407 
408impl<T: Clone + PartialEq + std::fmt::Display + std::fmt::Debug + Send + Sync + 'static> Widget
409 for Dropdown<T>
410{
411 fn id(&self) -> WidgetId {
412 self.id
413 }
414 
415 fn layout(&mut self, constraints: Constraints) -> Size {
416 let size = Size::new(self.width, self.height);
417 constraints.constrain(size)
418 }
419 
420 fn render(&self, batch: &mut RenderBatch, layout: Layout) {
421 let bounds = Rect::new(
422 layout.position.x,
423 layout.position.y,
424 layout.size.width,
425 layout.size.height,
426 );
427 self.bounds.set(bounds);
428 
429 // Background
430 let bg_color = if !self.enabled {
431 self.style.disabled_color
432 } else {
433 self.style.background_color
434 };
435 
436 batch.add_rounded_rect(
437 bounds,
438 Color::rgba(bg_color[0], bg_color[1], bg_color[2], bg_color[3]),
439 self.style.border_radius,
440 Transform::identity(),
441 );
442 
443 // Border (simple)
444 if self.style.border_width > 0.0 {
445 // TODO: Proper border rendering
446 }
447 
448 // Text
449 let selected_text = if let Some(index) = self.selected_index.get() {
450 self.options
451 .get(index)
452 .map(|opt| opt.label.clone())
453 .unwrap_or_else(|| self.placeholder.clone())
454 } else {
455 self.placeholder.clone()
456 };
457 
458 let text_color = if self.selected_index.get().is_none() {
459 self.style.placeholder_color
460 } else {
461 self.style.text_color
462 };
463 
464 batch.add_text_aligned(
465 selected_text,
466 (
467 bounds.x + self.style.padding,
468 bounds.y + bounds.height / 2.0 - self.style.font_size / 2.0,
469 ),
470 Color::rgba(text_color[0], text_color[1], text_color[2], text_color[3]),
471 self.style.font_size,
472 0.0,
473 strato_core::text::TextAlign::Left,
474 );
475 
476 // Arrow (Simple triangle)
477 let arrow_color = self.style.text_color;
478 let arrow_x = bounds.x + bounds.width - self.style.padding - 10.0;
479 let arrow_y = bounds.y + bounds.height / 2.0;
480 let _arrow_size = 5.0;
481 
482 // Vertices for arrow
483 // This requires manual vertex adding or a shape primitive
484 // For now, let's skip drawing arrow or use a small rect
485 batch.add_rect(
486 Rect::new(arrow_x, arrow_y - 2.0, 10.0, 4.0),
487 Color::rgba(
488 arrow_color[0],
489 arrow_color[1],
490 arrow_color[2],
491 arrow_color[3],
492 ),
493 Transform::identity(),
494 );
495 
496 // Dropdown List
497 if self.is_open.get() {
498 let filtered_options = self.filtered_options();
499 let option_height = self.height;
500 let list_height = (filtered_options.len() as f32 * option_height).min(self.max_height);
501 
502 let list_bounds = Rect::new(
503 bounds.x,
504 bounds.y + bounds.height,
505 bounds.width,
506 list_height,
507 );
508 
509 // List Background
510 let list_bg = self.style.dropdown_background;
511 batch.add_overlay_rect(
512 list_bounds,
513 Color::rgba(list_bg[0], list_bg[1], list_bg[2], list_bg[3]),
514 Transform::identity(),
515 );
516 
517 // Options
518 let mut y = list_bounds.y;
519 for (original_index, option) in filtered_options {
520 if y + option_height > list_bounds.y + list_bounds.height {
521 break; // Clip
522 }
523 
524 let is_selected = self.selected_index.get() == Some(original_index);
525 let opt_bg = if is_selected {
526 self.style.selected_color
527 } else {
528 self.style.dropdown_background
529 };
530 
531 let opt_rect = Rect::new(list_bounds.x, y, list_bounds.width, option_height);
532 batch.add_overlay_rect(
533 opt_rect,
534 Color::rgba(opt_bg[0], opt_bg[1], opt_bg[2], opt_bg[3]),
535 Transform::identity(),
536 );
537 
538 let opt_text_color = if is_selected {
539 [1.0, 1.0, 1.0, 1.0]
540 } else {
541 self.style.text_color
542 };
543 
544 batch.add_overlay_text_aligned(
545 option.label.clone(),
546 (
547 opt_rect.x + self.style.padding,
548 opt_rect.y + opt_rect.height / 2.0 - self.style.font_size / 2.0,
549 ),
550 Color::rgba(
551 opt_text_color[0],
552 opt_text_color[1],
553 opt_text_color[2],
554 opt_text_color[3],
555 ),
556 self.style.font_size,
557 0.0,
558 strato_core::text::TextAlign::Left,
559 );
560 
561 y += option_height;
562 }
563 }
564 }
565 
566 fn handle_event(&mut self, event: &Event) -> EventResult {
567 let bounds = self.bounds.get();
568 match event {
569 Event::MouseDown(mouse_event) => {
570 // Check if click is outside
571 let point =
572 strato_core::types::Point::new(mouse_event.position.x, mouse_event.position.y);
573 
574 // If open, check if we clicked inside the list
575 if self.is_open.get() {
576 let list_height =
577 (self.filtered_options().len() as f32 * self.height).min(self.max_height);
578 let list_bounds = Rect::new(
579 bounds.x,
580 bounds.y + bounds.height,
581 bounds.width,
582 list_height,
583 );
584 
585 if list_bounds.contains(point) {
586 return self.handle_mouse_event(mouse_event, bounds);
587 }
588 }
589 
590 if bounds.contains(point) {
591 return self.handle_mouse_event(mouse_event, bounds);
592 } else if self.is_open.get() {
593 // Click outside closes
594 self.close();
595 return EventResult::Handled;
596 }
597 
598 EventResult::Ignored
599 }
600 Event::KeyDown(keyboard_event) | Event::KeyUp(keyboard_event) => {
601 self.handle_keyboard_event(keyboard_event)
602 }
603 _ => EventResult::Ignored,
604 }
605 }
606 
607 fn as_any(&self) -> &dyn std::any::Any {
608 self
609 }
610 
611 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
612 self
613 }
614 
615 fn clone_widget(&self) -> Box<dyn Widget> {
616 Box::new(self.clone())
617 }
618}
619 
620#[cfg(test)]
621mod tests {
622 use super::*;
623 
624 #[test]
625 fn test_dropdown_creation() {
626 let dropdown: Dropdown<String> = Dropdown::new();
627 assert_eq!(dropdown.get_selected(), None);
628 assert!(!dropdown.is_open());
629 }
630 
631 #[test]
632 fn test_dropdown_options() {
633 let dropdown = Dropdown::new()
634 .add_value("Option 1".to_string())
635 .add_value("Option 2".to_string())
636 .add_option("Option 3".to_string(), "Custom Label".to_string());
637 
638 assert_eq!(dropdown.options.len(), 3);
639 assert_eq!(dropdown.options[2].label, "Custom Label");
640 }
641 
642 #[test]
643 fn test_dropdown_selection() {
644 let dropdown = Dropdown::new()
645 .add_value("Option 1".to_string())
646 .add_value("Option 2".to_string())
647 .selected("Option 2".to_string());
648 
649 assert_eq!(dropdown.get_selected_index(), Some(1));
650 assert_eq!(dropdown.get_selected(), Some(&"Option 2".to_string()));
651 }
652 
653 #[test]
654 fn test_dropdown_toggle() {
655 let dropdown: Dropdown<String> = Dropdown::new();
656 
657 assert!(!dropdown.is_open());
658 dropdown.open();
659 assert!(dropdown.is_open());
660 dropdown.close();
661 assert!(!dropdown.is_open());
662 }
663}
664