Files
OneCAD-Simulation/crates/ui-egui/src/timeline.rs
T

323 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Vec<Item>>, 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<Item>, usize) {
let done = app.session.journal.len();
let all = app.session.journal.iter().chain(app.session.redo_entries());
let mut out: Vec<Item> = 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<Badge> {
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<usize> = 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::<Vec<_>>(), [("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::<Vec<_>>(), [(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");
}
}