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

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

No files matched your search

+71 -8
View File
@@ -55,6 +55,10 @@ pub struct TreeState {
pub picked_components: Vec<u64>,
/// Canvases picked in the tree.
pub picked_canvases: Vec<u64>,
/// Folders picked in the feature history (features use the engine selection).
pub picked_timeline_groups: Vec<u64>,
/// Last feature-history item clicked, for Shift ranges (`f:<id>` or `g:<id>`).
pub timeline_anchor: Option<String>,
/// The Edit Canvas / Calibrate panel.
pub canvas_panel: Option<CanvasPanel>,
/// 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);
+10 -2
View File
@@ -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<Item> {
}
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<bool> = None;
+7 -3
View File
@@ -227,7 +227,7 @@ pub enum Kind {
},
/// Delete a feature that others depend on.
ConfirmDelete {
feature: u64,
features: Vec<u64>,
with: Vec<String>,
fail: Vec<String>,
},
@@ -617,7 +617,11 @@ impl Dialog {
}
pub fn confirm_delete(feature: u64, with: Vec<String>, fail: Vec<String>) -> Dialog {
Dialog::new(Kind::ConfirmDelete { feature, with, fail }, vec![])
Dialog::confirm_delete_many(vec![feature], with, fail)
}
pub fn confirm_delete_many(features: Vec<u64>, with: Vec<String>, fail: Vec<String>) -> 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<Vec<(String, Value)>, 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),
+44
View File
@@ -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) {
+11 -3
View File
@@ -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| {
+28 -2
View File
@@ -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"), "環境設定");
}
+22 -15
View File
@@ -7,7 +7,7 @@ use solidcraft_engine::doc::FeatureKind;
use crate::theme::Tokens;
use crate::{SolveApp, icons};
fn icon_of(k: &FeatureKind) -> &'static str {
pub(crate) fn icon_of(k: &FeatureKind) -> &'static str {
match k {
FeatureKind::Sketch { .. } => "sketch",
FeatureKind::Extrude { .. } => "extrude",
@@ -37,8 +37,7 @@ fn icon_of(k: &FeatureKind) -> &'static str {
}
}
/// What a drag in the timeline is moving.
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq)]
enum Drag {
Marker,
Feature(u64),
@@ -57,16 +56,24 @@ fn slot_at(xs: &[f32], end: f32, x: f32) -> usize {
}
/// Ask before deleting a feature others depend on; delete right away otherwise.
fn delete_feature(app: &mut SolveApp, id: u64) {
let deps = app.session.execute("timeline.dependents", &json!({ "feature": id })).unwrap_or_default();
let list = |k: &str| -> Vec<String> {
deps.get(k).and_then(|v| v.as_array()).into_iter().flatten().filter_map(|x| x.as_str().map(String::from)).collect()
};
let (with, fail) = (list("deleted_with_it"), list("would_fail"));
pub(crate) fn delete_feature(app: &mut SolveApp, id: u64) {
delete_features(app, vec![id]);
}
pub(crate) fn delete_features(app: &mut SolveApp, ids: Vec<u64>) {
let mut with = std::collections::BTreeSet::new();
let mut fail = std::collections::BTreeSet::new();
for id in &ids {
let deps = app.session.execute("timeline.dependents", &json!({ "feature": id })).unwrap_or_default();
for (key, output) in [("deleted_with_it", &mut with), ("would_fail", &mut fail)] {
output
.extend(deps.get(key).and_then(|value| value.as_array()).into_iter().flatten().filter_map(|value| value.as_str().map(String::from)));
}
}
if with.is_empty() && fail.is_empty() {
let _ = app.run("timeline.delete", json!({ "features": [id.to_string()] }));
let _ = app.run("timeline.delete", json!({ "features": ids }));
} else {
app.dialog = Some(crate::dialogs::Dialog::confirm_delete(id, with, fail));
app.dialog = Some(crate::dialogs::Dialog::confirm_delete_many(ids, with.into_iter().collect(), fail.into_iter().collect()));
}
}
@@ -281,12 +288,12 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) {
// The scroll range follows the drawn content (the buttons don't take layout space themselves).
ui.allocate_exact_size(vec2((content_end - origin).max(0.0), r.height()), Sense::hover());
// Drag feedback, and the drop.
if let (Some(dg), Some(pp)) = (drag, pointer) {
if let (Some(dg), Some(pp)) = (drag.as_ref(), pointer) {
let slot = slot_at(&xs, end, pp.x);
let sx = xs.get(slot).copied().unwrap_or(end) - 4.0;
p.line_segment(
[pos2(sx, r.top() + 3.0), pos2(sx, r.bottom() - 3.0)],
Stroke::new(2.0, if dg == Drag::Marker { t.accent } else { t.sketch_accent }),
Stroke::new(2.0, if matches!(dg, Drag::Marker) { t.accent } else { t.sketch_accent }),
);
}
let released = ui.input(|i| i.pointer.any_released());
@@ -312,7 +319,7 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) {
}
ui.data_mut(|d| match drag {
Some(dg) => {
d.insert_temp(drag_id, dg);
d.insert_temp(drag_id, dg.clone());
}
None => d.remove::<Drag>(drag_id),
});
@@ -334,7 +341,7 @@ fn find_in_browser(app: &mut SolveApp, id: u64) {
}
/// Transport buttons drawn as shapes (the UI font has no media glyphs).
fn transport_glyph(p: &egui::Painter, c: egui::Pos2, what: &str, col: Color32) {
pub(crate) fn transport_glyph(p: &egui::Painter, c: egui::Pos2, what: &str, col: Color32) {
let tri = |dir: f32, dx: f32| {
let pts = vec![pos2(c.x + dx - 4.0 * dir, c.y - 5.0), pos2(c.x + dx + 4.0 * dir, c.y), pos2(c.x + dx - 4.0 * dir, c.y + 5.0)];
p.add(egui::Shape::convex_polygon(pts, col, Stroke::NONE));
File diff suppressed because it is too large. Load diff
+5 -1
View File
@@ -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)),
}
}