use crate::core::{Color, HorizontalAlignment, Point, Rect};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::style::{MotionSlot, PropertyDriver};
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::metrics::{dimensions, effective_font};
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
use core::cell::{Ref, RefCell};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimelineItem {
pub id: String,
pub label: String,
pub start: i64,
pub end: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TimelineLaneMode {
#[default]
Row,
LanePacked,
}
impl TimelineLaneMode {
pub fn token(self) -> &'static str {
match self {
Self::Row => "row",
Self::LanePacked => "lane_packed",
}
}
pub fn from_token(token: &str) -> Option<Self> {
match token {
"row" => Some(Self::Row),
"lane_packed" => Some(Self::LanePacked),
_ => None,
}
}
}
const TIME_LABEL_HEIGHT: u32 = 16;
const TIMELINE_EDGE_GRAB: i32 = 4;
const TIMELINE_PAN_THRESHOLD: i32 = 3;
enum TimelineDrag {
Pan {
origin_x: i32,
start: i64,
end: i64,
moved: bool,
},
Move {
index: usize,
origin_x: i32,
start: i64,
end: i64,
},
ResizeStart {
index: usize,
origin_x: i32,
start: i64,
end: i64,
},
ResizeEnd {
index: usize,
origin_x: i32,
start: i64,
end: i64,
},
}
const TIME_LABEL_TICKS: u32 = 4;
impl TimelineItem {
pub fn new(id: impl Into<String>, label: impl Into<String>, start: i64, end: i64) -> Self {
let normalized_end = end.max(start);
Self { id: id.into(), label: label.into(), start, end: normalized_end }
}
}
pub struct TimelineWidget {
base: BaseWidget,
items: Vec<TimelineItem>,
selected_index: Option<usize>,
hovered_index: Option<usize>,
viewport_start: i64,
viewport_end: i64,
row_height: u32,
scroll_offset: usize,
now: Option<i64>,
animate_entry: bool,
reveal: PropertyDriver,
show_time_labels: bool,
lane_mode: TimelineLaneMode,
items_revision: u64,
lane_cache: RefCell<Option<(u64, TimelineLaneMode, Vec<Vec<usize>>)>>,
drag: Option<TimelineDrag>,
pub item_selected: Signal1<String>,
pub viewport_changed: Signal1<(i64, i64)>,
pub item_hovered: Signal1<Option<String>>,
pub item_moved: Signal1<(String, i64, i64)>,
pub item_resized: Signal1<(String, i64, i64)>,
}
impl TimelineWidget {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::Chart, geometry, "TimelineWidget"),
items: Vec::new(),
selected_index: None,
hovered_index: None,
viewport_start: 0,
viewport_end: 100,
row_height: 24,
scroll_offset: 0,
now: None,
animate_entry: false,
reveal: PropertyDriver::at(1.0, MotionSlot::Normal),
show_time_labels: false,
lane_mode: TimelineLaneMode::Row,
items_revision: 1,
lane_cache: RefCell::new(None),
drag: None,
item_selected: Signal1::new(),
viewport_changed: Signal1::new(),
item_hovered: Signal1::new(),
item_moved: Signal1::new(),
item_resized: Signal1::new(),
}
}
pub fn set_now(&mut self, now: Option<i64>) {
if self.now == now {
return;
}
self.now = now;
self.base.request_redraw();
}
pub fn now(&self) -> Option<i64> {
self.now
}
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
pub fn scroll_by(&mut self, delta: isize) {
let max = self.lane_count().saturating_sub(self.visible_count());
let next = (self.scroll_offset as isize + delta).clamp(0, max as isize) as usize;
if self.scroll_offset == next {
return;
}
self.scroll_offset = next;
self.base.request_redraw();
}
pub fn show_time_labels(&self) -> bool {
self.show_time_labels
}
pub fn set_show_time_labels(&mut self, show: bool) {
if self.show_time_labels == show {
return;
}
self.show_time_labels = show;
self.base.request_layout();
self.base.request_redraw();
}
pub fn lane_mode(&self) -> TimelineLaneMode {
self.lane_mode
}
pub fn set_lane_mode(&mut self, mode: TimelineLaneMode) {
if self.lane_mode == mode {
return;
}
self.lane_mode = mode;
self.scroll_offset = self.scroll_offset.min(self.lane_count().saturating_sub(1));
self.hovered_index = None;
self.drag = None;
self.base.request_layout();
self.base.request_redraw();
}
fn lanes(&self) -> Ref<'_, Vec<Vec<usize>>> {
let fresh = self.lane_cache.borrow().as_ref().is_some_and(|(revision, mode, _)| {
*revision == self.items_revision && *mode == self.lane_mode
});
if !fresh {
let lanes = self.compute_lanes();
*self.lane_cache.borrow_mut() = Some((self.items_revision, self.lane_mode, lanes));
}
Ref::map(self.lane_cache.borrow(), |cache| {
&cache.as_ref().expect("the lane packing was just computed").2
})
}
fn compute_lanes(&self) -> Vec<Vec<usize>> {
match self.lane_mode {
TimelineLaneMode::Row => (0..self.items.len()).map(|index| vec![index]).collect(),
TimelineLaneMode::LanePacked => {
let mut lanes: Vec<Vec<usize>> = Vec::new();
let mut ends: Vec<i64> = Vec::new();
for (index, item) in self.items.iter().enumerate() {
let mut placed = false;
for (lane, end) in ends.iter_mut().enumerate() {
if *end < item.start {
*end = item.end;
lanes[lane].push(index);
placed = true;
break;
}
}
if !placed {
ends.push(item.end);
lanes.push(vec![index]);
}
}
lanes
}
}
}
fn lane_count(&self) -> usize {
match self.lane_mode {
TimelineLaneMode::Row => self.items.len(),
TimelineLaneMode::LanePacked => self.lanes().len(),
}
}
fn axis_height(&self) -> u32 {
if self.show_time_labels {
TIME_LABEL_HEIGHT
} else {
0
}
}
pub fn set_animate_entry(&mut self, animate: bool) {
if self.animate_entry == animate {
return;
}
self.animate_entry = animate;
if animate {
self.reveal.jump_to(0.0);
self.reveal.set_target(1.0);
} else {
self.reveal.jump_to(1.0);
}
self.base.request_redraw();
}
pub fn animate_entry(&self) -> bool {
self.animate_entry
}
pub fn reveal_progress(&self) -> f32 {
if self.animate_entry {
self.reveal.value()
} else {
1.0
}
}
pub fn set_items(&mut self, items: Vec<TimelineItem>) {
self.items = items;
self.selected_index = None;
self.items_revision = self.items_revision.wrapping_add(1);
self.drag = None;
self.recompute_viewport();
self.base.request_layout();
self.base.request_redraw();
}
pub fn items(&self) -> &[TimelineItem] {
&self.items
}
pub fn selected_index(&self) -> Option<usize> {
self.selected_index.filter(|index| *index < self.items.len())
}
pub fn selected_id(&self) -> Option<&str> {
let index = self.selected_index()?;
self.items.get(index).map(|item| item.id.as_str())
}
pub fn viewport(&self) -> (i64, i64) {
(self.viewport_start, self.viewport_end)
}
pub fn set_viewport(&mut self, start: i64, end: i64) {
let end = end.max(start + 1);
if self.viewport_start == start && self.viewport_end == end {
return;
}
self.viewport_start = start;
self.viewport_end = end;
self.viewport_changed.emit((start, end));
self.base.request_redraw();
}
pub fn zoom(&mut self, factor: f32) {
if factor <= 0.0 {
return;
}
let span = (self.viewport_end - self.viewport_start).max(1) as f32;
let center = (self.viewport_start + self.viewport_end) as f32 / 2.0;
let next_half = (span / factor / 2.0).max(1.0);
let start = (center - next_half).floor() as i64;
let end = (center + next_half).ceil() as i64;
self.set_viewport(start, end);
}
pub fn set_row_height(&mut self, row_height: u32) {
let row_height = row_height.max(1);
if self.row_height == row_height {
return;
}
self.row_height = row_height;
self.base.request_layout();
self.base.request_redraw();
}
pub fn row_height(&self) -> u32 {
self.row_height
}
pub fn select_index(&mut self, index: usize) -> bool {
if index >= self.items.len() {
return false;
}
if self.selected_index == Some(index) {
return true;
}
self.selected_index = Some(index);
if let Some(item) = self.items.get(index) {
self.item_selected.emit(item.id.clone());
}
self.base.request_redraw();
true
}
fn recompute_viewport(&mut self) {
let (start, end) = if self.items.is_empty() {
(0, 100)
} else {
let mut min_start = i64::MAX;
let mut max_end = i64::MIN;
for item in &self.items {
min_start = min_start.min(item.start);
max_end = max_end.max(item.end);
}
if min_start >= max_end {
max_end = min_start + 1;
}
(min_start, max_end)
};
self.viewport_start = start;
self.viewport_end = end;
self.viewport_changed.emit((start, end));
}
fn visible_count(&self) -> usize {
let height = self.base.geometry().height.saturating_sub(self.axis_height()) as usize;
let row_h = self.row_height as usize;
if row_h == 0 {
return 0;
}
(height / row_h).max(1)
}
fn row_at(&self, pos: Point) -> Option<usize> {
let rect = self.geometry();
if pos.x < rect.x
|| pos.x >= rect.x + rect.width as i32
|| pos.y < rect.y
|| pos.y >= rect.y + rect.height as i32
{
return None;
}
let body_top = rect.y + self.axis_height() as i32;
if pos.y < body_top {
return None;
}
let row = ((pos.y - body_top) / self.row_height as i32) as usize;
let index = row + self.scroll_offset;
(index < self.lane_count().min(self.visible_count() + self.scroll_offset)).then_some(index)
}
fn item_at(&self, pos: Point) -> Option<usize> {
let lane = self.row_at(pos)?;
let lanes = self.lanes();
let members = lanes.get(lane)?;
let (track_x, track_w) = self.track_geometry();
for index in members {
let item = self.items.get(*index)?;
let x0 = self.project_x(item.start, track_x, track_w);
let x1 = self.project_x(item.end, track_x, track_w).max(x0 + 2);
if pos.x >= x0 - 2 && pos.x <= x1 + 2 {
return Some(*index);
}
}
members.first().copied()
}
fn track_geometry(&self) -> (i32, u32) {
let rect = self.geometry();
(
rect.x + dimensions::CHART_LABEL_GUTTER,
rect.width.saturating_sub(
dimensions::CHART_LABEL_GUTTER as u32 + dimensions::CHART_TRACK_MARGIN as u32,
),
)
}
fn pixels_to_time(&self, dx: i32, track_w: u32) -> i64 {
if track_w == 0 {
return 0;
}
let span = (self.viewport_end - self.viewport_start).max(1) as f64;
((dx as f64 / track_w as f64) * span).round() as i64
}
fn continue_drag(&mut self, drag: TimelineDrag, pos: Point) {
let (_, track_w) = self.track_geometry();
match drag {
TimelineDrag::Pan { origin_x, start, end, moved } => {
let dx = pos.x - origin_x;
if !moved && dx.abs() < TIMELINE_PAN_THRESHOLD {
self.drag = Some(TimelineDrag::Pan { origin_x, start, end, moved });
return;
}
let delta = self.pixels_to_time(dx, track_w);
self.set_viewport(start - delta, end - delta);
self.drag = Some(TimelineDrag::Pan { origin_x, start, end, moved: true });
}
TimelineDrag::Move { index, origin_x, start, end } => {
let delta = self.pixels_to_time(pos.x - origin_x, track_w);
if let Some(item) = self.items.get(index) {
self.item_moved.emit((item.id.clone(), start + delta, end + delta));
}
self.drag = Some(TimelineDrag::Move { index, origin_x, start, end });
self.base.request_redraw();
}
TimelineDrag::ResizeStart { index, origin_x, start, end } => {
let delta = self.pixels_to_time(pos.x - origin_x, track_w);
let next_start = (start + delta).min(end - 1);
if let Some(item) = self.items.get(index) {
self.item_resized.emit((item.id.clone(), next_start, end));
}
self.drag = Some(TimelineDrag::ResizeStart { index, origin_x, start, end });
self.base.request_redraw();
}
TimelineDrag::ResizeEnd { index, origin_x, start, end } => {
let delta = self.pixels_to_time(pos.x - origin_x, track_w);
let next_end = (end + delta).max(start + 1);
if let Some(item) = self.items.get(index) {
self.item_resized.emit((item.id.clone(), start, next_end));
}
self.drag = Some(TimelineDrag::ResizeEnd { index, origin_x, start, end });
self.base.request_redraw();
}
}
}
fn project_x(&self, value: i64, track_x: i32, track_w: u32) -> i32 {
let start = self.viewport_start;
let end = self.viewport_end.max(start + 1);
let span = (end - start) as f32;
let ratio = ((value - start) as f32 / span).clamp(0.0, 1.0);
track_x + (ratio * track_w as f32) as i32
}
}
impl Widget for TimelineWidget {
fn base(&self) -> &BaseWidget {
&self.base
}
fn set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> crate::core::Size {
crate::core::Size::new(600, 200)
}
fn tick(&mut self, delta_ms: u32) -> bool {
if !self.animate_entry {
return false;
}
let moving = self.reveal.tick(delta_ms);
if moving {
self.base.request_redraw();
}
moving
}
fn is_animating(&self) -> bool {
self.animate_entry && self.reveal.is_moving()
}
impl_draw_bridge!();
impl_widget_property_hooks!();
fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
use crate::signal::EventSignalRef;
#[allow(unused_imports)]
use crate::widget::capability::CapabilityValue;
match name {
"item_selected" => {
Some(EventSignalRef::mapped("item_selected", &self.item_selected, |v| {
CapabilityValue::String(v.clone())
}))
}
"viewport_changed" => {
Some(EventSignalRef::mapped("viewport_changed", &self.viewport_changed, |v| {
CapabilityValue::Tuple(alloc::vec![
CapabilityValue::Int(v.0),
CapabilityValue::Int(v.1),
])
}))
}
"item_hovered" => {
Some(EventSignalRef::mapped("item_hovered", &self.item_hovered, |v| match v {
Some(id) => CapabilityValue::String(id.clone()),
None => CapabilityValue::Null,
}))
}
"item_moved" => Some(EventSignalRef::mapped("item_moved", &self.item_moved, |v| {
CapabilityValue::Tuple(alloc::vec![
CapabilityValue::String(v.0.clone()),
CapabilityValue::Int(v.1),
CapabilityValue::Int(v.2),
])
})),
"item_resized" => {
Some(EventSignalRef::mapped("item_resized", &self.item_resized, |v| {
CapabilityValue::Tuple(alloc::vec![
CapabilityValue::String(v.0.clone()),
CapabilityValue::Int(v.1),
CapabilityValue::Int(v.2),
])
}))
}
_ => None,
}
}
}
impl WidgetProperties for TimelineWidget {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"item_count" => Ok(CapabilityValue::UInt(self.items().len() as u64)),
"selected_index" => match self.selected_index() {
Some(index) => Ok(CapabilityValue::UInt(index as u64)),
None => Ok(CapabilityValue::Null),
},
"viewport_start" => Ok(CapabilityValue::Int(self.viewport().0)),
"viewport_end" => Ok(CapabilityValue::Int(self.viewport().1)),
"row_height" => Ok(CapabilityValue::UInt(self.row_height() as u64)),
"show_time_labels" => Ok(CapabilityValue::Bool(self.show_time_labels())),
"lane_mode" => Ok(CapabilityValue::String(self.lane_mode().token().to_string())),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"viewport_start" => match value {
CapabilityValue::Int(start) => {
let end = self.viewport().1;
self.set_viewport(start, end);
Ok(())
}
_ => Err(CapabilityAccessError::TypeMismatch),
},
"viewport_end" => match value {
CapabilityValue::Int(end) => {
let start = self.viewport().0;
self.set_viewport(start, end);
Ok(())
}
_ => Err(CapabilityAccessError::TypeMismatch),
},
"row_height" => match value {
CapabilityValue::UInt(height) => {
let height =
u32::try_from(height).map_err(|_| CapabilityAccessError::TypeMismatch)?;
self.set_row_height(height);
Ok(())
}
_ => Err(CapabilityAccessError::TypeMismatch),
},
"show_time_labels" => {
let show = match value {
CapabilityValue::Bool(show) => show,
_ => return Err(CapabilityAccessError::TypeMismatch),
};
self.set_show_time_labels(show);
Ok(())
}
"lane_mode" => {
let token = match value {
CapabilityValue::String(token) => token,
_ => return Err(CapabilityAccessError::TypeMismatch),
};
let mode = TimelineLaneMode::from_token(&token)
.ok_or(CapabilityAccessError::TypeMismatch)?;
self.set_lane_mode(mode);
Ok(())
}
"item_count" | "selected_index" => Err(CapabilityAccessError::ReadOnlyProperty),
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"item_count",
"selected_index",
"viewport_start",
"viewport_end",
"row_height",
"show_time_labels",
"lane_mode",
BASE_PROPERTY_NAMES
]
}
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"zoom" => {
self.zoom(1.2);
Ok(())
}
"select_index" | "set_items" | "set_viewport" => Err(CapabilityAccessError::OutOfRange),
_ => Err(CapabilityAccessError::UnknownCommand),
}
}
}
impl EventHandler for TimelineWidget {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
match event {
Event::MouseMove { pos } => {
if let Some(drag) = self.drag.take() {
self.continue_drag(drag, *pos);
return;
}
let hovered = self.item_at(*pos);
if hovered != self.hovered_index {
self.hovered_index = hovered;
self.item_hovered.emit(
hovered.and_then(|index| self.items.get(index)).map(|item| item.id.clone()),
);
self.base.request_redraw();
}
}
Event::MouseLeave { .. } => {
self.drag = None;
if self.hovered_index.take().is_some() {
self.item_hovered.emit(None);
self.base.request_redraw();
}
}
Event::MousePress { pos, button: 1, .. } => {
let (track_x, track_w) = self.track_geometry();
if track_w == 0 {
return;
}
match self.item_at(*pos) {
Some(index) => {
let _ = self.select_index(index);
let Some(item) = self.items.get(index) else { return };
let x0 = self.project_x(item.start, track_x, track_w);
let x1 = self.project_x(item.end, track_x, track_w).max(x0 + 2);
let base = (index, pos.x, item.start, item.end);
self.drag = Some(if (pos.x - x0).abs() <= TIMELINE_EDGE_GRAB {
TimelineDrag::ResizeStart {
index: base.0,
origin_x: base.1,
start: base.2,
end: base.3,
}
} else if (pos.x - x1).abs() <= TIMELINE_EDGE_GRAB {
TimelineDrag::ResizeEnd {
index: base.0,
origin_x: base.1,
start: base.2,
end: base.3,
}
} else {
TimelineDrag::Move {
index: base.0,
origin_x: base.1,
start: base.2,
end: base.3,
}
});
}
None => {
let rect = self.geometry();
let body_top = rect.y + self.axis_height() as i32;
if pos.y >= body_top {
self.drag = Some(TimelineDrag::Pan {
origin_x: pos.x,
start: self.viewport_start,
end: self.viewport_end,
moved: false,
});
}
}
}
}
Event::MouseRelease { .. } => {
self.drag = None;
}
Event::Wheel { delta, modifiers } => {
let modifiers = crate::shortcut::Modifiers::from_event_bits(*modifiers);
if modifiers.contains(crate::shortcut::Modifiers::SHIFT) {
if delta.y != 0 {
self.scroll_by(if delta.y > 0 { 1 } else { -1 });
}
} else if delta.y < 0 {
self.zoom(1.2);
} else if delta.y > 0 {
self.zoom(0.8);
}
}
Event::KeyPress { key, modifiers: _ } => match *key {
38 => {
let next = self.selected_index.map_or(0, |index| index.saturating_sub(1));
let _ = self.select_index(next);
}
40 => {
if !self.items.is_empty() {
let next = self
.selected_index
.map_or(0, |index| (index + 1).min(self.items.len() - 1));
let _ = self.select_index(next);
}
}
33 => self.scroll_by(-(self.visible_count() as isize)),
34 => self.scroll_by(self.visible_count() as isize),
36 => self.scroll_by(-1),
35 => self.scroll_by(1),
_ => {}
},
_ => { }
}
}
}
impl Draw for TimelineWidget {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
let style = self.base.style();
let theme = crate::style::resolved_theme_style_shared("timeline_widget");
let resolved = style
.background_color
.or_else(|| theme.as_ref().and_then(|t| t.background_color))
.unwrap_or(Color::WHITE);
let border = style
.border_color
.or_else(|| theme.as_ref().and_then(|t| t.border_color))
.unwrap_or_else(|| crate::core::Color::border_of(resolved));
let text_color = style
.text_color
.or_else(|| theme.as_ref().and_then(|t| t.text_color))
.unwrap_or(Color::BLACK);
let background = resolved.blend(&text_color, 0.08);
let selected_background = background.blend(&text_color, 0.14);
let hovered_background = background.blend(&text_color, 0.07);
let separator = background.blend(&text_color, 0.12);
let bar = border.blend(&text_color, 0.25);
context.face_with_gradient(
rect,
background,
self.style().background_gradient.as_ref(),
self.style().surface.unwrap_or_default(),
self.style().border_radius.unwrap_or(0),
Color::BLACK,
);
crate::widget::draw::stroke_surface_border(
context,
rect,
self.style().border_radius.unwrap_or(0),
border,
1,
);
if self.items.is_empty() {
return;
}
let track_x = rect.x + dimensions::CHART_LABEL_GUTTER;
let track_w = rect.width.saturating_sub(
dimensions::CHART_LABEL_GUTTER as u32 + dimensions::CHART_TRACK_MARGIN as u32,
);
let growth = f64::from(self.reveal_progress());
if self.show_time_labels {
let header = Rect::new(rect.x, rect.y, rect.width, TIME_LABEL_HEIGHT);
context.draw_line(
Point::new(header.x, header.y + header.height as i32 - 1),
Point::new(header.x + header.width as i32, header.y + header.height as i32 - 1),
separator,
);
let span = self.viewport_end - self.viewport_start;
for tick in 0..=TIME_LABEL_TICKS {
let ratio = tick as f64 / f64::from(TIME_LABEL_TICKS);
let value = self.viewport_start + (span as f64 * ratio).round() as i64;
let x = track_x + (track_w as f64 * ratio) as i32;
let band = Rect::new((x - 24).max(rect.x), header.y, 48, header.height);
context.draw_text_line_cow(
band,
&value.to_string(),
effective_font(style),
text_color.blend(&background, 0.3),
HorizontalAlignment::Center,
);
}
}
let lanes = self.lanes();
let max_rows = self.visible_count().min(lanes.len().saturating_sub(self.scroll_offset));
let body_top = rect.y + self.axis_height() as i32;
for visible in 0..max_rows {
let lane_index = self.scroll_offset + visible;
let Some(members) = lanes.get(lane_index) else {
break;
};
let y = body_top + visible as i32 * self.row_height as i32;
let row_rect = Rect::new(rect.x, y, rect.width, self.row_height);
let highlighted =
|index: Option<usize>| index.is_some_and(|index| members.contains(&index));
if highlighted(self.selected_index) {
context.fill_rect(row_rect, selected_background);
} else if highlighted(self.hovered_index) {
context.fill_rect(row_rect, hovered_background);
}
for index in members {
let Some(item) = self.items.get(*index) else {
continue;
};
if self.lane_mode == TimelineLaneMode::Row {
let label_band = Rect::new(
rect.x,
y,
dimensions::CHART_LABEL_GUTTER as u32,
self.row_height,
);
context.draw_text_line_cow(
label_band,
&item.label,
effective_font(style),
text_color,
HorizontalAlignment::Left,
);
}
let x0 = self.project_x(item.start, track_x, track_w);
let x1 = self.project_x(item.end, track_x, track_w).max(x0 + 2);
let x1 = if growth >= 1.0 {
x1
} else {
x0 + ((x1 - x0) as f64 * growth).round() as i32
};
let bar_w = (x1 - x0).max(2) as u32;
let bar_rect = Rect::new(x0, y + 6, bar_w, self.row_height.saturating_sub(12));
context.fill_rect(bar_rect, bar);
if self.lane_mode == TimelineLaneMode::LanePacked {
let (label_x, label_w, label_color) = if bar_w >= 28 {
let inset = 4i32;
(x0 + inset, bar_w.saturating_sub(inset as u32 * 2), bar.contrast_color())
} else {
let right = x1 + 4;
let width = (track_x + track_w as i32 - right).max(0) as u32;
(right, width, text_color)
};
if label_w > 0 {
let line_height = 14u32.min(self.row_height);
context.draw_text_fitted_cow(
Rect::new(
label_x,
y + ((self.row_height.saturating_sub(line_height)) / 2) as i32,
label_w,
line_height,
),
&item.label,
effective_font(style),
label_color,
HorizontalAlignment::Left,
);
}
}
}
context.draw_line(
Point::new(rect.x, y + self.row_height as i32),
Point::new(rect.x + rect.width as i32, y + self.row_height as i32),
separator,
);
}
if let Some(now) = self.now {
let x = self.project_x(now, track_x, track_w);
let marker = crate::style::semantic_color(crate::style::SemanticColor::Error)
.unwrap_or(Color::ERROR);
context.draw_line_stroke(
Point::new(x, rect.y),
Point::new(x, rect.y + rect.height as i32),
marker,
1,
);
let label_band = Rect::new((x - 18).max(rect.x), rect.y, 36, 12);
context.draw_text_line_cow(
label_band,
"now",
effective_font(style),
marker,
HorizontalAlignment::Center,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
fn sample_items() -> Vec<TimelineItem> {
vec![
TimelineItem::new("a", "Design", 0, 10),
TimelineItem::new("b", "Build", 8, 22),
TimelineItem::new("c", "Verify", 20, 30),
]
}
#[test]
fn set_items_recomputes_viewport() {
let mut timeline = TimelineWidget::new(Rect::new(0, 0, 500, 120));
timeline.set_items(sample_items());
assert_eq!(timeline.viewport(), (0, 30));
assert_eq!(timeline.items().len(), 3);
}
#[test]
fn selection_emits_selected_item_id() {
let mut timeline = TimelineWidget::new(Rect::new(0, 0, 500, 120));
timeline.set_items(sample_items());
let emitted = Arc::new(Mutex::new(Vec::<String>::new()));
let sink = emitted.clone();
timeline.item_selected.connect(move |id| {
if let Ok(mut guard) = sink.lock() {
guard.push(id.as_ref().clone());
}
});
assert!(timeline.select_index(1));
assert_eq!(timeline.selected_id(), Some("b"));
let got = emitted.lock().ok().map(|guard| guard.clone()).unwrap_or_default();
assert_eq!(got, vec!["b".to_string()]);
}
#[test]
fn wheel_zoom_updates_viewport_span() {
let mut timeline = TimelineWidget::new(Rect::new(0, 0, 500, 120));
timeline.set_items(sample_items());
let before = timeline.viewport();
timeline.handle_event(&Event::wheel(0, -120, 0));
let after_in = timeline.viewport();
assert!((after_in.1 - after_in.0) < (before.1 - before.0));
timeline.handle_event(&Event::wheel(0, 120, 0));
let after_out = timeline.viewport();
assert!((after_out.1 - after_out.0) >= (after_in.1 - after_in.0));
}
#[test]
fn new_creates_default_state() {
let timeline = TimelineWidget::new(Rect::new(0, 0, 800, 600));
assert!(timeline.items().is_empty());
assert_eq!(timeline.selected_index(), None);
assert_eq!(timeline.selected_id(), None);
assert_eq!(timeline.viewport(), (0, 100));
assert_eq!(timeline.row_height(), 24);
}
#[test]
fn items_returns_items() {
let mut timeline = TimelineWidget::new(Rect::new(0, 0, 800, 600));
let items = sample_items();
timeline.set_items(items.clone());
assert_eq!(timeline.items(), items.as_slice());
}
#[test]
fn select_index_out_of_bounds_returns_false() {
let mut timeline = TimelineWidget::new(Rect::new(0, 0, 800, 600));
timeline.set_items(sample_items());
assert!(!timeline.select_index(10));
assert_eq!(timeline.selected_index(), None);
}
#[test]
fn select_index_duplicate_guard_returns_true() {
let mut timeline = TimelineWidget::new(Rect::new(0, 0, 800, 600));
timeline.set_items(sample_items());
assert!(timeline.select_index(0));
assert_eq!(timeline.selected_index(), Some(0));
let emitted = Arc::new(Mutex::new(Vec::<String>::new()));
let sink = emitted.clone();
timeline.item_selected.connect(move |id| {
if let Ok(mut guard) = sink.lock() {
guard.push(id.as_ref().clone());
}
});
assert!(timeline.select_index(0));
assert_eq!(emitted.lock().ok().map(|g| g.len()).unwrap_or_default(), 0);
}
#[test]
fn set_viewport_updates_range() {
let mut timeline = TimelineWidget::new(Rect::new(0, 0, 800, 600));
timeline.set_viewport(50, 150);
assert_eq!(timeline.viewport(), (50, 150));
}
#[test]
fn set_viewport_maintains_min_span() {
let mut timeline = TimelineWidget::new(Rect::new(0, 0, 800, 600));
timeline.set_viewport(10, 10);
assert_eq!(timeline.viewport(), (10, 11));
}
#[test]
fn empty_items_returns_default_viewport() {
let mut timeline = TimelineWidget::new(Rect::new(0, 0, 800, 600));
timeline.set_items(vec![]);
assert!(timeline.items().is_empty());
assert_eq!(timeline.selected_index(), None);
assert_eq!(timeline.viewport(), (0, 100));
}
#[test]
fn overlapping_items_viewport_spans_union() {
let items = vec![
TimelineItem::new("a", "Design", 0, 10),
TimelineItem::new("b", "Build", 8, 22),
TimelineItem::new("c", "Verify", 20, 30),
];
let mut timeline = TimelineWidget::new(Rect::new(0, 0, 800, 600));
timeline.set_items(items);
assert_eq!(timeline.viewport(), (0, 30));
}
#[test]
fn zoom_with_zero_factor_does_nothing() {
let mut timeline = TimelineWidget::new(Rect::new(0, 0, 800, 600));
timeline.set_items(sample_items());
let before = timeline.viewport();
timeline.zoom(0.0);
assert_eq!(timeline.viewport(), before);
}
#[test]
fn row_height_setter_validates() {
let mut timeline = TimelineWidget::new(Rect::new(0, 0, 800, 600));
timeline.set_row_height(0);
assert_eq!(timeline.row_height(), 1);
}
#[test]
fn lane_packing_compresses_non_overlapping_items() {
let mut timeline = TimelineWidget::new(Rect::new(0, 0, 500, 200));
timeline.set_items(vec![
TimelineItem::new("a", "Design", 0, 10),
TimelineItem::new("b", "Build", 5, 15),
TimelineItem::new("c", "Verify", 20, 30),
]);
assert_eq!(timeline.lanes().len(), 3);
timeline.set_lane_mode(TimelineLaneMode::LanePacked);
{
let lanes = timeline.lanes();
assert_eq!(lanes.len(), 2, "two overlaps need two lanes, not three rows");
assert!(lanes.iter().any(|lane| lane.contains(&0) && lane.contains(&2)));
assert!(!lanes.iter().any(|lane| lane.contains(&0) && lane.contains(&1)));
}
assert_eq!(
timeline.get("lane_mode").unwrap(),
CapabilityValue::String("lane_packed".into())
);
assert!(timeline.set("lane_mode", CapabilityValue::String("diagonal".into())).is_err());
}
#[test]
fn time_labels_reserve_a_header_that_is_not_a_row() {
let mut timeline = TimelineWidget::new(Rect::new(0, 0, 500, 200));
timeline.set_items(sample_items());
timeline.set_show_time_labels(true);
assert!(timeline.show_time_labels());
assert_eq!(timeline.row_at(Point::new(200, 2)), None);
assert_eq!(timeline.row_at(Point::new(200, TIME_LABEL_HEIGHT as i32 + 2)), Some(0));
}
#[test]
fn viewport_changed_fires_on_zoom() {
let mut timeline = TimelineWidget::new(Rect::new(0, 0, 500, 120));
timeline.set_items(sample_items());
let ranges = Arc::new(Mutex::new(Vec::<(i64, i64)>::new()));
let sink = ranges.clone();
timeline.viewport_changed.connect(move |range| {
if let Ok(mut guard) = sink.lock() {
guard.push(*range);
}
});
timeline.handle_event(&Event::wheel(0, -120, 0));
let got = ranges.lock().ok().map(|guard| guard.clone()).unwrap_or_default();
assert_eq!(got.len(), 1);
assert!(got[0].1 - got[0].0 < 30, "the zoom must shrink the range: {:?}", got[0]);
}
#[test]
fn hovering_an_item_reports_its_id_and_clearing_reports_none() {
let mut timeline = TimelineWidget::new(Rect::new(0, 0, 500, 120));
timeline.set_items(sample_items());
let hovered = Arc::new(Mutex::new(Vec::<Option<String>>::new()));
let sink = hovered.clone();
timeline.item_hovered.connect(move |id| {
if let Ok(mut guard) = sink.lock() {
guard.push(id.as_ref().clone());
}
});
timeline.handle_event(&Event::mouse_move(200, 2));
timeline.handle_event(&Event::MouseLeave { pos: Point::new(0, 0) });
let got = hovered.lock().ok().map(|guard| guard.clone()).unwrap_or_default();
assert_eq!(got, vec![Some("a".to_string()), None]);
}
}