Feature history on the left side, with contiguous folders (#449)

The feature history can sit in a panel on the left, with folders that hold one unbroken run of features, drag and drop as one undo step, and a resizable divider. Timeline folders save under format 17.
This commit is contained in:
Alexander Nutz authored and GitHub committed 2026-10-13 13:01:31 -04:00
1 parent be55a49b04
commit a8b3b21fd0
17 files changed
+2336 -37

No files matched your search

+22 -15
View File
@@ -7,7 +7,7 @@ use solidcraft_engine::doc::FeatureKind;
use crate::theme::Tokens;
use crate::{SolveApp, icons};
fn icon_of(k: &FeatureKind) -> &'static str {
pub(crate) fn icon_of(k: &FeatureKind) -> &'static str {
match k {
FeatureKind::Sketch { .. } => "sketch",
FeatureKind::Extrude { .. } => "extrude",
@@ -37,8 +37,7 @@ fn icon_of(k: &FeatureKind) -> &'static str {
}
}
/// What a drag in the timeline is moving.
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq)]
enum Drag {
Marker,
Feature(u64),
@@ -57,16 +56,24 @@ fn slot_at(xs: &[f32], end: f32, x: f32) -> usize {
}
/// Ask before deleting a feature others depend on; delete right away otherwise.
fn delete_feature(app: &mut SolveApp, id: u64) {
let deps = app.session.execute("timeline.dependents", &json!({ "feature": id })).unwrap_or_default();
let list = |k: &str| -> Vec<String> {
deps.get(k).and_then(|v| v.as_array()).into_iter().flatten().filter_map(|x| x.as_str().map(String::from)).collect()
};
let (with, fail) = (list("deleted_with_it"), list("would_fail"));
pub(crate) fn delete_feature(app: &mut SolveApp, id: u64) {
delete_features(app, vec![id]);
}
pub(crate) fn delete_features(app: &mut SolveApp, ids: Vec<u64>) {
let mut with = std::collections::BTreeSet::new();
let mut fail = std::collections::BTreeSet::new();
for id in &ids {
let deps = app.session.execute("timeline.dependents", &json!({ "feature": id })).unwrap_or_default();
for (key, output) in [("deleted_with_it", &mut with), ("would_fail", &mut fail)] {
output
.extend(deps.get(key).and_then(|value| value.as_array()).into_iter().flatten().filter_map(|value| value.as_str().map(String::from)));
}
}
if with.is_empty() && fail.is_empty() {
let _ = app.run("timeline.delete", json!({ "features": [id.to_string()] }));
let _ = app.run("timeline.delete", json!({ "features": ids }));
} else {
app.dialog = Some(crate::dialogs::Dialog::confirm_delete(id, with, fail));
app.dialog = Some(crate::dialogs::Dialog::confirm_delete_many(ids, with.into_iter().collect(), fail.into_iter().collect()));
}
}
@@ -281,12 +288,12 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) {
// The scroll range follows the drawn content (the buttons don't take layout space themselves).
ui.allocate_exact_size(vec2((content_end - origin).max(0.0), r.height()), Sense::hover());
// Drag feedback, and the drop.
if let (Some(dg), Some(pp)) = (drag, pointer) {
if let (Some(dg), Some(pp)) = (drag.as_ref(), pointer) {
let slot = slot_at(&xs, end, pp.x);
let sx = xs.get(slot).copied().unwrap_or(end) - 4.0;
p.line_segment(
[pos2(sx, r.top() + 3.0), pos2(sx, r.bottom() - 3.0)],
Stroke::new(2.0, if dg == Drag::Marker { t.accent } else { t.sketch_accent }),
Stroke::new(2.0, if matches!(dg, Drag::Marker) { t.accent } else { t.sketch_accent }),
);
}
let released = ui.input(|i| i.pointer.any_released());
@@ -312,7 +319,7 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) {
}
ui.data_mut(|d| match drag {
Some(dg) => {
d.insert_temp(drag_id, dg);
d.insert_temp(drag_id, dg.clone());
}
None => d.remove::<Drag>(drag_id),
});
@@ -334,7 +341,7 @@ fn find_in_browser(app: &mut SolveApp, id: u64) {
}
/// Transport buttons drawn as shapes (the UI font has no media glyphs).
fn transport_glyph(p: &egui::Painter, c: egui::Pos2, what: &str, col: Color32) {
pub(crate) fn transport_glyph(p: &egui::Painter, c: egui::Pos2, what: &str, col: Color32) {
let tri = |dir: f32, dx: f32| {
let pts = vec![pos2(c.x + dx - 4.0 * dir, c.y - 5.0), pos2(c.x + dx + 4.0 * dir, c.y), pos2(c.x + dx - 4.0 * dir, c.y + 5.0)];
p.add(egui::Shape::convex_polygon(pts, col, Stroke::NONE));