diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fd2c06111..14250ab3d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -909,6 +909,7 @@ impl LightcraftApp { export_task::poll(self, &ctx); pick::poll(self, &ctx); panels::grid::drag_feedback(self, &ctx); + panels::left::album_drag_feedback(self, &ctx); panels::toast(self, &ctx); self.widgets = widgets::take_registry(&ctx); self.end_frame(t0); diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 12db28b2f..e851473f3 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -52,10 +52,27 @@ fn row_named( count: Option, selected: bool, indent: f32, +) -> egui::Response { + row_sensed(app, ui, id, icon, label, spoken, count, selected, indent, Sense::click()) +} + +/// [`row_named`] with the given `sense` (rows that can be dragged add `drag`). +#[allow(clippy::too_many_arguments)] +fn row_sensed( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + id: &str, + icon: Icon, + label: &str, + spoken: Option<&str>, + count: Option, + selected: bool, + indent: f32, + sense: Sense, ) -> egui::Response { let label = if matches!(id, "all" | "recentlyAdded" | "picks" | "missing" | "recentlyDeleted") { crate::i18n::tr(label) } else { label }; let t = Tokens::get(ui.ctx()); - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 29.0), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 29.0), sense); register(ui.ctx(), format!("source:{id}"), r); let said = spoken.unwrap_or(label); let name = match count { @@ -229,6 +246,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { cur = albums.iter().find(|a| a.id == p).and_then(|a| a.parent); } albums_tree(app, ui, &albums, None, 0.0, &open_to); + top_level_drop_zone(app, ui); } ui.add_space(10.0); local_section(app, ui); @@ -664,9 +682,15 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent open = true; ui.data_mut(|d| d.insert_temp(open_id, true)); } - let resp = row(app, ui, &format!("folder:{}", a.id.0), Icon::Folder, &a.name, None, false, indent); + let resp = row_sensed(app, ui, &format!("folder:{}", a.id.0), Icon::Folder, &a.name, None, None, false, indent, Sense::click_and_drag()); // a folder is no source, so its row folds it too; the triangle is the same click, aimed let has_children = all.iter().any(|c| c.parent == Some(a.id)); + if resp.drag_started() { + app.ui.dragging_album = Some(a.id.0); + } + if album_drag_over(app, ui, &resp, a, has_children.then_some(&mut open)) { + ui.data_mut(|d| d.insert_temp(open_id, true)); + } let mut toggled = resp.clicked(); if has_children { let tri = disclosure_triangle(ui, &resp, indent, open, egui::Id::new(("album-tri", a.id.0)), format!("albumToggle:{}", a.id.0)); @@ -691,7 +715,10 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent let target = app.session.target_album.filter(|t| app.session.catalog.album(*t).is_some()).or_else(|| app.session.catalog.quick_collection()); let label = if target == Some(a.id) { format!("{} +", a.name) } else { a.name.clone() }; - let mut resp = row(app, ui, &format!("album:{}", a.id.0), icon, &label, Some(n), sel, indent); + let mut resp = row_sensed(app, ui, &format!("album:{}", a.id.0), icon, &label, None, Some(n), sel, indent, Sense::click_and_drag()); + if resp.drag_started() { + app.ui.dragging_album = Some(a.id.0); + } if !a.is_smart() { drop_target(app, ui, &resp, a); } @@ -706,6 +733,106 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent } } +/// How long a dragged album must rest on a closed folder before it opens. +const HOVER_OPEN_SECS: f64 = 0.6; + +/// Whether the album `dragged` may be dropped into the folder `target` (`None`: the top level): +/// a folder other than where it already is, and not itself or something inside it. +fn can_drop_album(app: &LightcraftApp, dragged: AlbumId, target: Option) -> bool { + let Some(d) = app.session.catalog.album(dragged) else { return false }; + match target { + None => d.parent.is_some(), + Some(t) => app.session.catalog.album(t).is_some_and(|f| f.folder) && d.parent != Some(t) && !is_within(app, t, dragged), + } +} + +/// Moves the dragged album (`album.move`, one undo step) and ends the drag; the folders down to it +/// open so it shows in its new place. +fn drop_album(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, target: Option) { + match app.run("album.move", json!({"id": dragged.0, "parent": target.map(|t| t.0)})) { + Ok(_) => { + app.ui.reveal_album = Some(dragged.0); + ui.ctx().request_repaint(); + } + Err(e) => app.toast(ui.ctx(), e), + } + app.ui.dragging_album = None; +} + +/// A folder row while an album is dragged: outlined under the pointer when the drop is allowed, +/// opened (`open`, when it has something inside) once the pointer rests on it, and the album moved +/// in on release. Returns whether it opened the folder. +fn album_drag_over(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, folder: &Album, open: Option<&mut bool>) -> bool { + let hover_id = egui::Id::new(("album-hover", folder.id.0)); + let Some(dragged) = app.ui.dragging_album.map(AlbumId) else { + ui.data_mut(|d| d.remove_temp::(hover_id)); + return false; + }; + if !ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| resp.rect.contains(p)) { + ui.data_mut(|d| d.remove_temp::(hover_id)); + return false; + } + let now = ui.input(|i| i.time); + let since = ui.data_mut(|d| *d.get_temp_mut_or_insert_with(hover_id, || now)); + let mut opened = false; + if let Some(open) = open + && !*open + { + if now - since >= HOVER_OPEN_SECS { + *open = true; + opened = true; + } else { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); + } + } + if can_drop_album(app, dragged, Some(folder.id)) { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_stroke(resp.rect.shrink2(vec2(8.0, 1.0)), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); + if ui.input(|i| i.pointer.any_released()) { + drop_album(app, ui, dragged, Some(folder.id)); + } + } + opened +} + +/// While an album that sits in a folder is dragged: a row under the tree that takes it back to the +/// top level. +fn top_level_drop_zone(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let Some(dragged) = app.ui.dragging_album.map(AlbumId) else { return }; + if !can_drop_album(app, dragged, None) { + return; + } + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 29.0), Sense::hover()); + register(ui.ctx(), "albumDrop:top", r); + let over = ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| r.contains(p)); + let stroke = if over { egui::Stroke::new(1.5, t.accent) } else { egui::Stroke::new(1.0, t.text_dim.gamma_multiply(0.5)) }; + ui.painter().rect_stroke(r.shrink2(vec2(8.0, 1.0)), 4.0, stroke, egui::StrokeKind::Inside); + ui.painter().text(pos2(r.left() + 18.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Top Level"), t.font(13.5), t.text_dim); + if over && ui.input(|i| i.pointer.any_released()) { + drop_album(app, ui, dragged, None); + } +} + +/// While an album is dragged in the sidebar: its name at the pointer; the drag ends when the +/// button is up (the drop targets act on the release frame, before this runs). +pub fn album_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { + let Some(id) = app.ui.dragging_album else { return }; + let (released, down, pos) = ctx.input(|i| (i.pointer.any_released(), i.pointer.any_down(), i.pointer.latest_pos())); + if released || !down { + app.ui.dragging_album = None; + return; + } + let (Some(pos), Some(name)) = (pos, app.session.catalog.album(AlbumId(id)).map(|a| a.name.clone())) else { return }; + let t = Tokens::get(ctx); + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + egui::Area::new(egui::Id::new("drag-album")).order(egui::Order::Tooltip).interactable(false).fixed_pos(pos + vec2(14.0, 10.0)).show(ctx, |ui| { + egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| { + ui.label(egui::RichText::new(name).color(egui::Color32::WHITE).font(t.semibold(12.0))); + }); + }); +} + /// An album row while photos are dragged from the grid: highlighted under the pointer; a /// release there adds them. fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, a: &Album) { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 5f00f5a52..bbf936dd5 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -375,6 +375,9 @@ pub struct UiState { /// An album just made: the Albums tree opens the folders down to it, once. #[serde(skip)] pub reveal_album: Option, + /// An album or folder row being dragged in the sidebar (dropped on a folder to move it there). + #[serde(skip)] + pub dragging_album: Option, /// Selected curve channel in the Curve flyout. pub curve_channel: String, /// Selected mixer mode: "hue" | "saturation" | "luminance" | "all". @@ -675,6 +678,7 @@ impl Default for UiState { quit: false, dragging_photos: None, reveal_album: None, + dragging_album: None, curve_channel: "parametric".into(), mixer_mode: "hue".into(), grading_mode: "3way".into(), diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 5f81ee36f..ba67867c3 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -354,6 +354,143 @@ fn album_dialogs_without_a_parent_still_deserialize() { assert!(matches!(d, Dialog::SmartRules { parent: None, .. })); } +/// An album tree to drag in: `Archive` (empty folder), `Trips` ⊃ `Sub` (folder) + `Best`, `Loose`. +struct AlbumTree { + h: Headless, + archive: u64, + trips: u64, + sub: u64, + best: u64, + loose: u64, +} + +fn album_tree() -> AlbumTree { + let mut h = demo([1400.0, 900.0], json!({"view": "photoGrid", "leftPanel": true})); + let make = |h: &mut Headless, params: serde_json::Value| h.app.session.execute("album.create", ¶ms).unwrap()["id"].as_u64().unwrap(); + let archive = make(&mut h, json!({"name": "Archive", "folder": true})); + let trips = make(&mut h, json!({"name": "Trips", "folder": true})); + let sub = make(&mut h, json!({"name": "Sub", "folder": true, "parent": trips})); + let best = make(&mut h, json!({"name": "Best", "parent": trips})); + let loose = make(&mut h, json!({"name": "Loose"})); + h.step(); + h.step(); + AlbumTree { h, archive, trips, sub, best, loose } +} + +impl AlbumTree { + fn row(&self, id: u64) -> egui::Rect { + let h = &self.h; + let name = ["source:folder:", "source:album:"].iter().map(|p| format!("{p}{id}")).find(|n| h.app.widgets.iter().any(|(w, _)| w == n)); + widget(h, &name.unwrap_or_else(|| panic!("no row for album {id}"))) + } + /// The middle of the row just under the last row of the Albums tree (where the top-level drop zone is). + fn below_tree(&self) -> egui::Pos2 { + let bottom = (self.h.app.widgets.iter()) + .filter(|(w, r)| (w.starts_with("source:album:") || w.starts_with("source:folder:")) && r.left() < 300.0) + .map(|(_, r)| r.bottom()) + .fold(0.0, f32::max); + egui::pos2(100.0, bottom + 14.5) + } + fn parent(&self, id: u64) -> Option { + self.h.app.session.catalog.album(lightcraft_catalog::AlbumId(id)).unwrap().parent.map(|p| p.0) + } + /// Press at `from`, move through each `(point, frames held there)`, release at the last one. + fn drag(&mut self, from: egui::Pos2, path: &[(egui::Pos2, usize)]) { + let mut ev = |e| self.h.app.synthetic.push(e); + ev(egui::Event::PointerMoved(from)); + ev(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + let mut at = from; + for (to, hold) in path { + for i in 1..=8 { + at = from + (*to - from) * (i as f32 / 8.0); + ev(egui::Event::PointerMoved(at)); + } + for _ in 0..*hold { + ev(egui::Event::PointerMoved(*to)); + } + at = *to; + } + ev(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + for _ in 0..400 { + if self.h.app.synthetic.is_empty() { + break; + } + self.h.step(); + } + self.h.step(); + self.h.step(); + } + fn drag_row(&mut self, id: u64, onto: u64) { + let (from, to) = (self.row(id).center(), self.row(onto).center()); + self.drag(from, &[(to, 3)]); + } +} + +/// Given albums and folders in the sidebar, when an album or folder is dragged onto a folder, +/// then it moves into it (as Move to does); onto a plain album, itself or something inside it, it +/// stays where it was. +#[test] +fn dragging_an_album_onto_a_folder_moves_it() { + let mut t = album_tree(); + t.drag_row(t.loose, t.archive); + assert_eq!(t.parent(t.loose), Some(t.archive), "an album into a folder"); + assert!(t.h.app.ui.dragging_album.is_none(), "the drag ended"); + // it is shown inside its new folder, which opened for it + assert!(t.h.app.widgets.iter().any(|(w, _)| *w == format!("source:album:{}", t.loose))); + t.drag_row(t.sub, t.archive); + assert_eq!(t.parent(t.sub), Some(t.archive), "a folder into a folder"); + // refused: onto a plain album, onto itself, into its own subfolder + t.drag_row(t.best, t.loose); + assert_eq!(t.parent(t.best), Some(t.trips), "a plain album is no destination"); + let (from, aside) = (t.row(t.archive).center(), t.row(t.archive).center() + egui::vec2(40.0, 0.0)); + t.drag(from, &[(aside, 3)]); + assert_eq!(t.parent(t.archive), None, "not onto itself"); + t.drag_row(t.archive, t.sub); + assert_eq!(t.parent(t.archive), None, "not into something inside it"); + assert!(t.h.app.ui.dragging_album.is_none()); + // a plain click still only opens the album + let c = t.row(t.best).center(); + t.drag(c, &[(c, 0)]); + assert_eq!(t.h.app.session.source, lightcraft_engine::LibrarySource::Album(lightcraft_catalog::AlbumId(t.best))); +} + +/// Dragging out of a folder: the drop zone under the tree puts the album back at the top level; +/// it only shows when there is somewhere to go, and the move is one undo step. +#[test] +fn dragging_an_album_to_the_top_level() { + let mut t = album_tree(); + let has = |t: &AlbumTree, id: &str| t.h.app.widgets.iter().any(|(w, _)| w == id); + assert!(!has(&t, "albumDrop:top"), "no zone unless an album is being dragged"); + // below the last row of the tree, where the zone appears once an album with a folder is dragged + let below = t.below_tree(); + let from = t.row(t.best).center(); + t.drag(from, &[(below, 3)]); + assert_eq!(t.parent(t.best), None, "moved out of Trips"); + // a top-level album has nowhere to go: dropping it there does nothing + let (from, below) = (t.row(t.best).center(), t.below_tree()); + t.drag(from, &[(below, 3)]); + assert_eq!(t.parent(t.best), None); + t.h.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(t.parent(t.best), Some(t.trips), "one undo puts it back"); +} + +/// Holding a dragged album over a closed folder opens it, so the drop can go into a subfolder. +#[test] +fn a_dragged_album_opens_the_folder_it_hovers_over() { + let mut t = album_tree(); + let r = t.h.request("ui.clickWidget", json!({"id": format!("albumToggle:{}", t.trips)}), T); + assert_eq!(r["ok"], true, "{r}"); + t.h.step(); + t.h.step(); + assert!(!t.h.app.widgets.iter().any(|(w, _)| *w == format!("source:folder:{}", t.sub)), "Trips is folded"); + // where Sub's row will be once Trips is open: one row below it + let trips = t.row(t.trips); + let inside = trips.center() + egui::vec2(0.0, trips.height()); + let from = t.row(t.loose).center(); + t.drag(from, &[(trips.center(), 90), (inside, 3)]); + assert_eq!(t.parent(t.loose), Some(t.sub), "dropped on Sub, which was only there after the hover"); +} + /// A headless app over a library of file-backed photos that exist only in the catalog. fn folders_app(paths: &[&str]) -> Headless { folders_app_sized(paths, [1400.0, 900.0])