Skip to main content

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}