Place: arrow keys cycle the loaded images between drags (place.cycle)

This commit is contained in:
aiongg committed 2026-11-14 05:56:46 +00:00
1 parent 91f78b2db9
commit f024edb544
4 files changed
+59 -7

No files matched your search

+33
View File
@@ -432,6 +432,24 @@ pub(super) fn place_skip(s: &mut Session, _p: &Value) -> Result<Value> {
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<Value> {
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<usize> {
@@ -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()];
+2 -1
View File
@@ -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<CommandSpec> {
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();
+11 -2
View File
@@ -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<Action> {
fn key(&mut self, cx: &ToolContext, key: crate::ToolKey, _mods: Mods) -> Vec<Action> {
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<crate::Overlay> {
let Some(a) = self.start else { return vec![] };
+13 -4
View File
@@ -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