mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-29 18:52:15 +01:00
Engine (scripts, the control channel and agents can drive it):
- `select.float {"dx","dy"}`: cuts the selected pixels of the active layer
into a floating piece the first time, then moves it by whole pixels.
The document is unchanged while it floats (`DocState::floating`);
`float_cmds::displayed` gives the document with the piece at its offset.
- `select.drop`: drops the piece into its layer and moves the selection
with it, one history step ("Move Selected Pixels").
- Any other command drops a floating piece first (a hook at the top of
dispatch, so it lands where it was shown); Undo puts it back instead.
- The layer is split once, only inside the selection, and the piece is
cropped to it: placing it costs the piece, not the layer (6000×4000
layer, 500×400 piece: 18 ms to cut, under 5 ms per move).
Canvas (Marquee / Lasso):
- A drag inside the ants moves the outline (`select.transformSelection`);
a click inside without moving deselects; ⇧ / ⌥ drags still draw.
- ⌘-drag floats the selected pixels and shows them at the pointer live;
plain drags on the piece move it again; a press elsewhere drops it.
- A move cursor over the ants; the ants follow the dragged / floating piece.
Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
214 lines
8.8 KiB
Rust
214 lines
8.8 KiB
Rust
//! Floating selection: the selected pixels of a layer cut out and moved as a piece, without
|
|
//! touching the document until the piece is dropped.
|
|
//!
|
|
//! - `select.float {"dx","dy"}` cuts the selected pixels of the active layer into a floating piece
|
|
//! (the first time) and moves it by whole pixels. Further calls move the same piece; nothing new
|
|
//! is cut.
|
|
//! - `select.drop` drops the piece into its layer and moves the selection with it, as one history
|
|
//! step.
|
|
//! - Any other command drops the piece first (so it lands exactly where it was shown), except Undo,
|
|
//! which puts it back where it was cut (nothing was changed yet).
|
|
//!
|
|
//! While floating, the document is unchanged; [`displayed`] gives the document with the piece at
|
|
//! its offset, for the canvas. The selection stays where it was until the drop.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use photocraft_color::PixelFormat;
|
|
use photocraft_doc::{Document, LayerContent, LayerId};
|
|
use photocraft_raster::Surface;
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::commands::CommandSpec;
|
|
use crate::{DocState, EngineError, Result, Session};
|
|
|
|
/// A layer split by its selection: the layer with the selected pixels cut out, and the cut pixels
|
|
/// (cropped to the selection). Splitting touches only the selection's area, and placing the piece
|
|
/// ([`CutParts::moved`]) only the piece's: the cut-out layer's other tiles are shared.
|
|
#[derive(Clone, Debug)]
|
|
pub struct CutParts {
|
|
pub layer: LayerId,
|
|
rest: Surface,
|
|
piece: Surface,
|
|
}
|
|
|
|
impl CutParts {
|
|
/// Split `layer` of `doc` by its selection. Errors without a selection or pixels.
|
|
pub fn new(doc: &Document, layer: LayerId) -> Result<Self> {
|
|
let sel = doc.selection.as_ref().ok_or_else(|| EngineError::Other("no selection".into()))?;
|
|
let l = doc.layer(layer).ok_or(EngineError::NoLayer(layer))?;
|
|
let LayerContent::Raster(surf) = &l.content else {
|
|
return Err(EngineError::Other("the layer has no pixels to move".into()));
|
|
};
|
|
let fmt = surf.format();
|
|
let with_alpha = PixelFormat::new(fmt.mode, fmt.sample, true);
|
|
let mut rest = if fmt == with_alpha { surf.clone() } else { surf.convert(with_alpha) };
|
|
let mut piece = Surface::new(with_alpha);
|
|
let b = sel.content_bounds().intersect(&surf.content_bounds());
|
|
if !b.is_empty() {
|
|
let n = with_alpha.channels();
|
|
let a = n - 1;
|
|
let mut rp = rest.read_region(b);
|
|
let mut pp = rp.clone();
|
|
let w = b.width() as usize;
|
|
for (i, (p, r)) in pp.chunks_exact_mut(n).zip(rp.chunks_exact_mut(n)).enumerate() {
|
|
let k = sel.sample_channel(b.x0 + (i % w) as i32, b.y0 + (i / w) as i32, 0);
|
|
if k <= 0.0 {
|
|
p.fill(0.0);
|
|
} else {
|
|
p[a] *= k;
|
|
}
|
|
r[a] *= 1.0 - k;
|
|
}
|
|
rest.write_region(b, &rp);
|
|
rest.prune();
|
|
piece.write_region(b, &pp);
|
|
piece.prune();
|
|
}
|
|
Ok(Self { layer, rest, piece })
|
|
}
|
|
|
|
/// `doc` with the piece moved by whole pixels (dx, dy) over the cut-out layer.
|
|
pub fn moved(&self, doc: &Document, dx: i32, dy: i32) -> Result<Document> {
|
|
let mut d = doc.clone();
|
|
let surf = d
|
|
.layer_mut(self.layer)
|
|
.ok_or(EngineError::NoLayer(self.layer))?
|
|
.surface_mut()
|
|
.ok_or_else(|| EngineError::Other("the layer has no pixels to move".into()))?;
|
|
let mut out = self.rest.clone();
|
|
crate::transform_cmds::composite_over(&mut out, &photocraft_algo::resample::translate_surface(&self.piece, dx, dy));
|
|
*surf = out;
|
|
Ok(d)
|
|
}
|
|
}
|
|
|
|
/// A piece floating `offset` whole pixels from where it was cut, over document `revision`.
|
|
#[derive(Clone, Debug)]
|
|
pub struct Floating {
|
|
pub layer: LayerId,
|
|
pub offset: (i32, i32),
|
|
pub revision: u64,
|
|
pub parts: Arc<CutParts>,
|
|
}
|
|
|
|
/// The floating piece of `st`, if it is still valid (the document hasn't changed under it).
|
|
pub fn floating(st: &DocState) -> Option<&Floating> {
|
|
st.floating.as_ref().filter(|f| f.revision == st.revision && st.doc.selection.is_some())
|
|
}
|
|
|
|
/// The document as shown while a piece floats `extra` further than its offset (a drag of it in
|
|
/// progress); `None` when nothing floats.
|
|
pub fn displayed(st: &DocState, extra: (i32, i32)) -> Option<Document> {
|
|
let f = floating(st)?;
|
|
f.parts.moved(&st.doc, f.offset.0 + extra.0, f.offset.1 + extra.1).ok()
|
|
}
|
|
|
|
fn int_param(p: &Value, key: &str) -> i32 {
|
|
p.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()).map_or(0, |v| v.round().clamp(-1e7, 1e7) as i32)
|
|
}
|
|
|
|
fn can_float(s: &Session) -> std::result::Result<(), String> {
|
|
let st = s.active().ok_or("no document")?;
|
|
if floating(st).is_some() {
|
|
return Ok(());
|
|
}
|
|
if st.doc.selection.is_none() {
|
|
return Err("there is no selection".into());
|
|
}
|
|
let l = st.active_layer.and_then(|id| st.doc.layer(id)).ok_or("no active layer")?;
|
|
if !matches!(l.content, LayerContent::Raster(_)) {
|
|
return Err("the active layer has no pixels to move".into());
|
|
}
|
|
if l.locks.all || l.locks.position {
|
|
return Err(format!("layer \"{}\" is locked", l.name));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn float(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let (dx, dy) = (int_param(p, "dx"), int_param(p, "dy"));
|
|
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
|
|
if floating(st).is_none() {
|
|
let layer = st.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
|
|
let parts = CutParts::new(&st.doc, layer)?;
|
|
st.floating = Some(Floating { layer, offset: (0, 0), revision: st.revision, parts: Arc::new(parts) });
|
|
}
|
|
let f = st.floating.as_mut().ok_or(EngineError::NoDocument)?;
|
|
f.offset = (f.offset.0.saturating_add(dx), f.offset.1.saturating_add(dy));
|
|
Ok(json!({"layer": f.layer.0, "offset": [f.offset.0, f.offset.1]}))
|
|
}
|
|
|
|
/// Drop the floating piece into its layer and move the selection with it (one history step).
|
|
pub fn drop_floating(s: &mut Session) -> Result<Value> {
|
|
let Some(st) = s.active_mut() else { return Ok(json!({"dropped": false})) };
|
|
let Some(f) = floating(st).cloned() else {
|
|
st.floating = None;
|
|
return Ok(json!({"dropped": false}));
|
|
};
|
|
st.floating = None;
|
|
let (dx, dy) = f.offset;
|
|
if (dx, dy) == (0, 0) {
|
|
return Ok(json!({"dropped": true, "offset": [0, 0]}));
|
|
}
|
|
s.edit("Move Selected Pixels", |doc, _| {
|
|
let moved = f.parts.moved(doc, dx, dy)?;
|
|
let surf = moved.layer(f.layer).and_then(|l| l.surface()).cloned().ok_or(EngineError::NoLayer(f.layer))?;
|
|
*doc.layer_mut(f.layer).ok_or(EngineError::NoLayer(f.layer))?.surface_mut().ok_or(EngineError::NoLayer(f.layer))? = surf;
|
|
doc.selection = doc.selection.as_ref().map(|sel| photocraft_algo::resample::translate_surface(sel, dx, dy));
|
|
Ok(())
|
|
})?;
|
|
Ok(json!({"dropped": true, "offset": [dx, dy]}))
|
|
}
|
|
|
|
/// Commands that leave a floating piece floating: its own, and ones that don't touch documents.
|
|
fn keeps_floating(id: &str) -> bool {
|
|
matches!(id, "select.float" | "select.drop") || ["view.", "window.", "help.", "prefs."].iter().any(|p| id.starts_with(p))
|
|
}
|
|
|
|
/// Before any command (`Session::dispatch`): Undo puts a floating piece back (and is used up:
|
|
/// returns its result); every other command but the floating ones drops it first.
|
|
pub(crate) fn before_command(s: &mut Session, id: &str) -> Result<Option<Value>> {
|
|
let Some(st) = s.active_mut() else { return Ok(None) };
|
|
if floating(st).is_none() {
|
|
st.floating = None;
|
|
return Ok(None);
|
|
}
|
|
match id {
|
|
"edit.undo" | "edit.stepBackward" => {
|
|
st.floating = None;
|
|
Ok(Some(json!({"floating": "returned"})))
|
|
}
|
|
_ if keeps_floating(id) => Ok(None),
|
|
_ => drop_floating(s).map(|_| None),
|
|
}
|
|
}
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
CommandSpec {
|
|
id: "select.float",
|
|
label: "Float Selection",
|
|
menu: &[],
|
|
shortcut: None,
|
|
params: r##"{"dx":px=0,"dy":px=0} → {layer, offset} (cuts the selected pixels of the active layer into a floating piece the first time, then moves it by whole pixels; dropped by select.drop or any other command, put back by edit.undo)"##,
|
|
enabled: can_float,
|
|
journal: true,
|
|
run: float,
|
|
},
|
|
CommandSpec {
|
|
id: "select.drop",
|
|
label: "Drop Floating Selection",
|
|
menu: &[],
|
|
shortcut: None,
|
|
params: r##"{} → {dropped, offset} (drops the floating piece into its layer and moves the selection with it: one history step)"##,
|
|
enabled: |s| s.active().map(|_| ()).ok_or_else(|| "no document".into()),
|
|
journal: true,
|
|
run: |s, _| drop_floating(s),
|
|
},
|
|
]
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|