Smart Filters: drag a filter up or down its stack to reorder it (#4897)
In Photoshop the order of smart filters matters (the bottom one is applied first: Unsharp Mask over a Gaussian Blur sharpens the blurred image, under it the sharpening gets blurred), and a filter row is dragged up or down the stack to change it. The engine already had `layer.smartFilter.move` and re-renders in the new order, but the Layers panel had no way to reach it: a filter row could only be dragged onto another smart object or onto Delete (#4271). Dragging a filter row over another row of its own stack now draws an insertion line above or below that row (the Smart Filters row: on top of the stack), and the release runs `layer.smartFilter.move`, one undo step. The moved filter ends up selected with its smart object active, as after a click. Drops next to the filter's current place, or on itself, do nothing. Tests: `smart_ui::tests::a_filter_dropped_above_or_below_a_row_lands_next_to_it` (the index math) and `smart_filter_rows_tests::dragging_a_filter_up_or_down_its_stack_reorders_it` (whole app: above, below, onto the Smart Filters row, selection, undo). Co-authored-by: abc2cb <271337822+abc2cb@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
33d9cb6ac4
commit
f4b3766b91
|
|
@ -163,6 +163,34 @@ fn a_camera_raw_filter_copies_and_its_copy_opens_on_the_target() {
|
|||
assert_eq!(dialog.layer, LayerId(b));
|
||||
}
|
||||
|
||||
/// Dragging a filter up or down its own stack reorders it (Photoshop), which changes the result:
|
||||
/// the bottom filter is applied first (`smart_cmds::tests::filter_stack_order_blend_disable_and_clear`). A drop on the upper half of a row lands above it, on the
|
||||
/// lower half below it, on the Smart Filters row on top; the moved filter stays selected.
|
||||
#[test]
|
||||
fn dragging_a_filter_up_or_down_its_stack_reorders_it() {
|
||||
let (mut h, a, _, _) = panel();
|
||||
let (blur, offset) = ("filter.blur.gaussianBlur", "filter.other.offset");
|
||||
let half = |p: Pos2, up: bool| p + egui::vec2(0.0, if up { -6.0 } else { 6.0 });
|
||||
// Gaussian Blur (bottom) onto the upper half of Offset's row: blur now on top.
|
||||
let (from, to) = (at(&h, "Gaussian Blur"), at(&h, "Offset"));
|
||||
drag(&mut h, from, half(to, true), Modifiers::NONE);
|
||||
assert_eq!(filters(&h, a), [offset, blur]);
|
||||
assert!(selected(&h, "Gaussian Blur"), "the moved filter stays selected");
|
||||
assert!(!h.state().ui.status_error, "{}", h.state().ui.status);
|
||||
// Its lower half: back under it.
|
||||
let (from, to) = (at(&h, "Gaussian Blur"), at(&h, "Offset"));
|
||||
drag(&mut h, from, half(to, false), Modifiers::NONE);
|
||||
assert_eq!(filters(&h, a), [blur, offset]);
|
||||
// The Smart Filters row: on top again.
|
||||
let (from, to) = (at(&h, "Gaussian Blur"), at(&h, "Smart Filters"));
|
||||
drag(&mut h, from, to, Modifiers::NONE);
|
||||
assert_eq!(filters(&h, a), [offset, blur]);
|
||||
// Each drag is one undo step.
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert_eq!(filters(&h, a), [blur, offset]);
|
||||
}
|
||||
|
||||
fn click_at(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, p: Pos2) {
|
||||
h.hover_at(p);
|
||||
h.run_steps(1);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! Layers-panel pieces for smart objects: the thumbnail badge and the Smart Filters sub-rows
|
||||
//! (eye toggles; double-click a filter to re-open its dialog with the recorded parameters; click
|
||||
//! to select one; drag onto another smart object to move it there, ⌥-drag to copy it, #4271).
|
||||
//! to select one; drag onto another smart object to move it there, ⌥-drag to copy it, #4271;
|
||||
//! drag it up or down its own stack to reorder it).
|
||||
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use photocraft_doc::{Document, Layer, LayerContent, LayerId, SmartObject};
|
||||
|
|
@ -87,6 +88,14 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth:
|
|||
if resp.drag_started() {
|
||||
ui.ctx().data_mut(|d| d.insert_temp::<FilterDrag>(drag_key(), (l.id.0, index)));
|
||||
}
|
||||
if let Some((to, action)) = reorder_drop(ui, l, sm.smart_filters.len(), index, rect) {
|
||||
// The moved filter ends up selected, with its smart object active, as after a click.
|
||||
ui.ctx().data_mut(|d| d.insert_temp::<FilterDrag>(selected_key(doc), (l.id.0, Some(to))));
|
||||
actions.push(action);
|
||||
if !active {
|
||||
actions.push(("layer.select".into(), json!({"layer": l.id.0})));
|
||||
}
|
||||
}
|
||||
// Selected as in Photoshop: the row stays highlighted while its smart object is active.
|
||||
let selected = active && picked == Some((l.id.0, index));
|
||||
if selected {
|
||||
|
|
@ -210,6 +219,53 @@ pub(crate) fn drop_on_row(ctx: &egui::Context, ui: &egui::Ui, l: &Layer, rect: R
|
|||
}
|
||||
}
|
||||
|
||||
/// Where `layer.smartFilter.move` puts filter `src` (stack index, 0 = bottom) dropped on the row
|
||||
/// of filter `row` (`None`: the Smart Filters row, i.e. on top of the stack), `above` or below
|
||||
/// it, in a stack of `n`. `None` when the filter would stay where it is.
|
||||
fn reorder_target(src: usize, row: Option<usize>, above: bool, n: usize) -> Option<usize> {
|
||||
let to = match row {
|
||||
None => n.checked_sub(1)?,
|
||||
Some(i) if i == src => return None,
|
||||
// Above a row is just over it in the stack, below a row just under it. `move` removes
|
||||
// the filter first, so the filters over `src` shift down by one.
|
||||
Some(i) if above => {
|
||||
if src < i {
|
||||
i
|
||||
} else {
|
||||
i + 1
|
||||
}
|
||||
}
|
||||
Some(i) => {
|
||||
if src < i {
|
||||
i - 1
|
||||
} else {
|
||||
i
|
||||
}
|
||||
}
|
||||
};
|
||||
(to < n && to != src).then_some(to)
|
||||
}
|
||||
|
||||
/// A filter dragged up or down its own smart object's stack (Photoshop): over another row of the
|
||||
/// stack a line marks where it lands, above or below that row (the Smart Filters row: on top),
|
||||
/// and the release moves it there. The bottom filter is applied first, so the order changes the
|
||||
/// result (Unsharp Mask over a blur sharpens the blurred image; under it, the sharpening is
|
||||
/// blurred). Returns the filter's new index and the command.
|
||||
fn reorder_drop(ui: &egui::Ui, l: &Layer, n: usize, row: Option<usize>, rect: Rect) -> Option<(usize, (String, Value))> {
|
||||
let ctx = ui.ctx();
|
||||
let (from, src) = ctx.data(|d| d.get_temp::<FilterDrag>(drag_key()))?;
|
||||
let (src, p) = (src?, ctx.input(|i| i.pointer.interact_pos())?);
|
||||
if from != l.id.0 || !rect.contains(p) {
|
||||
return None;
|
||||
}
|
||||
let above = p.y < rect.center().y;
|
||||
let to = reorder_target(src, row, above, n)?;
|
||||
let t = Tokens::get(ctx);
|
||||
let y = if above && row.is_some() { rect.top() } else { rect.bottom() };
|
||||
ui.painter().line_segment([pos2(rect.left() + 30.0, y), pos2(rect.right(), y)], Stroke::new(2.0, t.accent));
|
||||
ctx.input(|i| i.pointer.any_released()).then(|| (to, ("layer.smartFilter.move".into(), json!({"layer": l.id.0, "index": src, "to": to}))))
|
||||
}
|
||||
|
||||
/// Smart filters dropped on the footer's Delete button: a filter's row deletes that filter, the
|
||||
/// Smart Filters row clears them all.
|
||||
pub(crate) fn trash_action((layer, filter): FilterDrag) -> (String, Value) {
|
||||
|
|
@ -275,6 +331,25 @@ mod tests {
|
|||
[pixel, shape, smart].map(|id| doc.layer(photocraft_doc::LayerId(id)).unwrap().clone())
|
||||
}
|
||||
|
||||
/// Stack [A, B, C] (A at the bottom, drawn last): where a dragged filter lands.
|
||||
#[test]
|
||||
fn a_filter_dropped_above_or_below_a_row_lands_next_to_it() {
|
||||
// A over C: [B, C, A]. A under C: [B, A, C].
|
||||
assert_eq!(reorder_target(0, Some(2), true, 3), Some(2));
|
||||
assert_eq!(reorder_target(0, Some(2), false, 3), Some(1));
|
||||
// C under A: [C, A, B]. C over A: [A, C, B].
|
||||
assert_eq!(reorder_target(2, Some(0), false, 3), Some(0));
|
||||
assert_eq!(reorder_target(2, Some(0), true, 3), Some(1));
|
||||
// The Smart Filters row: on top.
|
||||
assert_eq!(reorder_target(0, None, false, 3), Some(2));
|
||||
// Next to where it already is, or on itself: nothing to do.
|
||||
assert_eq!(reorder_target(1, Some(2), false, 3), None);
|
||||
assert_eq!(reorder_target(1, Some(0), true, 3), None);
|
||||
assert_eq!(reorder_target(1, Some(1), true, 3), None);
|
||||
assert_eq!(reorder_target(2, None, true, 3), None);
|
||||
assert_eq!(reorder_target(0, None, true, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_layers_and_smart_objects_carry_a_thumbnail_badge() {
|
||||
let [pixel, shape, smart] = layers();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# UI parity: tools, handles, modifiers and feel
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the
|
||||
handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation,
|
||||
|
|
@ -211,6 +211,7 @@ Paragraph; the rest open as in-app floating windows.
|
|||
| Document tabs: overflow, context menu, reorder | done | UI-217-6; middle-click close #2570 |
|
||||
| Layers panel: wheel steps blend modes, drop on New/Trash | partial | UI-217-14 |
|
||||
| Smart filter rows: click selects, drag onto another smart object moves, ⌥-drag copies, drag onto Delete removes | done | `smart_ui.rs`, `layer.smartFilter.transfer` (#4271) |
|
||||
| Smart filter rows: drag up or down the stack to reorder (a line marks the drop; the bottom filter is applied first) | done | `smart_ui.rs` `reorder_drop`, `layer.smartFilter.move` |
|
||||
|
||||
## Revision history
|
||||
|
||||
|
|
@ -230,3 +231,4 @@ Paragraph; the rest open as in-app floating windows.
|
|||
| 2026-10-12 | minor | Toolbar tool shortcuts can be reassigned, removed and reset in Keyboard Shortcuts (#4086); capture, restart, group cycling, conflict and text-focus regressions |
|
||||
| 2026-10-12 | minor | Smart filter rows can be selected, and dragged (⌥: copied) onto another smart object or onto Delete (#4271) |
|
||||
| 2026-10-12 | minor | Free Transform options bar: H and V skew fields; a sheared box reads as skew instead of a taller H |
|
||||
| 2026-10-13 | minor | Smart filter rows can be dragged up or down their stack to reorder the filters |
|
||||
|
|
|
|||
Loading…
Reference in New Issue