Skip to main content

egui_material3/
chips.rs

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