#456: a bin can be dragged into another bin and back out
Problem: a bin stayed wherever it was created. `project.moveToBin` only moved project items, and bin rows and cards in the Project panel could not be dragged, so the only way to nest bins was to create them inside each other. Fix: `project.moveToBin` also takes bin ids in `items` (as `project.delete` does) and moves the bin with everything in it, skipping the root and any move that would put a bin inside itself. In the Project panel, bin rows (List view) and bin cards (Icon view) can be dragged: dropping one on another bin nests it there, and dropping it on empty space moves it into the bin the view shows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
794a25fc9e
commit
6f650a363a
|
|
@ -2765,7 +2765,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
s.fix_state();
|
||||
Ok(json!({"items": items.len(), "bins": bins.len()}))
|
||||
}),
|
||||
cmd!("project.moveToBin", "Move to Bin", [], None, r#"{"items":[id]?,"bin":binId|null}"#, always, |s, p| {
|
||||
cmd!("project.moveToBin", "Move to Bin", [], None, r#"{"items":[id|binId]?,"bin":binId|null}"#, always, |s, p| {
|
||||
let items: Vec<ItemId> = match p.get("items").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(|v| v.as_u64().map(ItemId)).collect(),
|
||||
None => s.state.project_selection.clone(),
|
||||
|
|
|
|||
|
|
@ -1380,20 +1380,35 @@ impl Project {
|
|||
id
|
||||
}
|
||||
|
||||
/// Move items into a bin (the root when `bin` is None). Returns how many moved; unknown items
|
||||
/// are skipped. Fails (returns None) when the bin does not exist.
|
||||
/// Move items, and bins named by id, into a bin (the root when `bin` is None). A bin goes with
|
||||
/// everything in it. Returns how many moved; unknown ids are skipped, and so is a bin that
|
||||
/// would land in itself or in one of its own sub-bins. Fails (returns None) when the target
|
||||
/// bin does not exist.
|
||||
pub fn move_to_bin(&mut self, items: &[ItemId], bin: Option<BinId>) -> Option<usize> {
|
||||
let target = bin.unwrap_or(self.root.id);
|
||||
self.root.find_bin(target)?;
|
||||
let mut moved = 0;
|
||||
for &id in items {
|
||||
if !self.items.contains_key(&id) || !self.root.remove_item(id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(b) = self.root.find_bin_mut(target) {
|
||||
b.children.push(BinEntry::Item(id));
|
||||
moved += 1;
|
||||
let entry = if self.items.contains_key(&id) {
|
||||
if !self.root.remove_item(id) {
|
||||
continue;
|
||||
}
|
||||
BinEntry::Item(id)
|
||||
} else {
|
||||
let b = BinId(id.0);
|
||||
// never the root, nor a bin that holds the target (or is it)
|
||||
if b == self.root.id || self.root.find_bin(b).is_none_or(|x| x.find_bin(target).is_some()) {
|
||||
continue;
|
||||
}
|
||||
let Some(sub) = self.root.remove_bin(b) else { continue };
|
||||
BinEntry::Bin(sub)
|
||||
};
|
||||
match self.root.find_bin_mut(target) {
|
||||
Some(t) => t.children.push(entry),
|
||||
// cannot happen (the target is outside what was taken out), but never lose it
|
||||
None => self.root.children.push(entry),
|
||||
}
|
||||
moved += 1;
|
||||
}
|
||||
Some(moved)
|
||||
}
|
||||
|
|
@ -1994,6 +2009,29 @@ mod tests {
|
|||
assert_eq!(p.root.parent_of(i), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bin_moves_into_another_bin_with_its_contents() {
|
||||
let mut p = Project::new("x");
|
||||
let outer = p.add_bin("Footage", None);
|
||||
let inner = p.add_bin("B-Roll", None);
|
||||
let deep = p.add_bin("Drone", Some(inner));
|
||||
let i = p.add_item("a", Label::Iris, ItemKind::AdjustmentLayer { width: 10, height: 10, rate: FrameRate::FPS_24, duration: Tick(1) }, Some(deep));
|
||||
assert_eq!(p.move_to_bin(&[ItemId(inner.0)], Some(outer)), Some(1));
|
||||
let o = p.root.find_bin(outer).unwrap();
|
||||
assert!(o.children.iter().any(|c| matches!(c, BinEntry::Bin(b) if b.id == inner)));
|
||||
assert!(!p.root.children.iter().any(|c| matches!(c, BinEntry::Bin(b) if b.id == inner)));
|
||||
assert_eq!(p.root.parent_of(i), Some(deep));
|
||||
assert!(o.find_bin(deep).is_some());
|
||||
// never into itself or its own sub-bin, and the root never moves
|
||||
assert_eq!(p.move_to_bin(&[ItemId(outer.0)], Some(outer)), Some(0));
|
||||
assert_eq!(p.move_to_bin(&[ItemId(outer.0)], Some(deep)), Some(0));
|
||||
assert_eq!(p.move_to_bin(&[ItemId(p.root.id.0)], Some(outer)), Some(0));
|
||||
assert!(p.root.find_bin(outer).unwrap().find_bin(deep).is_some());
|
||||
// and back out to the root
|
||||
assert_eq!(p.move_to_bin(&[ItemId(inner.0)], None), Some(1));
|
||||
assert!(p.root.children.iter().any(|c| matches!(c, BinEntry::Bin(b) if b.id == inner)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_clone_shares_sequences_until_mutated() {
|
||||
fn sequence_arc(p: &Project, id: ItemId) -> &std::sync::Arc<Sequence> {
|
||||
|
|
|
|||
|
|
@ -111,6 +111,8 @@ pub mod source_range;
|
|||
#[derive(Clone, Debug)]
|
||||
enum DragPayload {
|
||||
Item(ItemId),
|
||||
/// A bin (by id) dragged in the Project panel.
|
||||
Bin(u64),
|
||||
Source(source_drag::SourceDrag),
|
||||
Effect(String),
|
||||
/// A graphics template (id, name) from Essential Graphics ▸ Browse.
|
||||
|
|
@ -133,6 +135,15 @@ pub fn dragged_source(ui: &egui::Ui) -> Option<source_drag::SourceDrag> {
|
|||
pub fn start_drag_item(ui: &egui::Ui, item: ItemId) {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(payload_id(), Some(DragPayloadBox(DragPayload::Item(item)))));
|
||||
}
|
||||
pub fn start_drag_bin(ui: &egui::Ui, bin: u64) {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(payload_id(), Some(DragPayloadBox(DragPayload::Bin(bin)))));
|
||||
}
|
||||
pub fn dragged_bin(ui: &egui::Ui) -> Option<u64> {
|
||||
match payload(ui) {
|
||||
Some(DragPayload::Bin(b)) => Some(b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn start_drag_template(ui: &egui::Ui, id: &str, name: &str) {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(payload_id(), Some(DragPayloadBox(DragPayload::Template(id.to_string(), name.to_string())))));
|
||||
}
|
||||
|
|
@ -175,6 +186,7 @@ pub fn drag_ghost(app: &FilmcraftApp, ui: &egui::Ui) {
|
|||
if let Some(p) = ctx.pointer_hover_pos() {
|
||||
let label = match &pl {
|
||||
DragPayload::Item(i) => app.session.project.item(*i).map(|x| x.name.clone()).unwrap_or_default(),
|
||||
DragPayload::Bin(b) => app.session.project.root.find_bin(filmcraft_project::BinId(*b)).map(|x| x.name.clone()).unwrap_or_default(),
|
||||
DragPayload::Source(s) => {
|
||||
let name = app.session.project.item(s.item).map(|i| i.name.as_str()).unwrap_or("Source");
|
||||
let mode = match (s.video, s.audio) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use filmcraft_engine::project_panel::{self as pp, Card, SortSpec, ViewMode};
|
||||
use filmcraft_project::{Bin, BinEntry, ItemId, ItemKind};
|
||||
use filmcraft_project::{Bin, BinEntry, BinId, ItemId, ItemKind};
|
||||
use filmcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
|
|
@ -331,21 +331,46 @@ pub fn clear_params(selection: &[filmcraft_project::ItemId], selected_bin: Optio
|
|||
}
|
||||
|
||||
/// Drop project items dragged onto a bin: move them (the selection when the dragged item is in it).
|
||||
/// A dragged bin nests in it, unless that would put the bin inside itself.
|
||||
fn accept_bin_drop(app: &FilmcraftApp, ui: &egui::Ui, r: Rect, bin: u64, actions: &mut Actions) -> bool {
|
||||
let Some(item) = crate::panels::dragged_project_item(ui) else { return false };
|
||||
let items: Vec<u64> = if let Some(item) = crate::panels::dragged_project_item(ui) {
|
||||
let sel = &app.session.state.project_selection;
|
||||
if sel.contains(&item) { sel.iter().map(|i| i.0).collect() } else { vec![item.0] }
|
||||
} else if let Some(dragged) = crate::panels::dragged_bin(ui) {
|
||||
let root = &app.session.project.root;
|
||||
if root.find_bin(BinId(dragged)).is_none_or(|d| d.find_bin(BinId(bin)).is_some()) {
|
||||
return false;
|
||||
}
|
||||
vec![dragged]
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
if !ui.rect_contains_pointer(r) {
|
||||
return false;
|
||||
}
|
||||
ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, app.tokens.accent), StrokeKind::Inside);
|
||||
if ui.input(|i| i.pointer.any_released()) {
|
||||
let sel = &app.session.state.project_selection;
|
||||
let items: Vec<u64> = if sel.contains(&item) { sel.iter().map(|i| i.0).collect() } else { vec![item.0] };
|
||||
actions.push(("project.moveToBin".into(), json!({"items": items, "bin": bin})));
|
||||
crate::panels::clear_drag(ui);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// A bin dropped on empty space moves into the bin the view shows (out of the bin it was nested in).
|
||||
fn accept_view_drop(app: &FilmcraftApp, ui: &egui::Ui, r: Rect, v: &View, actions: &mut Actions) {
|
||||
let Some(dragged) = crate::panels::dragged_bin(ui) else { return };
|
||||
let root = &app.session.project.root;
|
||||
// already shown here, or the view is inside the dragged bin
|
||||
let here = root.find_bin(v.bin).is_some_and(|b| b.children.iter().any(|c| matches!(c, BinEntry::Bin(x) if x.id.0 == dragged)));
|
||||
let inside = root.find_bin(BinId(dragged)).is_none_or(|d| d.find_bin(v.bin).is_some());
|
||||
if here || inside || !ui.rect_contains_pointer(r) || !ui.input(|i| i.pointer.any_released()) {
|
||||
return;
|
||||
}
|
||||
let bin = (v.bin != root.id).then_some(v.bin.0);
|
||||
actions.push(("project.moveToBin".into(), json!({"items": [dragged], "bin": bin})));
|
||||
crate::panels::clear_drag(ui);
|
||||
}
|
||||
|
||||
/// The inline rename field at `r`; true while it is shown.
|
||||
fn rename_field(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, item: Option<u64>, bin: Option<u64>, pre: &str) -> bool {
|
||||
let Some(rn) = app.ui.project_panel.rename.clone() else { return false };
|
||||
|
|
@ -521,6 +546,7 @@ fn empty_space(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bg: &egui::R
|
|||
ui.allocate_rect(r, Sense::hover());
|
||||
app.auto.add(&format!("{}.empty", v.prefix), r.intersect(ui.clip_rect()), "Empty area");
|
||||
marquee(app, ui, v, bg, ui.max_rect().min, actions);
|
||||
accept_view_drop(app, ui, r, v, actions);
|
||||
if bg.clicked() {
|
||||
app.ui.project_panel.selected_bin = None;
|
||||
actions.push(("project.select".into(), json!({"items": []})));
|
||||
|
|
@ -614,7 +640,7 @@ fn list_bin(app: &mut FilmcraftApp, ui: &mut egui::Ui, bin: &Bin, depth: usize,
|
|||
for bid in bins {
|
||||
let Some(b) = app.session.project.root.find_bin(bid).cloned() else { continue };
|
||||
let open = app.ui.expanded_bins.contains(&b.id.0) || !filter.is_empty();
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(lc.width, lc.row_h), Sense::click());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(lc.width, lc.row_h), Sense::click_and_drag());
|
||||
let selected = app.ui.project_panel.selected_bin == Some(b.id.0);
|
||||
if selected {
|
||||
ui.painter().rect_filled(r, 0.0, t.row_selected);
|
||||
|
|
@ -633,6 +659,9 @@ fn list_bin(app: &mut FilmcraftApp, ui: &mut egui::Ui, bin: &Bin, depth: usize,
|
|||
app.auto.add(&format!("{}.bin.{}", v.prefix, b.id.0), r.intersect(ui.clip_rect()), &b.name);
|
||||
app.auto.add(&format!("{}.bin.{}.toggle", v.prefix, b.id.0), tri, "Expand");
|
||||
accept_bin_drop(app, ui, r, b.id.0, actions);
|
||||
if resp.drag_started() {
|
||||
crate::panels::start_drag_bin(ui, b.id.0);
|
||||
}
|
||||
if resp.clicked() {
|
||||
let on_tri = resp.interact_pointer_pos().is_some_and(|p| p.x < x + 12.0);
|
||||
if on_tri || app.ui.project_panel.selected_bin == Some(b.id.0) && !resp.double_clicked() {
|
||||
|
|
@ -844,7 +873,7 @@ fn card(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, id: ItemId, pre: &st
|
|||
fn bin_card(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, b: &Bin, v: &View, actions: &mut Actions) {
|
||||
let t = app.tokens;
|
||||
let th = Rect::from_min_size(r.min, vec2(r.width(), r.width() * 9.0 / 16.0));
|
||||
let resp = ui.interact(r.expand(2.0), egui::Id::new((&v.prefix, "bincard", b.id.0)), Sense::click());
|
||||
let resp = ui.interact(r.expand(2.0), egui::Id::new((&v.prefix, "bincard", b.id.0)), Sense::click_and_drag());
|
||||
let selected = app.ui.project_panel.selected_bin == Some(b.id.0);
|
||||
let bg = Rect::from_min_max(th.min - vec2(6.0, 6.0), pos2(th.max.x + 6.0, r.max.y + 2.0));
|
||||
ui.painter().rect_filled(bg, 4.0, if selected { t.tl_header_bg } else { t.panel_bg });
|
||||
|
|
@ -863,6 +892,9 @@ fn bin_card(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, b: &Bin, v: &Vie
|
|||
}
|
||||
app.auto.add(&format!("{}.bin.{}", v.prefix, b.id.0), th, &b.name);
|
||||
accept_bin_drop(app, ui, th, b.id.0, actions);
|
||||
if resp.drag_started() {
|
||||
crate::panels::start_drag_bin(ui, b.id.0);
|
||||
}
|
||||
if resp.clicked() {
|
||||
app.ui.project_panel.selected_bin = Some(b.id.0);
|
||||
actions.push(("project.select".into(), json!({"items": []})));
|
||||
|
|
|
|||
|
|
@ -485,6 +485,41 @@ fn select_all_takes_only_the_shown_bin() {
|
|||
assert_eq!(sorted(&d.app().session.state.project_selection), footage);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dragged_bin_nests_in_another_and_back_out() {
|
||||
// #456: a bin stayed wherever it was created; dragging it onto another bin did nothing
|
||||
let mut d = project_driver();
|
||||
d.exec("project.view.set", json!({"view": "list"}));
|
||||
let outer = d.exec("file.newBin", json!({"name": "Outer"}))["bin"].as_u64().unwrap();
|
||||
let inner = d.exec("file.newBin", json!({"name": "Inner"}))["bin"].as_u64().unwrap();
|
||||
d.app().ui.expanded_bins.clear();
|
||||
d.frames(3);
|
||||
let parent =
|
||||
|d: &mut Driver, b: u64| filmcraft_ui_egui::panels::project::parent_bin(&d.app().session.project.root, filmcraft_project::BinId(b)).map(|p| p.0);
|
||||
let root = d.app().session.project.root.id.0;
|
||||
let drag = |d: &mut Driver, from: [f32; 4], to: (f32, f32)| {
|
||||
let start = (from[0] + 80.0, from[1] + from[3] / 2.0);
|
||||
d.ok("ui.drag", json!({"from": {"x": start.0, "y": start.1}, "to": {"x": to.0, "y": to.1}, "steps": 12}));
|
||||
d.frames(3);
|
||||
};
|
||||
// onto the Outer row: Inner nests in it
|
||||
let (from, to) = (d.rect(&format!("project.bin.{inner}")), d.rect(&format!("project.bin.{outer}")));
|
||||
drag(&mut d, from, (to[0] + 80.0, to[1] + to[3] / 2.0));
|
||||
assert_eq!(parent(&mut d, inner), Some(outer));
|
||||
// a bin never goes into itself
|
||||
d.app().ui.expanded_bins.push(outer);
|
||||
d.frames(3);
|
||||
let (from, to) = (d.rect(&format!("project.bin.{outer}")), d.rect(&format!("project.bin.{inner}")));
|
||||
drag(&mut d, from, (to[0] + 80.0, to[1] + to[3] / 2.0));
|
||||
assert_eq!(parent(&mut d, outer), Some(root));
|
||||
assert_eq!(parent(&mut d, inner), Some(outer));
|
||||
// onto empty space: back to the bin the view shows
|
||||
let from = d.rect(&format!("project.bin.{inner}"));
|
||||
let to = centre(d.rect("project.empty"));
|
||||
drag(&mut d, from, to);
|
||||
assert_eq!(parent(&mut d, inner), Some(root));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_buttons_are_wired() {
|
||||
let mut d = project_driver();
|
||||
|
|
|
|||
Loading…
Reference in New Issue