Skip to main content

re_view_state_timeline/
view_class.rs

1use re_log_types::{
2    AbsoluteTimeRange, EntityPath, TimeCell, TimeInt, TimeReal, TimeType, TimelineName,
3    TimestampFormat,
4};
5use re_time_ruler::TimeRangesUi;
6use re_ui::{Help, UiExt as _, icons};
7use re_viewer_context::{
8    DataResultInteractionAddress, IdentifiedViewSystem as _, Item, TimeControlCommand, TimeView,
9    ViewClass, ViewClassLayoutPriority, ViewClassRegistryError, ViewId, ViewQuery,
10    ViewSpawnHeuristics, ViewState, ViewStateExt as _, ViewSystemExecutionError, ViewerContext,
11};
12
13use crate::data::{StateLane, StateLanePhase, StateLanesData};
14
15// Layout constants (in screen pixels).
16const LANE_BAND_HEIGHT: f32 = 22.0;
17const LANE_LABEL_HEIGHT: f32 = 14.0;
18const LANE_GAP: f32 = 4.0;
19const LANE_TOTAL_HEIGHT: f32 = LANE_BAND_HEIGHT + LANE_LABEL_HEIGHT + LANE_GAP;
20
21const TIME_AXIS_HEIGHT: f32 = 20.0;
22const TOP_MARGIN: f32 = 4.0;
23
24/// Phases narrower than this on screen get folded into a merged region with their
25/// narrow neighbors. Wide phases always render with their own color.
26const MERGE_PHASE_THRESHOLD_PIXEL: f32 = 4.0;
27
28/// One drawable item along a lane: either a single phase or a merged region.
29#[derive(Debug)]
30enum RenderItem<'a> {
31    /// A phase wide enough to render with its own color and label.
32    Single {
33        phase: &'a StateLanePhase,
34        x_start: f32,
35        x_end: f32,
36
37        /// End time of the phase (start of the next phase), if any.
38        end_time: Option<i64>,
39    },
40
41    /// Two or more consecutive narrow visible phases collapsed into one region.
42    Merged {
43        x_start: f32,
44        x_end: f32,
45        start_time: i64,
46
47        /// End time of the last phase in the group, if known.
48        end_time: Option<i64>,
49        count: usize,
50    },
51}
52
53impl RenderItem<'_> {
54    fn x_range(&self) -> (f32, f32) {
55        match self {
56            Self::Single { x_start, x_end, .. } | Self::Merged { x_start, x_end, .. } => {
57                (*x_start, *x_end)
58            }
59        }
60    }
61}
62
63/// View state for pan/zoom.
64#[derive(Default)]
65struct StateTimelineViewState {
66    /// Visible time range, in the same representation as the timeline panel.
67    /// `None` means "fit all data" — populated on the next frame from the data range.
68    time_view: Option<TimeView>,
69
70    /// The timeline we last rendered. When the active timeline changes,
71    /// we reset `time_view` so the view auto-fits to the new data.
72    active_timeline: Option<TimelineName>,
73
74    /// `true` if the current primary-button press landed on a phase rectangle.
75    /// A phase press selects the phase's entity and does NOT move the time cursor;
76    /// a press on empty space drags the time cursor.
77    press_on_phase: bool,
78}
79
80impl re_byte_size::SizeBytes for StateTimelineViewState {
81    fn heap_size_bytes(&self) -> u64 {
82        let Self {
83            time_view: _,
84            active_timeline,
85            press_on_phase: _,
86        } = self;
87
88        active_timeline.heap_size_bytes()
89    }
90}
91
92impl ViewState for StateTimelineViewState {
93    fn as_any(&self) -> &dyn std::any::Any {
94        self
95    }
96
97    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
98        self
99    }
100}
101
102#[derive(Default)]
103pub struct StateTimelineView;
104
105impl ViewClass for StateTimelineView {
106    fn identifier() -> re_sdk_types::ViewClassIdentifier {
107        "StateTimeline".into()
108    }
109
110    fn display_name(&self) -> &'static str {
111        "State timeline"
112    }
113
114    // TODO(RR-4506): Remove this function once the State Timeline view graduates from experimental.
115    fn is_experimental(&self) -> bool {
116        true
117    }
118
119    fn icon(&self) -> &'static re_ui::Icon {
120        &icons::VIEW_STATE_TIMELINE
121    }
122
123    fn new_state(&self) -> Box<dyn ViewState> {
124        Box::<StateTimelineViewState>::default()
125    }
126
127    fn help(&self, _os: egui::os::OperatingSystem) -> Help {
128        Help::new("State timeline view")
129            .markdown("Shows state transitions as horizontal colored lanes over time.")
130    }
131
132    fn on_register(
133        &self,
134        system_registry: &mut re_viewer_context::ViewSystemRegistrator<'_>,
135    ) -> Result<(), ViewClassRegistryError> {
136        system_registry.register_visualizer::<crate::StateVisualizer>()
137    }
138
139    fn preferred_tile_aspect_ratio(&self, _state: &dyn ViewState) -> Option<f32> {
140        Some(2.5)
141    }
142
143    fn layout_priority(&self) -> ViewClassLayoutPriority {
144        ViewClassLayoutPriority::Low
145    }
146
147    fn spawn_heuristics(
148        &self,
149        ctx: &ViewerContext<'_>,
150        include_entity: &dyn Fn(&EntityPath) -> bool,
151    ) -> re_viewer_context::ViewSpawnHeuristics {
152        re_tracing::profile_function!();
153
154        // Show every state change stream in a single view by default.
155        if ctx
156            .indicated_entities_per_visualizer
157            .get(&crate::StateVisualizer::identifier())
158            .is_some_and(|entities| entities.iter().any(include_entity))
159        {
160            ViewSpawnHeuristics::root()
161        } else {
162            ViewSpawnHeuristics::empty()
163        }
164    }
165
166    fn selection_ui(
167        &self,
168        _ctx: &ViewerContext<'_>,
169        _ui: &mut egui::Ui,
170        _state: &mut dyn ViewState,
171        _space_origin: &EntityPath,
172        _view_id: ViewId,
173    ) -> Result<(), ViewSystemExecutionError> {
174        Ok(())
175    }
176
177    fn ui(
178        &self,
179        ctx: &ViewerContext<'_>,
180        _missing_chunk_reporter: &re_viewer_context::MissingChunkReporter,
181        ui: &mut egui::Ui,
182        state: &mut dyn ViewState,
183        query: &ViewQuery<'_>,
184        system_output: re_viewer_context::SystemExecutionOutput,
185    ) -> Result<(), ViewSystemExecutionError> {
186        re_tracing::profile_function!();
187
188        let state = state.downcast_mut::<StateTimelineViewState>()?;
189
190        // Reset the view when the active timeline changes.
191        if state.active_timeline.as_ref() != Some(&query.timeline) {
192            state.active_timeline = Some(query.timeline);
193            state.time_view = None;
194        }
195
196        // Collect all lanes from all visualizers.
197        let all_lanes: Vec<&StateLane> = system_output
198            .iter_visualizer_data::<StateLanesData>()
199            .flat_map(|d| d.lanes.iter())
200            .collect();
201
202        if all_lanes.is_empty() {
203            ui.centered_and_justified(|ui| {
204                ui.label("No state change data. Add a visualizer that produces StateLanesData.");
205            });
206            return Ok(());
207        }
208
209        // Compute data time range.
210        let (data_min, data_max) = data_time_range(&all_lanes);
211
212        // Auto-fit on first frame.
213        // TODO(aedm): The calculation of the end time is incorrect since state transitions don't have an end time.
214        //      We should use an estimation so that the latest state is still somewhat visible. Maybe also consider
215        //      the density of states? An idea is to keep as much space for the last state as the average state
216        //      duration on the screen.
217        if state.time_view.is_none() {
218            let padding = (data_max - data_min).max(1.0) * 0.05;
219            let min = data_min - padding;
220            let max = data_max + padding;
221            state.time_view = Some(TimeView {
222                min: TimeReal::from(min),
223                time_spanned: max - min,
224            });
225        }
226
227        let Some(mut time_view) = state.time_view else {
228            return Ok(());
229        };
230
231        // Allocate the full available rect.
232        let (rect, response) =
233            ui.allocate_exact_size(ui.available_size(), egui::Sense::click_and_drag());
234
235        if !ui.is_rect_visible(rect) {
236            return Ok(());
237        }
238
239        // Layout: ruler at the top, lanes below.
240        let time_axis_rect = egui::Rect::from_min_max(
241            rect.left_top(),
242            egui::pos2(rect.right(), rect.top() + TIME_AXIS_HEIGHT),
243        );
244        let lanes_rect = egui::Rect::from_min_max(
245            egui::pos2(rect.left(), rect.top() + TIME_AXIS_HEIGHT),
246            rect.right_bottom(),
247        );
248
249        // Build the time↔screen map. A single contiguous segment matches today's
250        // state timeline view behavior (no gap collapsing).
251        let data_segment = AbsoluteTimeRange::new(
252            TimeInt::saturated_temporal_i64(data_min as i64),
253            TimeInt::saturated_temporal_i64(data_max.ceil() as i64),
254        );
255        let time_ranges_ui = TimeRangesUi::new(
256            rect.x_range(),
257            time_view,
258            std::slice::from_ref(&data_segment),
259        );
260
261        let current_time = TimeReal::from(query.latest_at.as_i64() as f64);
262        let cursor_x = time_ranges_ui.x_from_time_f32(current_time);
263
264        // On primary press, remember whether it landed on a phase. A phase press
265        // selects the entity; a press on empty space drags the time cursor.
266        if ui.input(|i| i.pointer.primary_pressed()) {
267            state.press_on_phase = response
268                .interact_pointer_pos()
269                .is_some_and(|pos| hit_test_phase(pos, lanes_rect, &all_lanes, &time_ranges_ui));
270        }
271
272        // While the primary button is active on the view and the press started on
273        // empty space, move the time cursor to the pointer. Using primary_pressed /
274        // primary_down / primary_released mirrors `re_time_panel` so that the cursor
275        // jumps on press and then follows during a drag.
276        let primary_active = response.hovered()
277            && ui.input(|i| {
278                i.pointer.primary_pressed()
279                    || i.pointer.primary_down()
280                    || i.pointer.primary_released()
281            });
282        let dragging_cursor = primary_active && !state.press_on_phase;
283        if dragging_cursor
284            && let Some(pos) = response.interact_pointer_pos()
285            && let Some(time) = time_ranges_ui.time_from_x_f32(pos.x)
286        {
287            ctx.send_time_commands([TimeControlCommand::Pause, TimeControlCommand::SetTime(time)]);
288        }
289
290        // Pan: right- or middle-click drag, plus two-finger touchpad horizontal scroll.
291        // Cmd+scroll is routed to `zoom_delta` by egui, so it won't double-fire here.
292        let mut pan_dx = 0.0;
293        if response.dragged_by(egui::PointerButton::Secondary)
294            || response.dragged_by(egui::PointerButton::Middle)
295        {
296            pan_dx += response.drag_delta().x;
297            ui.ctx().set_cursor_icon(egui::CursorIcon::AllScroll);
298        }
299        if response.hovered() {
300            pan_dx += ui.input(|i| i.smooth_scroll_delta.x);
301        }
302        if pan_dx != 0.0
303            && let Some(new_view) = time_ranges_ui.pan(-pan_dx)
304        {
305            time_view = new_view;
306        }
307
308        // Ctrl/Cmd + scroll to zoom.
309        let zoom_delta = ui.input(|i| i.zoom_delta());
310        if zoom_delta != 1.0
311            && response.hovered()
312            && let Some(pointer_pos) = ui.input(|i| i.pointer.hover_pos())
313            && let Some(new_view) = time_ranges_ui.zoom_at(pointer_pos.x, zoom_delta)
314        {
315            time_view = new_view;
316        }
317        state.time_view = Some(time_view);
318
319        // Background.
320        let painter = ui.painter_at(rect);
321        painter.rect_filled(rect, 0.0, ui.style().visuals.extreme_bg_color);
322
323        // Draw the time ruler at the top.
324        let time_type = ctx
325            .time_ctrl
326            .timeline()
327            .map_or(TimeType::Sequence, |tl| tl.typ());
328        let timestamp_format = ctx.app_options().timestamp_format;
329        re_time_ruler::paint_time_ranges_and_ticks(
330            &time_ranges_ui,
331            ui,
332            &painter.with_clip_rect(time_axis_rect),
333            time_axis_rect.y_range(),
334            time_type,
335            timestamp_format,
336        );
337        // Separator between ruler and lanes.
338        painter.line_segment(
339            [time_axis_rect.left_bottom(), time_axis_rect.right_bottom()],
340            egui::Stroke::new(1.0, ui.style().visuals.weak_text_color()),
341        );
342
343        // Draw lanes.
344        let label_color = ui.style().visuals.text_color();
345        for (lane_idx, lane) in all_lanes.iter().enumerate() {
346            paint_lane(
347                ui,
348                &painter,
349                lanes_rect,
350                lane_idx,
351                lane,
352                &time_ranges_ui,
353                time_type,
354                timestamp_format,
355                label_color,
356            );
357        }
358
359        // Handle selection: determine what's under the pointer (lane entity or view).
360        let hover_pos = ui.input(|i| i.pointer.hover_pos());
361        let hovered_lane = hover_pos.and_then(|pos| hovered_lane(pos, lanes_rect, &all_lanes));
362
363        // Time cursor — uses the same triangle-headed style as the time panel.
364        if let Some(cursor_x) = cursor_x
365            && rect.x_range().contains(cursor_x)
366        {
367            let cursor_response = if dragging_cursor || hovered_lane.is_none() {
368                Some(&response)
369            } else {
370                None
371            };
372            ui.paint_time_cursor(&painter, cursor_response, cursor_x, rect.y_range());
373        }
374
375        let interacted_item = if let Some(entity_path) = hovered_lane {
376            Item::DataResult(DataResultInteractionAddress::from_entity_path(
377                query.view_id,
378                entity_path.clone(),
379            ))
380        } else {
381            Item::View(query.view_id)
382        };
383        ctx.handle_select_hover_drag_interactions(&response, interacted_item, false);
384
385        Ok(())
386    }
387}
388
389/// Walk a lane's phases and produce the list of items to render at the current zoom level,
390/// merging consecutive narrow visible phases into [`RenderItem::Merged`] regions.
391///
392/// Invisible phases break the merge chain so that user-hidden states remain hidden
393/// rather than being folded into a visible merged region. A run of narrow phases that
394/// contains a single phase is emitted as a [`RenderItem::Single`] (no merge marker).
395fn compute_render_items<'a>(
396    lane: &'a StateLane,
397    lanes_rect: egui::Rect,
398    time_ranges_ui: &TimeRangesUi,
399) -> Vec<RenderItem<'a>> {
400    struct PendingNarrow<'a> {
401        phase: &'a StateLanePhase,
402        x_start: f32,
403        x_end: f32,
404        end_time: Option<i64>,
405    }
406
407    /// Accumulator for consecutive narrow visible phases. Tracks only the first
408    /// pending phase and the current tail, since `flush` never needs anything
409    /// in between — emitting a `Single` (count == 1) or a `Merged` (count >= 2)
410    /// uses just the first start and the last end.
411    #[derive(Default)]
412    struct Pending<'a> {
413        first: Option<PendingNarrow<'a>>,
414        last_x_end: f32,
415        last_end_time: Option<i64>,
416        count: usize,
417    }
418
419    impl<'a> Pending<'a> {
420        fn push(&mut self, p: PendingNarrow<'a>) {
421            self.last_x_end = p.x_end;
422            self.last_end_time = p.end_time;
423            self.count += 1;
424            if self.first.is_none() {
425                self.first = Some(p);
426            }
427        }
428
429        fn flush(&mut self, items: &mut Vec<RenderItem<'a>>) {
430            let count = std::mem::take(&mut self.count);
431            let Some(first) = self.first.take() else {
432                return;
433            };
434            if count == 1 {
435                items.push(RenderItem::Single {
436                    phase: first.phase,
437                    x_start: first.x_start,
438                    x_end: first.x_end,
439                    end_time: first.end_time,
440                });
441            } else {
442                items.push(RenderItem::Merged {
443                    x_start: first.x_start,
444                    x_end: self.last_x_end,
445                    start_time: first.phase.start_time,
446                    end_time: self.last_end_time,
447                    count,
448                });
449            }
450        }
451    }
452
453    let mut items: Vec<RenderItem<'a>> = Vec::new();
454    let mut pending = Pending::default();
455
456    for (i, phase) in lane.phases.iter().enumerate() {
457        // Invisible phases create a gap; they must not be merged across.
458        if !phase.visible {
459            pending.flush(&mut items);
460            continue;
461        }
462
463        let next_start_time = lane.phases.get(i + 1).map(|p| p.start_time);
464        let Some(x_start) = time_ranges_ui.x_from_time_f32(TimeReal::from(phase.start_time as f64))
465        else {
466            continue;
467        };
468        let x_end_unclipped = match next_start_time {
469            Some(t) => time_ranges_ui
470                .x_from_time_f32(TimeReal::from(t as f64))
471                .unwrap_or_else(|| lanes_rect.right()),
472            None => lanes_rect.right(),
473        };
474
475        // Off-screen to the right: nothing past this is visible either.
476        // The post-loop flush below will handle any remaining pending phases.
477        if x_start >= lanes_rect.right() {
478            break;
479        }
480        // Off-screen to the left: skip but keep the merge chain going so the next
481        // visible phase can still merge with later ones.
482        if x_end_unclipped <= lanes_rect.left() {
483            continue;
484        }
485
486        let visible_x_start = x_start.max(lanes_rect.left());
487        let visible_x_end = x_end_unclipped.min(lanes_rect.right());
488        let width = visible_x_end - visible_x_start;
489        if width <= 0.0 {
490            continue;
491        }
492
493        if width >= MERGE_PHASE_THRESHOLD_PIXEL {
494            pending.flush(&mut items);
495            items.push(RenderItem::Single {
496                phase,
497                x_start: visible_x_start,
498                x_end: visible_x_end,
499                end_time: next_start_time,
500            });
501        } else {
502            pending.push(PendingNarrow {
503                phase,
504                x_start: visible_x_start,
505                x_end: visible_x_end,
506                end_time: next_start_time,
507            });
508        }
509    }
510    pending.flush(&mut items);
511
512    items
513}
514
515/// Compute the (min, max) time range across all lanes.
516fn data_time_range(lanes: &[&StateLane]) -> (f64, f64) {
517    let mut min = f64::MAX;
518    let mut max = f64::MIN;
519    for lane in lanes {
520        for phase in &lane.phases {
521            let t = phase.start_time as f64;
522            min = min.min(t);
523            max = max.max(t);
524        }
525    }
526    if min > max {
527        (0.0, 1.0)
528    } else if (max - min).abs() < f64::EPSILON {
529        (min - 0.5, max + 0.5)
530    } else {
531        (min, max)
532    }
533}
534
535/// Returns the entity path of the lane under `pos`, if any.
536fn hovered_lane<'a>(
537    pos: egui::Pos2,
538    lanes_rect: egui::Rect,
539    lanes: &'a [&'a StateLane],
540) -> Option<&'a EntityPath> {
541    lanes.iter().enumerate().find_map(|(lane_idx, lane)| {
542        let y_top =
543            lanes_rect.top() + TOP_MARGIN + lane_idx as f32 * LANE_TOTAL_HEIGHT + LANE_LABEL_HEIGHT;
544        let y_bottom = y_top + LANE_BAND_HEIGHT;
545        (pos.y >= y_top && pos.y <= y_bottom).then_some(&lane.entity_path)
546    })
547}
548
549/// Returns `true` if `pos` lies inside any visible phase rectangle.
550fn hit_test_phase(
551    pos: egui::Pos2,
552    lanes_rect: egui::Rect,
553    lanes: &[&StateLane],
554    time_ranges_ui: &TimeRangesUi,
555) -> bool {
556    for (lane_idx, lane) in lanes.iter().enumerate() {
557        let y_top = lanes_rect.top() + TOP_MARGIN + lane_idx as f32 * LANE_TOTAL_HEIGHT;
558        let band_y_top = y_top + LANE_LABEL_HEIGHT;
559        let band_y_bottom = band_y_top + LANE_BAND_HEIGHT;
560        if pos.y < band_y_top || pos.y > band_y_bottom {
561            continue;
562        }
563        for (i, phase) in lane.phases.iter().enumerate() {
564            if !phase.visible {
565                continue;
566            }
567            let Some(x_start) =
568                time_ranges_ui.x_from_time_f32(TimeReal::from(phase.start_time as f64))
569            else {
570                continue;
571            };
572            let x_start = x_start.max(lanes_rect.left());
573            let x_end = if let Some(next) = lane.phases.get(i + 1) {
574                time_ranges_ui
575                    .x_from_time_f32(TimeReal::from(next.start_time as f64))
576                    .unwrap_or_else(|| lanes_rect.right())
577            } else {
578                lanes_rect.right()
579            }
580            .min(lanes_rect.right());
581            if x_end <= x_start {
582                continue;
583            }
584            if pos.x >= x_start && pos.x <= x_end {
585                return true;
586            }
587        }
588    }
589    false
590}
591
592/// Paint a single lane (label + colored band of phases) and show tooltips on hover.
593#[expect(clippy::too_many_arguments)]
594fn paint_lane(
595    ui: &egui::Ui,
596    painter: &egui::Painter,
597    lanes_rect: egui::Rect,
598    lane_idx: usize,
599    lane: &StateLane,
600    time_ranges_ui: &TimeRangesUi,
601    time_type: TimeType,
602    timestamp_format: TimestampFormat,
603    label_color: egui::Color32,
604) {
605    let y_top = lanes_rect.top() + TOP_MARGIN + lane_idx as f32 * LANE_TOTAL_HEIGHT;
606    let label_rect = egui::Rect::from_min_size(
607        egui::pos2(lanes_rect.left() + 4.0, y_top),
608        egui::vec2(lanes_rect.width() - 8.0, LANE_LABEL_HEIGHT),
609    );
610    let band_y_top = y_top + LANE_LABEL_HEIGHT;
611    let band_y_bottom = band_y_top + LANE_BAND_HEIGHT;
612
613    // Lane label.
614    painter.text(
615        label_rect.left_top(),
616        egui::Align2::LEFT_TOP,
617        &lane.label,
618        egui::FontId::proportional(11.0),
619        label_color,
620    );
621
622    let hover_pos = ui.input(|i| i.pointer.hover_pos());
623    let render_items = compute_render_items(lane, lanes_rect, time_ranges_ui);
624
625    let merged_fill_inactive = ui.visuals().widgets.inactive.bg_fill;
626    let merged_fill_hovered = ui.visuals().widgets.hovered.bg_fill;
627    let merged_text_color = ui.visuals().text_color();
628
629    for item in &render_items {
630        let (x_start, x_end) = item.x_range();
631        let item_rect = egui::Rect::from_min_max(
632            egui::pos2(x_start, band_y_top),
633            egui::pos2(x_end, band_y_bottom),
634        );
635        let hovered = hover_pos.is_some_and(|pos| item_rect.contains(pos));
636
637        match item {
638            RenderItem::Single { phase, .. } => paint_single(painter, item_rect, phase, hovered),
639            RenderItem::Merged { count, .. } => {
640                let fill = if hovered {
641                    merged_fill_hovered
642                } else {
643                    merged_fill_inactive
644                };
645                paint_merged(painter, item_rect, *count, fill, merged_text_color);
646            }
647        }
648
649        if let Some(pos) = hover_pos
650            && item_rect.contains(pos)
651        {
652            show_item_tooltip(ui, item, time_type, timestamp_format);
653        }
654    }
655}
656
657/// Paint one normal phase: filled band (dimmed when not hovered) + clipped label.
658fn paint_single(painter: &egui::Painter, rect: egui::Rect, phase: &StateLanePhase, hovered: bool) {
659    #[expect(clippy::disallowed_methods)] // Data-driven visualization color, not a UI theme color.
660    let fill = if hovered {
661        phase.color
662    } else {
663        let [r, g, b, _] = phase.color.to_array();
664        egui::Color32::from_rgba_unmultiplied(r, g, b, 200)
665    };
666    painter.add(egui::epaint::RectShape::new(
667        rect,
668        0.0,
669        fill,
670        egui::Stroke::NONE,
671        egui::StrokeKind::Outside,
672    ));
673
674    if rect.width() - 6.0 > 10.0 {
675        painter.with_clip_rect(rect).text(
676            egui::pos2(rect.left() + 4.0, rect.top() + 3.0),
677            egui::Align2::LEFT_TOP,
678            &phase.label,
679            egui::FontId::proportional(12.0),
680            readable_text_color(phase.color),
681        );
682    }
683}
684
685/// Paint a merged region: a flat band in a theme widget color signaling that many
686/// narrow phases have been collapsed at the current zoom level. The caller picks the
687/// fill from `widgets.inactive`/`widgets.hovered` so the hover state stays
688/// token-driven rather than relying on an arbitrary multiplier.
689fn paint_merged(
690    painter: &egui::Painter,
691    rect: egui::Rect,
692    count: usize,
693    fill: egui::Color32,
694    text_color: egui::Color32,
695) {
696    painter.add(egui::epaint::RectShape::new(
697        rect,
698        0.0,
699        fill,
700        egui::Stroke::NONE,
701        egui::StrokeKind::Outside,
702    ));
703
704    if rect.width() - 6.0 > 24.0 {
705        let label = format!("{count} states");
706        painter.with_clip_rect(rect).text(
707            egui::pos2(rect.left() + 4.0, rect.top() + 3.0),
708            egui::Align2::LEFT_TOP,
709            label,
710            egui::FontId::proportional(12.0),
711            text_color,
712        );
713    }
714}
715
716fn show_item_tooltip(
717    ui: &egui::Ui,
718    item: &RenderItem<'_>,
719    time_type: TimeType,
720    timestamp_format: TimestampFormat,
721) {
722    egui::Tooltip::always_open(
723        ui.ctx().clone(),
724        ui.layer_id(),
725        egui::Id::new("state_tooltip"),
726        egui::PopupAnchor::Pointer,
727    )
728    .show(|ui| {
729        let weak = ui.visuals().weak_text_color();
730        let small = egui::FontId::proportional(11.0);
731        match item {
732            RenderItem::Single {
733                phase, end_time, ..
734            } => {
735                ui.label(&phase.label);
736                ui.add_space(4.0);
737                let start = TimeCell::new(time_type, phase.start_time).format(timestamp_format);
738                ui.label(
739                    egui::RichText::new(format!("Start: {start}"))
740                        .font(small.clone())
741                        .color(weak),
742                );
743                if let Some(end) = end_time {
744                    let end = TimeCell::new(time_type, *end).format(timestamp_format);
745                    ui.label(
746                        egui::RichText::new(format!("End: {end}"))
747                            .font(small)
748                            .color(weak),
749                    );
750                }
751            }
752            RenderItem::Merged {
753                start_time,
754                end_time,
755                count,
756                ..
757            } => {
758                ui.label(format!("{count} states (zoom in to see details)"));
759                ui.add_space(4.0);
760                let start = TimeCell::new(time_type, *start_time).format(timestamp_format);
761                ui.label(
762                    egui::RichText::new(format!("Start: {start}"))
763                        .font(small.clone())
764                        .color(weak),
765                );
766                if let Some(end) = end_time {
767                    let end = TimeCell::new(time_type, *end).format(timestamp_format);
768                    ui.label(
769                        egui::RichText::new(format!("End: {end}"))
770                            .font(small)
771                            .color(weak),
772                    );
773                }
774            }
775        }
776    });
777}
778
779/// Choose white or black text depending on background luminance.
780fn readable_text_color(bg: egui::Color32) -> egui::Color32 {
781    if bg.intensity() > 0.6 {
782        egui::Color32::BLACK
783    } else {
784        egui::Color32::WHITE
785    }
786}
787
788#[test]
789fn test_help_view() {
790    re_test_context::TestContext::test_help_view(|ctx| StateTimelineView.help(ctx));
791}
792
793#[cfg(test)]
794mod tests {
795    use super::*;
796    use re_log_types::EntityPath;
797
798    /// Construct a `StateLane` from `(start_time, visible)` pairs. Color/label are
799    /// unused by `compute_render_items`, so we leave them dummy.
800    fn lane(phases: &[(i64, bool)]) -> StateLane {
801        StateLane {
802            label: "test".into(),
803            entity_path: EntityPath::from("/test"),
804            phases: phases
805                .iter()
806                .map(|&(t, visible)| StateLanePhase {
807                    start_time: t,
808                    label: String::new(),
809                    color: egui::Color32::TRANSPARENT,
810                    visible,
811                })
812                .collect(),
813        }
814    }
815
816    /// 100-pixel-wide lane rect; combined with a `TimeView` covering `[0, 100]`
817    /// this maps one time unit to one pixel, so phase widths in time equal pixel
818    /// widths.
819    fn unit_rect() -> egui::Rect {
820        egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(100.0, 22.0))
821    }
822
823    fn ranges_ui(t_min: f64, t_max: f64) -> TimeRangesUi {
824        let time_view = TimeView {
825            min: TimeReal::from(t_min),
826            time_spanned: t_max - t_min,
827        };
828        let segment = AbsoluteTimeRange::new(
829            TimeInt::saturated_temporal_i64(t_min as i64),
830            TimeInt::saturated_temporal_i64(t_max.ceil() as i64),
831        );
832        TimeRangesUi::new(
833            unit_rect().x_range(),
834            time_view,
835            std::slice::from_ref(&segment),
836        )
837    }
838
839    fn is_single(item: &RenderItem<'_>, expected_start: i64) -> bool {
840        matches!(item, RenderItem::Single { phase, .. } if phase.start_time == expected_start)
841    }
842
843    fn is_merged(item: &RenderItem<'_>, expected_start: i64, expected_count: usize) -> bool {
844        matches!(
845            item,
846            RenderItem::Merged { start_time, count, .. }
847                if *start_time == expected_start && *count == expected_count
848        )
849    }
850
851    #[test]
852    fn empty_lane_produces_no_items() {
853        let lane = lane(&[]);
854        let items = compute_render_items(&lane, unit_rect(), &ranges_ui(0.0, 100.0));
855        assert!(items.is_empty(), "{items:?}");
856    }
857
858    #[test]
859    fn single_wide_phase_renders_as_single() {
860        // One phase covering x=0..100 — well above the merge threshold.
861        let lane = lane(&[(0, true)]);
862        let items = compute_render_items(&lane, unit_rect(), &ranges_ui(0.0, 100.0));
863        assert_eq!(items.len(), 1, "{items:?}");
864        assert!(is_single(&items[0], 0), "{items:?}");
865    }
866
867    #[test]
868    fn lone_narrow_phase_renders_as_single_not_merged() {
869        // Phase 0: x=0..2 (narrow). Phase 1: x=2..100 (wide).
870        // The narrow phase has no narrow neighbor to merge with, so it stays Single.
871        let lane = lane(&[(0, true), (2, true)]);
872        let items = compute_render_items(&lane, unit_rect(), &ranges_ui(0.0, 100.0));
873        assert_eq!(items.len(), 2, "{items:?}");
874        assert!(is_single(&items[0], 0), "{items:?}");
875        assert!(is_single(&items[1], 2), "{items:?}");
876    }
877
878    #[test]
879    fn two_consecutive_narrow_phases_merge() {
880        // Two narrow (x=0..2, 2..4) + one wide (x=4..100).
881        let lane = lane(&[(0, true), (2, true), (4, true)]);
882        let items = compute_render_items(&lane, unit_rect(), &ranges_ui(0.0, 100.0));
883        assert_eq!(items.len(), 2, "{items:?}");
884        assert!(is_merged(&items[0], 0, 2), "{items:?}");
885        assert!(is_single(&items[1], 4), "{items:?}");
886    }
887
888    #[test]
889    fn wide_phase_breaks_merge_chain() {
890        // Wide (0..10), narrow (10..12), wide (12..100) — the lone narrow stays Single.
891        let lane = lane(&[(0, true), (10, true), (12, true)]);
892        let items = compute_render_items(&lane, unit_rect(), &ranges_ui(0.0, 100.0));
893        assert_eq!(items.len(), 3, "{items:?}");
894        assert!(is_single(&items[0], 0), "{items:?}");
895        assert!(is_single(&items[1], 10), "{items:?}");
896        assert!(is_single(&items[2], 12), "{items:?}");
897    }
898
899    #[test]
900    fn invisible_phase_breaks_merge_chain() {
901        // narrow visible (0..2), narrow invisible (2..4), narrow visible (4..6), wide (6..100).
902        // The two visible narrow phases must NOT merge across the invisible gap.
903        let lane = lane(&[(0, true), (2, false), (4, true), (6, true)]);
904        let items = compute_render_items(&lane, unit_rect(), &ranges_ui(0.0, 100.0));
905        assert_eq!(items.len(), 3, "{items:?}");
906        assert!(is_single(&items[0], 0), "{items:?}");
907        assert!(is_single(&items[1], 4), "{items:?}");
908        assert!(is_single(&items[2], 6), "{items:?}");
909    }
910
911    #[test]
912    fn off_screen_left_phases_dont_break_merge_chain() {
913        // Viewport t=[30, 130]: phases at 0 and 5 are entirely off-screen left;
914        // phases at 10 and 32 are narrow on-screen; phase at 34 is wide.
915        // The two on-screen narrow phases must merge — the off-screen phases
916        // shouldn't terminate the run.
917        let lane = lane(&[(0, true), (5, true), (10, true), (32, true), (34, true)]);
918        let items = compute_render_items(&lane, unit_rect(), &ranges_ui(30.0, 130.0));
919        assert_eq!(items.len(), 2, "{items:?}");
920        assert!(is_merged(&items[0], 10, 2), "{items:?}");
921        assert!(is_single(&items[1], 34), "{items:?}");
922    }
923
924    #[test]
925    fn off_screen_right_phase_stops_iteration() {
926        // Viewport t=[0, 100], two visible wide phases, then one off-screen right.
927        let lane = lane(&[(0, true), (10, true), (200, true)]);
928        let items = compute_render_items(&lane, unit_rect(), &ranges_ui(0.0, 100.0));
929        assert_eq!(items.len(), 2, "{items:?}");
930        assert!(is_single(&items[0], 0), "{items:?}");
931        assert!(is_single(&items[1], 10), "{items:?}");
932    }
933
934    #[test]
935    fn trailing_narrow_run_flushes_as_merged_after_loop() {
936        // 50 narrow phases spaced 2 apart, covering the entire visible range.
937        // Verifies that the post-loop flush emits the Merged region (no wide phase
938        // forces an earlier flush).
939        let phases: Vec<(i64, bool)> = (0..50).map(|i| (i * 2, true)).collect();
940        let lane = lane(&phases);
941        let items = compute_render_items(&lane, unit_rect(), &ranges_ui(0.0, 100.0));
942        assert_eq!(items.len(), 1, "{items:?}");
943        assert!(is_merged(&items[0], 0, 50), "{items:?}");
944    }
945
946    #[test]
947    fn trailing_narrow_run_flushes_when_remaining_phases_are_off_screen_right() {
948        // Two narrow phases (50..52, 52..54), then a wide (54..100), then a phase
949        // at t=200 that's off-screen-right. The merge group must still be emitted.
950        let lane = lane(&[(50, true), (52, true), (54, true), (200, true)]);
951        let items = compute_render_items(&lane, unit_rect(), &ranges_ui(0.0, 100.0));
952        assert_eq!(items.len(), 2, "{items:?}");
953        assert!(is_merged(&items[0], 50, 2), "{items:?}");
954        assert!(is_single(&items[1], 54), "{items:?}");
955    }
956}