forked from 1b-agent/fieldcraft-upstream
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
323 lines
14 KiB
Rust
323 lines
14 KiB
Rust
//! 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");
|
||
}
|
||
}
|