From 3dbdb71e591781906a0b506f69689c4c54d2da7b Mon Sep 17 00:00:00 2001 From: JIJINBEI <87472238+jijinbei@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:24:04 +0900 Subject: [PATCH] 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). --- crates/ui-egui/src/timeline.rs | 114 ++++++++++++++++++++++++++++ crates/ui-egui/src/timeline_left.rs | 16 +--- 2 files changed, 117 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index 736a5988..2127a5c3 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -85,6 +85,51 @@ pub(crate) fn delete_features(app: &mut SolveApp, ids: Vec) { } } +/// The selected timeline features, in timeline order. +pub(crate) 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() +} + +/// 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 { + 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 { + 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 = 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]); + } +} diff --git a/crates/ui-egui/src/timeline_left.rs b/crates/ui-egui/src/timeline_left.rs index 9bc665a7..dba193b7 100644 --- a/crates/ui-egui/src/timeline_left.rs +++ b/crates/ui-egui/src/timeline_left.rs @@ -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 { - 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 } } @@ -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() {