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:
ReReddo 2026-10-10 11:14:13 +03:00
parent 1201458630
commit d67583385b
3 changed files with 224 additions and 10 deletions

View File

@ -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),
}
}
}
}

View File

@ -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));

View File

@ -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");
}