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) {
|
pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||||
let t = Tokens::get();
|
let t = Tokens::get();
|
||||||
egui::Panel::bottom("sc_timeline").exact_size(40.0).frame(egui::Frame::NONE.fill(t.timeline)).show(ui, |ui| {
|
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 });
|
d.pick(&app.session, solidcraft_engine::Sel::Feature { id: *id });
|
||||||
app.dialog = Some(d);
|
app.dialog = Some(d);
|
||||||
}
|
}
|
||||||
|
} else if ui.input(|i| i.modifiers.command || i.modifiers.ctrl) {
|
||||||
|
toggle_feature(app, *id);
|
||||||
} else {
|
} else {
|
||||||
let _ = app.run("select.set", json!({"items": [{"type": "feature", "id": id}]}));
|
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.close();
|
||||||
}
|
}
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
clipboard_buttons(app, ui, *id);
|
||||||
|
ui.separator();
|
||||||
if ui.button("Delete").clicked() {
|
if ui.button("Delete").clicked() {
|
||||||
delete_feature(app, *id);
|
delete_feature(app, *id);
|
||||||
ui.close();
|
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 items = if bodies.is_empty() { vec![json!({"type": "feature", "id": id})] } else { bodies };
|
||||||
let _ = app.run("select.set", json!({ "items": items }));
|
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 solidcraft_engine::doc::FeatureKind;
|
||||||
|
|
||||||
use crate::theme::Tokens;
|
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};
|
use crate::{SolveApp, icons};
|
||||||
|
|
||||||
const FOLDER_INDENT: f32 = 14.0;
|
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)
|
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 {
|
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 }
|
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.close();
|
||||||
}
|
}
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
clipboard_buttons(app, ui, *id);
|
||||||
|
ui.separator();
|
||||||
selection_actions(app, ui, &groups);
|
selection_actions(app, ui, &groups);
|
||||||
ui.separator();
|
ui.separator();
|
||||||
if ui.button("Delete").clicked() {
|
if ui.button("Delete").clicked() {
|
||||||
|
|||||||
Reference in new issue
Block a user