//! The timeline (bottom): the command journal in order, as SolidCraft shows its features. Runs of //! the same command make one item with a count; the marker stands after the current state, and //! undone commands follow it greyed, ready to redo. Clicking an item (or the transport buttons) //! undoes or redoes up to it with `edit.undo` / `edit.redo`; commands older than the undo history //! cannot be reached and say so. //! //! Layout follows SolidCraft's `crates/ui-egui/src/timeline.rs` (MIT OR Apache-2.0), see //! ATTRIBUTION.md. use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, UiBuilder, Vec2, pos2, vec2}; use serde_json::{Value, json}; use crate::ModelApp; use crate::icons::{self, Look}; use crate::theme::Tokens; /// The timeline's height. pub const HEIGHT: f32 = 40.0; const ITEM: f32 = 28.0; const STRIDE: f32 = 30.0; /// A run of the same command in the journal (`start..end`), or among the undone commands /// (positions past the journal's end). #[derive(Clone, Debug, PartialEq)] pub struct Item { pub command: String, /// Journal positions: after this item the journal holds `end` commands. pub start: usize, pub end: usize, /// The first command's parameters (the tooltip). pub params: Value, } impl Item { pub fn count(&self) -> usize { self.end - self.start } } /// The journal and the undone commands as runs of the same command, rebuilt only when the journal /// or the undo history changed. fn cached_items(app: &mut ModelApp) -> (std::sync::Arc>, usize) { let key = (app.session.revision(), app.session.journal.len(), app.session.undo_depth()); match &app.timeline { Some((k, list)) if *k == key => (list.clone(), key.1), _ => { let (list, done) = items(app); let list = std::sync::Arc::new(list); app.timeline = Some((key, list.clone())); (list, done) } } } /// The journal and the undone commands as runs of the same command. pub fn items(app: &ModelApp) -> (Vec, usize) { let done = app.session.journal.len(); let all = app.session.journal.iter().chain(app.session.redo_entries()); let mut out: Vec = Vec::new(); for (i, (command, params)) in all.enumerate() { // A run never crosses the current position. match out.last_mut() { Some(last) if last.command == *command && last.end == i && i != done => last.end = i + 1, _ => out.push(Item { command: command.clone(), start: i, end: i + 1, params: params.clone() }), } } (out, done) } impl ModelApp { /// Undo or redo until the journal holds `target` commands (as far as the history reaches): /// one `edit.undo` or `edit.redo` with a `count`. pub fn roll_to(&mut self, target: usize) { let (undo, redo) = self.session.undo_depth(); let done = self.session.journal.len(); let target = target.clamp(done.saturating_sub(undo), done + redo); let step = if target < done { "edit.undo" } else { "edit.redo" }; if target != done { // An error shows in the status bar. let _ = self.run(step, json!({"count": done.abs_diff(target)})); } } } /// What an item's commands did, in words: "Delete reference point RP1", "Add Node × 3", "Open /// Sample cantilever". Never the parameters' JSON. fn describe(item: &Item) -> String { let name = item.params.get("name").and_then(Value::as_str); let def = crate::browser::Def::ALL .into_iter() .find_map(|kind| ["create", "edit", "delete"].into_iter().find(|verb| kind.command(verb) == item.command).map(|verb| (kind, verb))); let text = match (def, name) { (Some((kind, verb)), Some(name)) => { let did = match verb { "create" => "Add", "edit" => "Edit", _ => "Delete", }; format!("{did} {} {name}", kind.label().to_lowercase()) } (_, Some(name)) => format!("{} {name}", crate::command_label(&item.command)), _ => crate::command_label(&item.command).to_string(), }; if item.count() > 1 { format!("{text} × {}", item.count()) } else { text } } /// What a command does to the item its icon shows, when it is not making it: deleting or changing. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Badge { Delete, Edit, } fn badge(command: &str) -> Option { let verb = command.rsplit('.').next()?; match verb { "delete" | "clear" => Some(Badge::Delete), "edit" => Some(Badge::Edit), _ => None, } } /// The badge at an item's top-left corner: a minus on the error colour for a deletion, a pencil /// stroke on the accent for an edit. fn paint_badge(p: &egui::Painter, item: Rect, b: Badge, t: &Tokens) { let c = item.left_top() + vec2(5.0, 5.0); let (fill, white) = (if b == Badge::Delete { t.error } else { t.accent }, Stroke::new(1.4, Color32::WHITE)); p.circle(c, 5.0, fill, Stroke::new(1.0, t.timeline_item)); match b { Badge::Delete => { p.line_segment([c - vec2(2.5, 0.0), c + vec2(2.5, 0.0)], white); } Badge::Edit => { p.line_segment([c + vec2(-2.2, 2.2), c + vec2(2.2, -2.2)], white); } } } /// Text for an item's tooltip: what it did, and whether a click reaches it. fn tip(item: &Item, reachable: bool) -> String { let mut s = describe(item); s.push_str(if reachable { "\nClick to roll the model to here" } else { "\nOlder than the undo history" }); s } /// The transport buttons' glyphs: to the start, back, forward, to the end. fn transport_glyph(p: &egui::Painter, c: egui::Pos2, what: &str, col: Color32) { let tri = |x: f32, left: bool| { let (a, b) = if left { (x + 3.5, x - 3.5) } else { (x - 3.5, x + 3.5) }; egui::Shape::convex_polygon(vec![pos2(a, c.y - 4.5), pos2(a, c.y + 4.5), pos2(b, c.y)], col, Stroke::NONE) }; let bar = |x: f32| egui::Shape::line_segment([pos2(x, c.y - 4.5), pos2(x, c.y + 4.5)], Stroke::new(1.5, col)); match what { "first" => { p.add(bar(c.x - 4.5)); p.add(tri(c.x + 1.0, true)); } "back" => { p.add(tri(c.x, true)); } "forward" => { p.add(tri(c.x, false)); } _ => { p.add(tri(c.x - 1.0, false)); p.add(bar(c.x + 4.5)); } } } pub fn timeline(app: &mut ModelApp, ui: &mut egui::Ui) { let t = Tokens::get(app.ui.dark); let mut roll: Option = None; egui::Panel::bottom("fc_timeline").exact_size(HEIGHT).show_separator_line(false).frame(egui::Frame::NONE.fill(t.timeline)).show(ui, |ui| { let r = ui.max_rect(); app.place("timeline", r); let p = ui.painter().clone(); p.line_segment([r.left_top(), r.right_top()], Stroke::new(1.0, t.border)); let (undo, redo) = app.session.undo_depth(); let done = app.session.journal.len(); let oldest = done.saturating_sub(undo); let mut x = r.left() + 10.0; for (what, target, tip) in [ ("first", oldest, "Undo everything the history holds"), ("back", done.saturating_sub(1), "Undo"), ("forward", done + 1, "Redo"), ("last", done + redo, "Redo everything undone"), ] { let br = Rect::from_center_size(pos2(x + 11.0, r.center().y), Vec2::splat(22.0)); let enabled = target != done && target >= oldest && target <= done + redo; let resp = ui.interact(br, ui.id().with(("fc_transport", what)), if enabled { Sense::click() } else { Sense::hover() }); if resp.hovered() && enabled { p.rect_filled(br, 3.0, t.hover); } transport_glyph(&p, br.center(), what, if enabled { t.icon } else { t.border }); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tip)); if resp.on_hover_text(tip).clicked() && enabled { roll = Some(target); } x += 24.0; } x += 14.0; let (list, done) = cached_items(app); let strip = Rect::from_min_max(pos2(x, r.top()), r.right_bottom()); if list.is_empty() { p.text( pos2(x, r.center().y), Align2::LEFT_CENTER, "The journal is empty: every command that changes the model appears here", FontId::proportional(12.0), t.text_dim, ); return; } ui.scope_builder(UiBuilder::new().max_rect(strip), |ui| { egui::ScrollArea::horizontal().id_salt("fc_timeline_scroll").stick_to_right(true).auto_shrink([false, false]).show(ui, |ui| { let marker_at = list.iter().position(|i| i.start >= done).unwrap_or(list.len()); let width = list.len() as f32 * STRIDE + 12.0; let (area, _) = ui.allocate_exact_size(vec2(width, r.height()), Sense::hover()); let p = ui.painter().clone(); let marker = |x: f32| p.rect_filled(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(5.0, r.height() - 10.0)), 2.0, t.timeline_marker); let mut x = area.left(); for (k, item) in list.iter().enumerate() { if k == marker_at { marker(x); x += 10.0; } let br = Rect::from_min_size(pos2(x, r.top() + 6.0), Vec2::splat(ITEM)); x += STRIDE; if !ui.is_rect_visible(br) { continue; } let undone = item.start >= done; let reachable = item.end >= oldest; let resp = ui.interact(br, ui.id().with(("fc_journal", k)), if reachable { Sense::click() } else { Sense::hover() }); let bg = if resp.hovered() && reachable { t.hover } else { t.timeline_item }; p.rect(br, 3.0, bg, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); let icon = fieldcraft_engine::catalogue::entry_of(&item.command).map_or("ui.views", |c| c.id); let tint = if undone || !reachable { Color32::from_white_alpha(110) } else { Color32::WHITE }; icons::paint_tinted(app, ui, icon, br.shrink(4.0), Look::of(!undone), tint); if let Some(b) = badge(&item.command) { paint_badge(&p, br, b, &t); } if item.count() > 1 { let text = p.layout_no_wrap(format!("{}", item.count()), FontId::proportional(9.0), t.text); let b = Rect::from_min_size(br.right_bottom() - text.size() - vec2(3.0, 1.0), text.size() + vec2(3.0, 1.0)); p.rect_filled(b, 3.0, t.panel_header); p.galley(b.min + vec2(1.5, 0.5), text, t.text); } let label = describe(item); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, reachable, &label)); if resp .on_hover_ui(|ui| { ui.label(tip(item, reachable)); }) .clicked() && reachable { roll = Some(item.end); } } if marker_at == list.len() { marker(x); } }); }); }); if let Some(target) = roll { app.roll_to(target); } } #[cfg(test)] mod tests { use fieldcraft_engine::Session; use super::*; #[test] fn runs_of_a_command_are_one_item_and_rolling_undoes_and_redoes() { let mut app = ModelApp::new(Session::default()); for l in 1..=3 { app.run("mesh.node.add", json!({"label": l, "xyz": [l, 0, 0]})).unwrap(); } app.run( "model.material.create", json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}, "density": 7.85e-9}), ) .unwrap(); let (list, done) = items(&app); assert_eq!(done, 4); assert_eq!(list.iter().map(|i| (i.command.as_str(), i.count())).collect::>(), [("mesh.node.add", 3), ("model.material.create", 1)]); app.roll_to(2); assert_eq!(app.session.journal.len(), 2); assert!(app.session.model.node(3).is_none()); // The undone commands follow the marker, split from the done ones. let (list, done) = items(&app); assert_eq!(done, 2); assert_eq!(list.iter().map(|i| (i.start, i.end)).collect::>(), [(0, 2), (2, 3), (3, 4)]); app.roll_to(4); assert!(app.session.model.materials().contains_key("Steel")); app.roll_to(100); assert_eq!(app.session.journal.len(), 4, "clamped to what can be redone"); } /// A deletion or an edit reads apart from the creation it undoes (#359): its own badge, and /// a tooltip in words. #[test] fn deletions_and_edits_read_apart_from_creations() { let item = |command: &str, params: Value| Item { command: command.into(), start: 0, end: 1, params }; let create = item("model.reference_point.create", json!({"name": "RP1", "xyz": [0, 0, 0]})); let delete = item("model.reference_point.delete", json!({"name": "RP1"})); assert_eq!((badge(&create.command), badge(&delete.command)), (None, Some(Badge::Delete))); assert_eq!(badge("model.load.edit"), Some(Badge::Edit)); assert_eq!(describe(&create), "Add reference point RP1"); assert_eq!(describe(&delete), "Delete reference point RP1"); assert!(!tip(&delete, true).contains('{')); let nodes = Item { end: 3, ..item("mesh.node.add", json!({"label": 1, "xyz": [0, 0, 0]})) }; assert_eq!(describe(&nodes), "Add Node × 3"); } }