egui_material3/timeline.rs
1use crate::get_global_color;
2use egui::{self, Color32, Pos2, Rect, Response, Sense, Stroke, Ui, Vec2, Widget};
3
4/// Position where timeline content appears relative to the timeline axis.
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum TimelinePosition {
7 /// Content appears on the left side of the timeline
8 Left,
9 /// Content appears on the right side of the timeline
10 Right,
11 /// Content alternates between left and right sides
12 Alternate,
13 /// Content alternates between right and left sides (starts on right)
14 AlternateReverse,
15}
16
17impl Default for TimelinePosition {
18 fn default() -> Self {
19 Self::Right
20 }
21}
22
23/// Variant for timeline dot appearance.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum TimelineDotVariant {
26 /// Filled solid dot
27 Filled,
28 /// Outlined dot with border
29 Outlined,
30}
31
32/// Color scheme for timeline dot.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum TimelineDotColor {
35 /// Grey color (default)
36 Grey,
37 /// Inherit color from context
38 Inherit,
39 /// Primary theme color
40 Primary,
41 /// Secondary theme color
42 Secondary,
43 /// Error/danger color
44 Error,
45 /// Info color
46 Info,
47 /// Success color
48 Success,
49 /// Warning color
50 Warning,
51}
52
53impl Default for TimelineDotColor {
54 fn default() -> Self {
55 Self::Grey
56 }
57}
58
59impl Default for TimelineDotVariant {
60 fn default() -> Self {
61 Self::Filled
62 }
63}
64
65/// Material Design timeline component.
66///
67/// Timelines display a list of events in chronological order.
68/// They can be used to show a sequence of events, process steps, or historical data.
69///
70/// # Example
71/// ```rust
72/// # egui::__run_test_ui(|ui| {
73/// ui.add(MaterialTimeline::new()
74/// .position(TimelinePosition::Right)
75/// .item(TimelineItem::new()
76/// .content("First event")
77/// .dot(TimelineDot::new()
78/// .color(TimelineDotColor::Primary)))
79/// .item(TimelineItem::new()
80/// .content("Second event")
81/// .dot(TimelineDot::new()
82/// .color(TimelineDotColor::Success))));
83/// # });
84/// ```
85#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
86pub struct MaterialTimeline<'a> {
87 /// Position of content relative to timeline
88 position: TimelinePosition,
89 /// List of timeline items
90 items: Vec<TimelineItem<'a>>,
91 /// Optional unique ID for this timeline
92 id: Option<egui::Id>,
93}
94
95/// Individual item in a timeline.
96pub struct TimelineItem<'a> {
97 /// Main content text
98 content: Option<String>,
99 /// Custom content renderer (takes precedence over content text)
100 content_custom: Option<Box<dyn Fn(&mut Ui) + 'a>>,
101 /// Optional opposite side content
102 opposite_content: Option<String>,
103 /// Timeline dot configuration
104 dot: Option<TimelineDot>,
105 /// Whether to show connector line below this item
106 show_connector: bool,
107 /// Optional callback when item is clicked
108 action: Option<Box<dyn Fn() + 'a>>,
109 /// Optional custom content color
110 content_color: Option<Color32>,
111 /// Optional custom opposite content color
112 opposite_content_color: Option<Color32>,
113 /// Custom min height for this item (useful for cards/complex content)
114 min_height: Option<f32>,
115}
116
117/// Timeline dot/indicator configuration.
118pub struct TimelineDot {
119 /// Visual variant (filled or outlined)
120 variant: TimelineDotVariant,
121 /// Color scheme
122 color: TimelineDotColor,
123 /// Optional icon text to display in the dot
124 icon: Option<String>,
125 /// Optional custom color
126 custom_color: Option<Color32>,
127 /// Optional custom dot size (defaults to DOT_SIZE constant)
128 size: Option<f32>,
129}
130
131impl<'a> MaterialTimeline<'a> {
132 /// Create a new timeline with default right-aligned position.
133 ///
134 /// # Example
135 /// ```rust
136 /// let timeline = MaterialTimeline::new();
137 /// ```
138 pub fn new() -> Self {
139 Self {
140 position: TimelinePosition::default(),
141 items: Vec::new(),
142 id: None,
143 }
144 }
145
146 /// Set the position where content appears relative to the timeline axis.
147 ///
148 /// # Arguments
149 /// * `position` - The position (Left, Right, Alternate, or AlternateReverse)
150 ///
151 /// # Example
152 /// ```rust
153 /// let timeline = MaterialTimeline::new()
154 /// .position(TimelinePosition::Alternate);
155 /// ```
156 pub fn position(mut self, position: TimelinePosition) -> Self {
157 self.position = position;
158 self
159 }
160
161 /// Add an item to the timeline.
162 ///
163 /// # Arguments
164 /// * `item` - The timeline item to add
165 ///
166 /// # Example
167 /// ```rust
168 /// let timeline = MaterialTimeline::new()
169 /// .item(TimelineItem::new().content("Event"));
170 /// ```
171 pub fn item(mut self, item: TimelineItem<'a>) -> Self {
172 self.items.push(item);
173 self
174 }
175
176 /// Set a unique ID for this timeline to avoid widget ID collisions.
177 ///
178 /// # Arguments
179 /// * `id` - Unique identifier
180 ///
181 /// # Example
182 /// ```rust
183 /// let timeline = MaterialTimeline::new()
184 /// .id(egui::Id::new("my_timeline"));
185 /// ```
186 pub fn id(mut self, id: egui::Id) -> Self {
187 self.id = Some(id);
188 self
189 }
190}
191
192impl<'a> TimelineItem<'a> {
193 /// Create a new timeline item.
194 ///
195 /// # Example
196 /// ```rust
197 /// let item = TimelineItem::new();
198 /// ```
199 pub fn new() -> Self {
200 Self {
201 content: None,
202 content_custom: None,
203 opposite_content: None,
204 dot: None,
205 show_connector: true,
206 action: None,
207 content_color: None,
208 opposite_content_color: None,
209 min_height: None,
210 }
211 }
212
213 /// Set the main content text.
214 ///
215 /// # Arguments
216 /// * `text` - Content text to display
217 ///
218 /// # Example
219 /// ```rust
220 /// let item = TimelineItem::new()
221 /// .content("Event description");
222 /// ```
223 pub fn content(mut self, text: impl Into<String>) -> Self {
224 self.content = Some(text.into());
225 self
226 }
227
228 /// Set custom content renderer with a closure.
229 ///
230 /// This takes precedence over the text-based `content()` method.
231 ///
232 /// # Arguments
233 /// * `render` - Closure that renders custom UI
234 ///
235 /// # Example
236 /// ```rust
237 /// let item = TimelineItem::new()
238 /// .content_custom(|ui| {
239 /// ui.label("Custom content");
240 /// ui.button("Click me");
241 /// });
242 /// ```
243 pub fn content_custom<F: Fn(&mut Ui) + 'a>(mut self, render: F) -> Self {
244 self.content_custom = Some(Box::new(render));
245 self
246 }
247
248 /// Set minimum height for this timeline item.
249 ///
250 /// Useful when using custom content that needs more vertical space.
251 ///
252 /// # Arguments
253 /// * `height` - Minimum height in pixels
254 pub fn min_height(mut self, height: f32) -> Self {
255 self.min_height = Some(height);
256 self
257 }
258
259 /// Set the opposite side content text.
260 ///
261 /// This appears on the opposite side of the timeline axis from the main content.
262 ///
263 /// # Arguments
264 /// * `text` - Opposite content text to display
265 ///
266 /// # Example
267 /// ```rust
268 /// let item = TimelineItem::new()
269 /// .content("Event description")
270 /// .opposite_content("09:30 am");
271 /// ```
272 pub fn opposite_content(mut self, text: impl Into<String>) -> Self {
273 self.opposite_content = Some(text.into());
274 self
275 }
276
277 /// Set the timeline dot configuration.
278 ///
279 /// # Arguments
280 /// * `dot` - TimelineDot configuration
281 ///
282 /// # Example
283 /// ```rust
284 /// let item = TimelineItem::new()
285 /// .content("Event")
286 /// .dot(TimelineDot::new()
287 /// .color(TimelineDotColor::Primary));
288 /// ```
289 pub fn dot(mut self, dot: TimelineDot) -> Self {
290 self.dot = Some(dot);
291 self
292 }
293
294 /// Set whether to show the connector line below this item.
295 ///
296 /// # Arguments
297 /// * `show` - true to show connector, false to hide
298 ///
299 /// # Example
300 /// ```rust
301 /// let item = TimelineItem::new()
302 /// .content("Final event")
303 /// .show_connector(false); // Last item doesn't need connector
304 /// ```
305 pub fn show_connector(mut self, show: bool) -> Self {
306 self.show_connector = show;
307 self
308 }
309
310 /// Set a callback to execute when this item is clicked.
311 ///
312 /// # Arguments
313 /// * `action` - Callback function
314 ///
315 /// # Example
316 /// ```rust
317 /// let item = TimelineItem::new()
318 /// .content("Clickable event")
319 /// .on_click(|| println!("Item clicked"));
320 /// ```
321 pub fn on_click<F: Fn() + 'a>(mut self, action: F) -> Self {
322 self.action = Some(Box::new(action));
323 self
324 }
325
326 /// Set custom color for the main content.
327 ///
328 /// # Arguments
329 /// * `color` - Custom color
330 pub fn content_color(mut self, color: Color32) -> Self {
331 self.content_color = Some(color);
332 self
333 }
334
335 /// Set custom color for the opposite content.
336 ///
337 /// # Arguments
338 /// * `color` - Custom color
339 pub fn opposite_content_color(mut self, color: Color32) -> Self {
340 self.opposite_content_color = Some(color);
341 self
342 }
343}
344
345impl TimelineDot {
346 /// Create a new timeline dot with default settings.
347 ///
348 /// # Example
349 /// ```rust
350 /// let dot = TimelineDot::new();
351 /// ```
352 pub fn new() -> Self {
353 Self {
354 variant: TimelineDotVariant::default(),
355 color: TimelineDotColor::default(),
356 icon: None,
357 custom_color: None,
358 size: None,
359 }
360 }
361
362 /// Set the visual variant (filled or outlined).
363 ///
364 /// # Arguments
365 /// * `variant` - Dot variant
366 ///
367 /// # Example
368 /// ```rust
369 /// let dot = TimelineDot::new()
370 /// .variant(TimelineDotVariant::Outlined);
371 /// ```
372 pub fn variant(mut self, variant: TimelineDotVariant) -> Self {
373 self.variant = variant;
374 self
375 }
376
377 /// Set the color scheme.
378 ///
379 /// # Arguments
380 /// * `color` - Color scheme
381 ///
382 /// # Example
383 /// ```rust
384 /// let dot = TimelineDot::new()
385 /// .color(TimelineDotColor::Primary);
386 /// ```
387 pub fn color(mut self, color: TimelineDotColor) -> Self {
388 self.color = color;
389 self
390 }
391
392 /// Set an icon to display in the dot.
393 ///
394 /// # Arguments
395 /// * `icon` - Icon text (emoji or character)
396 ///
397 /// # Example
398 /// ```rust
399 /// let dot = TimelineDot::new()
400 /// .icon("✓");
401 /// ```
402 pub fn icon(mut self, icon: impl Into<String>) -> Self {
403 self.icon = Some(icon.into());
404 self
405 }
406
407 /// Set a custom color for the dot.
408 ///
409 /// # Arguments
410 /// * `color` - Custom color
411 pub fn custom_color(mut self, color: Color32) -> Self {
412 self.custom_color = Some(color);
413 self
414 }
415
416 /// Set a custom size for the dot.
417 ///
418 /// # Arguments
419 /// * `size` - Dot diameter in pixels
420 ///
421 /// # Example
422 /// ```rust
423 /// let dot = TimelineDot::new()
424 /// .size(40.0) // Large dot
425 /// .icon("🚀");
426 /// ```
427 pub fn size(mut self, size: f32) -> Self {
428 self.size = Some(size);
429 self
430 }
431}
432
433impl Default for TimelineDot {
434 fn default() -> Self {
435 Self::new()
436 }
437}
438
439impl<'a> Default for TimelineItem<'a> {
440 fn default() -> Self {
441 Self::new()
442 }
443}
444
445impl<'a> Default for MaterialTimeline<'a> {
446 fn default() -> Self {
447 Self::new()
448 }
449}
450
451// Constants for Material Design 3 timeline styling
452const DOT_SIZE: f32 = 12.0;
453const DOT_ICON_SIZE: f32 = 16.0;
454const CONNECTOR_WIDTH: f32 = 2.0;
455const CONTENT_PADDING: f32 = 32.0; // Increased padding to prevent icon overlap with text
456const MIN_ITEM_SPACING: f32 = 24.0; // Minimum spacing between items
457const OPPOSITE_CONTENT_WIDTH: f32 = 80.0;
458
459impl<'a> Widget for MaterialTimeline<'a> {
460 fn ui(mut self, ui: &mut Ui) -> Response {
461 let base_id = self.id.unwrap_or_else(|| ui.make_persistent_id("timeline"));
462
463 let mut total_height = 0.0;
464 let item_count = self.items.len();
465
466 // Calculate total height needed based on actual dot sizes
467 for (index, item) in self.items.iter_mut().enumerate() {
468 let dot_size = item.dot.as_ref().and_then(|d| d.size).unwrap_or(DOT_SIZE);
469 // Spacing should be at least dot_size + padding, or MIN_ITEM_SPACING, whichever is larger
470 let item_spacing = (dot_size + CONTENT_PADDING).max(MIN_ITEM_SPACING);
471 total_height += item_spacing;
472
473 if index == item_count - 1 {
474 // Last item doesn't need connector
475 item.show_connector = false;
476 }
477 }
478
479 let available_width = ui.available_width();
480 let desired_size = Vec2::new(available_width, total_height.max(50.0));
481 let (rect, response) = ui.allocate_exact_size(desired_size, Sense::hover());
482
483 // Material Design 3 colors
484 let on_surface = get_global_color("onSurface");
485 let on_surface_variant = get_global_color("onSurfaceVariant");
486 let outline = get_global_color("outline");
487
488 let mut current_y = rect.min.y;
489
490 // Check if we're in alternate mode - if so, center the separator
491 let is_alternate_mode = matches!(self.position, TimelinePosition::Alternate | TimelinePosition::AlternateReverse);
492
493 for (index, item) in self.items.iter().enumerate() {
494 // Determine position for this item
495 let item_position = match self.position {
496 TimelinePosition::Left => TimelinePosition::Left,
497 TimelinePosition::Right => TimelinePosition::Right,
498 TimelinePosition::Alternate => {
499 if index % 2 == 0 {
500 TimelinePosition::Right
501 } else {
502 TimelinePosition::Left
503 }
504 }
505 TimelinePosition::AlternateReverse => {
506 if index % 2 == 0 {
507 TimelinePosition::Left
508 } else {
509 TimelinePosition::Right
510 }
511 }
512 };
513
514 let has_opposite = item.opposite_content.is_some();
515
516 // Calculate dot size and spacing early for use throughout
517 let dot_config = item.dot.as_ref();
518 let dot_size = dot_config.and_then(|d| d.size).unwrap_or(DOT_SIZE);
519 // Icon size should be smaller than dot for better fit
520 let icon_size = (dot_size * 0.7).max(10.0);
521 // Use custom min_height if provided, otherwise calculate from dot size
522 let base_spacing = (dot_size + CONTENT_PADDING).max(MIN_ITEM_SPACING);
523 let item_spacing = item.min_height.unwrap_or(base_spacing).max(base_spacing);
524
525 // Calculate layout positions
526 let (opposite_x, separator_x, content_x, is_content_right) = if is_alternate_mode {
527 // For alternate mode, center the separator
528 let center_x = rect.center().x;
529
530 match item_position {
531 TimelinePosition::Right => {
532 if has_opposite {
533 (
534 center_x - OPPOSITE_CONTENT_WIDTH - CONTENT_PADDING - DOT_SIZE / 2.0,
535 center_x,
536 center_x + DOT_SIZE / 2.0 + CONTENT_PADDING,
537 true,
538 )
539 } else {
540 (
541 center_x - DOT_SIZE / 2.0,
542 center_x,
543 center_x + DOT_SIZE / 2.0 + CONTENT_PADDING,
544 true,
545 )
546 }
547 }
548 TimelinePosition::Left => {
549 // For left-positioned items in alternate mode, calculate content_x
550 // so the content rect ends near the separator
551 let half_width = available_width / 2.0;
552 let left_content_width = if has_opposite {
553 half_width - OPPOSITE_CONTENT_WIDTH - CONTENT_PADDING * 2.0 - DOT_SIZE / 2.0
554 } else {
555 half_width - CONTENT_PADDING - DOT_SIZE / 2.0
556 };
557 let content_start_x = center_x - DOT_SIZE / 2.0 - CONTENT_PADDING - left_content_width;
558
559 if has_opposite {
560 (
561 center_x + DOT_SIZE / 2.0 + CONTENT_PADDING,
562 center_x,
563 content_start_x,
564 false,
565 )
566 } else {
567 (
568 center_x + DOT_SIZE / 2.0,
569 center_x,
570 content_start_x,
571 false,
572 )
573 }
574 }
575 _ => unreachable!(),
576 }
577 } else {
578 // For non-alternate mode (Left or Right), use edge-based layout
579 match item_position {
580 TimelinePosition::Right => {
581 if has_opposite {
582 (
583 rect.min.x,
584 rect.min.x + OPPOSITE_CONTENT_WIDTH + CONTENT_PADDING,
585 rect.min.x + OPPOSITE_CONTENT_WIDTH + CONTENT_PADDING * 2.0 + DOT_SIZE,
586 true,
587 )
588 } else {
589 (rect.min.x, rect.min.x, rect.min.x + DOT_SIZE + CONTENT_PADDING, true)
590 }
591 }
592 TimelinePosition::Left => {
593 if has_opposite {
594 (
595 rect.max.x - OPPOSITE_CONTENT_WIDTH,
596 rect.max.x - OPPOSITE_CONTENT_WIDTH - CONTENT_PADDING - DOT_SIZE,
597 rect.min.x,
598 false,
599 )
600 } else {
601 (rect.max.x, rect.max.x, rect.min.x, false)
602 }
603 }
604 _ => unreachable!(),
605 }
606 };
607
608 // Draw opposite content (e.g., timestamp)
609 if let Some(opposite_text) = &item.opposite_content {
610 let opposite_color = item.opposite_content_color.unwrap_or(on_surface_variant);
611
612 let opposite_width = if has_opposite {
613 OPPOSITE_CONTENT_WIDTH
614 } else {
615 100.0
616 };
617
618 // Calculate spacing for vertical centering
619 let item_spacing = (dot_size + CONTENT_PADDING).max(MIN_ITEM_SPACING);
620
621 // Use allocate_ui_at_rect for proper text rendering with unique ID
622 // Rect spans full item height for proper vertical centering
623 let opposite_rect = Rect::from_min_size(
624 Pos2::new(opposite_x, current_y),
625 Vec2::new(opposite_width, item_spacing),
626 );
627
628 ui.allocate_ui_at_rect(opposite_rect, |ui| {
629 // Properly clip to both the rect and parent's clip rect
630 let parent_clip = ui.clip_rect();
631 let clipped = opposite_rect.intersect(parent_clip);
632 ui.set_clip_rect(clipped);
633
634 // Right-align when content is on the left (opposite on right)
635 // Use Center for vertical alignment with dot
636 let layout = if is_content_right {
637 egui::Layout::left_to_right(egui::Align::Center)
638 } else {
639 egui::Layout::right_to_left(egui::Align::Center)
640 };
641 ui.with_layout(layout, |ui| {
642 let label = egui::Label::new(
643 egui::RichText::new(opposite_text)
644 .size(14.0)
645 .color(opposite_color)
646 ).wrap_mode(egui::TextWrapMode::Truncate);
647 ui.add(label);
648 });
649 }).response.context_menu(|_ui| {}); // Add context menu to force unique ID
650 }
651
652 // Draw dot
653 let dot_center = Pos2::new(separator_x, current_y + dot_size / 2.0);
654
655 let dot_color = if let Some(dot) = dot_config {
656 if let Some(custom) = dot.custom_color {
657 custom
658 } else {
659 match dot.color {
660 TimelineDotColor::Grey => get_global_color("outline"),
661 TimelineDotColor::Inherit => on_surface,
662 TimelineDotColor::Primary => get_global_color("primary"),
663 TimelineDotColor::Secondary => get_global_color("secondary"),
664 TimelineDotColor::Error => get_global_color("error"),
665 TimelineDotColor::Info => get_global_color("tertiary"),
666 TimelineDotColor::Success => Color32::from_rgb(76, 175, 80),
667 TimelineDotColor::Warning => Color32::from_rgb(255, 152, 0),
668 }
669 }
670 } else {
671 outline
672 };
673
674 // Draw dot based on variant
675 if let Some(dot) = dot_config {
676 match dot.variant {
677 TimelineDotVariant::Filled => {
678 ui.painter().circle_filled(dot_center, dot_size / 2.0, dot_color);
679
680 // Draw icon if present - use allocate_at_rect for unique ID
681 if let Some(icon_text) = &dot.icon {
682 let icon_color = if dot_color.r() as u32 + dot_color.g() as u32 + dot_color.b() as u32 > 384 {
683 Color32::BLACK
684 } else {
685 Color32::WHITE
686 };
687 let icon_rect = Rect::from_center_size(dot_center, Vec2::splat(icon_size));
688 ui.allocate_ui_at_rect(icon_rect, |ui| {
689 // Clip icon to parent's clip rect
690 let parent_clip = ui.clip_rect();
691 let clipped = icon_rect.intersect(parent_clip);
692 ui.set_clip_rect(clipped);
693
694 ui.with_layout(egui::Layout::centered_and_justified(egui::Direction::TopDown), |ui| {
695 let label = egui::Label::new(
696 egui::RichText::new(icon_text)
697 .size(icon_size)
698 .color(icon_color)
699 );
700 ui.add(label);
701 });
702 });
703 }
704 }
705 TimelineDotVariant::Outlined => {
706 let stroke_width = (dot_size / 6.0).max(2.0); // Scale stroke with dot size
707 ui.painter().circle_stroke(
708 dot_center,
709 dot_size / 2.0,
710 Stroke::new(stroke_width, dot_color),
711 );
712
713 // Draw icon if present - use allocate_at_rect for unique ID
714 if let Some(icon_text) = &dot.icon {
715 let icon_rect = Rect::from_center_size(dot_center, Vec2::splat(icon_size));
716 ui.allocate_ui_at_rect(icon_rect, |ui| {
717 // Clip icon to parent's clip rect
718 let parent_clip = ui.clip_rect();
719 let clipped = icon_rect.intersect(parent_clip);
720 ui.set_clip_rect(clipped);
721
722 ui.with_layout(egui::Layout::centered_and_justified(egui::Direction::TopDown), |ui| {
723 let label = egui::Label::new(
724 egui::RichText::new(icon_text)
725 .size(icon_size)
726 .color(dot_color)
727 );
728 ui.add(label);
729 });
730 });
731 }
732 }
733 }
734 } else {
735 // Default dot
736 ui.painter().circle_filled(dot_center, dot_size / 2.0, dot_color);
737 }
738
739 // Draw connector line if not the last item
740 if item.show_connector {
741 let connector_start = Pos2::new(separator_x, current_y + dot_size);
742 let connector_end = Pos2::new(separator_x, current_y + item_spacing);
743 ui.painter().line_segment(
744 [connector_start, connector_end],
745 Stroke::new(CONNECTOR_WIDTH, outline),
746 );
747 }
748
749 // Draw content (custom or text-based)
750 if item.content_custom.is_some() || item.content.is_some() {
751 let content_color = item.content_color.unwrap_or(on_surface);
752 let content_width = if is_alternate_mode {
753 // For alternate mode, content takes up half the width minus padding and dot
754 let half_width = available_width / 2.0;
755 if has_opposite {
756 half_width - OPPOSITE_CONTENT_WIDTH - CONTENT_PADDING * 2.0 - DOT_SIZE / 2.0
757 } else {
758 half_width - CONTENT_PADDING - DOT_SIZE / 2.0
759 }
760 } else {
761 // For Left/Right mode, content can use most of the width
762 if has_opposite {
763 available_width - OPPOSITE_CONTENT_WIDTH - CONTENT_PADDING * 3.0 - DOT_SIZE
764 } else {
765 available_width - DOT_SIZE - CONTENT_PADDING * 2.0
766 }
767 };
768
769 // Use full item height for proper vertical centering with dot
770 let content_rect = Rect::from_min_size(
771 Pos2::new(content_x, current_y),
772 Vec2::new(content_width, item_spacing),
773 );
774
775 // Use allocate_ui_at_rect for proper rendering with interaction
776 let content_inner = ui.allocate_ui_at_rect(content_rect, |ui| {
777 // Properly clip to both the rect and parent's clip rect
778 let parent_clip = ui.clip_rect();
779 let clipped = content_rect.intersect(parent_clip);
780 ui.set_clip_rect(clipped);
781
782 let has_action = item.action.is_some();
783 let item_id = base_id.with(("content", index));
784 let sense = if has_action { Sense::click() } else { Sense::hover() };
785 let interact_response = ui.interact(content_rect, item_id, sense);
786
787 // Draw hover effect
788 if interact_response.hovered() && has_action {
789 let hover_color = Color32::from_rgba_unmultiplied(
790 on_surface.r(),
791 on_surface.g(),
792 on_surface.b(),
793 10,
794 );
795 ui.painter().rect_filled(content_rect, 4.0, hover_color);
796 }
797
798 // Render custom content or text label
799 if let Some(custom_render) = &item.content_custom {
800 // Custom content rendering - use vertical layout for cards/complex content
801 let align = if is_content_right {
802 egui::Align::LEFT
803 } else {
804 egui::Align::RIGHT
805 };
806 let layout = egui::Layout::top_down(align);
807
808 ui.with_layout(layout, |ui| {
809 custom_render(ui);
810 });
811 } else if let Some(content_text) = &item.content {
812 // Text-based content rendering - use center alignment
813 let layout = if is_content_right {
814 egui::Layout::left_to_right(egui::Align::Center)
815 } else {
816 egui::Layout::right_to_left(egui::Align::Center)
817 };
818
819 ui.with_layout(layout, |ui| {
820 let label = egui::Label::new(
821 egui::RichText::new(content_text)
822 .size(16.0)
823 .color(content_color)
824 ).wrap_mode(egui::TextWrapMode::Wrap);
825 ui.add(label);
826 });
827 }
828
829 (interact_response, has_action)
830 });
831
832 // Handle click
833 if content_inner.inner.0.clicked() && content_inner.inner.1 {
834 if let Some(action) = &item.action {
835 action();
836 }
837 }
838 }
839
840 current_y += item_spacing;
841 }
842
843 response
844 }
845}
846
847/// Convenience function to create a timeline.
848///
849/// # Example
850/// ```rust
851/// # egui::__run_test_ui(|ui| {
852/// ui.add(timeline()
853/// .item(TimelineItem::new().content("Event 1"))
854/// .item(TimelineItem::new().content("Event 2")));
855/// # });
856/// ```
857pub fn timeline<'a>() -> MaterialTimeline<'a> {
858 MaterialTimeline::new()
859}