Copy and Paste in the timeline's feature menu (#1131)

The feature menu of the bottom timeline and the side history gets Copy (the selection, or the right-clicked feature) and Paste (at the marker). Ctrl+click in the bottom timeline toggles a feature in the selection (#1034).
This commit is contained in:
JIJINBEI authored and GitHub committed 2026-10-14 13:24:04 -04:00
1 parent c26da73f5f
commit 3dbdb71e59
2 files changed
+117 -13

No files matched your search

+114
View File
@@ -85,6 +85,51 @@ pub(crate) fn delete_features(app: &mut SolveApp, ids: Vec<u64>) {
}
}
/// The selected timeline features, in timeline order.
pub(crate) fn selected_feature_ids(app: &SolveApp) -> Vec<u64> {
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()
}
/// What Copy in a feature's menu copies: the selected features when the right-clicked one is
/// among them, else that feature alone.
pub(crate) fn copy_targets(app: &SolveApp, id: u64) -> Vec<u64> {
let selected = selected_feature_ids(app);
if selected.contains(&id) { selected } else { vec![id] }
}
/// Copy and Paste in a feature's timeline menu. Paste puts the copies at the history marker.
pub(crate) fn clipboard_buttons(app: &mut SolveApp, ui: &mut egui::Ui, id: u64) {
if ui.button(crate::i18n::tr_label("Copy")).clicked() {
let features = copy_targets(app, id);
let _ = app.run("timeline.copy", json!({ "features": features }));
ui.close();
}
if ui.add_enabled(!app.session.clipboard.is_empty(), egui::Button::new(crate::i18n::tr_label("Paste"))).clicked() {
let _ = app.run("timeline.paste", json!({}));
ui.close();
}
}
/// Ctrl+click on a feature: add it to the selection, or take it out.
fn toggle_feature(app: &mut SolveApp, id: u64) {
let selected = selected_feature_ids(app);
if selected.contains(&id) {
let rest: Vec<_> =
app.session.selection.iter().filter(|s| !matches!(s, solidcraft_engine::Sel::Feature { id: f } if *f == id)).cloned().collect();
let _ = app.run("select.set", json!({ "items": rest }));
} else {
let _ = app.run("select.set", json!({ "items": [{"type": "feature", "id": id}], "add": true }));
}
}
pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get();
egui::Panel::bottom("sc_timeline").exact_size(40.0).frame(egui::Frame::NONE.fill(t.timeline)).show(ui, |ui| {
@@ -230,6 +275,8 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) {
d.pick(&app.session, solidcraft_engine::Sel::Feature { id: *id });
app.dialog = Some(d);
}
} else if ui.input(|i| i.modifiers.command || i.modifiers.ctrl) {
toggle_feature(app, *id);
} else {
let _ = app.run("select.set", json!({"items": [{"type": "feature", "id": id}]}));
}
@@ -271,6 +318,8 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.close();
}
ui.separator();
clipboard_buttons(app, ui, *id);
ui.separator();
if ui.button("Delete").clicked() {
delete_feature(app, *id);
ui.close();
@@ -347,3 +396,68 @@ fn find_in_browser(app: &mut SolveApp, id: u64) {
let items = if bodies.is_empty() { vec![json!({"type": "feature", "id": id})] } else { bodies };
let _ = app.run("select.set", json!({ "items": items }));
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::scenario::Harness;
fn two_boxes(left: bool) -> Harness {
let mut h = Harness::new();
h.app.preferences.timeline_left = left;
h.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "First", "body_name": "One"})).unwrap();
h.app
.run(
"solid.box",
json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "new_body", "name": "Second", "body_name": "Two"}),
)
.unwrap();
h.app.run("select.clear", json!({})).unwrap();
h.frames(4);
h
}
fn copied(h: &Harness) -> Vec<String> {
h.app.session.clipboard.iter().map(|f| f.name.clone()).collect()
}
/// Ctrl+click picks two features; Copy in the menu copies both; Paste puts copies of both at
/// the marker. In the bottom timeline and in the history beside the browser.
#[test]
fn the_feature_menu_copies_the_selected_features_and_pastes_them() {
for left in [false, true] {
let mut h = two_boxes(left);
h.step(&json!({"click": {"handle": "timeline:First"}})).unwrap();
h.step(&json!({"click": {"handle": "timeline:Second"}, "ctrl": true})).unwrap();
let first = h.app.session.doc.find_feature("First").map(|f| f.id).unwrap();
let second = h.app.session.doc.find_feature("Second").map(|f| f.id).unwrap();
assert_eq!(super::selected_feature_ids(&h.app), [first, second], "left: {left}");
h.step(&json!({"click": {"handle": "timeline:Second"}, "button": "right"})).unwrap();
h.step(&json!({"widget": "Copy"})).unwrap();
assert_eq!(copied(&h), ["First", "Second"], "left: {left}");
h.step(&json!({"click": {"handle": "timeline:Second"}, "button": "right"})).unwrap();
h.step(&json!({"widget": "Paste"})).unwrap();
assert_eq!(h.app.session.doc.features.len(), 4, "left: {left}");
let bodies: Vec<String> = h.app.session.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(bodies, ["One", "Two", "One (copy)", "Two (copy)"], "left: {left}");
}
}
/// Copy on a feature outside the selection copies that feature alone.
#[test]
fn copy_on_an_unselected_feature_copies_only_it() {
let mut h = two_boxes(false);
h.step(&json!({"click": {"handle": "timeline:First"}})).unwrap();
h.step(&json!({"click": {"handle": "timeline:Second"}, "button": "right"})).unwrap();
h.step(&json!({"widget": "Copy"})).unwrap();
assert_eq!(copied(&h), ["Second"]);
// Ctrl+click again takes a feature back out of the selection.
h.step(&json!({"click": {"handle": "timeline:Second"}, "ctrl": true})).unwrap();
h.step(&json!({"click": {"handle": "timeline:Second"}, "ctrl": true})).unwrap();
let first = h.app.session.doc.find_feature("First").map(|f| f.id).unwrap();
assert_eq!(super::selected_feature_ids(&h.app), [first]);
}
}
+3 -13
View File
@@ -5,7 +5,7 @@ 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::timeline::{clipboard_buttons, delete_feature, delete_features, icon_of, selected_feature_ids, transport_glyph};
use crate::{SolveApp, icons};
const FOLDER_INDENT: f32 = 14.0;
@@ -209,18 +209,6 @@ fn group_for(groups: &[solidcraft_engine::doc::TimelineGroup], feature: u64) ->
groups.iter().find(|group| group.features.contains(&feature)).map(|group| group.id)
}
fn selected_feature_ids(app: &SolveApp) -> Vec<u64> {
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<u64>) -> DragItems {
DragItems { features: selected_feature_ids(app), groups: app.tree.picked_timeline_groups.clone(), origin_x, source_group }
}
@@ -797,6 +785,8 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.close();
}
ui.separator();
clipboard_buttons(app, ui, *id);
ui.separator();
selection_actions(app, ui, &groups);
ui.separator();
if ui.button("Delete").clicked() {