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:
abc2cb 2026-10-13 19:18:42 +03:00 committed by GitHub
parent 33d9cb6ac4
commit f4b3766b91
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 107 additions and 2 deletions

View File

@ -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);

View File

@ -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();

View File

@ -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 |