project panel: drag in empty space to marquee-select, in List, Icon and Freeform views (#578)
Dragging with the left button from empty space in the Project panel draws a selection rectangle, and every clip whose row or card it touches is selected, live. In Icon view the gaps between cards count as empty space too, so a click there deselects. A plain drag replaces the selection, Shift or Ctrl adds to it, and Escape cancels and restores the selection the drag began with. The selection goes through project.select; the rectangle registers project.marquee. Synthetic input (control channel, MCP, UI tests) now sends ModifiersChanged around a pointer press and release that carry modifiers, so a drag that egui recognises frames after the press still sees Shift / Ctrl, as with real keys. This also lets automated Shift-drags of the Timeline marquee add to the selection. Limits: the rectangle does not select bins (the panel tracks one selected bin), and in List view, where rows span the full width, it starts in the empty space below the rows. Tests: five project_panel_ui tests. The ui-egui suite passes except the two Windows CRLF failures fixed in #488.
This commit is contained in:
parent
1201458630
commit
d67583385b
|
|
@ -1609,7 +1609,21 @@ impl eframe::App for FilmcraftApp {
|
|||
.position(|e| pointer(e) || matches!(e, egui::Event::Key { pressed: false, .. }))
|
||||
.map_or(self.synthetic.len(), |i| if pointer(&self.synthetic[i]) { i.max(1) } else { i + 1 })
|
||||
};
|
||||
raw_input.events.extend(self.synthetic.drain(..n));
|
||||
// A press's modifiers go down before it and up after its release, as real keys would,
|
||||
// so a drag that egui recognises frames after the press still sees Shift / Ctrl.
|
||||
for e in self.synthetic.drain(..n) {
|
||||
match e {
|
||||
egui::Event::PointerButton { pressed: true, modifiers, .. } if !modifiers.is_none() => {
|
||||
raw_input.events.push(egui::Event::ModifiersChanged(modifiers));
|
||||
raw_input.events.push(e);
|
||||
}
|
||||
egui::Event::PointerButton { pressed: false, modifiers, .. } if !modifiers.is_none() => {
|
||||
raw_input.events.push(e);
|
||||
raw_input.events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
||||
}
|
||||
_ => raw_input.events.push(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -422,6 +422,7 @@ pub fn list_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View
|
|||
let root = app.session.project.root.find_bin(v.bin).cloned().unwrap_or_default();
|
||||
let out = egui::ScrollArea::both().id_salt((&v.prefix, "list-scroll")).auto_shrink([false, false]).show(&mut bui, |ui| {
|
||||
ui.set_min_width(lc.width);
|
||||
let bg = background(ui, v);
|
||||
let mut row = 0usize;
|
||||
list_bin(app, ui, &root, 0, filter, &mut row, actions, v, &lc);
|
||||
if v.inst == Inst::Main && v.bin == app.session.project.root.id {
|
||||
|
|
@ -430,7 +431,7 @@ pub fn list_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View
|
|||
};
|
||||
crate::panels::menu_dialogs::search_bin_rows(app, ui, &mut row, actions, &mut draw);
|
||||
}
|
||||
empty_space(app, ui, v, actions);
|
||||
empty_space(app, ui, v, &bg, actions);
|
||||
});
|
||||
// pinned header, scrolled horizontally with the rows
|
||||
let hr = Rect::from_min_size(rect.min, vec2(rect.width(), header_h));
|
||||
|
|
@ -491,21 +492,88 @@ pub fn list_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View
|
|||
}
|
||||
}
|
||||
|
||||
/// Empty space under the rows / cards: click deselects, double-click imports, right-click menu.
|
||||
fn empty_space(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, actions: &mut Actions) {
|
||||
/// The List / Icon view background, under the rows and cards (call it before drawing them, so they
|
||||
/// sit on top). Starts the frame's [`note_hit`] list.
|
||||
fn background(ui: &egui::Ui, v: &View) -> egui::Response {
|
||||
ui.data_mut(|d| d.remove::<Vec<(u64, Rect)>>(hits_id(v)));
|
||||
ui.interact(ui.clip_rect(), egui::Id::new((&v.prefix, "background")), Sense::click_and_drag())
|
||||
}
|
||||
|
||||
/// Empty space under the rows / cards, and the gaps between them (`bg`): click deselects,
|
||||
/// double-click imports, right-click menu, drag selects with a marquee.
|
||||
fn empty_space(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bg: &egui::Response, actions: &mut Actions) {
|
||||
let rest = ui.available_rect_before_wrap();
|
||||
let r = Rect::from_min_size(rest.min, vec2(rest.width(), rest.height().max(40.0)));
|
||||
let resp = ui.allocate_rect(r, Sense::click());
|
||||
ui.allocate_rect(r, Sense::hover());
|
||||
app.auto.add(&format!("{}.empty", v.prefix), r.intersect(ui.clip_rect()), "Empty area");
|
||||
if resp.clicked() {
|
||||
marquee(app, ui, v, bg, ui.max_rect().min, actions);
|
||||
if bg.clicked() {
|
||||
app.ui.project_panel.selected_bin = None;
|
||||
actions.push(("project.select".into(), json!({"items": []})));
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
if bg.double_clicked() {
|
||||
actions.push(("file.import".into(), json!({})));
|
||||
}
|
||||
let bin = (v.bin != app.session.project.root.id).then_some(v.bin.0);
|
||||
resp.context_menu(|ui| background_menu(app, ui, v, bin, actions));
|
||||
bg.context_menu(|ui| background_menu(app, ui, v, bin, actions));
|
||||
}
|
||||
|
||||
fn hits_id(v: &View) -> egui::Id {
|
||||
egui::Id::new((&v.prefix, "marquee-hits"))
|
||||
}
|
||||
|
||||
/// Remember where an item's row or card was drawn this frame, for [`marquee`].
|
||||
fn note_hit(ui: &egui::Ui, v: &View, id: ItemId, r: Rect) {
|
||||
ui.data_mut(|d| d.get_temp_mut_or_default::<Vec<(u64, Rect)>>(hits_id(v)).push((id.0, r)));
|
||||
}
|
||||
|
||||
/// Marquee selection: dragging `bg` (empty space) selects every item drawn this frame whose rect
|
||||
/// the rectangle touches, live. Shift/Ctrl add to the selection the drag started with; Escape
|
||||
/// cancels and restores it. `origin` is the content's top-left, so scrolling keeps the anchor.
|
||||
fn marquee(app: &mut FilmcraftApp, ui: &egui::Ui, v: &View, bg: &egui::Response, origin: egui::Pos2, actions: &mut Actions) {
|
||||
// (press point relative to `origin`, selection when the drag began, adds to it)
|
||||
type Drag = (egui::Vec2, Vec<u64>, bool);
|
||||
let key = egui::Id::new((&v.prefix, "marquee"));
|
||||
let hits: Vec<(u64, Rect)> = ui.data_mut(|d| d.remove_temp(hits_id(v))).unwrap_or_default();
|
||||
let current: Vec<u64> = app.session.state.project_selection.iter().map(|i| i.0).collect();
|
||||
// the left button only: right-click opens the menu, the middle button is not a selection
|
||||
let primary = egui::PointerButton::Primary;
|
||||
if bg.drag_started_by(primary) {
|
||||
let press = ui.input(|i| i.pointer.press_origin()).unwrap_or(origin);
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
let add = m.shift || m.command;
|
||||
if !add {
|
||||
app.ui.project_panel.selected_bin = None;
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp::<Drag>(key, (press - origin, current.clone(), add)));
|
||||
}
|
||||
let Some((anchor, before, add)) = ui.data(|d| d.get_temp::<Drag>(key)) else { return };
|
||||
let cancel = ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
if cancel || !(bg.dragged_by(primary) || bg.drag_stopped_by(primary)) {
|
||||
ui.data_mut(|d| d.remove::<Drag>(key));
|
||||
if cancel && before != current {
|
||||
actions.push(("project.select".into(), json!({"items": before})));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(now) = ui.ctx().pointer_latest_pos() else { return };
|
||||
let r = Rect::from_two_pos(origin + anchor, now);
|
||||
let mut sel = if add { before } else { Vec::new() };
|
||||
for (id, hr) in &hits {
|
||||
if hr.intersects(r) && !sel.contains(id) {
|
||||
sel.push(*id);
|
||||
}
|
||||
}
|
||||
if sel != current {
|
||||
actions.push(("project.select".into(), json!({"items": sel})));
|
||||
}
|
||||
let t = app.tokens;
|
||||
ui.painter().rect_filled(r, 0.0, t.accent.gamma_multiply(0.18));
|
||||
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside);
|
||||
app.auto.add(&format!("{}.marquee", v.prefix), r, "Marquee");
|
||||
if bg.drag_stopped_by(primary) {
|
||||
ui.data_mut(|d| d.remove::<Drag>(key));
|
||||
}
|
||||
}
|
||||
|
||||
fn background_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bin: Option<u64>, actions: &mut Actions) {
|
||||
|
|
@ -580,6 +648,7 @@ fn list_bin(app: &mut FilmcraftApp, ui: &mut egui::Ui, bin: &Bin, depth: usize,
|
|||
}
|
||||
let Some(it) = app.session.project.item(id).cloned() else { continue };
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(lc.width, lc.row_h), Sense::click_and_drag());
|
||||
note_hit(ui, v, id, r);
|
||||
let selected = app.session.state.project_selection.contains(&id);
|
||||
if selected {
|
||||
ui.painter().rect_filled(r, 0.0, t.row_selected);
|
||||
|
|
@ -806,6 +875,7 @@ pub fn icon_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View
|
|||
};
|
||||
let n = bins.len() + ids.len();
|
||||
egui::ScrollArea::vertical().id_salt((&v.prefix, "icon-scroll")).auto_shrink([false, false]).show(ui, |ui| {
|
||||
let bg = background(ui, v);
|
||||
let per_row = ((rect.width() - 12.0) / cell.x).floor().max(1.0) as usize;
|
||||
if v.inst == Inst::Main {
|
||||
app.ui.keys.icon_columns = per_row;
|
||||
|
|
@ -827,13 +897,14 @@ pub fn icon_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View
|
|||
let Some(kind) = app.session.project.item(id).map(|x| x.kind.clone()) else { continue };
|
||||
let was_selected = app.session.state.project_selection.contains(&id);
|
||||
if let Some(resp) = card(app, ui, r, id, &v.prefix, true, true, Sense::click_and_drag()) {
|
||||
note_hit(ui, v, id, r);
|
||||
let name_r = Rect::from_min_max(pos2(r.min.x, r.min.y + th_h), r.max);
|
||||
slow_click_rename(app, ui, &resp, name_r, id, was_selected);
|
||||
item_interactions(app, ui, &resp, id, &kind, actions, true, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
empty_space(app, ui, v, actions);
|
||||
empty_space(app, ui, v, &bg, actions);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -856,7 +927,8 @@ pub fn freeform_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &
|
|||
};
|
||||
let extent = cards.iter().fold(vec2(rect.width(), rect.height()), |e, c| vec2(e.x.max(c.x + c.size + 40.0), e.y.max(c.y + c.size * 9.0 / 16.0 + 60.0)));
|
||||
egui::ScrollArea::both().id_salt((&v.prefix, "ff-scroll")).auto_shrink([false, false]).show(ui, |ui| {
|
||||
let (canvas, bg) = ui.allocate_exact_size(extent, Sense::click());
|
||||
ui.data_mut(|d| d.remove::<Vec<(u64, Rect)>>(hits_id(v)));
|
||||
let (canvas, bg) = ui.allocate_exact_size(extent, Sense::click_and_drag());
|
||||
app.auto.add(&format!("{}.freeform", v.prefix), canvas, "Freeform canvas");
|
||||
if opts.snap {
|
||||
let g = opts.grid.max(4.0);
|
||||
|
|
@ -888,6 +960,7 @@ pub fn freeform_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &
|
|||
let r = Rect::from_min_size(canvas.min + vec2(c.x, c.y) + off, vec2(c.size, c.size * 9.0 / 16.0 + 20.0));
|
||||
let Some(kind) = app.session.project.item(ItemId(c.item)).map(|x| x.kind.clone()) else { continue };
|
||||
let Some(resp) = card(app, ui, r, ItemId(c.item), &v.prefix, opts.show_names, opts.show_durations, Sense::click_and_drag()) else { continue };
|
||||
note_hit(ui, v, ItemId(c.item), r);
|
||||
if let Some(s) = c.stack
|
||||
&& s == c.item
|
||||
&& let Some(n) = stack_n.get(&s)
|
||||
|
|
@ -914,6 +987,7 @@ pub fn freeform_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &
|
|||
}
|
||||
resp.context_menu(|ui| card_menu(app, ui, v, c.item, actions));
|
||||
}
|
||||
marquee(app, ui, v, &bg, canvas.min, actions);
|
||||
if let Some((item, off)) = released {
|
||||
ui.ctx().data_mut(|d| d.remove::<(u64, egui::Vec2)>(drag_key));
|
||||
let inside = ui.ctx().pointer_latest_pos().is_some_and(|p| rect.contains(p));
|
||||
|
|
|
|||
|
|
@ -626,3 +626,129 @@ fn media_browser_drag_imports_into_the_project_panel() {
|
|||
assert_eq!(d.app().session.project.items.len(), n0 + 1, "kept after a drop on the Timeline");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// ---- marquee selection (#578)
|
||||
|
||||
/// The Footage bin open in place in `view`, with its items' rects (row by row, left to right).
|
||||
fn footage_in(d: &mut Driver, view: &str) -> Vec<(u64, [f32; 4])> {
|
||||
let (bin, items) = d.footage();
|
||||
d.exec("project.view.set", json!({"view": view}));
|
||||
d.ok("ui.menu.invoke", json!({"id": "projectPanel.openBin", "params": {"bin": bin, "how": "inPlace"}}));
|
||||
d.wait_for(&format!("project.item.{}", items[0]));
|
||||
let mut rects: Vec<(u64, [f32; 4])> = items.iter().map(|i| (*i, d.rect(&format!("project.item.{i}")))).collect();
|
||||
rects.sort_by(|a, b| (a.1[1], a.1[0]).partial_cmp(&(b.1[1], b.1[0])).unwrap());
|
||||
rects
|
||||
}
|
||||
|
||||
fn selection(d: &mut Driver) -> Vec<u64> {
|
||||
let mut s: Vec<u64> = d.app().session.state.project_selection.iter().map(|i| i.0).collect();
|
||||
s.sort_unstable();
|
||||
s
|
||||
}
|
||||
|
||||
fn sorted(mut v: Vec<u64>) -> Vec<u64> {
|
||||
v.sort_unstable();
|
||||
v
|
||||
}
|
||||
|
||||
fn centre(r: [f32; 4]) -> (f32, f32) {
|
||||
(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0)
|
||||
}
|
||||
|
||||
fn marquee(d: &mut Driver, from: (f32, f32), to: (f32, f32), shift: bool) {
|
||||
d.ok("ui.drag", json!({"from": {"x": from.0, "y": from.1}, "to": {"x": to.0, "y": to.1}, "steps": 12, "modifiers": {"shift": shift}}));
|
||||
d.frames(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marquee_selects_cards_in_icon_view() {
|
||||
let mut d = project_driver();
|
||||
let cards = footage_in(&mut d, "icon");
|
||||
let [(i0, a), (i1, b), (i2, c)] = [cards[0], cards[1], cards[2]];
|
||||
assert!((a[1] - c[1]).abs() < 1.0, "the first three cards share a row");
|
||||
// from the gap above-left of the first card to the middle of the second
|
||||
marquee(&mut d, (a[0] - 4.0, a[1] - 4.0), centre(b), false);
|
||||
assert_eq!(selection(&mut d), sorted(vec![i0, i1]));
|
||||
// Shift adds
|
||||
marquee(&mut d, (c[0] + 4.0, c[1] - 4.0), centre(c), true);
|
||||
assert_eq!(selection(&mut d), sorted(vec![i0, i1, i2]));
|
||||
// a plain drag replaces
|
||||
marquee(&mut d, (a[0] - 4.0, a[1] - 4.0), centre(a), false);
|
||||
assert_eq!(selection(&mut d), vec![i0]);
|
||||
// the gaps between cards are empty space: a click there deselects
|
||||
d.ok("ui.click", json!({"x": a[0] - 4.0, "y": a[1] - 4.0}));
|
||||
d.frames(2);
|
||||
assert!(selection(&mut d).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_cancels_a_marquee_and_keeps_the_selection() {
|
||||
let mut d = project_driver();
|
||||
let cards = footage_in(&mut d, "icon");
|
||||
let [(i0, a), (i1, b), (i2, c)] = [cards[0], cards[1], cards[2]];
|
||||
d.exec("project.select", json!({"items": [i2]}));
|
||||
let send = |d: &mut Driver, e: egui::Event| {
|
||||
d.harness.input_mut().events.push(e);
|
||||
d.frames(1);
|
||||
};
|
||||
let (from, to) = (egui::pos2(a[0] - 4.0, a[1] - 4.0), egui::pos2(centre(b).0, centre(b).1));
|
||||
send(&mut d, egui::Event::PointerMoved(from));
|
||||
send(&mut d, egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
for k in 1..=10 {
|
||||
send(&mut d, egui::Event::PointerMoved(from.lerp(to, k as f32 / 10.0)));
|
||||
}
|
||||
assert_eq!(selection(&mut d), sorted(vec![i0, i1]), "the selection follows the rectangle");
|
||||
send(&mut d, egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
|
||||
let end = egui::pos2(centre(c).0, centre(c).1);
|
||||
send(&mut d, egui::Event::PointerMoved(end));
|
||||
send(&mut d, egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
d.frames(2);
|
||||
assert_eq!(selection(&mut d), vec![i2], "Escape restored the selection the drag began with");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_left_button_draws_a_marquee() {
|
||||
let mut d = project_driver();
|
||||
let cards = footage_in(&mut d, "icon");
|
||||
let [(i0, a), (_, b)] = [cards[0], cards[1]];
|
||||
d.exec("project.select", json!({"items": [i0]}));
|
||||
let (from, to) = (egui::pos2(a[0] - 4.0, a[1] - 4.0), egui::pos2(centre(b).0, centre(b).1));
|
||||
for button in [egui::PointerButton::Middle, egui::PointerButton::Secondary] {
|
||||
let mut send = |e: egui::Event| {
|
||||
d.harness.input_mut().events.push(e);
|
||||
d.frames(1);
|
||||
};
|
||||
send(egui::Event::PointerMoved(from));
|
||||
send(egui::Event::PointerButton { pos: from, button, pressed: true, modifiers: Default::default() });
|
||||
for k in 1..=10 {
|
||||
send(egui::Event::PointerMoved(from.lerp(to, k as f32 / 10.0)));
|
||||
}
|
||||
send(egui::Event::PointerButton { pos: to, button, pressed: false, modifiers: Default::default() });
|
||||
d.key("Escape");
|
||||
assert_eq!(selection(&mut d), vec![i0], "{button:?} drag left the selection alone");
|
||||
assert!(!d.has("project.marquee"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marquee_selects_rows_in_list_view() {
|
||||
let mut d = project_driver();
|
||||
let rows = footage_in(&mut d, "list");
|
||||
let n = rows.len();
|
||||
let (last, before_last) = (rows[n - 1], rows[n - 2]);
|
||||
let empty = d.rect("project.empty");
|
||||
// from the empty space under the rows up into the second-to-last row
|
||||
marquee(&mut d, (empty[0] + 60.0, empty[1] + 20.0), (empty[0] + 60.0, centre(before_last.1).1), false);
|
||||
assert_eq!(selection(&mut d), sorted(vec![last.0, before_last.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marquee_selects_cards_in_freeform_view() {
|
||||
let mut d = project_driver();
|
||||
let cards = footage_in(&mut d, "freeform");
|
||||
let [(i0, a), (i1, b)] = [cards[0], cards[1]];
|
||||
assert!((a[1] - b[1]).abs() < 1.0, "the first two cards share a row");
|
||||
marquee(&mut d, (a[0] - 6.0, a[1] - 6.0), centre(b), false);
|
||||
assert_eq!(selection(&mut d), sorted(vec![i0, i1]));
|
||||
assert!(!d.app().session.project.item(ItemId(i0)).unwrap().metadata.contains_key(FREEFORM_POS), "the marquee moved no card");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue