Skip to main content

egui_material3/
chips.rs

1use crate::{get_global_color, image_utils};
2use egui::{
3    self, Color32, Pos2, Rect, Response, Sense, Stroke, TextureHandle, Ui, Vec2, Widget,
4};
5
6/// Material Design chip variants following Material Design 3 specifications
7#[derive(Clone, Copy, PartialEq)]
8pub enum ChipVariant {
9    /// Assist chips help users take actions or get information about their current context
10    Assist,
11    /// Filter chips let users refine content by selecting or deselecting options
12    Filter,
13    /// Input chips represent discrete pieces of information entered by a user
14    Input,
15    /// Suggestion chips help users discover relevant, actionable content
16    Suggestion,
17}
18
19/// Types of icons that can be displayed in chips
20#[derive(Clone)]
21pub enum IconType {
22    /// Material Design icon using icon name or unicode
23    MaterialIcon(String),
24    /// Custom SVG icon data
25    SvgData(String),
26    /// PNG image data as bytes
27    PngBytes(Vec<u8>),
28    /// Pre-loaded egui texture handle
29    Texture(TextureHandle),
30}
31
32/// Material Design chip component following Material Design 3 specifications
33///
34/// Chips are compact elements that represent an input, attribute, or action.
35/// They allow users to enter information, make selections, filter content, or trigger actions.
36///
37/// ## Usage Examples
38/// ```rust
39/// # egui::__run_test_ui(|ui| {
40/// // Assist chip - helps users with contextual actions
41/// if ui.add(MaterialChip::assist("Settings")).clicked() {
42///     // Open settings
43/// }
44///
45/// // Filter chip - for filtering content
46/// let mut filter_active = false;
47/// ui.add(MaterialChip::filter("Photos")
48///     .selected(&mut filter_active));
49///
50/// // Input chip - represents entered data
51/// ui.add(MaterialChip::input("john@example.com")
52///     .removable(true));
53///
54/// // Suggestion chip - suggests actions or content
55/// ui.add(MaterialChip::suggestion("Try this feature"));
56/// # });
57/// ```
58///
59/// ## Material Design Spec
60/// - Height: 32dp
61/// - Corner radius: 8dp
62/// - Text: Label Large (14sp/500 weight)
63/// - Touch target: Minimum 48x48dp
64pub struct MaterialChip<'a> {
65    /// Text content displayed on the chip
66    text: String,
67    /// Which type of chip this is (affects styling and behavior)
68    variant: ChipVariant,
69    /// Optional mutable reference to selection state (for filter chips)
70    selected: Option<&'a mut bool>,
71    /// Whether the chip is interactive
72    enabled: bool,
73    /// Whether the chip is soft-disabled (different visual treatment)
74    soft_disabled: bool,
75    /// Whether the chip has elevation shadow
76    elevated: bool,
77    /// Whether the chip can be removed (shows X icon)
78    removable: bool,
79    /// Optional leading icon to display
80    leading_icon: Option<IconType>,
81    /// Whether to use avatar-style rounded appearance
82    avatar: bool,
83    /// Whether to use small size (24dp height instead of 32dp)
84    is_small: bool,
85    /// Optional action callback when chip is clicked
86    action: Option<Box<dyn Fn() + 'a>>,
87}
88
89impl<'a> MaterialChip<'a> {
90    /// Create a new chip with specified text and variant
91    ///
92    /// ## Parameters
93    /// - `text`: Text to display on the chip
94    /// - `variant`: Type of chip (Assist, Filter, Input, Suggestion)
95    pub fn new(text: impl Into<String>, variant: ChipVariant) -> Self {
96        Self {
97            text: text.into(),
98            variant,
99            selected: None,
100            enabled: true,
101            soft_disabled: false,
102            elevated: false,
103            removable: false,
104            leading_icon: None,
105            avatar: false, // regular chips are more rectangular by default
106            is_small: false,
107            action: None,
108        }
109    }
110
111    /// Create an assist chip for contextual actions
112    ///
113    /// Assist chips help users take actions or get information about their current context.
114    /// They should appear dynamically and contextually in the UI.
115    ///
116    /// ## Material Design Usage
117    /// - Display contextually relevant actions
118    /// - Usually triggered by user actions or context changes  
119    /// - Should not be persistent in the interface
120    pub fn assist(text: impl Into<String>) -> Self {
121        Self::new(text, ChipVariant::Assist)
122    }
123
124    /// Create a filter chip for content filtering
125    ///
126    /// Filter chips are used for filtering content and are typically displayed in a set.
127    /// They can be selected/deselected to refine displayed content.
128    ///
129    /// ## Parameters
130    /// - `text`: Label for the filter option
131    /// - `selected`: Mutable reference to selection state
132    ///
133    /// ## Material Design Usage
134    /// - Group related filter options together
135    /// - Allow multiple selections for broad filtering
136    /// - Provide clear visual feedback for selected state
137    pub fn filter(text: impl Into<String>, selected: &'a mut bool) -> Self {
138        let mut chip = Self::new(text, ChipVariant::Filter);
139        chip.selected = Some(selected);
140        chip
141    }
142
143    /// Create an input chip representing user-entered data
144    ///
145    /// Input chips represent discrete pieces of information entered by a user,
146    /// such as tags, contacts, or other structured data.
147    ///
148    /// ## Material Design Usage
149    /// - Represent complex entities in a compact form
150    /// - Often removable to allow editing of input data
151    /// - Used in forms and data entry interfaces
152    pub fn input(text: impl Into<String>) -> Self {
153        Self::new(text, ChipVariant::Input)
154    }
155
156    /// Create a suggestion chip that provides actionable content suggestions
157    ///
158    /// Suggestion chips are used to help users discover relevant actions or content.
159    /// They can be used in conjunction with dynamic features like autocomplete or
160    /// content recommendations.
161    pub fn suggestion(text: impl Into<String>) -> Self {
162        Self::new(text, ChipVariant::Suggestion)
163    }
164
165    /// Set whether the chip should have elevation (shadow) effect
166    ///
167    /// Elevated chips have a surface-container-high background and a shadow
168    /// to indicate elevation. This is typically used for assist and suggestion chips.
169    pub fn elevated(mut self, elevated: bool) -> Self {
170        self.elevated = elevated;
171        self
172    }
173
174    /// Enable or disable the chip
175    ///
176    /// Disabled chips have a different visual treatment and do not respond to
177    /// user interactions. Soft-disabled chips are still visible but appear
178    /// with reduced opacity.
179    pub fn enabled(mut self, enabled: bool) -> Self {
180        self.enabled = enabled;
181        if enabled {
182            self.soft_disabled = false; // if enabled, can't be soft disabled
183        }
184        self
185    }
186
187    /// Set the chip as soft-disabled
188    ///
189    /// Soft-disabled chips have a different visual treatment (e.g., lighter opacity)
190    /// compared to hard-disabled chips. They are still interactive but indicate
191    /// that the action is unavailable.
192    pub fn soft_disabled(mut self, soft_disabled: bool) -> Self {
193        self.soft_disabled = soft_disabled;
194        if soft_disabled {
195            self.enabled = false; // soft disabled means not enabled
196        }
197        self
198    }
199
200    /// Create a small variant of the chip (24dp height instead of 32dp)
201    ///
202    /// Small chips are more compact and useful when space is limited or when
203    /// displaying many chips in a constrained area.
204    pub fn small(mut self) -> Self {
205        self.is_small = true;
206        self
207    }
208
209    /// Set whether the chip can be removed
210    ///
211    /// Removable chips show an X icon that allows users to remove the chip
212    /// from the UI. This is useful for input and filter chips.
213    pub fn removable(mut self, removable: bool) -> Self {
214        self.removable = removable;
215        self
216    }
217
218    /// Set a leading icon for the chip using a Material icon name
219    ///
220    /// The icon will be displayed on the left side of the chip's text.
221    /// This is commonly used for assist and filter chips.
222    pub fn leading_icon(mut self, icon: impl Into<String>) -> Self {
223        self.leading_icon = Some(IconType::MaterialIcon(icon.into()));
224        self
225    }
226
227    /// Set a leading icon for the chip using SVG data
228    ///
229    /// The SVG data will be converted to a texture and displayed on the left
230    /// side of the chip's text. This allows for custom icons with scalable
231    /// vector graphics.
232    pub fn leading_icon_svg(mut self, svg_data: impl Into<String>) -> Self {
233        self.leading_icon = Some(IconType::SvgData(svg_data.into()));
234        self
235    }
236
237    /// Set a leading icon for the chip using PNG image data
238    ///
239    /// The PNG image data will be converted to a texture and displayed on the left
240    /// side of the chip's text. This is useful for using raster images as icons.
241    pub fn leading_icon_png(mut self, png_bytes: Vec<u8>) -> Self {
242        self.leading_icon = Some(IconType::PngBytes(png_bytes));
243        self
244    }
245
246    /// Set a pre-loaded texture as the leading icon for the chip
247    ///
248    /// This allows using any texture as an icon, without the need to convert
249    /// from image data. The texture should be created and managed externally.
250    pub fn leading_icon_texture(mut self, texture: TextureHandle) -> Self {
251        self.leading_icon = Some(IconType::Texture(texture));
252        self
253    }
254
255    /// Set whether to use avatar-style rounded appearance for the chip
256    ///
257    /// Avatar-style chips have a more pronounced roundness, making them suitable
258    /// for representing users or profile-related content. Regular chips are more
259    /// rectangular.
260    pub fn avatar(mut self, avatar: bool) -> Self {
261        self.avatar = avatar;
262        self
263    }
264
265    /// Set a callback function to be called when the chip is clicked
266    ///
267    /// This allows defining custom actions for each chip, such as navigating to
268    /// a different view, opening a dialog, or triggering any other behavior.
269    pub fn on_click<F>(mut self, f: F) -> Self
270    where
271        F: Fn() + 'a,
272    {
273        self.action = Some(Box::new(f));
274        self
275    }
276}
277
278/// Resolved chip colors for rendering
279struct ChipColors {
280    bg: Color32,
281    border: Color32,
282    text: Color32,
283    icon: Color32,
284    delete_icon: Color32,
285    state_layer: Color32,
286}
287
288/// Resolve chip colors per Material Design 3 spec (_ChipDefaultsM3)
289fn resolve_chip_colors(
290    variant: ChipVariant,
291    is_selected: bool,
292    enabled: bool,
293    soft_disabled: bool,
294    elevated: bool,
295    is_hovered: bool,
296    is_pressed: bool,
297) -> ChipColors {
298    let on_surface = get_global_color("onSurface");
299    let on_surface_variant = get_global_color("onSurfaceVariant");
300    let outline_variant = get_global_color("outlineVariant");
301    let surface_container_low = get_global_color("surfaceContainerLow");
302    let secondary_container = get_global_color("secondaryContainer");
303    let on_secondary_container = get_global_color("onSecondaryContainer");
304    let primary = get_global_color("primary");
305
306    // Disabled states (shared across all variants per M3 spec)
307    if !enabled {
308        let (bg, border, text) = if soft_disabled {
309            (
310                on_surface.gamma_multiply(0.12),
311                Color32::TRANSPARENT,
312                on_surface.gamma_multiply(0.60),
313            )
314        } else {
315            (
316                on_surface.gamma_multiply(0.12),
317                on_surface.gamma_multiply(0.12),
318                on_surface.gamma_multiply(0.38),
319            )
320        };
321        return ChipColors {
322            bg,
323            border,
324            text,
325            icon: text,
326            delete_icon: text,
327            state_layer: Color32::TRANSPARENT,
328        };
329    }
330
331    // State layer (shared logic for all enabled variants)
332    let state_layer_base = if is_selected { on_secondary_container } else { on_surface_variant };
333    let state_layer = if is_pressed {
334        state_layer_base.gamma_multiply(0.12)
335    } else if is_hovered {
336        state_layer_base.gamma_multiply(0.08)
337    } else {
338        Color32::TRANSPARENT
339    };
340
341    // Selected filter chip
342    if variant == ChipVariant::Filter && is_selected {
343        return ChipColors {
344            bg: secondary_container,
345            border: Color32::TRANSPARENT,
346            text: on_secondary_container,
347            icon: primary,
348            delete_icon: on_secondary_container,
349            state_layer,
350        };
351    }
352
353    // Elevated (unselected)
354    if elevated {
355        return ChipColors {
356            bg: surface_container_low,
357            border: Color32::TRANSPARENT,
358            text: on_surface_variant,
359            icon: primary,
360            delete_icon: on_surface_variant,
361            state_layer,
362        };
363    }
364
365    // Default (flat, unselected)
366    ChipColors {
367        bg: Color32::TRANSPARENT,
368        border: outline_variant,
369        text: on_surface_variant,
370        icon: primary,
371        delete_icon: on_surface_variant,
372        state_layer,
373    }
374}
375
376impl<'a> Widget for MaterialChip<'a> {
377    fn ui(self, ui: &mut Ui) -> Response {
378        let is_selected = self.selected.as_ref().map_or(false, |s| **s);
379
380        let text_width = ui.painter().layout_no_wrap(
381            self.text.clone(),
382            egui::FontId::default(),
383            egui::Color32::WHITE,
384        ).rect.width();
385
386        let has_leading = self.leading_icon.is_some()
387            || (self.variant == ChipVariant::Filter && is_selected);
388        let height = if self.is_small { 24.0 } else { 32.0 };
389        let icon_size = if self.is_small { 18.0 } else { 24.0 };
390        let icon_width = if has_leading { icon_size } else { 0.0 };
391        let remove_width = if self.removable { icon_size } else { 0.0 };
392        let padding = if self.is_small { 12.0 } else { 16.0 };
393
394        let desired_size = Vec2::new(
395            (text_width + icon_width + remove_width + padding).min(ui.available_width()),
396            height,
397        );
398
399        let (rect, mut response) = ui.allocate_exact_size(desired_size, Sense::click());
400
401        let is_pressed = response.is_pointer_button_down_on();
402        let is_hovered = response.hovered();
403
404        let colors = resolve_chip_colors(
405            self.variant,
406            is_selected,
407            self.enabled,
408            self.soft_disabled,
409            self.elevated,
410            is_hovered,
411            is_pressed,
412        );
413
414        let corner_radius = 8.0;
415
416        // Draw elevation shadow (before background)
417        if self.elevated && self.enabled {
418            let shadow_rect = rect.translate(Vec2::new(0.0, 2.0));
419            ui.painter().rect_filled(
420                shadow_rect,
421                corner_radius,
422                Color32::from_rgba_unmultiplied(0, 0, 0, 30),
423            );
424        }
425
426        // Draw chip background
427        ui.painter().rect_filled(rect, corner_radius, colors.bg);
428
429        // Draw state layer (hover/pressed overlay)
430        if colors.state_layer != Color32::TRANSPARENT {
431            ui.painter()
432                .rect_filled(rect, corner_radius, colors.state_layer);
433        }
434
435        // Draw chip border
436        if colors.border != Color32::TRANSPARENT {
437            ui.painter().rect_stroke(
438                rect,
439                corner_radius,
440                Stroke::new(1.0, colors.border),
441                egui::epaint::StrokeKind::Outside,
442            );
443        }
444
445        // Layout content
446        let mut content_x = rect.min.x + 8.0;
447
448        // Draw leading icon or checkmark
449        if let Some(icon) = &self.leading_icon {
450            let icon_display_size = icon_size * 0.833; // 20/24 ratio for visual balance
451            let icon_rect = Rect::from_min_size(
452                Pos2::new(content_x, rect.center().y - icon_display_size / 2.0),
453                Vec2::splat(icon_display_size),
454            );
455
456            match icon {
457                IconType::MaterialIcon(icon_str) => {
458                    let font_size = if self.is_small { 14.0 } else { 16.0 };
459                    ui.painter().text(
460                        icon_rect.center(),
461                        egui::Align2::CENTER_CENTER,
462                        icon_str,
463                        egui::FontId::proportional(font_size),
464                        colors.icon,
465                    );
466                }
467                IconType::SvgData(svg_data) => {
468                    if let Ok(texture) = image_utils::create_texture_from_svg(
469                        ui.ctx(),
470                        svg_data,
471                        &format!("chip_svg_{}", svg_data.len()),
472                    ) {
473                        ui.painter().image(
474                            texture.id(),
475                            icon_rect,
476                            Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)),
477                            Color32::WHITE,
478                        );
479                    }
480                }
481                IconType::PngBytes(png_bytes) => {
482                    if let Ok(texture) = image_utils::create_texture_from_png_bytes(
483                        ui.ctx(),
484                        png_bytes,
485                        &format!("chip_png_{}", png_bytes.len()),
486                    ) {
487                        ui.painter().image(
488                            texture.id(),
489                            icon_rect,
490                            Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)),
491                            Color32::WHITE,
492                        );
493                    }
494                }
495                IconType::Texture(texture) => {
496                    ui.painter().image(
497                        texture.id(),
498                        icon_rect,
499                        Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)),
500                        Color32::WHITE,
501                    );
502                }
503            }
504            content_x += icon_size;
505        } else if self.variant == ChipVariant::Filter && is_selected {
506            // Draw checkmark for selected filter chips
507            let icon_display_size = icon_size * 0.833; // 20/24 ratio for visual balance
508            let icon_rect = Rect::from_min_size(
509                Pos2::new(content_x, rect.center().y - icon_display_size / 2.0),
510                Vec2::splat(icon_display_size),
511            );
512
513            let center = icon_rect.center();
514            let checkmark_size = if self.is_small { 10.0 } else { 12.0 };
515
516            let start = Pos2::new(center.x - checkmark_size * 0.3, center.y);
517            let middle = Pos2::new(
518                center.x - checkmark_size * 0.1,
519                center.y + checkmark_size * 0.2,
520            );
521            let end = Pos2::new(
522                center.x + checkmark_size * 0.3,
523                center.y - checkmark_size * 0.2,
524            );
525
526            let stroke_width = if self.is_small { 1.5 } else { 2.0 };
527            ui.painter()
528                .line_segment([start, middle], Stroke::new(stroke_width, colors.icon));
529            ui.painter()
530                .line_segment([middle, end], Stroke::new(stroke_width, colors.icon));
531            content_x += icon_size;
532        }
533
534        // Draw text (offset by 1px to visually center, compensating for font descender space)
535        let text_pos = Pos2::new(content_x, rect.center().y + 2.0);
536        ui.painter().text(
537            text_pos,
538            egui::Align2::LEFT_CENTER,
539            &self.text,
540            egui::FontId::default(),
541            colors.text,
542        );
543
544        // Draw remove button for removable chips
545        if self.removable {
546            let icon_display_size = icon_size * 0.833; // 20/24 ratio for visual balance
547            let remove_rect = Rect::from_min_size(
548                Pos2::new(rect.max.x - icon_size, rect.center().y - icon_display_size / 2.0),
549                Vec2::splat(icon_display_size),
550            );
551
552            let center = remove_rect.center();
553            let cross_size = if self.is_small { 6.0 } else { 8.0 };
554            let stroke_width = if self.is_small { 1.2 } else { 1.5 };
555            ui.painter().line_segment(
556                [
557                    Pos2::new(center.x - cross_size / 2.0, center.y - cross_size / 2.0),
558                    Pos2::new(center.x + cross_size / 2.0, center.y + cross_size / 2.0),
559                ],
560                Stroke::new(stroke_width, colors.delete_icon),
561            );
562            ui.painter().line_segment(
563                [
564                    Pos2::new(center.x + cross_size / 2.0, center.y - cross_size / 2.0),
565                    Pos2::new(center.x - cross_size / 2.0, center.y + cross_size / 2.0),
566                ],
567                Stroke::new(stroke_width, colors.delete_icon),
568            );
569        }
570
571        // Handle interactions
572        if response.clicked() && self.enabled {
573            match self.variant {
574                ChipVariant::Filter => {
575                    if let Some(selected) = self.selected {
576                        *selected = !*selected;
577                        response.mark_changed();
578                    }
579                }
580                _ => {
581                    if let Some(action) = self.action {
582                        action();
583                    }
584                }
585            }
586        }
587
588        response
589    }
590}
591
592pub fn assist_chip(text: impl Into<String>) -> MaterialChip<'static> {
593    MaterialChip::assist(text)
594}
595
596pub fn filter_chip(text: impl Into<String>, selected: &mut bool) -> MaterialChip<'_> {
597    MaterialChip::filter(text, selected)
598}
599
600pub fn input_chip(text: impl Into<String>) -> MaterialChip<'static> {
601    MaterialChip::input(text)
602}
603
604pub fn suggestion_chip(text: impl Into<String>) -> MaterialChip<'static> {
605    MaterialChip::suggestion(text)
606}