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:
1 parent
c26da73f5f
commit
3dbdb71e59
2 files changed
+117
-13
No files matched your search
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user