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}