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
15const 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
24const MERGE_PHASE_THRESHOLD_PIXEL: f32 = 4.0;
27
28#[derive(Debug)]
30enum RenderItem<'a> {
31 Single {
33 phase: &'a StateLanePhase,
34 x_start: f32,
35 x_end: f32,
36
37 end_time: Option<i64>,
39 },
40
41 Merged {
43 x_start: f32,
44 x_end: f32,
45 start_time: i64,
46
47 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#[derive(Default)]
65struct StateTimelineViewState {
66 time_view: Option<TimeView>,
69
70 active_timeline: Option<TimelineName>,
73
74 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 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 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 if state.active_timeline.as_ref() != Some(&query.timeline) {
192 state.active_timeline = Some(query.timeline);
193 state.time_view = None;
194 }
195
196 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 let (data_min, data_max) = data_time_range(&all_lanes);
211
212 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 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 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 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 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 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 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 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 let painter = ui.painter_at(rect);
321 painter.rect_filled(rect, 0.0, ui.style().visuals.extreme_bg_color);
322
323 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 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 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 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 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
389fn 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 #[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 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 if x_start >= lanes_rect.right() {
478 break;
479 }
480 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
515fn 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
535fn 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
549fn 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#[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 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
657fn paint_single(painter: &egui::Painter, rect: egui::Rect, phase: &StateLanePhase, hovered: bool) {
659 #[expect(clippy::disallowed_methods)] 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
685fn 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
779fn 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 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 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 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 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 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 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 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 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 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 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 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}