mod layout;
mod paint;
mod readout;
#[cfg(test)]
mod tests;
use crate::date::TimeOfDay;
use crate::event::{Event, MouseButton, MouseKind};
use crate::geometry::{Rect, Size};
use crate::keymap::Key;
use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
use super::IndexMessage;
use super::axis::DAY;
const ZOOM_SPANS: [u32; 5] = [DAY, 43_200, 21_600, 10_800, 3_600];
type ZoomMessage<Msg> = Box<dyn Fn(TimeOfDay, TimeOfDay) -> Msg>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimeBlock {
label: String,
start: TimeOfDay,
end: TimeOfDay,
tone: Option<usize>,
open_start: bool,
open_end: bool,
faint: bool,
}
impl TimeBlock {
#[must_use]
pub fn new(label: impl Into<String>, start: TimeOfDay, end: TimeOfDay) -> Self {
Self { label: label.into(), start, end, tone: None, open_start: false, open_end: false, faint: false }
}
#[must_use]
pub fn tone(mut self, index: usize) -> Self {
self.tone = Some(index);
self
}
#[must_use]
pub fn open_end(mut self) -> Self {
self.open_end = true;
self
}
#[must_use]
pub fn open_start(mut self) -> Self {
self.open_start = true;
self
}
#[must_use]
pub fn faint(mut self) -> Self {
self.faint = true;
self
}
}
pub struct Timeline<Msg> {
blocks: Vec<TimeBlock>,
day_start: TimeOfDay,
range: Option<(TimeOfDay, TimeOfDay)>,
axis: bool,
readout: bool,
selected: Option<usize>,
disabled: bool,
on_select: Option<IndexMessage<Msg>>,
on_zoom: Option<ZoomMessage<Msg>>,
}
impl<Msg: 'static> Timeline<Msg> {
#[must_use]
pub fn new(blocks: impl IntoIterator<Item = TimeBlock>) -> Self {
Self {
blocks: blocks.into_iter().collect(),
day_start: TimeOfDay::default(),
range: None,
axis: false,
readout: false,
selected: None,
disabled: false,
on_select: None,
on_zoom: None,
}
}
#[must_use]
pub fn day_starts_at(mut self, time: TimeOfDay) -> Self {
self.day_start = time;
self
}
#[must_use]
pub fn range(mut self, from: TimeOfDay, to: TimeOfDay) -> Self {
self.range = Some((from, to));
self
}
#[must_use]
pub fn axis(mut self) -> Self {
self.axis = true;
self
}
#[must_use]
pub fn readout(mut self) -> Self {
self.readout = true;
self
}
#[must_use]
pub fn selected(mut self, index: Option<usize>) -> Self {
self.selected = index;
self
}
#[must_use]
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
#[must_use]
pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
self.on_select = Some(Box::new(message));
self
}
#[must_use]
pub fn on_zoom(mut self, message: impl Fn(TimeOfDay, TimeOfDay) -> Msg + 'static) -> Self {
self.on_zoom = Some(Box::new(message));
self
}
fn selectable(&self) -> bool {
self.on_select.is_some() && !self.disabled && !self.blocks.is_empty()
}
fn zoomable(&self) -> bool {
self.on_zoom.is_some() && !self.disabled
}
fn select(&self, cx: &mut EventCx<'_, Msg>, index: usize) {
if let Some(message) = &self.on_select
&& self.selected != Some(index)
{
cx.emit(message(index));
}
let (from, span) = self.visible();
let Some(block) = self.blocks.get(index) else { return };
let (start, end) = self.extent(block);
if span >= DAY || (start >= from && end <= from + span) {
return;
}
let middle = start + (end - start) / 2;
let moved = middle.saturating_sub(span / 2).min(DAY - span);
self.ask_range(cx, moved - moved % 60, span);
}
fn zoom(&self, cx: &mut EventCx<'_, Msg>, inward: bool, anchor: u32) {
let (from, span) = self.visible();
let next = if inward {
ZOOM_SPANS.into_iter().find(|s| *s < span)
} else {
ZOOM_SPANS.into_iter().rev().find(|s| *s > span)
};
let Some(next) = next else { return };
let anchor = anchor.clamp(from, from + span);
let before = u64::from(anchor - from) * u64::from(next) / u64::from(span);
let start = anchor.saturating_sub(u32::try_from(before).unwrap_or(0)).min(DAY - next);
self.ask_range(cx, start - start % 60, next);
}
fn ask_range(&self, cx: &mut EventCx<'_, Msg>, from: u32, span: u32) {
if let Some(message) = &self.on_zoom
&& (from, span) != self.visible()
{
cx.emit(message(self.clock(from), self.clock(from + span)));
}
}
fn keyboard_anchor(&self) -> u32 {
let (from, span) = self.visible();
self.selected
.and_then(|index| self.blocks.get(index))
.map(|block| self.extent(block))
.filter(|(start, end)| *end > from && *start < from + span)
.map_or(from + span / 2, |(start, end)| start + (end - start) / 2)
}
fn key_target(&self, key: &crate::event::KeyEvent) -> Option<usize> {
let order = self.order();
let last = order.len().checked_sub(1)?;
let at = self.selected.and_then(|selected| order.iter().position(|index| *index == selected));
let position = if key.is_plain(Key::Left) || key.is_plain(Key::Char('h')) {
at.map_or(last, |at| at.saturating_sub(1))
} else if key.is_plain(Key::Right) || key.is_plain(Key::Char('l')) {
at.map_or(0, |at| (at + 1).min(last))
} else if key.is_plain(Key::Home) {
0
} else if key.is_plain(Key::End) {
last
} else {
return None;
};
order.get(position).copied()
}
fn zoom_key(&self, cx: &mut EventCx<'_, Msg>, key: &crate::event::KeyEvent) -> bool {
let plain = |c: char| key.is_plain(Key::Char(c));
if plain('+') || plain('=') {
self.zoom(cx, true, self.keyboard_anchor());
} else if plain('-') {
self.zoom(cx, false, self.keyboard_anchor());
} else if plain('0') {
self.ask_range(cx, 0, DAY);
} else {
return false;
}
true
}
}
impl<Msg: 'static> Widget<Msg> for Timeline<Msg> {
fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
let rows = self.lanes().saturating_add(u16::from(self.axis)).saturating_add(u16::from(self.readout));
Size::new(available.width, rows).min(available)
}
fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
if area.is_empty() {
return;
}
if self.selectable() || self.zoomable() {
cx.register_hit(area);
}
self.paint_all(cx, area);
}
fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
if !self.focusable() {
return false;
}
let area = cx.area();
match event {
Event::Key(key) => {
if self.zoomable() && self.zoom_key(cx, key) {
return true;
}
if !self.selectable() {
return false;
}
let Some(target) = self.key_target(key) else { return false };
self.select(cx, target);
true
}
Event::Mouse(mouse) => match mouse.kind {
MouseKind::Down(MouseButton::Left) if self.selectable() => {
let Some(index) = self.block_at(area, mouse.x, mouse.y) else { return false };
self.select(cx, index);
true
}
MouseKind::ScrollUp | MouseKind::ScrollDown if self.zoomable() && cx.is_focused() => {
let anchor = self.time_at(area, mouse.x);
self.zoom(cx, mouse.kind == MouseKind::ScrollUp, anchor);
true
}
_ => false,
},
_ => false,
}
}
fn focusable(&self) -> bool {
self.selectable() || self.zoomable()
}
}