diff --git a/ROADMAP.md b/ROADMAP.md index f4ac066f..479efb08 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -49,7 +49,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. | M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 | | M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 | | M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 | -| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 | +| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 | | M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 | | M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep rails, twist and scale (#771, loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — | | M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 | diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index adf2aa3f..88801abe 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -1802,6 +1802,10 @@ pub struct Document { /// Browser groups (folders of bodies, sketches or construction planes). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub browser_groups: Vec, + /// Named folders used to organise features in the timeline. They do not affect evaluation + /// order; feature ids remain the source of truth when features are renamed or reordered. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub timeline_groups: Vec, /// Browser item order per component folder (`"/"` → item keys). #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub browser_order: std::collections::BTreeMap>, @@ -1868,6 +1872,66 @@ pub struct BrowserGroup { pub items: Vec, } +/// A saved feature-history folder. Folders may nest, and a feature can belong to at most one folder. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct TimelineGroup { + pub id: u64, + pub name: String, + /// The containing feature folder. `None` places the folder at the timeline root. + #[serde(default)] + pub parent: Option, + #[serde(default)] + pub features: Vec, +} + +/// The first feature-history folder whose own and nested features do not form one +/// uninterrupted run in the timeline. +pub fn non_contiguous_timeline_group(features: &[Feature], groups: &[TimelineGroup]) -> Option { + let positions: std::collections::BTreeMap = features.iter().enumerate().map(|(index, feature)| (feature.id, index)).collect(); + let mut stats = std::collections::BTreeMap::new(); + let mut children = std::collections::BTreeMap::new(); + for group in groups { + let mut count = 0usize; + let mut first = usize::MAX; + let mut last = 0usize; + for index in group.features.iter().filter_map(|feature| positions.get(feature)) { + count = count.saturating_add(1); + first = first.min(*index); + last = last.max(*index); + } + stats.insert(group.id, (count, first, last)); + children.entry(group.id).or_insert(0usize); + if let Some(parent) = group.parent { + let child_count = children.entry(parent).or_insert(0); + *child_count = child_count.saturating_add(1); + } + } + let parents: std::collections::BTreeMap> = groups.iter().map(|group| (group.id, group.parent)).collect(); + let mut pending: Vec = groups.iter().filter(|group| children.get(&group.id) == Some(&0)).map(|group| group.id).collect(); + while let Some(id) = pending.pop() { + let (count, first, last) = stats.get(&id).copied().unwrap_or_default(); + if count > 0 && last.saturating_sub(first).saturating_add(1) != count { + return Some(id); + } + if let Some(parent) = parents.get(&id).copied().flatten() { + if let Some((parent_count, parent_first, parent_last)) = stats.get_mut(&parent) { + *parent_count = parent_count.saturating_add(count); + if count > 0 { + *parent_first = (*parent_first).min(first); + *parent_last = (*parent_last).max(last); + } + } + if let Some(remaining) = children.get_mut(&parent) { + *remaining = remaining.saturating_sub(1); + if *remaining == 0 { + pending.push(parent); + } + } + } + } + None +} + fn default_format() -> String { crate::format::format_string(crate::format::FORMAT) } @@ -1908,6 +1972,7 @@ impl Document { canvases: Vec::new(), param_comments: Default::default(), browser_groups: Vec::new(), + timeline_groups: Vec::new(), browser_order: Default::default(), named_views: Vec::new(), visibility: Visibility::default(), @@ -2092,6 +2157,40 @@ impl Document { } } + /// The deepest timeline folder spanning the insertion slot between `marker - 1` and + /// `marker`. A slot at a folder's outer boundary is not inside that folder. + fn timeline_group_at_insertion(&self, marker: usize) -> Option { + let before = self.features.get(marker.checked_sub(1)?)?.id; + let after = self.features.get(marker)?.id; + let direct = |feature| self.timeline_groups.iter().find(|group| group.features.contains(&feature)).map(|group| group.id); + let parents: std::collections::BTreeMap> = self.timeline_groups.iter().map(|group| (group.id, group.parent)).collect(); + + let mut before_ancestors = std::collections::BTreeSet::new(); + let mut current = direct(before); + let mut steps = 0usize; + while let Some(id) = current { + if !before_ancestors.insert(id) || steps >= self.timeline_groups.len() { + return None; + } + current = parents.get(&id).copied().flatten(); + steps = steps.saturating_add(1); + } + + current = direct(after); + steps = 0; + while let Some(id) = current { + if before_ancestors.contains(&id) { + return Some(id); + } + if steps >= self.timeline_groups.len() { + return None; + } + current = parents.get(&id).copied().flatten(); + steps = steps.saturating_add(1); + } + None + } + /// Insert a feature at the marker (or the end) and return its id. pub fn add_feature(&mut self, kind: FeatureKind, name: Option<&str>) -> Result { if self.features.len() >= MAX_FEATURES { @@ -2123,6 +2222,7 @@ impl Document { shell_piece_order: false, kind, }; + let insertion_group = self.marker.and_then(|marker| self.timeline_group_at_insertion(marker)); match self.marker { Some(m) if m < self.features.len() => { self.features.insert(m, f); @@ -2130,6 +2230,14 @@ impl Document { } _ => self.features.push(f), } + if let Some(group_id) = insertion_group { + let positions: std::collections::BTreeMap = + self.features.iter().enumerate().map(|(index, feature)| (feature.id, index)).collect(); + if let Some(group) = self.timeline_groups.iter_mut().find(|group| group.id == group_id) { + group.features.push(id); + group.features.sort_by_key(|feature| positions.get(feature).copied().unwrap_or(usize::MAX)); + } + } self.name_feature_inputs(id); Ok(id) } @@ -2144,6 +2252,9 @@ impl Document { .rules .retain(|rule| !rule.references_features(&gone) && !rule.parameter_references().iter().any(|name| gone_params.contains(name))); self.features.retain(|f| !gone.contains(&f.id)); + for group in &mut self.timeline_groups { + group.features.retain(|feature| !gone.contains(feature)); + } // Configuration columns of deleted features go with them. let keep: Vec = self .configs diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index eaac25d3..9ccf6076 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -91,6 +91,9 @@ //! edited. //! A fillet's `fit` can be `taper` (the radius follows the room along each edge it doesn't //! fit). Fillets with `exact` or `reduce` save as before; 0.8.0 would refuse the unknown fit. +//! Feature-history folders are also first saved in 17 (`timeline_groups`: stable folder id, +//! name, optional parent folder and feature ids). Version 16 designs have no saved timeline +//! folders and open with the empty default. //! A fillet's `type` can be `spline` (the radius along each edge a B-spline: `start`, `knots`, //! and the control radii after `radius` in `radii`). Other fillets save as before; 0.8.0 would //! refuse the unknown type. @@ -367,9 +370,43 @@ pub fn read(s: &str) -> Result { if d.features.len() > crate::document::MAX_FEATURES || d.params.len() > crate::document::MAX_PARAMS || d.components.len() > crate::document::MAX_FEATURES + || d.timeline_groups.len() > crate::document::MAX_FEATURES + || d.timeline_groups.iter().any(|group| group.features.len() > crate::document::MAX_FEATURES) { return Err(DocError::Invalid("document too large".into())); } + let feature_ids: std::collections::BTreeSet<_> = d.features.iter().map(|feature| feature.id).collect(); + let mut group_ids = std::collections::BTreeSet::new(); + let mut grouped_features = std::collections::BTreeSet::new(); + for group in &d.timeline_groups { + if group.name.trim().is_empty() || group.name.len() > 128 || !group_ids.insert(group.id) { + return Err(DocError::Invalid("damaged design: invalid timeline folder".into())); + } + if group.parent.is_some_and(|parent| parent == group.id) { + return Err(DocError::Invalid("damaged design: timeline folder cannot contain itself".into())); + } + if group.features.iter().any(|feature| !feature_ids.contains(feature) || !grouped_features.insert(*feature)) { + return Err(DocError::Invalid("damaged design: invalid feature in a timeline folder".into())); + } + } + for group in &d.timeline_groups { + if let Some(parent) = group.parent { + if !group_ids.contains(&parent) { + return Err(DocError::Invalid("damaged design: timeline folder has an unknown parent".into())); + } + let mut seen = std::collections::BTreeSet::new(); + let mut current = Some(parent); + while let Some(id) = current { + if !seen.insert(id) { + return Err(DocError::Invalid("damaged design: cyclic timeline folders".into())); + } + current = d.timeline_groups.iter().find(|candidate| candidate.id == id).and_then(|candidate| candidate.parent); + } + } + } + if let Some(id) = crate::non_contiguous_timeline_group(&d.features, &d.timeline_groups) { + return Err(DocError::Invalid(format!("damaged design: timeline folder {id} is not contiguous"))); + } for o in &d.occurrences { if !rigid(&o.transform) { return Err(DocError::Invalid(format!("damaged design: occurrence `{}` has an invalid placement", o.name))); diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 75cd08ab..59cf0512 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -386,6 +386,25 @@ fn timeline_clones_share_meshes_even_when_cloned_before_tessellation() { assert!(!a.mesh().triangles.is_empty()); } +#[test] +fn feature_inserted_at_a_marker_inside_a_timeline_folder_joins_it() { + let mut doc = Document::new("folder insertion"); + let first = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: Sketch::new() }, Some("First")).unwrap(); + let second = + doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: Sketch::new() }, Some("Second")).unwrap(); + doc.timeline_groups.push(TimelineGroup { id: 100, name: "Pair".into(), parent: None, features: vec![first, second] }); + doc.marker = Some(1); + + let inserted = + doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: Sketch::new() }, Some("Inserted")).unwrap(); + + assert_eq!(doc.features.iter().map(|feature| feature.id).collect::>(), vec![first, inserted, second]); + assert_eq!(doc.timeline_groups[0].features, vec![first, inserted, second]); + assert_eq!(doc.marker, Some(2)); + assert!(non_contiguous_timeline_group(&doc.features, &doc.timeline_groups).is_none()); + assert!(Document::from_json(&doc.to_json()).is_ok(), "the inserted feature must leave a reopenable design"); +} + /// A construction plane that is its own side resolves through the model and back into the /// document. The depth count has to survive that hop, or the recursion overflows the stack and /// aborts the process, which no panic guard can intercept. diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 7a7355e6..2e4c3e3e 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -211,6 +211,28 @@ mod tests { assert!(t.execute("timeline.paste", &json!({})).is_err()); } + #[test] + fn paste_at_a_marker_inside_a_timeline_folder_joins_the_folder() { + let mut s = Session::default(); + for (name, body, x) in [("First", "FirstBody", 0), ("Second", "SecondBody", 20), ("Source", "SourceBody", 40)] { + run( + &mut s, + "solid.box", + json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}), + ); + } + let group = run(&mut s, "timeline.group", json!({"name": "Pair", "features": ["First", "Second"]}))["group"].as_u64().unwrap(); + run(&mut s, "timeline.copy", json!({"features": ["Source"]})); + run(&mut s, "timeline.roll_to", json!({"position": 1})); + + let pasted = run(&mut s, "timeline.paste", json!({}))["pasted"][0]["id"].as_u64().unwrap(); + let folder = s.doc.timeline_groups.iter().find(|folder| folder.id == group).unwrap(); + let first = s.doc.find_feature("First").unwrap().id; + let second = s.doc.find_feature("Second").unwrap().id; + assert_eq!(folder.features, vec![first, pasted, second]); + assert!(solidcraft_doc::Document::from_json(&s.doc.to_json()).is_ok(), "pasting inside a folder must leave a reopenable design"); + } + #[test] fn pasted_fillet_rounds_the_copied_body_it_names() { let mut s = Session::default(); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3b27016b..c4b0af84 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -25,11 +25,28 @@ pub static COMMANDS: &[CommandSpec] = &[ .key("Delete") .params("features: [id or name] (a sketch takes its dependent features with it) → deleted: [ids]; failing?: [{feature, error}] (later features that worked and now fail)"), CommandSpec::new("timeline.rename", "Rename", rename).params("feature: id|name, name (references to it follow)"), + CommandSpec::new("timeline.group", "New Feature Folder", group) + .icon("folder") + .doc_only() + .params("name?; features?: [id|name]; parent?: folder id (features leave any other timeline folder)"), + CommandSpec::new("timeline.ungroup", "Remove Feature Folders", ungroup) + .doc_only() + .params("group: id | groups: [ids] (features remain in the timeline)"), + CommandSpec::new("timeline.rename_group", "Rename Feature Folder", rename_group).doc_only().params("group: id, name"), + CommandSpec::new("timeline.move_to_group", "Move Features to Folder", move_to_group) + .doc_only() + .params("features?: [id|name], groups?: [ids], group?: id (omit or null to move to the timeline root)"), + CommandSpec::new("timeline.move_many", "Move Timeline Items", move_many).params( + "features?: [id|name], groups?: [folder ids], group?: folder id, position: insertion slot; atomically changes folder membership and moves all selected contents as one block", + ), CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).params("position: number of features to keep active (omit = end)"), CommandSpec::new("timeline.roll_to", "Roll History Marker", rollback) .params("position: number of features to keep active (omit = end) | feature: id|name (the marker goes right after it)"), CommandSpec::new("timeline.reorder", "Reorder Feature", reorder) .params("feature: id|name, position: its new index; refused when a feature that worked would fail"), + CommandSpec::new("timeline.reorder_many", "Reorder Features", reorder_many).params( + "features: [id|name], position: insertion slot in the current history; selected features keep their relative order; refused when a feature that worked would fail", + ), CommandSpec::new("timeline.redefine", "Edit Feature", redefine) .params("feature: id|name, command: the feature's command id, params: its parameters (the feature is rebuilt in place)"), CommandSpec::new("timeline.dependents", "Feature Dependents", dependents) @@ -187,6 +204,302 @@ fn rename(s: &mut Session, p: &Value) -> Result { Ok(json!({"feature": id, "name": name})) } +fn timeline_group_id(s: &Session, p: &Value, cmd: &str) -> Result { + timeline_group_id_field(s, p, "group", cmd) +} + +fn timeline_group_id_field(s: &Session, p: &Value, field: &str, cmd: &str) -> Result { + let id = p.get(field).and_then(Value::as_u64).ok_or_else(|| bad(cmd, format!("`{field}` must be a timeline folder id")))?; + if s.doc.timeline_groups.iter().any(|group| group.id == id) { Ok(id) } else { Err(bad(cmd, format!("no timeline folder {id}"))) } +} + +fn group_features(s: &Session, p: &Value, required: bool, cmd: &str) -> Result> { + let values = match p.get("features") { + Some(Value::Array(values)) => values, + None if !required => return Ok(Vec::new()), + _ => return Err(bad(cmd, "`features` must be a list of feature ids or names")), + }; + if values.is_empty() && required { + return Err(bad(cmd, "`features` must list at least one feature")); + } + if values.len() > 10_000 { + return Err(bad(cmd, "too many features")); + } + let mut ids = Vec::new(); + for value in values { + let id = feature_id(s, Some(value), cmd)?; + if !ids.contains(&id) { + ids.push(id); + } + } + Ok(ids) +} + +fn group_ids(s: &Session, p: &Value, required: bool, cmd: &str) -> Result> { + let values = match p.get("groups") { + Some(Value::Array(values)) => values, + None if !required => return Ok(Vec::new()), + None => return Err(bad(cmd, "`groups` must be a list of timeline folder ids")), + _ => return Err(bad(cmd, "`groups` must be a list of timeline folder ids")), + }; + if values.is_empty() && required { + return Err(bad(cmd, "`groups` must list at least one folder")); + } + if values.len() > 10_000 { + return Err(bad(cmd, "too many folders")); + } + let mut ids = Vec::new(); + for value in values { + let id = value.as_u64().ok_or_else(|| bad(cmd, "`groups` must contain folder ids"))?; + if !s.doc.timeline_groups.iter().any(|group| group.id == id) { + return Err(bad(cmd, format!("no timeline folder {id}"))); + } + if !ids.contains(&id) { + ids.push(id); + } + } + Ok(ids) +} + +fn group_move_would_cycle(s: &Session, target: u64, sources: &[u64]) -> bool { + let sources: std::collections::BTreeSet = sources.iter().copied().collect(); + let parents: std::collections::BTreeMap> = s.doc.timeline_groups.iter().map(|group| (group.id, group.parent)).collect(); + let mut current = Some(target); + let mut steps = 0usize; + while let Some(id) = current { + if sources.contains(&id) { + return true; + } + steps += 1; + if steps > s.doc.timeline_groups.len() { + return true; + } + current = parents.get(&id).copied().flatten(); + } + false +} + +fn group_feature_ids(doc: &solidcraft_doc::Document, roots: &[u64]) -> std::collections::BTreeSet { + let by_id: std::collections::BTreeMap = doc.timeline_groups.iter().map(|group| (group.id, group)).collect(); + let mut children = std::collections::BTreeMap::>::new(); + for group in &doc.timeline_groups { + if let Some(parent) = group.parent { + children.entry(parent).or_default().push(group.id); + } + } + let mut pending = roots.to_vec(); + let mut visited = std::collections::BTreeSet::new(); + let mut features = std::collections::BTreeSet::new(); + while let Some(id) = pending.pop() { + if !visited.insert(id) { + continue; + } + if let Some(group) = by_id.get(&id) { + features.extend(group.features.iter().copied()); + pending.extend(children.get(&id).into_iter().flatten().copied()); + } + } + features +} + +fn sort_group_features(doc: &mut solidcraft_doc::Document) { + let positions: std::collections::BTreeMap = doc.features.iter().enumerate().map(|(index, feature)| (feature.id, index)).collect(); + for group in &mut doc.timeline_groups { + group.features.sort_by_key(|feature| positions.get(feature).copied().unwrap_or(usize::MAX)); + } +} + +fn require_contiguous_groups(doc: &solidcraft_doc::Document, cmd: &str) -> Result<()> { + let Some(id) = solidcraft_doc::non_contiguous_timeline_group(&doc.features, &doc.timeline_groups) else { return Ok(()) }; + let name = doc.timeline_groups.iter().find(|group| group.id == id).map(|group| group.name.as_str()).unwrap_or("unknown"); + Err(bad(cmd, format!("timeline folder `{name}` must contain one contiguous run of features"))) +} + +fn group(s: &mut Session, p: &Value) -> Result { + let cmd = "timeline.group"; + let mut features = group_features(s, p, false, cmd)?; + let groups = group_ids(s, p, false, cmd)?; + let name = match str_(p, "name").map(str::trim).filter(|name| !name.is_empty()) { + Some(name) if name.len() <= 128 => name.to_string(), + Some(_) => return Err(bad(cmd, "`name` must be 1…128 characters")), + None => format!("Folder{}", s.doc.timeline_groups.len() + 1), + }; + let id = s + .doc + .timeline_groups + .iter() + .map(|group| group.id) + .max() + .unwrap_or(0) + .checked_add(1) + .ok_or_else(|| bad(cmd, "no more timeline folder ids are available"))?; + let parent = match p.get("parent") { + Some(Value::Null) | None => None, + Some(_) => Some(timeline_group_id_field(s, p, "parent", cmd)?), + }; + if let Some(parent) = parent + && group_move_would_cycle(s, parent, &groups) + { + return Err(bad(cmd, "a new folder cannot contain an ancestor of its parent")); + } + let nested = group_feature_ids(&s.doc, &groups); + features.retain(|feature| !nested.contains(feature)); + let mut next = (*s.doc).clone(); + for group in &mut next.timeline_groups { + group.features.retain(|feature| !features.contains(feature)); + if groups.contains(&group.id) { + group.parent = Some(id); + } + } + next.timeline_groups.push(solidcraft_doc::TimelineGroup { id, name: name.clone(), parent, features }); + sort_group_features(&mut next); + require_contiguous_groups(&next, cmd)?; + *s.doc_mut() = next; + Ok(json!({"group": id, "name": name, "parent": parent, "groups": groups})) +} + +fn ungroup(s: &mut Session, p: &Value) -> Result { + let cmd = "timeline.ungroup"; + let ids = match p.get("groups") { + Some(Value::Array(values)) if !values.is_empty() && values.len() <= 10_000 => { + let mut ids = Vec::new(); + for value in values { + let id = value.as_u64().ok_or_else(|| bad(cmd, "`groups` must contain timeline folder ids"))?; + if !s.doc.timeline_groups.iter().any(|group| group.id == id) { + return Err(bad(cmd, format!("no timeline folder {id}"))); + } + if !ids.contains(&id) { + ids.push(id); + } + } + ids + } + Some(Value::Array(_)) => return Err(bad(cmd, "`groups` must list 1…10000 timeline folder ids")), + Some(_) => return Err(bad(cmd, "`groups` must be a list of timeline folder ids")), + None => vec![timeline_group_id(s, p, cmd)?], + }; + let parents: std::collections::BTreeMap> = + s.doc.timeline_groups.iter().filter(|group| ids.contains(&group.id)).map(|group| (group.id, group.parent)).collect(); + let surviving_parent = |mut parent: Option| { + let mut steps = 0usize; + while let Some(id) = parent.filter(|id| ids.contains(id)) { + parent = parents.get(&id).copied().flatten(); + steps += 1; + if steps > parents.len() { + return None; + } + } + parent + }; + let mut next = (*s.doc).clone(); + let moved_features: Vec<(Option, Vec)> = next + .timeline_groups + .iter() + .filter(|group| ids.contains(&group.id)) + .map(|group| (surviving_parent(group.parent), group.features.clone())) + .collect(); + for group in &mut next.timeline_groups { + if ids.contains(&group.id) { + continue; + } + group.parent = surviving_parent(group.parent); + for (_, features) in moved_features.iter().filter(|(parent, _)| *parent == Some(group.id)) { + group.features.extend(features.iter().copied()); + } + } + next.timeline_groups.retain(|group| !ids.contains(&group.id)); + sort_group_features(&mut next); + require_contiguous_groups(&next, cmd)?; + *s.doc_mut() = next; + Ok(json!({"groups": ids})) +} + +fn rename_group(s: &mut Session, p: &Value) -> Result { + let cmd = "timeline.rename_group"; + let id = timeline_group_id(s, p, cmd)?; + let name = str_(p, "name") + .map(str::trim) + .filter(|name| !name.is_empty() && name.len() <= 128) + .ok_or_else(|| bad(cmd, "`name` must be 1…128 characters"))?; + if let Some(group) = s.doc_mut().timeline_groups.iter_mut().find(|group| group.id == id) { + group.name = name.to_string(); + } + Ok(json!({"group": id, "name": name})) +} + +fn move_to_group(s: &mut Session, p: &Value) -> Result { + let cmd = "timeline.move_to_group"; + let features = group_features(s, p, false, cmd)?; + let groups = group_ids(s, p, false, cmd)?; + if features.is_empty() && groups.is_empty() { + return Err(bad(cmd, "list at least one feature or folder")); + } + let target = match p.get("group") { + Some(Value::Null) | None => None, + Some(_) => Some(timeline_group_id(s, p, cmd)?), + }; + if let Some(target) = target + && group_move_would_cycle(s, target, &groups) + { + return Err(bad(cmd, "a folder cannot be moved into itself or one of its descendants")); + } + let mut next = (*s.doc).clone(); + for group in &mut next.timeline_groups { + group.features.retain(|feature| !features.contains(feature)); + if Some(group.id) == target { + group.features.extend(features.iter().copied()); + } + } + for group in &mut next.timeline_groups { + if groups.contains(&group.id) { + group.parent = target; + } + } + sort_group_features(&mut next); + require_contiguous_groups(&next, cmd)?; + *s.doc_mut() = next; + Ok(json!({"features": features, "groups": groups, "group": target})) +} + +fn move_many(s: &mut Session, p: &Value) -> Result { + let cmd = "timeline.move_many"; + let mut features = group_features(s, p, false, cmd)?; + let groups = group_ids(s, p, false, cmd)?; + if features.is_empty() && groups.is_empty() { + return Err(bad(cmd, "list at least one feature or folder")); + } + let target = match p.get("group") { + Some(Value::Null) | None => None, + Some(_) => Some(timeline_group_id(s, p, cmd)?), + }; + if let Some(target) = target + && group_move_would_cycle(s, target, &groups) + { + return Err(bad(cmd, "a folder cannot be moved into itself or one of its descendants")); + } + let nested = group_feature_ids(&s.doc, &groups); + features.retain(|feature| !nested.contains(feature)); + let moving: Vec = + s.doc.features.iter().filter(|feature| nested.contains(&feature.id) || features.contains(&feature.id)).map(|feature| feature.id).collect(); + let mut next = (*s.doc).clone(); + for group in &mut next.timeline_groups { + group.features.retain(|feature| !features.contains(feature)); + if Some(group.id) == target { + group.features.extend(features.iter().copied()); + } + if groups.contains(&group.id) { + group.parent = target; + } + } + sort_group_features(&mut next); + let slot = num(p, "position").map(|x| x.max(0.0) as usize).ok_or_else(|| bad(cmd, "`position` must be a number"))?; + let (next, ordered) = reorder_features(s, next, &moving, slot, cmd)?; + if next != *s.doc { + *s.doc_mut() = next; + } + Ok(json!({"features": ordered, "groups": groups, "group": target, "position": slot})) +} + fn rollback(s: &mut Session, p: &Value) -> Result { let n = s.doc.features.len(); let pos = match p.get("feature") { @@ -423,6 +736,8 @@ fn reorder(s: &mut Session, p: &Value) -> Result { let f = next.features.remove(from); let name = f.name.clone(); next.features.insert(to, f); + sort_group_features(&mut next); + require_contiguous_groups(&next, cmd)?; // Everything that evaluated before must still evaluate. let mut after = solidcraft_doc::Model::new(); after.evaluate(&next); @@ -435,6 +750,60 @@ fn reorder(s: &mut Session, p: &Value) -> Result { Ok(json!({"feature": id, "position": to})) } +fn reorder_many(s: &mut Session, p: &Value) -> Result { + let cmd = "timeline.reorder_many"; + let ids = group_features(s, p, true, cmd)?; + let slot = num(p, "position").map(|x| x.max(0.0) as usize).ok_or_else(|| bad(cmd, "`position` must be a number"))?; + let (next, ordered) = reorder_features(s, (*s.doc).clone(), &ids, slot, cmd)?; + if next != *s.doc { + *s.doc_mut() = next; + } + Ok(json!({"features": ordered, "position": slot.min(s.doc.features.len())})) +} + +fn reorder_features( + s: &Session, + mut next: solidcraft_doc::Document, + ids: &[u64], + slot: usize, + cmd: &str, +) -> Result<(solidcraft_doc::Document, Vec)> { + let n = next.features.len(); + let slot = slot.min(n); + let selected: std::collections::BTreeSet = ids.iter().copied().collect(); + let ordered: Vec = next.features.iter().filter(|feature| selected.contains(&feature.id)).map(|feature| feature.id).collect(); + let before_slot = next.features.iter().take(slot).filter(|feature| selected.contains(&feature.id)).count(); + let insert_at = slot.saturating_sub(before_slot).min(n.saturating_sub(ordered.len())); + + let mut moving = Vec::new(); + next.features.retain(|feature| { + if selected.contains(&feature.id) { + moving.push(feature.clone()); + false + } else { + true + } + }); + let unchanged = s.doc.features.iter().map(|feature| feature.id).eq(next.features[..insert_at] + .iter() + .chain(moving.iter()) + .chain(next.features[insert_at..].iter()) + .map(|feature| feature.id)); + next.features.splice(insert_at..insert_at, moving); + sort_group_features(&mut next); + require_contiguous_groups(&next, cmd)?; + if !unchanged { + let mut after = solidcraft_doc::Model::new(); + after.evaluate(&next); + let broken = newly_broken(&s.model, &after); + if !broken.is_empty() { + let list: Vec = broken.iter().map(|(name, error)| format!("{name} ({error})")).collect(); + return Err(bad(cmd, format!("moving the selected features there breaks {}", list.join("; ")))); + } + } + Ok((next, ordered)) +} + fn input_keys(kind: &FeatureKind) -> Vec<(String, solidcraft_doc::expr::Kind)> { let mut keys: Vec<_> = kind.inputs().into_iter().map(|(label, _, unit)| (label.to_string(), unit)).collect(); if let FeatureKind::Fillet { style, .. } = kind { diff --git a/crates/engine/src/cmd/edit_tests.rs b/crates/engine/src/cmd/edit_tests.rs index 65d866cb..ff4b55f5 100644 --- a/crates/engine/src/cmd/edit_tests.rs +++ b/crates/engine/src/cmd/edit_tests.rs @@ -310,3 +310,163 @@ fn inserting_variable_fillet_control_preserves_parameters_at_existing_positions( assert_eq!(length["value"], 3.0); assert_eq!(width["value"], 4.0); } + +#[test] +fn timeline_folders_are_command_backed_saved_undoable_and_follow_feature_ids() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Base"})); + run(&mut s, "solid.fillet", json!({"edges": [[10, 5, 10]], "radius": 1, "name": "Round"})); + let base = s.doc.find_feature("Base").unwrap().id; + let round = s.doc.find_feature("Round").unwrap().id; + + let made = run(&mut s, "timeline.group", json!({"name": "Housing", "features": ["Base", round]})); + let group = made["group"].as_u64().unwrap(); + assert_eq!(s.doc.timeline_groups[0].features, vec![base, round]); + run(&mut s, "timeline.rename", json!({"feature": base, "name": "Foundation"})); + assert_eq!(s.doc.timeline_groups[0].features, vec![base, round], "folders follow stable ids, not names"); + run(&mut s, "timeline.rename_group", json!({"group": group, "name": "Main housing"})); + run(&mut s, "timeline.move_to_group", json!({"features": [round], "group": null})); + assert_eq!(s.doc.timeline_groups[0].features, vec![base]); + + let reopened = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap(); + assert_eq!(reopened.timeline_groups, s.doc.timeline_groups); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.timeline_groups[0].features, vec![base, round]); + run(&mut s, "timeline.delete", json!({"features": [base]})); + assert_eq!(s.doc.timeline_groups[0].features, vec![round], "deleted features leave folders cleanly"); + run(&mut s, "timeline.ungroup", json!({"group": group})); + assert!(s.doc.timeline_groups.is_empty()); +} + +#[test] +fn timeline_reorders_selected_features_as_one_block_and_removes_folders_in_one_step() { + let mut s = Session::default(); + for (name, body, x) in [("First", "FirstBody", 0), ("Second", "SecondBody", 20), ("Third", "ThirdBody", 40)] { + run( + &mut s, + "solid.box", + json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}), + ); + } + let names = |session: &Session| session.doc.features.iter().map(|feature| feature.name.clone()).collect::>(); + assert_eq!(names(&s), vec!["First", "Second", "Third"]); + + run(&mut s, "timeline.reorder_many", json!({"features": ["First", "Third"], "position": 3})); + assert_eq!(names(&s), vec!["Second", "First", "Third"], "selected features keep their relative order"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(names(&s), vec!["First", "Second", "Third"]); + run(&mut s, "edit.redo", json!({})); + assert_eq!(names(&s), vec!["Second", "First", "Third"]); + run(&mut s, "edit.undo", json!({})); + + let first = run(&mut s, "timeline.group", json!({"name": "One", "features": ["First"]}))["group"].as_u64().unwrap(); + run(&mut s, "edit.undo", json!({})); + assert!(s.doc.timeline_groups.is_empty(), "creating a folder is undoable"); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc.timeline_groups.first().map(|group| group.id), Some(first)); + let second = run(&mut s, "timeline.group", json!({"name": "Two", "features": ["Second"]}))["group"].as_u64().unwrap(); + run(&mut s, "timeline.ungroup", json!({"groups": [first, second]})); + assert!(s.doc.timeline_groups.is_empty()); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.timeline_groups.len(), 2, "removing several folders is one undoable command"); + run(&mut s, "edit.redo", json!({})); + assert!(s.doc.timeline_groups.is_empty(), "removing folders is redoable"); +} + +#[test] +fn timeline_folders_refuse_unknown_non_contiguous_and_split_contents() { + let mut s = Session::default(); + for (name, body, x) in [("First", "FirstBody", 0), ("Second", "SecondBody", 20), ("Third", "ThirdBody", 40)] { + run( + &mut s, + "solid.box", + json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}), + ); + } + let before = s.doc.clone(); + let error = s.execute("timeline.group", &json!({"features": ["First", "Third"]})).unwrap_err().to_string(); + assert!(error.contains("contiguous"), "{error}"); + assert_eq!(s.doc, before); + + let group = run(&mut s, "timeline.group", json!({"name": "Pair", "features": ["First", "Second"]}))["group"].as_u64().unwrap(); + let before = s.doc.clone(); + let error = s.execute("timeline.reorder_many", &json!({"features": ["First"], "position": 3})).unwrap_err().to_string(); + assert!(error.contains("contiguous"), "{error}"); + assert_eq!(s.doc, before, "a refused reorder must not split the folder"); + + for params in [json!({"features": [999_u64], "group": group}), json!({"groups": [999_u64], "group": group})] { + let error = s.execute("timeline.move_to_group", ¶ms).unwrap_err().to_string(); + assert!(error.contains("no feature") || error.contains("no timeline folder"), "{error}"); + } + + let mut damaged: Value = serde_json::from_str(&s.doc.to_json()).unwrap(); + let first = s.doc.find_feature("First").unwrap().id; + let third = s.doc.find_feature("Third").unwrap().id; + damaged["timeline_groups"] = json!([{"id": 1, "name": "Broken", "features": [first, third]}]); + let error = solidcraft_doc::Document::from_json(&damaged.to_string()).unwrap_err().to_string(); + assert!(error.contains("not contiguous"), "{error}"); +} + +#[test] +fn timeline_move_many_is_atomic_and_one_undo_step() { + let mut s = Session::default(); + for (name, body, x) in [("First", "FirstBody", 0), ("Second", "SecondBody", 20), ("Third", "ThirdBody", 40)] { + run( + &mut s, + "solid.box", + json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}), + ); + } + let group = run(&mut s, "timeline.group", json!({"name": "Pair", "features": ["First"]}))["group"].as_u64().unwrap(); + let before = (*s.doc).clone(); + run(&mut s, "timeline.move_many", json!({"features": ["Third"], "group": group, "position": 1})); + assert_eq!(s.doc.features.iter().map(|feature| feature.name.as_str()).collect::>(), vec!["First", "Third", "Second"]); + assert_eq!(s.doc.timeline_groups[0].features.len(), 2); + run(&mut s, "edit.undo", json!({})); + assert_eq!(*s.doc, before, "one undo restores both order and folder membership"); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc.timeline_groups[0].features.len(), 2); + + let child = run(&mut s, "timeline.group", json!({"name": "Child", "features": ["Second"]}))["group"].as_u64().unwrap(); + let before_parent = (*s.doc).clone(); + let parent = run(&mut s, "timeline.group", json!({"name": "Parent", "groups": [group, child]}))["group"].as_u64().unwrap(); + assert!(s.doc.timeline_groups.iter().filter(|folder| folder.parent == Some(parent)).count() == 2); + run(&mut s, "edit.undo", json!({})); + assert_eq!(*s.doc, before_parent, "creating a parent for selected folders is one undo step"); +} + +#[test] +fn failed_timeline_move_many_keeps_folder_membership_and_order() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Base"})); + run(&mut s, "solid.fillet", json!({"edges": [[10, 5, 10]], "radius": 1, "name": "Round"})); + let group = run(&mut s, "timeline.group", json!({"name": "Destination"}))["group"].as_u64().unwrap(); + let before = s.doc.clone(); + let undo = s.undo.len(); + let error = s.execute("timeline.move_many", &json!({"features": ["Base"], "group": group, "position": 2})).unwrap_err().to_string(); + assert!(error.contains("breaks"), "{error}"); + assert_eq!(s.doc, before); + assert_eq!(s.undo.len(), undo, "a refused atomic move adds no undo step"); +} + +#[test] +fn timeline_folders_can_nest_and_reject_cycles() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Base"})); + let root = run(&mut s, "timeline.group", json!({"name": "Root"}))["group"].as_u64().unwrap(); + let child = run(&mut s, "timeline.group", json!({"name": "Child", "parent": root}))["group"].as_u64().unwrap(); + assert_eq!(s.doc.timeline_groups.iter().find(|group| group.id == child).unwrap().parent, Some(root)); + run(&mut s, "timeline.move_to_group", json!({"groups": [child], "group": null})); + assert_eq!(s.doc.timeline_groups.iter().find(|group| group.id == child).unwrap().parent, None); + run(&mut s, "timeline.move_to_group", json!({"groups": [child], "group": root})); + let error = s.execute("timeline.move_to_group", &json!({"groups": [root], "group": child})).unwrap_err().to_string(); + assert!(error.contains("itself") || error.contains("descendant"), "{error}"); + let before = s.doc.clone(); + let error = s.execute("timeline.move_many", &json!({"groups": [root], "group": child, "position": 0})).unwrap_err().to_string(); + assert!(error.contains("itself") || error.contains("descendant"), "{error}"); + assert_eq!(s.doc, before); + let reopened = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap(); + assert_eq!(reopened.timeline_groups, s.doc.timeline_groups); + run(&mut s, "timeline.ungroup", json!({"group": root})); + assert_eq!(s.doc.timeline_groups.iter().find(|group| group.id == child).unwrap().parent, None, "deleting a folder promotes its children"); +} diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 77169d11..55f55674 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -55,6 +55,10 @@ pub struct TreeState { pub picked_components: Vec, /// Canvases picked in the tree. pub picked_canvases: Vec, + /// Folders picked in the feature history (features use the engine selection). + pub picked_timeline_groups: Vec, + /// Last feature-history item clicked, for Shift ranges (`f:` or `g:`). + pub timeline_anchor: Option, /// The Edit Canvas / Calibrate panel. pub canvas_panel: Option, /// What waits for the Capture Position question: a command id, or [`SAVE`]. @@ -496,6 +500,15 @@ struct Actions { } pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) { + browser_panel(app, ui, false); +} + +/// The left-sidebar layout: feature history first, parts and scene browser below it. +pub fn browser_below_timeline(app: &mut SolveApp, ui: &mut egui::Ui) { + browser_panel(app, ui, true); +} + +fn browser_panel(app: &mut SolveApp, ui: &mut egui::Ui, timeline_above: bool) { let t = Tokens::get(); app.tree.order.clear(); DRAWN.with(|d| d.borrow_mut().clear()); @@ -506,16 +519,66 @@ pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) { .resizable(false) .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(6, 6))) .show(ui, |ui| { - ui.label(RichText::new("BROWSER").size(11.0).strong().color(t.text_dim)); - ui.add_space(4.0); - egui::ScrollArea::vertical() - .auto_shrink([false, false]) - .scroll_source(egui::scroll_area::ScrollSource { scroll_bar: true, mouse_wheel: true, ..egui::scroll_area::ScrollSource::NONE }) - .show(ui, |ui| { - ui.spacing_mut().item_spacing.y = 0.0; - component_rows(app, ui, 0, 0, &mut acts); + if timeline_above { + let available = ui.available_height(); + let browser_collapsed = app.preferences.timeline_left_browser_collapsed; + let max_timeline_height = (available - if browser_collapsed { 30.0 } else { 130.0 }).max(150.0); + let remembered = app.preferences.timeline_left_height; + let timeline_height = + if remembered.is_finite() { remembered.clamp(150.0, max_timeline_height) } else { 360.0_f32.clamp(150.0, max_timeline_height) }; + ui.allocate_ui_with_layout(vec2(ui.available_width(), timeline_height), egui::Layout::top_down(egui::Align::Min), |ui| { + crate::timeline_left::left_timeline(app, ui) }); + if !browser_collapsed { + let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0), egui::Sense::hover()); + let response = ui + .interact(rect, egui::Id::new("left_history_divider"), egui::Sense::drag()) + .on_hover_cursor(egui::CursorIcon::ResizeVertical); + if response.dragged() { + let delta = ui.input(|input| input.pointer.delta().y); + if delta.is_finite() { + app.preferences.timeline_left_height = (timeline_height + delta).clamp(150.0, max_timeline_height); + } + } + ui.painter().hline(rect.x_range(), rect.center().y, egui::Stroke::new(1.0, t.border)); + crate::scenario::publish_handle("left-history-divider", rect.center()); + } + let arrow = if browser_collapsed { "▶" } else { "▼" }; + let response = + ui.add(egui::Button::new(RichText::new(format!("{arrow} BROWSER")).size(11.0).strong().color(t.text_dim)).frame(false)); + crate::scenario::publish_handle("left-browser-toggle", response.rect.center()); + if response.clicked() { + app.preferences.timeline_left_browser_collapsed = !browser_collapsed; + } + if !browser_collapsed { + ui.add_space(4.0); + browser_rows(app, ui, &mut acts); + } + } else { + browser_contents(app, ui, &mut acts); + } }); + finish_browser(app, ui, acts); +} + +fn browser_contents(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions) { + let t = Tokens::get(); + ui.label(RichText::new("BROWSER").size(11.0).strong().color(t.text_dim)); + ui.add_space(4.0); + browser_rows(app, ui, acts); +} + +fn browser_rows(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions) { + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .scroll_source(egui::scroll_area::ScrollSource { scroll_bar: true, mouse_wheel: true, ..egui::scroll_area::ScrollSource::NONE }) + .show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + component_rows(app, ui, 0, 0, acts); + }); +} + +fn finish_browser(app: &mut SolveApp, ui: &mut egui::Ui, acts: Actions) { app.viewport.hover_bodies = acts.hover_bodies; if let Some(d) = acts.drop { apply_drop(app, d); diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 146d5623..629ecc70 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -174,6 +174,7 @@ pub enum RenameWhat { Feature(u64), Component(u64), Group(u64), + TimelineGroup(u64), Canvas(u64), View(String), Joint(u64), @@ -184,7 +185,7 @@ pub struct Rename { pub what: RenameWhat, pub text: String, pub at: Pos2, - focused: bool, + pub(crate) focused: bool, } /// Radial slots, clockwise from the top. @@ -1136,6 +1137,7 @@ pub fn start_rename(app: &mut SolveApp, what: RenameWhat, at: Pos2) { RenameWhat::Component(0) => doc.name.clone(), RenameWhat::Component(c) => doc.components.iter().find(|x| x.id == *c).map(|x| x.name.clone()).unwrap_or_default(), RenameWhat::Group(g) => crate::browser::group_name(app, *g).unwrap_or_default(), + RenameWhat::TimelineGroup(g) => doc.timeline_groups.iter().find(|group| group.id == *g).map(|group| group.name.clone()).unwrap_or_default(), RenameWhat::Canvas(c) => doc.canvases.iter().find(|x| x.id == *c).map(|x| x.name.clone()).unwrap_or_default(), RenameWhat::View(v) => v.clone(), RenameWhat::Joint(j) => doc.assembly.joints.iter().find(|x| x.id == *j).map(|x| x.name.clone()).unwrap_or_default(), @@ -1143,7 +1145,7 @@ pub fn start_rename(app: &mut SolveApp, what: RenameWhat, at: Pos2) { app.menu.rename = Some(Rename { what, text, at, focused: false }); } -fn commit_rename(app: &mut SolveApp, r: &Rename) { +pub(crate) fn commit_rename(app: &mut SolveApp, r: &Rename) { let name = r.text.trim().to_string(); if name.is_empty() { return; @@ -1161,6 +1163,7 @@ fn commit_rename(app: &mut SolveApp, r: &Rename) { RenameWhat::Feature(f) => drop(app.run("timeline.rename", json!({ "feature": f, "name": name }))), RenameWhat::Component(c) => drop(app.run("component.rename", json!({ "component": c, "name": name }))), RenameWhat::Group(g) => crate::browser::group_action(app, "ui.renameGroup", &json!({ "group": g, "name": name })), + RenameWhat::TimelineGroup(g) => drop(app.run("timeline.rename_group", json!({ "group": g, "name": name }))), RenameWhat::Canvas(c) => drop(app.run("canvas.edit", json!({ "canvas": c, "name": name }))), RenameWhat::View(v) => drop(app.run("view.rename", json!({ "view": v, "name": name }))), RenameWhat::Joint(j) => drop(app.run("joint.edit", json!({ "joint": j, "name": name }))), @@ -1566,6 +1569,11 @@ pub fn list_row_lit(ui: &mut egui::Ui, item: &Item, lit: bool) -> Option { } fn show_rename(app: &mut SolveApp, ctx: &egui::Context) { + // Feature-history folders own their editor inside the row. Keep the shared rename state so + // automation and keyboard handling still use the same path, but do not draw the floating box. + if app.preferences.timeline_left && matches!(app.menu.rename.as_ref().map(|rename| &rename.what), Some(RenameWhat::TimelineGroup(_))) { + return; + } let Some(mut r) = app.menu.rename.take() else { return }; let t = Tokens::get(); let mut done: Option = None; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 168d6516..bab47fd2 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -227,7 +227,7 @@ pub enum Kind { }, /// Delete a feature that others depend on. ConfirmDelete { - feature: u64, + features: Vec, with: Vec, fail: Vec, }, @@ -617,7 +617,11 @@ impl Dialog { } pub fn confirm_delete(feature: u64, with: Vec, fail: Vec) -> Dialog { - Dialog::new(Kind::ConfirmDelete { feature, with, fail }, vec![]) + Dialog::confirm_delete_many(vec![feature], with, fail) + } + + pub fn confirm_delete_many(features: Vec, with: Vec, fail: Vec) -> Dialog { + Dialog::new(Kind::ConfirmDelete { features, with, fail }, vec![]) } pub fn edit_param(s: &Session, name: &str) -> Dialog { @@ -3212,7 +3216,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S } Kind::EditParam { name, expr } => ("parameters.change", json!({"name": name, "expression": expr})), Kind::Rename { feature, name } => ("timeline.rename", json!({"feature": feature, "name": name})), - Kind::ConfirmDelete { feature, .. } => ("timeline.delete", json!({ "features": [feature.to_string()] })), + Kind::ConfirmDelete { features, .. } => ("timeline.delete", json!({ "features": features })), Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } => return Ok(Vec::new()), Kind::Assembly(k) => return crate::dialogs_assembly::commands(app, k, &d.inputs), Kind::Sheet(k) => return crate::dialogs_sheet::commands(app, k, &d.inputs, &d.extra), diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 52ff9f30..0ab3c1a1 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -1049,6 +1049,50 @@ pub fn timeline_joints(app: &mut SolveApp, ui: &mut egui::Ui, p: &egui::Painter, x } +/// Named joint rows for the vertical feature-history layout. +pub fn timeline_joints_vertical(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + let joints: Vec<(u64, String, JointKind, bool)> = + app.session.doc.assembly.joints.iter().map(|joint| (joint.id, joint.name.clone(), joint.kind, joint.suppressed)).collect(); + if joints.is_empty() { + return; + } + ui.add_space(4.0); + ui.label(egui::RichText::new("JOINTS").size(10.0).strong().color(t.text_dim)); + let mut open = None; + let mut run = None; + for (id, name, kind, suppressed) in joints { + let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), egui::Sense::click()); + if response.hovered() { + ui.painter().rect_filled(rect, 3.0, t.hover); + } + let icon = egui::Rect::from_min_size(egui::pos2(rect.left() + 4.0, rect.top() + 4.0), vec2(22.0, 22.0)); + crate::icons::paint(ui.painter(), icon, "joint", if suppressed { t.border } else { t.icon }, t.icon_fill, t.accent); + if suppressed { + ui.painter().line_segment([icon.left_bottom(), icon.right_top()], Stroke::new(1.5, t.text_dim)); + } + ui.painter().text( + egui::pos2(icon.right() + 7.0, rect.center().y), + egui::Align2::LEFT_CENTER, + &name, + egui::FontId::proportional(12.0), + if suppressed { t.text_dim } else { t.text }, + ); + let response = response.on_hover_text(format!("{name} ({kind:?} joint)\nDouble-click to edit · right-click for more")); + if response.double_clicked() { + open = edit_joint(app, id); + } + response.context_menu(|ui| joint_menu(app, ui, (id, kind, suppressed), &mut open, &mut run)); + } + if let Some((command, params)) = run { + let _ = app.run(command, params); + } + if let Some(dialog) = open { + app.tool = None; + app.dialog = Some(dialog); + } +} + /// The Joints folder at the end of the Browser: a row per joint (double-click edits, the menu /// edits, drives, suppresses or deletes). pub fn browser_joints(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f7697d8b..35c42156 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -4,7 +4,7 @@ //! commands ([`SolveApp::run`]). Nothing here owns design data. The layout follows the familiar //! parametric-CAD desktop: application bar, a toolbar of workspace tabs and panels, the browser //! on the left, the 3D viewport with an axis view and navigation bar, the command dialog on the -//! right of the viewport, the command palette and the timeline at the bottom. +//! right of the viewport, the command palette and a bottom or left-sidebar feature timeline. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] @@ -117,6 +117,7 @@ pub mod sketch_tools; pub mod stand_up; pub mod theme; pub mod timeline; +pub mod timeline_left; pub mod titlebar; pub mod toolbar; pub mod toolbar_custom; @@ -1077,12 +1078,19 @@ impl SolveApp { } } toolbar::toolbar(self, ui); - if self.ui.show_timeline { + let left_timeline = self.ui.show_timeline && self.preferences.timeline_left; + if self.ui.show_timeline && !left_timeline { timeline::timeline(self, ui); } palette::palette_bar(self, ui); if self.ui.show_browser { - browser::browser(self, ui); + if left_timeline { + browser::browser_below_timeline(self, ui); + } else { + browser::browser(self, ui); + } + } else if left_timeline { + timeline_left::left_panel(self, ui); } preview::update(self, ui.ctx()); egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 3a5be486..e1bb1807 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -1,6 +1,6 @@ //! Preferences, one dialog in the spirit of Fusion's, opened from the gear at the top right of the //! window, File › Preferences or Ctrl+, (Cmd+, on macOS): General (theme, UI scale, units of new -//! designs, autosave, display precision, the agent cursor), Navigation (mouse presets, zoom +//! designs, autosave, display precision, timeline layout, the agent cursor), Navigation (mouse presets, zoom //! direction, zoom to the cursor, orbit centre, Look At animation), Keyboard Shortcuts (search, //! rebind, conflicts; `keymap`), Design (default modelling orientation, Y up or Z up), Graphics //! (visual style, grid, shadow, perspective, anti-aliasing), Sketch and Selection. Changes apply @@ -223,6 +223,13 @@ pub struct Prefs { pub language: String, /// Interface scale (1 = 100 %). pub ui_scale: f32, + /// Put the feature history above the parts browser in the left sidebar instead of along the + /// bottom of the viewport. + pub timeline_left: bool, + /// Height of the feature-history section when it shares the left sidebar with the browser. + pub timeline_left_height: f32, + /// Whether the browser below the left feature history is collapsed. + pub timeline_left_browser_collapsed: bool, /// The wheel zooms toward the point under the cursor (else toward the view's centre). pub zoom_to_cursor: bool, /// Spread discrete wheel input across frames (off: apply each event immediately). @@ -285,6 +292,9 @@ impl Default for Prefs { theme: "dark".into(), language: "en".into(), ui_scale: 1.0, + timeline_left: false, + timeline_left_height: 360.0, + timeline_left_browser_collapsed: false, zoom_to_cursor: true, smooth_wheel_zoom: true, trackpad_pan: true, @@ -376,6 +386,9 @@ fn defaults(app: &mut SolveApp, page: usize) { p.theme = d.theme; p.language = d.language; p.ui_scale = d.ui_scale; + p.timeline_left = d.timeline_left; + p.timeline_left_height = d.timeline_left_height; + p.timeline_left_browser_collapsed = d.timeline_left_browser_collapsed; p.default_units = d.default_units; p.length_decimals = d.length_decimals; p.angle_decimals = d.angle_decimals; @@ -847,6 +860,13 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { ); crate::scenario::publish_rect("prefs:scale", scale.rect); ui.end_row(); + ui.label("Feature history"); + ui.horizontal(|ui| { + ui.radio_value(&mut app.preferences.timeline_left, false, "Bottom timeline"); + ui.radio_value(&mut app.preferences.timeline_left, true, "Left sidebar"); + }); + ui.end_row(); + note(ui, "The left layout shows the rollback bar above the parts and scene browser."); ui.label(app.tr("ui.units_of_new_designs")); egui::ComboBox::from_id_salt("pref_units").selected_text(app.preferences.default_units.clone()).show_ui(ui, |ui| { for u in UNITS { @@ -1405,8 +1425,11 @@ mod tests { assert_eq!(app.tr("ui.extrude"), "Extrude"); // Switch language to Japanese - set(&mut app, &json!({"language": "ja"})).unwrap(); + set(&mut app, &json!({"language": "ja", "timeline_left": true})).unwrap(); + app.preferences.timeline_left_height = 275.0; + app.preferences.timeline_left_browser_collapsed = true; assert_eq!(app.preferences.language, "ja"); + assert!(app.preferences.timeline_left); assert_eq!(app.tr("ui.preferences"), "環境設定"); assert_eq!(app.tr("ui.extrude"), "押し出し"); @@ -1414,6 +1437,9 @@ mod tests { let mut other = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); other.load_prefs(&app.prefs()); assert_eq!(other.preferences.language, "ja"); + assert!(other.preferences.timeline_left); + assert_eq!(other.preferences.timeline_left_height, 275.0); + assert!(other.preferences.timeline_left_browser_collapsed); assert_eq!(other.tr("ui.preferences"), "環境設定"); } diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index a54a7e38..aa4611f3 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -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 { - 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) { + 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_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)); diff --git a/crates/ui-egui/src/timeline_left.rs b/crates/ui-egui/src/timeline_left.rs new file mode 100644 index 00000000..9bc665a7 --- /dev/null +++ b/crates/ui-egui/src/timeline_left.rs @@ -0,0 +1,1411 @@ +//! The left feature-history panel: named rows, folders, rollback and drag-and-drop. + +use egui::{Color32, Rect, RichText, Sense, Stroke, pos2, vec2}; +use serde_json::json; +use solidcraft_engine::doc::FeatureKind; + +use crate::theme::Tokens; +use crate::timeline::{delete_feature, delete_features, icon_of, transport_glyph}; +use crate::{SolveApp, icons}; + +const FOLDER_INDENT: f32 = 14.0; +const DROP_LEVEL_ANCHOR: f32 = 12.0; +const DROP_BAR_INSET: f32 = 4.0; + +/// What a drag in the timeline is moving. +#[derive(Clone, Debug, PartialEq)] +enum Drag { + Marker, + Items(DragItems), +} + +#[derive(Clone, Debug, PartialEq)] +struct DragItems { + /// Features selected directly, whose folder membership changes on an indented drop. + features: Vec, + /// Folders selected directly, whose parent changes on an indented drop. + groups: Vec, + /// Pointer position and folder level at drag start. Indentation follows horizontal movement + /// from this level, independent of whether the row was grabbed by its icon or its text. + origin_x: f32, + source_group: Option, +} + +#[derive(Clone, Copy, Debug)] +struct VerticalSlot { + y: f32, + position: usize, + /// Folder containing an item inserted at this boundary without extra indentation. + group: Option, + /// Folder being exited at this boundary; dragging right keeps the item inside it. + indent_group: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TimelineItem { + Group(u64), + Feature(u64), +} + +impl TimelineItem { + fn key(self) -> String { + match self { + TimelineItem::Group(id) => format!("g:{id}"), + TimelineItem::Feature(id) => format!("f:{id}"), + } + } +} + +/// A standalone left feature-history panel, used when the parts browser is hidden. +pub fn left_panel(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + egui::Panel::left("sc_timeline_left") + .exact_size(250.0) + .resizable(false) + .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(6, 6))) + .show(ui, |ui| left_timeline(app, ui)); +} + +/// Vertical feature history with named rows, folders and a horizontal rollback bar. The caller +/// gives this a bounded height so the parts and scene browser can use the space below it. +pub fn left_timeline(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + let groups = app.session.doc.timeline_groups.clone(); + ui.horizontal(|ui| { + ui.set_min_height(22.0); + ui.label(RichText::new("FEATURE HISTORY").size(11.0).strong().color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let count = selected_feature_ids(app).len() + app.tree.picked_timeline_groups.len(); + if count > 0 { + ui.menu_button(format!("{count} selected"), |ui| selection_actions(app, ui, &groups)); + } + }); + }); + timeline_transport(app, ui); + ui.separator(); + + egui::ScrollArea::vertical() + .id_salt("sc_left_timeline_scroll") + .auto_shrink([false, false]) + .scroll_source(egui::scroll_area::ScrollSource { scroll_bar: true, mouse_wheel: true, ..egui::scroll_area::ScrollSource::NONE }) + .show(ui, |ui| vertical_features(app, ui)); +} + +fn timeline_transport(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + let n = app.session.doc.features.len(); + let marker = app.session.doc.marker.unwrap_or(n); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + let controls = [("first", Some(0usize)), ("back", Some(marker.saturating_sub(1))), ("forward", Some((marker + 1).min(n))), ("last", None)]; + for (what, target) in controls { + let (rect, response) = ui.allocate_exact_size(vec2(24.0, 22.0), Sense::click()); + if response.hovered() { + ui.painter().rect_filled(rect, 3.0, t.hover); + } + transport_glyph(ui.painter(), rect.center(), what, t.icon); + if response + .on_hover_text(match what { + "first" => "Roll back to the start", + "back" => "Step back one feature", + "forward" => "Step forward one feature", + _ => "Play to the end", + }) + .clicked() + { + let params = match target { + Some(position) if position < n => json!({"position": position}), + _ => json!({}), + }; + let _ = app.run("timeline.roll_to", params); + } + } + }); +} + +fn nearest_vertical_slot(slots: &[VerticalSlot], y: f32) -> Option { + slots.iter().min_by(|a, b| (a.y - y).abs().total_cmp(&(b.y - y).abs())).copied() +} + +fn valid_folder_target(items: &DragItems, groups: &[solidcraft_engine::doc::TimelineGroup], target: u64) -> bool { + !items.groups.iter().any(|source| *source == target || group_is_descendant(groups, target, *source)) +} + +/// Resolve the folder implied by a vertical drop. A folder row is always a target. At a +/// folder boundary, horizontal indentation selects every available ancestor rather than only +/// the timeline root or the deepest folder. +fn vertical_drop_group( + pointer: egui::Pos2, + slot: VerticalSlot, + folder_drop_zones: &[(Rect, u64)], + groups: &[solidcraft_engine::doc::TimelineGroup], + panel_left: f32, + drag_x: f32, + source_group: Option, +) -> Option { + if let Some((_, group)) = folder_drop_zones.iter().find(|(rect, _)| rect.shrink2(vec2(0.0, 5.0)).contains(pointer)) { + return Some(*group); + } + let (deepest, base, start) = match slot.indent_group { + Some(deepest) => (deepest, slot.group, drop_level_anchor(panel_left, slot.group, groups)), + None if slot.group == source_group => { + let source = source_group?; + (source, None, drop_level_anchor(panel_left, Some(source), groups)) + } + None => return slot.group, + }; + let mut path = Vec::new(); + let mut current = Some(deepest); + let mut steps = 0usize; + while current != base { + let Some(id) = current else { return base }; + path.push(id); + current = groups.iter().find(|group| group.id == id).and_then(|group| group.parent); + steps = steps.saturating_add(1); + if steps > groups.len() { + return base; + } + } + path.reverse(); + let mut target = base; + let relative_indent_x = start + drag_x; + // A deeply nested row can be grabbed close enough to the panel edge that there is not + // enough screen space for one full drag step per ancestor. Moving left therefore also + // honours the pointer's absolute indentation, so the panel edge always reaches root. + let indent_x = if drag_x < 0.0 { relative_indent_x.min(pointer.x) } else { relative_indent_x }; + for id in path { + let threshold = drop_level_anchor(panel_left, Some(id), groups); + if indent_x >= threshold { + target = Some(id); + } + } + target +} + +fn drop_level_anchor(panel_left: f32, group: Option, groups: &[solidcraft_engine::doc::TimelineGroup]) -> f32 { + panel_left + DROP_LEVEL_ANCHOR + group.map(|id| (group_depth(groups, id) + 1) as f32 * FOLDER_INDENT).unwrap_or(0.0) +} + +fn vertical_drop_bar_left(panel_left: f32, target: Option, groups: &[solidcraft_engine::doc::TimelineGroup]) -> f32 { + target.map(|group| panel_left + DROP_BAR_INSET + (group_depth(groups, group) + 1) as f32 * FOLDER_INDENT).unwrap_or(panel_left + DROP_BAR_INSET) +} + +fn vertical_marker(app: &mut SolveApp, ui: &mut egui::Ui, drag: &mut Option) { + let t = Tokens::get(); + let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::drag()); + let line = Rect::from_min_max(pos2(rect.left() + 8.0, rect.center().y - 1.5), pos2(rect.right() - 5.0, rect.center().y + 1.5)); + let colour = t.accent; + ui.painter().rect_filled(line, 1.5, colour); + ui.painter().circle_filled(pos2(line.right(), line.center().y), 4.0, colour); + crate::scenario::publish_handle("timeline:rollback", rect.center()); + if response.drag_started() { + *drag = Some(Drag::Marker); + } + response.on_hover_text("Rollback bar: drag to roll the model back; new features are inserted here"); + let _ = app; +} + +fn group_for(groups: &[solidcraft_engine::doc::TimelineGroup], feature: u64) -> Option { + groups.iter().find(|group| group.features.contains(&feature)).map(|group| group.id) +} + +fn selected_feature_ids(app: &SolveApp) -> Vec { + app.session + .doc + .features + .iter() + .filter(|feature| { + app.session.selection.iter().any(|selection| matches!(selection, solidcraft_engine::Sel::Feature { id } if *id == feature.id)) + }) + .map(|feature| feature.id) + .collect() +} + +fn selected_drag_items(app: &SolveApp, origin_x: f32, source_group: Option) -> DragItems { + DragItems { features: selected_feature_ids(app), groups: app.tree.picked_timeline_groups.clone(), origin_x, source_group } +} + +fn drag_press_x(response: &egui::Response, fallback: f32) -> f32 { + response.ctx.input(|input| input.pointer.press_origin()).map_or(fallback, |origin| origin.x) +} + +fn drag_x(items: &DragItems, pointer_x: f32) -> f32 { + pointer_x - items.origin_x +} + +fn select_only(app: &mut SolveApp, item: TimelineItem) { + match item { + TimelineItem::Group(id) => { + app.tree.picked_timeline_groups.clear(); + app.tree.picked_timeline_groups.push(id); + let _ = app.run("select.clear", json!({})); + } + TimelineItem::Feature(id) => { + app.tree.picked_timeline_groups.clear(); + let _ = app.run("select.set", json!({"items": [{"type": "feature", "id": id}]})); + } + } + app.tree.timeline_anchor = Some(item.key()); +} + +fn select_item(app: &mut SolveApp, ui: &egui::Ui, item: TimelineItem, order: &[TimelineItem]) { + let modifiers = ui.input(|input| input.modifiers); + if modifiers.shift + && let Some(anchor) = app.tree.timeline_anchor.as_deref() + && let (Some(from), Some(to)) = (order.iter().position(|entry| entry.key() == anchor), order.iter().position(|entry| *entry == item)) + { + let (lo, hi) = (from.min(to), from.max(to)); + let range = &order[lo..=hi]; + let mut group_ids: Vec = range + .iter() + .filter_map(|entry| match entry { + TimelineItem::Group(id) => Some(*id), + TimelineItem::Feature(_) => None, + }) + .collect(); + if modifiers.command || modifiers.ctrl { + group_ids.extend(app.tree.picked_timeline_groups.iter().copied()); + group_ids.sort_unstable(); + group_ids.dedup(); + } + app.tree.picked_timeline_groups = group_ids; + let items: Vec<_> = range + .iter() + .filter_map(|entry| match entry { + TimelineItem::Feature(id) => Some(json!({"type": "feature", "id": id})), + TimelineItem::Group(_) => None, + }) + .collect(); + if items.is_empty() && !(modifiers.command || modifiers.ctrl) { + let _ = app.run("select.clear", json!({})); + } else if !items.is_empty() { + let _ = app.run("select.set", json!({"items": items, "add": modifiers.command || modifiers.ctrl})); + } + return; + } + + app.tree.timeline_anchor = Some(item.key()); + let additive = modifiers.command || modifiers.ctrl; + match item { + TimelineItem::Group(id) => { + if !additive { + app.tree.picked_timeline_groups.clear(); + let _ = app.run("select.clear", json!({})); + } + if app.tree.picked_timeline_groups.contains(&id) { + app.tree.picked_timeline_groups.retain(|group| *group != id); + } else { + app.tree.picked_timeline_groups.push(id); + } + } + TimelineItem::Feature(id) => { + if !additive { + app.tree.picked_timeline_groups.clear(); + let _ = app.run("select.set", json!({"items": [{"type": "feature", "id": id}]})); + } else { + let selected = selected_feature_ids(app); + if selected.contains(&id) { + let items: Vec<_> = + selected.into_iter().filter(|selected| *selected != id).map(|selected| json!({"type": "feature", "id": selected})).collect(); + let _ = app.run("select.set", json!({"items": items})); + } else { + let _ = app.run("select.set", json!({"items": [{"type": "feature", "id": id}], "add": true})); + } + } + } + } +} + +fn selection_actions(app: &mut SolveApp, ui: &mut egui::Ui, groups: &[solidcraft_engine::doc::TimelineGroup]) { + let features = selected_feature_ids(app); + let selected_groups = app.tree.picked_timeline_groups.clone(); + let has_selection = !features.is_empty() || !selected_groups.is_empty(); + if has_selection { + if ui.button("Add to New Folder").clicked() { + let result = app.run("timeline.group", json!({"features": features, "groups": selected_groups})); + if let Ok(result) = result + && let Some(group) = result.get("group").and_then(serde_json::Value::as_u64) + { + app.tree.collapsed.remove(&format!("timeline_group:{group}")); + crate::context_menu::start_rename(app, crate::context_menu::RenameWhat::TimelineGroup(group), pos2(0.0, 0.0)); + } + ui.close(); + } + let grouped = features.iter().any(|feature| group_for(groups, *feature).is_some()) || !selected_groups.is_empty(); + if grouped && ui.button("Remove from Folder").clicked() { + let mut params = json!({"group": null}); + if !features.is_empty() { + params["features"] = json!(features); + } + if !selected_groups.is_empty() { + params["groups"] = json!(selected_groups); + } + let _ = app.run("timeline.move_to_group", params); + ui.close(); + } + } + if !app.tree.picked_timeline_groups.is_empty() { + if !features.is_empty() || !selected_groups.is_empty() { + ui.separator(); + } + if ui.button("Delete Selected Folders").on_hover_text("Keep their features and nested folders in the history").clicked() { + let ids = app.tree.picked_timeline_groups.clone(); + let _ = app.run("timeline.ungroup", json!({"groups": ids})); + app.tree.picked_timeline_groups.clear(); + ui.close(); + } + } + if !features.is_empty() { + ui.separator(); + if ui.button("Delete Selected Features…").clicked() { + delete_features(app, features); + ui.close(); + } + } +} + +/// Delete selected timeline folders while preserving their features and nested folders. +pub fn delete_selected_folders(app: &mut SolveApp) -> bool { + if app.tree.picked_timeline_groups.is_empty() { + return false; + } + let groups = app.tree.picked_timeline_groups.clone(); + if app.run("timeline.ungroup", json!({"groups": groups})).is_ok() { + app.tree.picked_timeline_groups.clear(); + } + true +} + +fn group_is_descendant(groups: &[solidcraft_engine::doc::TimelineGroup], group: u64, ancestor: u64) -> bool { + let mut current = groups.iter().find(|candidate| candidate.id == group).and_then(|candidate| candidate.parent); + let mut steps = 0usize; + while let Some(id) = current { + if id == ancestor { + return true; + } + steps += 1; + if steps > groups.len() { + return true; + } + current = groups.iter().find(|candidate| candidate.id == id).and_then(|candidate| candidate.parent); + } + false +} + +fn group_depth(groups: &[solidcraft_engine::doc::TimelineGroup], id: u64) -> usize { + let mut depth = 0; + let mut current = groups.iter().find(|group| group.id == id).and_then(|group| group.parent); + while let Some(parent) = current { + depth += 1; + if depth > groups.len() { + break; + } + current = groups.iter().find(|group| group.id == parent).and_then(|group| group.parent); + } + depth +} + +fn group_is_open(app: &SolveApp, groups: &[solidcraft_engine::doc::TimelineGroup], id: u64) -> bool { + let mut current = Some(id); + let mut steps = 0usize; + while let Some(group_id) = current { + if app.tree.collapsed.contains(&format!("timeline_group:{group_id}")) { + return false; + } + steps += 1; + if steps > groups.len() { + return false; + } + current = groups.iter().find(|group| group.id == group_id).and_then(|group| group.parent); + } + true +} + +fn group_ancestors_are_open(app: &SolveApp, groups: &[solidcraft_engine::doc::TimelineGroup], id: u64) -> bool { + let mut current = groups.iter().find(|group| group.id == id).and_then(|group| group.parent); + let mut steps = 0usize; + while let Some(group_id) = current { + if app.tree.collapsed.contains(&format!("timeline_group:{group_id}")) { + return false; + } + steps += 1; + if steps > groups.len() { + return false; + } + current = groups.iter().find(|group| group.id == group_id).and_then(|group| group.parent); + } + true +} + +fn group_first_feature( + groups: &[solidcraft_engine::doc::TimelineGroup], + features: &[(u64, String, &'static str, bool, &'static str, FeatureKind)], + id: u64, +) -> Option { + let group = groups.iter().find(|group| group.id == id)?; + let direct = group.features.iter().filter_map(|feature| features.iter().position(|item| item.0 == *feature)).min(); + let children = groups.iter().filter(|child| child.parent == Some(id)).filter_map(|child| group_first_feature(groups, features, child.id)).min(); + direct.into_iter().chain(children).min() +} + +fn group_last_feature( + groups: &[solidcraft_engine::doc::TimelineGroup], + features: &[(u64, String, &'static str, bool, &'static str, FeatureKind)], + id: u64, +) -> Option { + let group = groups.iter().find(|group| group.id == id)?; + let direct = group.features.iter().filter_map(|feature| features.iter().position(|item| item.0 == *feature)).max(); + let children = groups.iter().filter(|child| child.parent == Some(id)).filter_map(|child| group_last_feature(groups, features, child.id)).max(); + direct.into_iter().chain(children).max() +} + +fn visible_order( + app: &SolveApp, + features: &[(u64, String, &'static str, bool, &'static str, FeatureKind)], + groups: &[solidcraft_engine::doc::TimelineGroup], +) -> Vec { + let mut order = Vec::new(); + let mut shown = std::collections::BTreeSet::new(); + for (index, (id, ..)) in features.iter().enumerate() { + let mut at_index: Vec<_> = groups.iter().filter(|group| group_first_feature(groups, features, group.id) == Some(index)).collect(); + at_index.sort_by_key(|group| (group_depth(groups, group.id), group.id)); + for group in at_index { + if group_ancestors_are_open(app, groups, group.id) && shown.insert(group.id) { + order.push(TimelineItem::Group(group.id)); + } + } + let group_id = group_for(groups, *id); + let open = group_id.is_none_or(|group_id| group_is_open(app, groups, group_id)); + if open { + order.push(TimelineItem::Feature(*id)); + } + } + let mut trailing: Vec<_> = groups.iter().filter(|group| !shown.contains(&group.id)).collect(); + trailing.sort_by_key(|group| (group_first_feature(groups, features, group.id).unwrap_or(usize::MAX), group_depth(groups, group.id), group.id)); + for group in trailing { + if group_ancestors_are_open(app, groups, group.id) && shown.insert(group.id) { + order.push(TimelineItem::Group(group.id)); + } + } + order +} + +fn timeline_group_name( + app: &mut SolveApp, + ui: &mut egui::Ui, + group: &solidcraft_engine::doc::TimelineGroup, + rect: Rect, + offset: f32, + t: &Tokens, +) -> bool { + let editing = + matches!(app.menu.rename.as_ref().map(|rename| &rename.what), Some(crate::context_menu::RenameWhat::TimelineGroup(id)) if *id == group.id); + if !editing { + ui.painter().text( + pos2(rect.left() + 40.0 + offset, rect.center().y), + egui::Align2::LEFT_CENTER, + &group.name, + egui::FontId::proportional(12.0), + t.text, + ); + return false; + } + + let Some(mut rename) = app.menu.rename.take() else { return false }; + let left = rect.left() + 38.0 + offset; + let edit_rect = Rect::from_min_max(pos2(left, rect.top() + 1.0), pos2(rect.right() - 3.0, rect.bottom() - 1.0)); + let edit_id = ui.id().with(("timeline_group_rename", group.id)); + let response = ui.put( + edit_rect, + egui::TextEdit::singleline(&mut rename.text).id(edit_id).frame(egui::Frame::NONE).text_color(t.text).vertical_align(egui::Align::Center), + ); + crate::scenario::publish_handle(&format!("timeline-folder-rename:{}", group.name), edit_rect.center()); + if !rename.focused { + if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), edit_id) { + let end = rename.text.chars().count(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(end)))); + state.store(ui.ctx(), edit_id); + } + response.request_focus(); + rename.focused = true; + } + let escape = ui.input(|input| input.key_pressed(egui::Key::Escape)); + let commit = ui.input(|input| input.key_pressed(egui::Key::Enter)) || response.lost_focus(); + if escape { + // Dropping the shared state cancels the edit and leaves the old name intact. + } else if commit { + crate::context_menu::commit_rename(app, &rename); + } else { + app.menu.rename = Some(rename); + } + true +} + +fn vertical_group_row( + app: &mut SolveApp, + ui: &mut egui::Ui, + group: &solidcraft_engine::doc::TimelineGroup, + groups: &[solidcraft_engine::doc::TimelineGroup], + order: &[TimelineItem], + drag: &mut Option, + depth: usize, + position: usize, + end_position: usize, + previous_visible_group: Option, + slots: &mut Vec, + drop_zones: &mut Vec<(Rect, u64)>, +) -> bool { + let t = Tokens::get(); + let key = format!("timeline_group:{}", group.id); + let open = !app.tree.collapsed.contains(&key); + let selected = app.tree.picked_timeline_groups.contains(&group.id); + slots.push(VerticalSlot { + y: ui.cursor().top(), + position, + group: group.parent, + indent_group: previous_visible_group.filter(|previous| Some(*previous) != group.parent), + }); + let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), 23.0), Sense::click_and_drag()); + drop_zones.push((rect, group.id)); + slots.push(VerticalSlot { y: rect.bottom(), position: if open { position } else { end_position }, group: Some(group.id), indent_group: None }); + if selected || response.hovered() { + ui.painter().rect_filled(rect, 3.0, if selected { t.accent_soft } else { t.hover }); + } + let offset = depth as f32 * 14.0; + let centre = pos2(rect.left() + 9.0 + offset, rect.center().y); + let arrow = if open { + vec![centre + vec2(-4.0, -2.0), centre + vec2(4.0, -2.0), centre + vec2(0.0, 3.0)] + } else { + vec![centre + vec2(-2.0, -4.0), centre + vec2(-2.0, 4.0), centre + vec2(3.0, 0.0)] + }; + ui.painter().add(egui::Shape::convex_polygon(arrow, t.text_dim, Stroke::NONE)); + let icon = Rect::from_center_size(pos2(rect.left() + 27.5 + offset, rect.center().y), vec2(17.0, 17.0)); + icons::paint(ui.painter(), icon, "folder", t.icon, t.icon_fill, t.accent); + let renaming = timeline_group_name(app, ui, group, rect, offset, &t); + crate::scenario::publish_handle(&format!("timeline-folder:{}", group.name), rect.center()); + let fold_rect = Rect::from_min_max(pos2(rect.left() + offset, rect.top()), pos2(rect.left() + offset + 18.0, rect.bottom())); + crate::scenario::publish_handle(&format!("timeline-folder-fold:{}", group.name), fold_rect.center()); + let fold = ui.interact(fold_rect, ui.id().with(("timeline_group_fold", group.id)), Sense::click()); + if fold.clicked() { + if open { + app.tree.collapsed.insert(key.clone()); + } else { + app.tree.collapsed.remove(&key); + } + } else if !renaming && response.clicked() { + select_item(app, ui, TimelineItem::Group(group.id), order); + } + if !renaming && response.drag_started() { + if !app.tree.picked_timeline_groups.contains(&group.id) { + select_only(app, TimelineItem::Group(group.id)); + } + let origin_x = drag_press_x(&response, rect.center().x); + *drag = Some(Drag::Items(selected_drag_items(app, origin_x, group.parent))); + } + if !renaming && response.double_clicked() { + crate::context_menu::start_rename(app, crate::context_menu::RenameWhat::TimelineGroup(group.id), rect.center()); + } + if response.secondary_clicked() && !selected { + select_only(app, TimelineItem::Group(group.id)); + } + response.context_menu(|ui| { + if ui.button("Rename Folder").clicked() { + crate::context_menu::start_rename(app, crate::context_menu::RenameWhat::TimelineGroup(group.id), rect.center()); + ui.close(); + } + ui.separator(); + selection_actions(app, ui, groups); + }); + open +} + +fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + ui.spacing_mut().item_spacing.y = 0.0; + let features: Vec<_> = app + .session + .doc + .features + .iter() + .map(|feature| (feature.id, feature.name.clone(), icon_of(&feature.kind), feature.suppressed, feature.kind.type_name(), feature.kind.clone())) + .collect(); + let groups = app.session.doc.timeline_groups.clone(); + let order = visible_order(app, &features, &groups); + let first_indices: std::collections::BTreeMap> = + groups.iter().map(|group| (group.id, group_first_feature(&groups, &features, group.id))).collect(); + let end_indices: std::collections::BTreeMap = groups + .iter() + .map(|group| (group.id, group_last_feature(&groups, &features, group.id).map_or(features.len(), |index| index.saturating_add(1)))) + .collect(); + let n = features.len(); + let marker = app.session.doc.marker.unwrap_or(n); + let from_view = match &app.viewport.hover { + Some(crate::viewport::Hit::Face { body, .. } | crate::viewport::Hit::Edge { body, .. } | crate::viewport::Hit::Vertex { body, .. }) => { + app.session.world_state().body(body).map(|body| body.feature) + } + _ => None, + }; + let drag_id = ui.id().with("tl_vertical_drag"); + let mut drag: Option = ui.data(|data| data.get_temp(drag_id)); + let pointer = ui.input(|input| input.pointer.hover_pos()); + let mut slots = Vec::new(); + let mut folder_drop_zones = Vec::new(); + let mut shown_groups = std::collections::BTreeSet::new(); + let mut previous_visible_group = None; + let mut hovered = None; + let mut edit = None; + + for (index, (id, name, icon, suppressed, ty, kind)) in features.iter().enumerate() { + let group_id = group_for(&groups, *id); + let mut at_index: Vec<_> = groups.iter().filter(|group| first_indices.get(&group.id).copied().flatten() == Some(index)).collect(); + at_index.sort_by_key(|group| (group_depth(&groups, group.id), group.id)); + let marker_before_group = index == marker && !at_index.is_empty(); + if marker_before_group { + let group = at_index.first().and_then(|group| group.parent); + let indent_group = previous_visible_group.filter(|previous| Some(*previous) != group); + slots.push(VerticalSlot { y: ui.cursor().top(), position: index, group, indent_group }); + vertical_marker(app, ui, &mut drag); + } + let mut rendered_group_header = false; + for group in at_index { + if group_ancestors_are_open(app, &groups, group.id) && shown_groups.insert(group.id) { + vertical_group_row( + app, + ui, + group, + &groups, + &order, + &mut drag, + group_depth(&groups, group.id), + index, + end_indices.get(&group.id).copied().unwrap_or(index), + previous_visible_group, + &mut slots, + &mut folder_drop_zones, + ); + rendered_group_header = true; + } + } + let group_open = group_id.is_none_or(|id| group_is_open(app, &groups, id)); + let indent_group = (!rendered_group_header).then_some(previous_visible_group).flatten().filter(|previous| Some(*previous) != group_id); + if index == marker && !marker_before_group { + slots.push(VerticalSlot { y: ui.cursor().top(), position: index, group: group_open.then_some(group_id).flatten(), indent_group }); + vertical_marker(app, ui, &mut drag); + } + if !group_open { + continue; + } + slots.push(VerticalSlot { y: ui.cursor().top(), position: index, group: group_id, indent_group }); + let indent = group_id.map(|id| (group_depth(&groups, id) + 1) as f32 * 14.0).unwrap_or(0.0); + let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click_and_drag()); + crate::scenario::publish_handle(&format!("timeline:{name}"), rect.center()); + let result = app.session.model.result(*id); + let error = result.and_then(|result| result.error.clone()); + let warning = result.and_then(|result| result.warning.clone()); + let rolled = index >= marker; + let selected = + app.session.selection.iter().any(|selection| matches!(selection, solidcraft_engine::Sel::Feature { id: selected } if selected == id)); + let background = if error.is_some() { + t.timeline_item_error + } else if selected { + t.accent_soft + } else if response.hovered() || from_view == Some(*id) { + t.hover + } else { + Color32::TRANSPARENT + }; + if background != Color32::TRANSPARENT { + ui.painter().rect_filled(rect, 3.0, background); + } + let icon_rect = Rect::from_center_size(pos2(rect.left() + 13.0 + indent, rect.center().y), vec2(18.0, 18.0)); + let ink = if rolled || *suppressed { t.border } else { t.icon }; + icons::paint(ui.painter(), icon_rect, icon, ink, if rolled { t.panel_header } else { t.icon_fill }, if rolled { t.border } else { t.accent }); + if *suppressed { + ui.painter().line_segment([icon_rect.left_bottom(), icon_rect.right_top()], Stroke::new(1.5, t.text_dim)); + } + let text_colour = if rolled || *suppressed { t.text_dim } else { t.text }; + ui.painter().text( + pos2(icon_rect.right() + 7.0, rect.center().y), + egui::Align2::LEFT_CENTER, + name, + egui::FontId::proportional(12.0), + text_colour, + ); + if warning.is_some() && error.is_none() { + icons::paint( + ui.painter(), + Rect::from_min_size(pos2(rect.right() - 15.0, rect.center().y - 6.0), vec2(12.0, 12.0)), + "warning", + t.icon, + t.warning, + t.warning, + ); + } + if response.hovered() { + hovered = Some(*id); + } + if response.drag_started() { + if !selected { + select_only(app, TimelineItem::Feature(*id)); + } + let origin_x = drag_press_x(&response, rect.center().x); + drag = Some(Drag::Items(selected_drag_items(app, origin_x, group_id))); + } + let mut tip = format!("{name} ({ty})"); + if let Some(error) = &error { + tip.push_str(&format!("\nError: {error}")); + } + if let Some(warning) = &warning { + tip.push_str(&format!("\nWarning: {warning}")); + } + tip.push_str("\nDouble-click to edit · drag to reorder · right-click for more"); + let response = response.on_hover_text(tip); + if response.clicked() { + select_item(app, ui, TimelineItem::Feature(*id), &order); + } + if response.double_clicked() { + edit = Some(*id); + } + let sketch = match kind { + FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } | FeatureKind::Sweep { sketch, .. } => Some(*sketch), + _ => None, + }; + if response.secondary_clicked() && !selected { + select_only(app, TimelineItem::Feature(*id)); + } + response.context_menu(|ui| { + if ui.button("Edit Feature").clicked() { + edit = Some(*id); + ui.close(); + } + if let Some(sketch) = sketch + && ui.button("Edit Profile Sketch").clicked() + { + app.edit_sketch(sketch); + ui.close(); + } + if ui.button("Rename Feature").clicked() { + crate::context_menu::start_rename(app, crate::context_menu::RenameWhat::Feature(*id), rect.center()); + ui.close(); + } + if ui.button(if *suppressed { "Unsuppress Feature" } else { "Suppress Feature" }).clicked() { + let _ = app.run("timeline.suppress", json!({"feature": id})); + ui.close(); + } + if ui.button("Roll Back Here").clicked() { + let _ = app.run("timeline.roll_to", json!({"feature": id})); + ui.close(); + } + ui.separator(); + selection_actions(app, ui, &groups); + ui.separator(); + if ui.button("Delete").clicked() { + delete_feature(app, *id); + ui.close(); + } + }); + previous_visible_group = group_id; + } + let mut trailing: Vec<_> = groups.iter().filter(|group| !shown_groups.contains(&group.id)).collect(); + trailing.sort_by_key(|group| (first_indices.get(&group.id).copied().flatten().unwrap_or(usize::MAX), group_depth(&groups, group.id), group.id)); + for group in trailing { + if group_ancestors_are_open(app, &groups, group.id) && shown_groups.insert(group.id) { + vertical_group_row( + app, + ui, + group, + &groups, + &order, + &mut drag, + group_depth(&groups, group.id), + n, + n, + previous_visible_group, + &mut slots, + &mut folder_drop_zones, + ); + } + } + slots.push(VerticalSlot { y: ui.cursor().top(), position: n, group: None, indent_group: previous_visible_group }); + if marker >= n { + vertical_marker(app, ui, &mut drag); + } + crate::dialogs_assembly::timeline_joints_vertical(app, ui); + + if let (Some(active), Some(pointer)) = (drag.as_ref(), pointer) + && let Some(slot) = nearest_vertical_slot(&slots, pointer.y) + { + let folder_target = match active { + Drag::Items(items) => { + let panel_left = ui.min_rect().left(); + vertical_drop_group(pointer, slot, &folder_drop_zones, &groups, panel_left, drag_x(items, pointer.x), items.source_group) + .filter(|target| valid_folder_target(items, &groups, *target)) + } + Drag::Marker => None, + }; + let hovered_folder = folder_target.and_then(|target| { + folder_drop_zones.iter().find(|(rect, group)| *group == target && rect.shrink2(vec2(0.0, 5.0)).contains(pointer)).map(|(rect, _)| *rect) + }); + if let Some(rect) = hovered_folder { + ui.painter().rect_stroke(rect.shrink(1.0), 3.0, Stroke::new(2.0, t.sketch_accent), egui::StrokeKind::Inside); + } else { + let left = vertical_drop_bar_left(ui.min_rect().left(), folder_target, &groups); + ui.painter().line_segment( + [pos2(left, slot.y), pos2(ui.max_rect().right() - 5.0, slot.y)], + Stroke::new(2.0, if matches!(active, Drag::Marker) { t.accent } else { t.sketch_accent }), + ); + } + } + if ui.input(|input| input.pointer.any_released()) + && let Some(active) = drag.take() + && let Some(pointer) = pointer + && let Some(slot) = nearest_vertical_slot(&slots, pointer.y) + { + match active { + Drag::Marker => { + let _ = app.run("timeline.roll_to", if slot.position >= n { json!({}) } else { json!({"position": slot.position}) }); + } + Drag::Items(items) => { + let panel_left = ui.min_rect().left(); + let folder_target = + vertical_drop_group(pointer, slot, &folder_drop_zones, &groups, panel_left, drag_x(&items, pointer.x), items.source_group); + let valid_target = folder_target.is_none_or(|target| valid_folder_target(&items, &groups, target)); + if valid_target { + let mut params = json!({"group": folder_target, "position": slot.position}); + if !items.features.is_empty() { + params["features"] = json!(items.features); + } + if !items.groups.is_empty() { + params["groups"] = json!(items.groups); + } + let _ = app.run("timeline.move_many", params); + if let Some(group) = folder_target { + app.tree.collapsed.remove(&format!("timeline_group:{group}")); + } + } + } + } + } + ui.data_mut(|data| match drag { + Some(active) => { + data.insert_temp(drag_id, active.clone()); + } + None => data.remove::(drag_id), + }); + app.viewport.hover_feature = hovered; + if let Some(feature) = edit { + app.edit_feature(feature); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scenario::{Harness, handle_at}; + + #[test] + fn left_history_shows_named_features_and_folders_above_the_browser() { + let mut harness = Harness::new(); + harness.app.preferences.timeline_left = true; + harness.app.run("solid.box", json!({"length": 20, "width": 15, "height": 10, "name": "Base"})).unwrap(); + harness.app.run("solid.fillet", json!({"edges": [[20, 7.5, 10]], "radius": 1, "name": "Round"})).unwrap(); + harness.app.run("timeline.group", json!({"name": "Housing", "features": ["Base", "Round"]})).unwrap(); + harness.frames(4); + + let folder = handle_at("timeline-folder:Housing").unwrap(); + let base = handle_at("timeline:Base").unwrap(); + let round = handle_at("timeline:Round").unwrap(); + let browser = handle_at("row:Untitled").unwrap(); + assert!(folder.x < 250.0 && base.x < 250.0 && round.x < 250.0); + assert!(folder.y < base.y && base.y < round.y); + assert!(round.y < browser.y, "the parts/scene browser belongs below the feature history"); + } + + #[test] + fn history_browser_divider_resizes_and_browser_collapses() { + let mut harness = Harness::new(); + harness.app.preferences.timeline_left = true; + harness.app.preferences.timeline_left_height = 180.0; + harness.frames(4); + + let divider = handle_at("left-history-divider").unwrap(); + let before = harness.app.preferences.timeline_left_height; + harness.step(&json!({"drag": [[divider.x, divider.y], [divider.x, divider.y + 40.0]]})).unwrap(); + assert!( + harness.app.preferences.timeline_left_height > before + 20.0, + "dragging the divider should remember a taller history section: {before} -> {}", + harness.app.preferences.timeline_left_height + ); + + harness.step(&json!({"click": {"handle": "left-browser-toggle"}})).unwrap(); + assert!(harness.app.preferences.timeline_left_browser_collapsed); + assert!(handle_at("row:Untitled").is_none(), "the collapsed browser should hide its rows"); + harness.step(&json!({"click": {"handle": "left-browser-toggle"}})).unwrap(); + assert!(!harness.app.preferences.timeline_left_browser_collapsed); + assert!(handle_at("row:Untitled").is_some(), "expanding the browser should restore its rows"); + } + + #[test] + fn rollback_bar_above_last_feature_can_be_dragged_to_the_end() { + let mut harness = Harness::new(); + harness.app.preferences.timeline_left = true; + harness.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "First"})).unwrap(); + harness + .app + .run("solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "new_body", "name": "Last"})) + .unwrap(); + harness.app.run("timeline.roll_to", json!({"position": 1})).unwrap(); + harness.frames(4); + + let last = handle_at("timeline:Last").unwrap(); + harness.step(&json!({"drag": [{"handle": "timeline:rollback"}, [last.x, last.y + 15.0]]})).unwrap(); + assert_eq!(harness.app.session.doc.marker, None, "dropping below the final row must restore the fully evaluated history"); + } + + #[test] + fn rollback_bar_before_a_folders_first_feature_is_above_its_header() { + let mut harness = Harness::new(); + harness.app.preferences.timeline_left = true; + harness.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "First"})).unwrap(); + harness + .app + .run("solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "new_body", "name": "Second"})) + .unwrap(); + harness.app.run("timeline.group", json!({"name": "Folder", "features": ["First", "Second"]})).unwrap(); + harness.app.run("timeline.roll_to", json!({"position": 0})).unwrap(); + harness.frames(4); + + let marker = handle_at("timeline:rollback").unwrap(); + let folder = handle_at("timeline-folder:Folder").unwrap(); + let first = handle_at("timeline:First").unwrap(); + assert!(marker.y < folder.y && folder.y < first.y, "rollback marker must precede the folder containing the first rolled-back feature"); + } + + #[test] + fn feature_folder_selection_can_move_as_a_block() { + let mut harness = Harness::new(); + harness.app.preferences.timeline_left = true; + for (name, body, x) in [("First", "FirstBody", 0), ("Second", "SecondBody", 20), ("Third", "ThirdBody", 40)] { + harness + .app + .run( + "solid.box", + json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}), + ) + .unwrap(); + } + harness.app.run("timeline.group", json!({"name": "Pair", "features": ["First", "Second"]})).unwrap(); + harness.frames(4); + + let third = handle_at("timeline:Third").unwrap(); + harness.step(&json!({"drag": [{"handle": "timeline-folder:Pair"}, [third.x, third.y + 15.0]]})).unwrap(); + let names: Vec<_> = harness.app.session.doc.features.iter().map(|feature| feature.name.as_str()).collect(); + assert_eq!(names, vec!["Third", "First", "Second"]); + } + + #[test] + fn selected_folders_and_features_can_be_dragged_into_a_folder() { + let mut harness = Harness::new(); + harness.app.preferences.timeline_left = true; + harness.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Grouped"})).unwrap(); + harness + .app + .run("solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "new_body", "name": "Loose"})) + .unwrap(); + let group = harness.app.run("timeline.group", json!({"name": "Folder", "features": ["Grouped"]})).unwrap()["group"].as_u64().unwrap(); + let destination = harness.app.run("timeline.group", json!({"name": "Destination"})).unwrap()["group"].as_u64().unwrap(); + harness.frames(4); + + harness.step(&json!({"click": {"handle": "timeline-folder:Folder"}})).unwrap(); + harness.step(&json!({"click": {"handle": "timeline:Loose"}, "ctrl": true})).unwrap(); + assert_eq!(harness.app.tree.picked_timeline_groups, vec![group]); + assert!(harness + .app + .session + .selection + .iter() + .any(|selection| matches!(selection, solidcraft_engine::Sel::Feature { id } if harness.app.session.doc.feature(*id).is_some_and(|feature| feature.name == "Loose")))); + harness.step(&json!({"drag": [{"handle": "timeline:Loose"}, {"handle": "timeline-folder:Destination"}]})).unwrap(); + let source = harness.app.session.doc.timeline_groups.iter().find(|entry| entry.id == group).unwrap(); + let target = harness.app.session.doc.timeline_groups.iter().find(|entry| entry.id == destination).unwrap(); + let loose = harness.app.session.doc.find_feature("Loose").unwrap().id; + assert_eq!(source.parent, Some(destination), "the selected folder should nest in the drop target"); + assert!(target.features.contains(&loose), "the selected feature should move into the drop target"); + } + + #[test] + fn vertical_folder_drop_targets_follow_each_available_indent_level() { + let inside = VerticalSlot { y: 40.0, position: 1, group: Some(7), indent_group: None }; + let at_end = VerticalSlot { y: 60.0, position: 2, group: None, indent_group: Some(3) }; + let folder_rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(200.0, 20.0)); + let groups = vec![ + solidcraft_engine::doc::TimelineGroup { id: 1, name: "Outer".into(), parent: None, features: vec![] }, + solidcraft_engine::doc::TimelineGroup { id: 2, name: "Middle".into(), parent: Some(1), features: vec![] }, + solidcraft_engine::doc::TimelineGroup { id: 3, name: "Inner".into(), parent: Some(2), features: vec![] }, + solidcraft_engine::doc::TimelineGroup { id: 7, name: "Target".into(), parent: None, features: vec![] }, + ]; + + assert_eq!( + vertical_drop_group(pos2(80.0, 10.0), at_end, &[(folder_rect, 7)], &groups, 0.0, 80.0, None), + Some(7), + "dropping over a folder row" + ); + assert_eq!(vertical_drop_group(pos2(80.0, 40.0), inside, &[], &groups, 0.0, 80.0, None), Some(7), "dropping between expanded folder items"); + assert_eq!(vertical_drop_group(pos2(20.0, 60.0), at_end, &[], &groups, 0.0, 0.0, None), None); + assert_eq!(vertical_drop_group(pos2(30.0, 60.0), at_end, &[], &groups, 0.0, 14.0, None), Some(1)); + assert_eq!(vertical_drop_group(pos2(44.0, 60.0), at_end, &[], &groups, 0.0, 28.0, None), Some(2)); + assert_eq!(vertical_drop_group(pos2(58.0, 60.0), at_end, &[], &groups, 0.0, 42.0, None), Some(3)); + + let same_inner_row = VerticalSlot { y: 80.0, position: 2, group: Some(3), indent_group: None }; + assert_eq!( + vertical_drop_group(pos2(44.0, 80.0), same_inner_row, &[], &groups, 0.0, -14.0, Some(3)), + Some(2), + "dragging left on the same row should outdent without vertical movement" + ); + assert_eq!(vertical_drop_bar_left(0.0, Some(2), &groups), 32.0, "the bar should begin before the target level's feature icon"); + let root_drag = DragItems { features: vec![9], groups: vec![], origin_x: 100.0, source_group: None }; + assert_eq!(drag_x(&root_drag, 114.0), 14.0, "every folder level should require the same 14-point drag"); + } + + #[test] + fn dragging_out_of_a_folder_updates_membership() { + let mut harness = Harness::new(); + harness.app.preferences.timeline_left = true; + harness.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Inside"})).unwrap(); + harness + .app + .run("solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "new_body", "name": "Loose"})) + .unwrap(); + let group = harness.app.run("timeline.group", json!({"name": "Folder", "features": ["Inside"]})).unwrap()["group"].as_u64().unwrap(); + harness.frames(4); + + let loose = handle_at("timeline:Loose").unwrap(); + harness.step(&json!({"drag": [{"handle": "timeline:Inside"}, [loose.x, loose.y + 15.0]]})).unwrap(); + + let inside = harness.app.session.doc.find_feature("Inside").unwrap().id; + let group = harness.app.session.doc.timeline_groups.iter().find(|candidate| candidate.id == group).unwrap(); + assert!(!group.features.contains(&inside), "reordering outside a folder must also remove folder membership"); + } + + #[test] + fn folder_header_top_edge_is_an_insertion_slot() { + let mut harness = Harness::new(); + harness.app.preferences.timeline_left = true; + harness.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Grouped"})).unwrap(); + harness + .app + .run("solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "new_body", "name": "Loose"})) + .unwrap(); + let group = harness.app.run("timeline.group", json!({"name": "Folder", "features": ["Grouped"]})).unwrap()["group"].as_u64().unwrap(); + harness.frames(4); + + let folder = handle_at("timeline-folder:Folder").unwrap(); + harness.step(&json!({"drag": [{"handle": "timeline:Loose"}, [folder.x, folder.y - 10.0]]})).unwrap(); + + let loose = harness.app.session.doc.find_feature("Loose").unwrap().id; + let group = harness.app.session.doc.timeline_groups.iter().find(|candidate| candidate.id == group).unwrap(); + assert!(!group.features.contains(&loose), "the folder header's top edge must not capture the drop"); + assert_eq!(harness.app.session.doc.features.first().map(|feature| feature.name.as_str()), Some("Loose")); + } + + #[test] + fn selected_count_does_not_move_the_history_rows() { + let mut harness = Harness::new(); + harness.app.preferences.timeline_left = true; + harness.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Fixed"})).unwrap(); + harness.frames(4); + let before = handle_at("timeline:Fixed").unwrap(); + + harness.step(&json!({"click": {"handle": "timeline:Fixed"}})).unwrap(); + let after = handle_at("timeline:Fixed").unwrap(); + assert!((after.y - before.y).abs() < 0.5, "selection count shifted the row from {} to {}", before.y, after.y); + } + + #[test] + fn new_folder_starts_inline_rename_with_its_name_selected() { + let mut harness = Harness::new(); + harness.app.preferences.timeline_left = true; + harness.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Feature"})).unwrap(); + harness.frames(4); + + harness.step(&json!({"click": {"handle": "timeline:Feature"}})).unwrap(); + harness.step(&json!({"widget": "1 selected"})).unwrap(); + harness.step(&json!({"widget": "Add to New Folder"})).unwrap(); + harness.frames(2); + + let original = harness.app.session.doc.timeline_groups.first().unwrap().name.clone(); + assert!(handle_at(&format!("timeline-folder-rename:{original}")).is_some(), "new folder did not show its inline name editor"); + harness.step(&json!({"text": "Renamed"})).unwrap(); + harness.step(&json!({"key": "Enter"})).unwrap(); + assert_eq!(harness.app.session.doc.timeline_groups.first().map(|group| group.name.as_str()), Some("Renamed")); + + let group = harness.app.session.doc.timeline_groups.first().unwrap().id; + crate::context_menu::start_rename(&mut harness.app, crate::context_menu::RenameWhat::TimelineGroup(group), pos2(0.0, 0.0)); + harness.frames(2); + assert!(handle_at("timeline-folder-rename:Renamed").is_some(), "folder rename did not stay in the folder row"); + harness.step(&json!({"text": "Again"})).unwrap(); + harness.step(&json!({"key": "Enter"})).unwrap(); + assert_eq!(harness.app.session.doc.timeline_groups.first().map(|group| group.name.as_str()), Some("Again")); + } + + #[test] + fn nested_folder_delete_keeps_parent_and_features() { + let mut harness = Harness::new(); + harness.app.preferences.timeline_left = true; + harness.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Feature"})).unwrap(); + let root = harness.app.run("timeline.group", json!({"name": "Root"})).unwrap()["group"].as_u64().unwrap(); + let child = + harness.app.run("timeline.group", json!({"name": "Child", "parent": root, "features": ["Feature"]})).unwrap()["group"].as_u64().unwrap(); + harness.frames(4); + harness.step(&json!({"click": {"handle": "timeline-folder:Child"}})).unwrap(); + harness.step(&json!({"key": "Delete"})).unwrap(); + assert!(harness.app.session.doc.timeline_groups.iter().all(|group| group.id != child)); + assert!(harness.app.session.doc.timeline_groups.iter().any(|group| group.id == root)); + assert!(harness.app.session.doc.find_feature("Feature").is_some()); + } + + #[test] + fn collapsed_folder_stays_visible_while_its_contents_hide() { + let mut harness = Harness::new(); + harness.app.preferences.timeline_left = true; + harness.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Inside"})).unwrap(); + harness.app.run("timeline.group", json!({"name": "Folder", "features": ["Inside"]})).unwrap(); + harness.frames(4); + assert!(handle_at("timeline:Inside").is_some()); + + harness.step(&json!({"click": {"handle": "timeline-folder-fold:Folder"}})).unwrap(); + assert!(handle_at("timeline-folder:Folder").is_some(), "collapsed folder row disappeared"); + assert!(handle_at("timeline:Inside").is_none(), "collapsed folder still showed its contents"); + } + + #[test] + fn drop_below_a_collapsed_folder_appends_to_that_folder() { + let mut harness = Harness::new(); + harness.app.preferences.timeline_left = true; + for (name, body, x) in [("First", "FirstBody", 0), ("Second", "SecondBody", 20), ("Loose", "LooseBody", 40)] { + harness + .app + .run( + "solid.box", + json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}), + ) + .unwrap(); + } + let group = harness.app.run("timeline.group", json!({"name": "Folder", "features": ["First", "Second"]})).unwrap()["group"].as_u64().unwrap(); + harness.frames(4); + harness.step(&json!({"click": {"handle": "timeline-folder-fold:Folder"}})).unwrap(); + let folder = handle_at("timeline-folder:Folder").unwrap(); + + harness.step(&json!({"drag": [{"handle": "timeline:Loose"}, [folder.x, folder.y + 14.0]]})).unwrap(); + + let names: Vec<_> = harness.app.session.doc.features.iter().map(|feature| feature.name.as_str()).collect(); + assert_eq!(names, vec!["First", "Second", "Loose"], "the dropped feature belongs at the collapsed folder's end"); + let folder = harness.app.session.doc.timeline_groups.iter().find(|folder| folder.id == group).unwrap(); + let loose = harness.app.session.doc.find_feature("Loose").unwrap().id; + assert_eq!(folder.features.last(), Some(&loose)); + } + + #[test] + fn drop_at_nested_folder_bottom_can_choose_the_outer_folder() { + let mut harness = Harness::new(); + harness.app.preferences.timeline_left = true; + for (name, body, x) in [("Inside", "InsideBody", 0), ("Loose", "LooseBody", 20)] { + harness + .app + .run( + "solid.box", + json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}), + ) + .unwrap(); + } + let inner = harness.app.run("timeline.group", json!({"name": "Inner", "features": ["Inside"]})).unwrap()["group"].as_u64().unwrap(); + let outer = harness.app.run("timeline.group", json!({"name": "Outer", "groups": [inner]})).unwrap()["group"].as_u64().unwrap(); + harness.frames(4); + let loose_row = handle_at("timeline:Loose").unwrap(); + + harness.step(&json!({"drag": [{"handle": "timeline:Loose"}, [loose_row.x + FOLDER_INDENT, loose_row.y - 12.0]]})).unwrap(); + + let loose = harness.app.session.doc.find_feature("Loose").unwrap().id; + let outer = harness.app.session.doc.timeline_groups.iter().find(|folder| folder.id == outer).unwrap(); + let inner = harness.app.session.doc.timeline_groups.iter().find(|folder| folder.id == inner).unwrap(); + assert!(outer.features.contains(&loose), "the intermediate indentation should put the feature directly in the outer folder"); + assert!(!inner.features.contains(&loose)); + } + + #[test] + fn last_features_in_inner_folder_can_move_out_to_their_parent_without_reordering() { + let mut harness = Harness::new(); + harness.app.preferences.timeline_left = true; + for (name, body, x) in [("First", "FirstBody", 0), ("Penultimate", "PenultimateBody", 20), ("Last", "LastBody", 40)] { + harness + .app + .run( + "solid.box", + json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}), + ) + .unwrap(); + } + let inner = harness.app.run("timeline.group", json!({"name": "Inner", "features": ["First", "Penultimate", "Last"]})).unwrap()["group"] + .as_u64() + .unwrap(); + let outer = harness.app.run("timeline.group", json!({"name": "Outer", "groups": [inner]})).unwrap()["group"].as_u64().unwrap(); + harness.frames(4); + harness.step(&json!({"click": {"handle": "timeline:Penultimate"}})).unwrap(); + harness.step(&json!({"click": {"handle": "timeline:Last"}, "ctrl": true})).unwrap(); + let last_row = handle_at("timeline:Last").unwrap(); + + let start = last_row; + let outdented = pos2(start.x - 14.0, start.y); + let destination = outdented; + harness.app.synthetic.push(egui::Event::PointerMoved(start)); + harness.frame(); + harness.app.synthetic.push(egui::Event::PointerButton { + pos: start, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + harness.frame(); + // Cross the drag threshold horizontally and release without moving vertically. The + // initial movement must select the parent folder while keeping the timeline position. + harness.app.synthetic.push(egui::Event::PointerMoved(outdented)); + harness.frame(); + harness.app.synthetic.push(egui::Event::PointerButton { + pos: destination, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + harness.frames(4); + + let moved: Vec<_> = ["Penultimate", "Last"].into_iter().map(|name| harness.app.session.doc.find_feature(name).unwrap().id).collect(); + let outer = harness.app.session.doc.timeline_groups.iter().find(|folder| folder.id == outer).unwrap(); + let inner = harness.app.session.doc.timeline_groups.iter().find(|folder| folder.id == inner).unwrap(); + assert!( + moved.iter().all(|feature| outer.features.contains(feature)), + "dragging the inner folder's last features left should move them to their parent without moving them down" + ); + assert!(moved.iter().all(|feature| !inner.features.contains(feature))); + assert_eq!( + harness.app.session.doc.features.iter().map(|feature| feature.name.as_str()).collect::>(), + vec!["First", "Penultimate", "Last"], + "outdenting should preserve timeline order" + ); + } + + #[test] + fn last_features_in_middle_folder_can_move_to_outer_without_reordering() { + let mut harness = Harness::new(); + harness.app.preferences.timeline_left = true; + for (name, body, x) in [ + ("InnerFeature", "InnerBody", 0), + ("MiddleFirst", "MiddleFirstBody", 20), + ("MiddleLast", "MiddleLastBody", 40), + ("OuterFeature", "OuterBody", 60), + ] { + harness + .app + .run( + "solid.box", + json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}), + ) + .unwrap(); + } + let inner = harness.app.run("timeline.group", json!({"name": "Inner", "features": ["InnerFeature"]})).unwrap()["group"].as_u64().unwrap(); + let middle = harness + .app + .run("timeline.group", json!({"name": "Middle", "features": ["MiddleFirst", "MiddleLast"], "groups": [inner]})) + .unwrap()["group"] + .as_u64() + .unwrap(); + let outer = harness.app.run("timeline.group", json!({"name": "Outer", "features": ["OuterFeature"], "groups": [middle]})).unwrap()["group"] + .as_u64() + .unwrap(); + harness.frames(4); + harness.step(&json!({"click": {"handle": "timeline:MiddleFirst"}})).unwrap(); + harness.step(&json!({"click": {"handle": "timeline:MiddleLast"}, "ctrl": true})).unwrap(); + let start = handle_at("timeline:MiddleLast").unwrap(); + let destination = pos2(start.x - FOLDER_INDENT, start.y); + + harness.step(&json!({"drag": [[start.x, start.y], [destination.x, destination.y]]})).unwrap(); + + let moved: Vec<_> = ["MiddleFirst", "MiddleLast"].into_iter().map(|name| harness.app.session.doc.find_feature(name).unwrap().id).collect(); + let outer = harness.app.session.doc.timeline_groups.iter().find(|folder| folder.id == outer).unwrap(); + let middle = harness.app.session.doc.timeline_groups.iter().find(|folder| folder.id == middle).unwrap(); + assert!(moved.iter().all(|feature| outer.features.contains(feature)), "middle-folder items should move directly into the outer folder"); + assert!(moved.iter().all(|feature| !middle.features.contains(feature))); + assert_eq!( + harness.app.session.doc.features.iter().map(|feature| feature.name.as_str()).collect::>(), + vec!["InnerFeature", "MiddleFirst", "MiddleLast", "OuterFeature"] + ); + } + + #[test] + fn last_feature_in_middle_sibling_folder_can_exit_with_a_vertical_drag() { + let mut harness = Harness::new(); + harness.app.preferences.timeline_left = true; + for (name, body, x) in [("TopFeature", "TopBody", 0), ("MiddleFeature", "MiddleBody", 20), ("BottomFeature", "BottomBody", 40)] { + harness + .app + .run( + "solid.box", + json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}), + ) + .unwrap(); + } + harness.app.run("timeline.group", json!({"name": "Top", "features": ["TopFeature"]})).unwrap(); + let middle = harness.app.run("timeline.group", json!({"name": "Middle", "features": ["MiddleFeature"]})).unwrap()["group"].as_u64().unwrap(); + harness.app.run("timeline.group", json!({"name": "Bottom", "features": ["BottomFeature"]})).unwrap(); + harness.frames(4); + let start = handle_at("timeline:MiddleFeature").unwrap(); + let bottom = handle_at("timeline-folder:Bottom").unwrap(); + + harness.step(&json!({"drag": [[start.x, start.y], [start.x, bottom.y - 10.0]]})).unwrap(); + + let feature = harness.app.session.doc.find_feature("MiddleFeature").unwrap().id; + let middle = harness.app.session.doc.timeline_groups.iter().find(|folder| folder.id == middle).unwrap(); + assert!(!middle.features.contains(&feature), "a straight vertical drag to the outside boundary should remove the item from its folder"); + assert!(harness.app.session.doc.timeline_groups.iter().all(|folder| !folder.features.contains(&feature))); + } + + #[test] + fn last_nested_feature_can_outdent_two_levels_at_the_same_position() { + let mut harness = Harness::new(); + harness.app.preferences.timeline_left = true; + for (name, body, x) in [ + ("Shell", "ShellBody", 0), + ("Lip", "LipBody", 20), + ("SnapFit", "SnapFitBody", 40), + ("Boss1", "Boss1Body", 60), + ("Boss2", "Boss2Body", 80), + ("Rest", "RestBody", 100), + ] { + harness + .app + .run( + "solid.box", + json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}), + ) + .unwrap(); + } + let inner = harness.app.run("timeline.group", json!({"name": "Folder1", "features": ["Lip", "SnapFit", "Boss1", "Boss2"]})).unwrap()["group"] + .as_u64() + .unwrap(); + harness.app.run("timeline.group", json!({"name": "Folder2", "features": ["Shell"], "groups": [inner]})).unwrap(); + harness.frames(4); + let row = handle_at("timeline:Boss2").unwrap(); + let start = pos2(20.0, row.y); + + harness.step(&json!({"drag": [[start.x, start.y], [6.0, start.y]]})).unwrap(); + + let feature = harness.app.session.doc.find_feature("Boss2").unwrap().id; + assert!( + harness.app.session.doc.timeline_groups.iter().all(|folder| !folder.features.contains(&feature)), + "the last nested item should reach root without moving vertically" + ); + assert_eq!( + harness.app.session.doc.features.iter().map(|feature| feature.name.as_str()).collect::>(), + vec!["Shell", "Lip", "SnapFit", "Boss1", "Boss2", "Rest"] + ); + } +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 78aebf3f..76b0d0cc 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -1235,7 +1235,11 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { crate::context_menu::rename_selection(app, at); } Key::F6 => app.animate_view("fit"), - Key::Delete | Key::Backspace => crate::viewport::delete_selection(app), + Key::Delete | Key::Backspace => { + if !crate::timeline_left::delete_selected_folders(app) { + crate::viewport::delete_selection(app); + } + } _ => crate::keymap::press(app, &crate::keymap::key_name(k, mods)), } } diff --git a/docs/features.md b/docs/features.md index b469f779..62fd4be7 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,6 +1,6 @@ # Features -SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **328 commands.** Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). +SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **335 commands.** Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). ## Sketch (98) @@ -257,16 +257,22 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Configuration Rule | `config.rule` | – | | Configuration Rules | `config.rules` | – | -## Timeline (13) +## Timeline (19) | Command | Id | Toolbar | |---|---|---| | Compute All | `timeline.compute_all` | SOLID › MODIFY | | Delete (Delete) | `timeline.delete` | SOLID › MODIFY | | Rename | `timeline.rename` | – | +| New Feature Folder | `timeline.group` | – | +| Remove Feature Folders | `timeline.ungroup` | – | +| Rename Feature Folder | `timeline.rename_group` | – | +| Move Features to Folder | `timeline.move_to_group` | – | +| Move Timeline Items | `timeline.move_many` | – | | Move Timeline Marker | `timeline.rollback` | – | | Roll History Marker | `timeline.roll_to` | – | | Reorder Feature | `timeline.reorder` | – | +| Reorder Features | `timeline.reorder_many` | – | | Edit Feature | `timeline.redefine` | – | | Feature Dependents | `timeline.dependents` | – | | Suppress Feature | `timeline.suppress` | – |