1use super::menu::{self, Menu};
6use crate::widget::icon;
7use derive_setters::Setters;
8use iced_core::event::{self, Event};
9use iced_core::text::{self, Paragraph, Text};
10use iced_core::widget::tree::{self, Tree};
11use iced_core::{
12 Clipboard, Layout, Length, Padding, Pixels, Rectangle, Shell, Size, Vector, Widget,
13};
14use iced_core::{Shadow, alignment, keyboard, layout, mouse, overlay, renderer, svg, touch};
15use iced_widget::pick_list;
16use std::ffi::OsStr;
17
18pub use iced_widget::pick_list::{Catalog, Style};
19
20#[derive(Setters)]
22pub struct Dropdown<'a, S: AsRef<str>, Message, Item> {
23 #[setters(skip)]
24 on_selected: Box<dyn Fn(Item) -> Message + 'a>,
25 #[setters(skip)]
26 selections: &'a super::Model<S, Item>,
27 #[setters(into)]
28 width: Length,
29 gap: f32,
30 #[setters(into)]
31 padding: Padding,
32 #[setters(strip_option)]
33 text_size: Option<f32>,
34 text_line_height: text::LineHeight,
35 #[setters(strip_option)]
36 font: Option<crate::font::Font>,
37}
38
39impl<'a, S: AsRef<str>, Message, Item: Clone + PartialEq + 'static> Dropdown<'a, S, Message, Item> {
40 pub const DEFAULT_GAP: f32 = 4.0;
42
43 pub const DEFAULT_PADDING: Padding = Padding::new(8.0);
45
46 pub fn new(
49 selections: &'a super::Model<S, Item>,
50 on_selected: impl Fn(Item) -> Message + 'a,
51 ) -> Self {
52 Self {
53 on_selected: Box::new(on_selected),
54 selections,
55 width: Length::Shrink,
56 gap: Self::DEFAULT_GAP,
57 padding: Self::DEFAULT_PADDING,
58 text_size: None,
59 text_line_height: text::LineHeight::Relative(1.2),
60 font: None,
61 }
62 }
63}
64
65impl<'a, S: AsRef<str>, Message: 'a, Item: Clone + PartialEq + 'static>
66 Widget<Message, crate::Theme, crate::Renderer> for Dropdown<'a, S, Message, Item>
67{
68 fn tag(&self) -> tree::Tag {
69 tree::Tag::of::<State<Item>>()
70 }
71
72 fn state(&self) -> tree::State {
73 tree::State::new(State::<Item>::new())
74 }
75
76 fn size(&self) -> Size<Length> {
77 Size::new(self.width, Length::Shrink)
78 }
79
80 fn layout(
81 &self,
82 tree: &mut Tree,
83 renderer: &crate::Renderer,
84 limits: &layout::Limits,
85 ) -> layout::Node {
86 layout(
87 renderer,
88 limits,
89 self.width,
90 self.gap,
91 self.padding,
92 self.text_size.unwrap_or(14.0),
93 self.text_line_height,
94 self.font,
95 self.selections.selected.as_ref().and_then(|id| {
96 self.selections.get(id).map(AsRef::as_ref).zip({
97 let state = tree.state.downcast_mut::<State<Item>>();
98
99 if state.selections.is_empty() {
100 for list in &self.selections.lists {
101 for (_, item) in &list.options {
102 state
103 .selections
104 .push((item.clone(), crate::Plain::default()));
105 }
106 }
107 }
108
109 state
110 .selections
111 .iter_mut()
112 .find(|(i, _)| i == id)
113 .map(|(_, p)| p)
114 })
115 }),
116 )
117 }
118
119 fn on_event(
120 &mut self,
121 tree: &mut Tree,
122 event: Event,
123 layout: Layout<'_>,
124 cursor: mouse::Cursor,
125 _renderer: &crate::Renderer,
126 _clipboard: &mut dyn Clipboard,
127 shell: &mut Shell<'_, Message>,
128 _viewport: &Rectangle,
129 ) -> event::Status {
130 update(
131 &event,
132 layout,
133 cursor,
134 shell,
135 self.on_selected.as_ref(),
136 self.selections,
137 || tree.state.downcast_mut::<State<Item>>(),
138 )
139 }
140
141 fn mouse_interaction(
142 &self,
143 _tree: &Tree,
144 layout: Layout<'_>,
145 cursor: mouse::Cursor,
146 _viewport: &Rectangle,
147 _renderer: &crate::Renderer,
148 ) -> mouse::Interaction {
149 mouse_interaction(layout, cursor)
150 }
151
152 fn draw(
153 &self,
154 tree: &Tree,
155 renderer: &mut crate::Renderer,
156 theme: &crate::Theme,
157 _style: &iced_core::renderer::Style,
158 layout: Layout<'_>,
159 cursor: mouse::Cursor,
160 viewport: &Rectangle,
161 ) {
162 let font = self.font.unwrap_or_else(crate::font::default);
163
164 draw(
165 renderer,
166 theme,
167 layout,
168 cursor,
169 self.gap,
170 self.padding,
171 self.text_size,
172 self.text_line_height,
173 font,
174 self.selections
175 .selected
176 .as_ref()
177 .and_then(|id| self.selections.get(id)),
178 tree.state.downcast_ref::<State<Item>>(),
179 viewport,
180 );
181 }
182
183 fn overlay<'b>(
184 &'b mut self,
185 tree: &'b mut Tree,
186 layout: Layout<'_>,
187 renderer: &crate::Renderer,
188 translation: Vector,
189 ) -> Option<overlay::Element<'b, Message, crate::Theme, crate::Renderer>> {
190 let state = tree.state.downcast_mut::<State<Item>>();
191
192 overlay(
193 layout,
194 renderer,
195 state,
196 self.gap,
197 self.padding,
198 self.text_size.unwrap_or(14.0),
199 self.font,
200 self.text_line_height,
201 self.selections,
202 &self.on_selected,
203 translation,
204 )
205 }
206}
207
208impl<'a, S: AsRef<str>, Message: 'a, Item: Clone + PartialEq + 'static>
209 From<Dropdown<'a, S, Message, Item>> for crate::Element<'a, Message>
210{
211 fn from(pick_list: Dropdown<'a, S, Message, Item>) -> Self {
212 Self::new(pick_list)
213 }
214}
215
216#[derive(Debug)]
218pub struct State<Item: Clone + PartialEq + 'static> {
219 icon: Option<svg::Handle>,
220 menu: menu::State,
221 keyboard_modifiers: keyboard::Modifiers,
222 is_open: bool,
223 hovered_option: Option<Item>,
224 selections: Vec<(Item, crate::Plain)>,
225 descriptions: Vec<crate::Plain>,
226}
227
228impl<Item: Clone + PartialEq + 'static> State<Item> {
229 pub fn new() -> Self {
231 Self {
232 icon: match icon::from_name("pan-down-symbolic").size(16).handle().data {
233 icon::Data::Name(named) => named
234 .path()
235 .filter(|path| path.extension().is_some_and(|ext| ext == OsStr::new("svg")))
236 .map(iced_core::svg::Handle::from_path),
237 icon::Data::Svg(handle) => Some(handle),
238 icon::Data::Image(_) => None,
239 },
240 menu: menu::State::default(),
241 keyboard_modifiers: keyboard::Modifiers::default(),
242 is_open: false,
243 hovered_option: None,
244 selections: Vec::new(),
245 descriptions: Vec::new(),
246 }
247 }
248}
249
250impl<Item: Clone + PartialEq + 'static> Default for State<Item> {
251 fn default() -> Self {
252 Self::new()
253 }
254}
255
256#[allow(clippy::too_many_arguments)]
258pub fn layout(
259 renderer: &crate::Renderer,
260 limits: &layout::Limits,
261 width: Length,
262 gap: f32,
263 padding: Padding,
264 text_size: f32,
265 text_line_height: text::LineHeight,
266 font: Option<crate::font::Font>,
267 selection: Option<(&str, &mut crate::Plain)>,
268) -> layout::Node {
269 use std::f32;
270
271 let limits = limits.width(width).height(Length::Shrink).shrink(padding);
272
273 let max_width = match width {
274 Length::Shrink => {
275 let measure = move |(label, paragraph): (_, &mut crate::Plain)| -> f32 {
276 paragraph.update(Text {
277 content: label,
278 bounds: Size::new(f32::MAX, f32::MAX),
279 size: iced::Pixels(text_size),
280 line_height: text_line_height,
281 font: font.unwrap_or_else(crate::font::default),
282 horizontal_alignment: alignment::Horizontal::Left,
283 vertical_alignment: alignment::Vertical::Top,
284 shaping: text::Shaping::Advanced,
285 wrapping: text::Wrapping::default(),
286 });
287 paragraph.min_width().round()
288 };
289
290 selection.map(measure).unwrap_or_default()
291 }
292 _ => 0.0,
293 };
294
295 let size = {
296 let intrinsic = Size::new(
297 max_width + gap + 16.0,
298 f32::from(text_line_height.to_absolute(Pixels(text_size))),
299 );
300
301 limits
302 .resolve(width, Length::Shrink, intrinsic)
303 .expand(padding)
304 };
305
306 layout::Node::new(size)
307}
308
309#[allow(clippy::too_many_arguments)]
312pub fn update<'a, S: AsRef<str>, Message, Item: Clone + PartialEq + 'static + 'a>(
313 event: &Event,
314 layout: Layout<'_>,
315 cursor: mouse::Cursor,
316 shell: &mut Shell<'_, Message>,
317 on_selected: &dyn Fn(Item) -> Message,
318 selections: &super::Model<S, Item>,
319 state: impl FnOnce() -> &'a mut State<Item>,
320) -> event::Status {
321 match event {
322 Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
323 | Event::Touch(touch::Event::FingerPressed { .. }) => {
324 let state = state();
325
326 if state.is_open {
327 state.is_open = false;
330
331 event::Status::Captured
332 } else if cursor.is_over(layout.bounds()) {
333 state.is_open = true;
334 state.hovered_option = selections.selected.clone();
335
336 event::Status::Captured
337 } else {
338 event::Status::Ignored
339 }
340 }
341 Event::Mouse(mouse::Event::WheelScrolled {
342 delta: mouse::ScrollDelta::Lines { .. },
343 }) => {
344 let state = state();
345
346 if state.keyboard_modifiers.command()
347 && cursor.is_over(layout.bounds())
348 && !state.is_open
349 {
350 if let Some(option) = selections.next() {
351 shell.publish((on_selected)(option.1.clone()));
352 }
353
354 event::Status::Captured
355 } else {
356 event::Status::Ignored
357 }
358 }
359 Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => {
360 let state = state();
361
362 state.keyboard_modifiers = *modifiers;
363
364 event::Status::Ignored
365 }
366 _ => event::Status::Ignored,
367 }
368}
369
370#[must_use]
372pub fn mouse_interaction(layout: Layout<'_>, cursor: mouse::Cursor) -> mouse::Interaction {
373 let bounds = layout.bounds();
374 let is_mouse_over = cursor.is_over(bounds);
375
376 if is_mouse_over {
377 mouse::Interaction::Pointer
378 } else {
379 mouse::Interaction::default()
380 }
381}
382
383#[allow(clippy::too_many_arguments)]
385pub fn overlay<'a, S: AsRef<str>, Message: 'a, Item: Clone + PartialEq + 'static>(
386 layout: Layout<'_>,
387 renderer: &crate::Renderer,
388 state: &'a mut State<Item>,
389 gap: f32,
390 padding: Padding,
391 text_size: f32,
392 font: Option<crate::font::Font>,
393 text_line_height: text::LineHeight,
394 selections: &'a super::Model<S, Item>,
395 on_selected: &'a dyn Fn(Item) -> Message,
396 translation: Vector,
397) -> Option<overlay::Element<'a, Message, crate::Theme, crate::Renderer>> {
398 if state.is_open {
399 let description_line_height = text::LineHeight::Absolute(Pixels(
400 text_line_height.to_absolute(Pixels(text_size)).0 + 4.0,
401 ));
402
403 let bounds = layout.bounds();
404
405 let menu = Menu::new(
406 &mut state.menu,
407 selections,
408 &mut state.hovered_option,
409 selections.selected.as_ref(),
410 |option| {
411 state.is_open = false;
412
413 (on_selected)(option)
414 },
415 None,
416 )
417 .width({
418 let measure =
419 |label: &str, paragraph: &mut crate::Plain, line_height: text::LineHeight| {
420 paragraph.update(Text {
421 content: label,
422 bounds: Size::new(f32::MAX, f32::MAX),
423 size: iced::Pixels(text_size),
424 line_height,
425 font: font.unwrap_or_else(crate::font::default),
426 horizontal_alignment: alignment::Horizontal::Left,
427 vertical_alignment: alignment::Vertical::Top,
428 shaping: text::Shaping::Advanced,
429 wrapping: text::Wrapping::default(),
430 });
431 paragraph.min_width().round()
432 };
433
434 let mut desc_count = 0;
435 selections
436 .elements()
437 .map(|element| match element {
438 super::menu::OptionElement::Description(desc) => {
439 let paragraph = if state.descriptions.len() > desc_count {
440 &mut state.descriptions[desc_count]
441 } else {
442 state.descriptions.push(crate::Plain::default());
443 state.descriptions.last_mut().unwrap()
444 };
445 desc_count += 1;
446 measure(desc.as_ref(), paragraph, description_line_height)
447 }
448
449 super::menu::OptionElement::Option((option, item)) => {
450 let selection_index = state.selections.iter().position(|(i, _)| i == item);
451
452 let selection_index = match selection_index {
453 Some(index) => index,
454 None => {
455 state
456 .selections
457 .push((item.clone(), crate::Plain::default()));
458 state.selections.len() - 1
459 }
460 };
461
462 let paragraph = &mut state.selections[selection_index].1;
463
464 measure(option.as_ref(), paragraph, text_line_height)
465 }
466
467 super::menu::OptionElement::Separator => 1.0,
468 })
469 .fold(0.0, |next, current| current.max(next))
470 + gap
471 + 16.0
472 + (padding.horizontal() * 2.0)
473 })
474 .padding(padding)
475 .text_size(text_size);
476
477 let mut position = layout.position();
478 position.x -= padding.left;
479 position.x += translation.x;
480 position.y += translation.y;
481 Some(menu.overlay(position, bounds.height))
482 } else {
483 None
484 }
485}
486
487#[allow(clippy::too_many_arguments)]
489pub fn draw<'a, S, Item: Clone + PartialEq + 'static>(
490 renderer: &mut crate::Renderer,
491 theme: &crate::Theme,
492 layout: Layout<'_>,
493 cursor: mouse::Cursor,
494 gap: f32,
495 padding: Padding,
496 text_size: Option<f32>,
497 text_line_height: text::LineHeight,
498 font: crate::font::Font,
499 selected: Option<&'a S>,
500 state: &'a State<Item>,
501 viewport: &Rectangle,
502) where
503 S: AsRef<str> + 'a,
504{
505 let bounds = layout.bounds();
506 let is_mouse_over = cursor.is_over(bounds);
507
508 let style = if is_mouse_over {
509 theme.style(&(), pick_list::Status::Hovered)
510 } else {
511 theme.style(&(), pick_list::Status::Active)
512 };
513
514 iced_core::Renderer::fill_quad(
515 renderer,
516 renderer::Quad {
517 bounds,
518 border: style.border,
519 shadow: Shadow::default(),
520 },
521 style.background,
522 );
523
524 if let Some(handle) = state.icon.clone() {
525 let svg_handle = iced_core::Svg::new(handle).color(style.text_color);
526 svg::Renderer::draw_svg(
527 renderer,
528 svg_handle,
529 Rectangle {
530 x: bounds.x + bounds.width - gap - 16.0,
531 y: bounds.center_y() - 8.0,
532 width: 16.0,
533 height: 16.0,
534 },
535 );
536 }
537
538 if let Some(content) = selected.map(AsRef::as_ref) {
539 let text_size = text_size.unwrap_or_else(|| text::Renderer::default_size(renderer).0);
540
541 let bounds = Rectangle {
542 x: bounds.x + padding.left,
543 y: bounds.center_y(),
544 width: bounds.width - padding.horizontal(),
545 height: f32::from(text_line_height.to_absolute(Pixels(text_size))),
546 };
547
548 text::Renderer::fill_text(
549 renderer,
550 Text {
551 content: content.to_string(),
552 size: iced::Pixels(text_size),
553 line_height: text_line_height,
554 font,
555 bounds: bounds.size(),
556 horizontal_alignment: alignment::Horizontal::Left,
557 vertical_alignment: alignment::Vertical::Center,
558 shaping: text::Shaping::Advanced,
559 wrapping: text::Wrapping::default(),
560 },
561 bounds.position(),
562 style.text_color,
563 *viewport,
564 );
565 }
566}