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}