From f024edb544ec51c2aa79d2fdac0de4a32c7f7923 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 14 Nov 2026 05:56:46 +0000 Subject: [PATCH] Place: arrow keys cycle the loaded images between drags (place.cycle) --- crates/engine/src/cmd/file/place.rs | 33 ++++++++++++++++++++++++++ crates/engine/src/cmd/file/registry.rs | 3 ++- crates/tools/src/nav.rs | 13 ++++++++-- crates/tools/src/tests.rs | 17 +++++++++---- 4 files changed, 59 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/file/place.rs b/crates/engine/src/cmd/file/place.rs index b5ebe8d1f..9dd51d61e 100644 --- a/crates/engine/src/cmd/file/place.rs +++ b/crates/engine/src/cmd/file/place.rs @@ -432,6 +432,24 @@ pub(super) fn place_skip(s: &mut Session, _p: &Value) -> Result { Ok(json!({"remaining": loaded_count(s)})) } +/// Rotate the loaded images: `dir` 1 makes the next one current (the current one goes to the back), +/// -1 the previous one (the last goes to the front). +pub(super) fn place_cycle(s: &mut Session, p: &Value) -> Result { + const ID: &str = "place.cycle"; + let dir = p.get("dir").and_then(Value::as_i64).filter(|d| matches!(d, 1 | -1)).ok_or_else(|| bad(ID, "dir must be 1 or -1"))?; + validate_place_cursor(s, ID)?; + if let Some(current) = s.loaded { + if dir == 1 { + s.loaded_queue.push_back(current); + s.loaded = s.loaded_queue.pop_front(); + } else if let Some(last) = s.loaded_queue.pop_back() { + s.loaded_queue.push_front(current); + s.loaded = Some(last); + } + } + Ok(json!({"remaining": loaded_count(s)})) +} + /// Validate bounded grid dimensions and a finite nonnegative gutter. fn grid_dimensions(p: &Value, command: &str, default_columns: usize, default_rows: usize) -> Result<(usize, usize, f64)> { let dimension = |key, default| -> Result { @@ -684,6 +702,21 @@ pub(super) mod image_place_tests { assert!(s.execute("place.skip", &json!({})).is_err()); } + #[test] + fn cycle_rotates_the_loaded_images_both_ways() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let files: Vec<_> = (0..3).map(|i| json!({"base64": test_png(i as u8), "name": format!("{i}.png")})).collect(); + s.execute("place.load", &json!({"files": files})).unwrap(); + s.execute("place.cycle", &json!({"dir": 1})).unwrap(); + assert_eq!(loaded_names(&s), ["1.png", "2.png", "0.png"]); + s.execute("place.cycle", &json!({"dir": -1})).unwrap(); + s.execute("place.cycle", &json!({"dir": -1})).unwrap(); + assert_eq!(loaded_names(&s), ["2.png", "0.png", "1.png"]); + assert!(s.execute("place.cycle", &json!({"dir": 2})).is_err()); + assert!(s.execute("place.cycle", &json!({})).is_err()); + } + #[test] fn consecutive_path_placements_create_distinct_graphics() { let images = [image_file_with_color(30).unwrap(), image_file_with_color(31).unwrap(), image_file_with_color(32).unwrap()]; diff --git a/crates/engine/src/cmd/file/registry.rs b/crates/engine/src/cmd/file/registry.rs index 0cc8425e1..8fbc8136a 100644 --- a/crates/engine/src/cmd/file/registry.rs +++ b/crates/engine/src/cmd/file/registry.rs @@ -3,7 +3,7 @@ use super::EngineError; use super::bytes::{base64_encode, to_bytes, to_bytes_with_preview, with_document_extension}; use super::new::{file_new, file_sample, save_preset}; use super::open::{file_open, file_open_bytes, file_open_recent, has_recent, recent_entry}; -use super::place::{file_place, place_contact_sheet, place_drop, place_grid, place_load, place_skip, read_source}; +use super::place::{file_place, place_contact_sheet, place_cycle, place_drop, place_grid, place_load, place_skip, read_source}; use super::save::{FolderAccess, can_revert, file_revert, file_save, recovery_dir}; use super::snippets::{snippet_export, snippet_place}; use super::{CommandSpec, PRESETS, Value, always, bad, cmd, has_doc, json, ok, str_param}; @@ -161,6 +161,7 @@ pub fn specs() -> Vec { place_contact_sheet ), cmd!(noundo "place.skip", "Skip Loaded Image", [], None, "{} — drops the current loaded image without placing it and loads the next; the last one ends placing", has_doc, place_skip), + cmd!(noundo "place.cycle", "Cycle Loaded Images", [], None, "{dir: 1|-1} — makes the next (1) or previous (-1) loaded image the current one", has_doc, place_cycle), cmd!(noundo "place.cancel", "Cancel Image Placement", [], None, "{} — clears every loaded image", has_doc, |s, _| { s.loaded = None; s.loaded_queue.clear(); diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index c7ed65751..6af9761df 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -171,7 +171,7 @@ impl Tool for PlaceGun { fn busy(&self) -> bool { self.start.is_some() } - fn key(&mut self, _cx: &ToolContext, key: crate::ToolKey, _mods: Mods) -> Vec { + fn key(&mut self, cx: &ToolContext, key: crate::ToolKey, _mods: Mods) -> Vec { use crate::ToolKey; // Escape drops the current image and loads the next; `place.cancel` clears them all. if key == ToolKey::Escape { @@ -190,7 +190,16 @@ impl Tool for PlaceGun { } return vec![Action::Handled]; } - vec![] + // Between drags the arrows cycle the loaded images, and don't nudge the selection. + let dir = match key { + ToolKey::Right | ToolKey::Down => 1, + ToolKey::Left | ToolKey::Up => -1, + _ => return vec![], + }; + if cx.loaded.is_none() { + return vec![]; + } + vec![Action::Exec("place.cycle".into(), json!({"dir": dir}))] } fn overlays(&self, cx: &ToolContext) -> Vec { let Some(a) = self.start else { return vec![] }; diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 1155dcca5..1e898cb26 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -2527,14 +2527,23 @@ fn place_gun_grid_arrow_keys_are_handled_during_a_drag() { assert!(t.key(&cx, ToolKey::Delete, Mods::default()).is_empty()); } -/// Escape drops only the current loaded image; `place.cancel` clears them all. +/// Escape drops only the current loaded image; between drags the arrows cycle the loaded images, +/// and during a drag they keep their grid meaning. #[test] -fn place_gun_escape_skips_the_current_image() { +fn place_gun_escape_skips_and_arrows_cycle_between_drags() { let d = Document::new(&NewDocument::default()); let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); - let cx = ctx(&d, &s, &c, &l); + let mut cx = ctx(&d, &s, &c, &l); + cx.loaded = Some((40.0, 20.0)); let mut t = create("placeGun"); - assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Exec("place.skip".into(), serde_json::json!({}))]); + let exec = |cmd: &str, p: serde_json::Value| vec![Action::Exec(cmd.into(), p)]; + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), exec("place.skip", serde_json::json!({}))); + for (key, dir) in [(ToolKey::Right, 1), (ToolKey::Down, 1), (ToolKey::Left, -1), (ToolKey::Up, -1)] { + assert_eq!(t.key(&cx, key, Mods::default()), exec("place.cycle", serde_json::json!({"dir": dir})), "{key:?}"); + } + let _ = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + let _ = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 110.0, 60.0)); + assert_eq!(t.key(&cx, ToolKey::Right, Mods::default()), vec![Action::Handled]); } /// A place-cursor grid drag too thin for its cells shows and places the single frame an ordinary