Skip to content

Image Rotation, Unsharp Mask and High Pass, Align and Distribute, layer commands, Edit › Stroke - #2

Merged
itsjavi merged 8 commits into
mainfrom
feat/image-and-layer-commands
Oct 7, 2026
Merged

itsjavi merged 8 commits into
mainfrom
feat/image-and-layer-commands

Conversation

@itsjavi

@itsjavi itsjavi commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Summary

Five Photoshop staples ported from upstream, each adapted where the backlog asked:

  • Image › Image Rotation (90° clockwise, 90° counter clockwise, 180°), lossless, keeping effects, live text and shapes (TASK-14)
  • Filter › Unsharp Mask and High Pass (TASK-15)
  • Align and Distribute layers, measured by the pixels each layer shows rather than its box (TASK-16)
  • Layer commands: Apply Layer Mask, Merge Visible, Flatten Image, Copy/Paste/Clear Layer Style, Show/Hide All Other Layers, and intersecting a layer's pixels or mask with the selection (TASK-17)
  • Edit › Stroke along the selection's outline, on pixels or masks (by luminance) (TASK-19)

Each is one undo step. One commit per task, plus two small follow-ups (the Stroke sheet's slider ticks, and fitting Align and Distribute into the Move tool's bar) and the backlog records with screenshots.

Merge note: merge this after the upstream-fixes PR (TASK-8, effects dropped when a document is rebuilt from a snapshot). Rotation doesn't go through that rebuild (it turns the layers as they are), and a test checks that effects survive a turn, but TASK-14 depends on TASK-8 in the backlog.

Image › Image Rotation (TASK-14)

Ported from upstream PR robbietilton#195. Layer and mask pixels are turned whole with vImage, off the main thread, so nothing is resampled and four quarter turns give back the same bytes. The selection and guides turn too, and a quarter turn swaps the canvas's width and height. Live text and shapes keep their settings and turn by their angle, so they stay editable. Undo and redo of a size change now refit the view.

Rotation builds the turned document from the layers as they are, not from a project snapshot, so layer effects, text and shape settings carry over. Effects keep their angle, as Photoshop's Global Light does: a drop shadow still falls downward after the turn. New test: effectsLiveTextAndShapesSurviveATurn.

Filter › Unsharp Mask and High Pass (TASK-15)

Ported from upstream PR robbietilton#196 as-is. Unsharp Mask (Amount, Radius, Threshold) and High Pass (Radius) blur the layer with its edge held, then C kernels in AdjustPixels.c push each channel away from the blur, or keep only the detail around middle gray. They preview live in the existing filter panel and keep to the selection and the layer's size. The Filter menu lists them through FilterKind.allCases. New test: through the session with a selection, one undo step, same size and place, pixels outside the selection unchanged.

Align and Distribute (TASK-16)

Ported from upstream PR robbietilton#191, with one change. Upstream measured each layer's transform box, but Photoshop measures the pixels a layer shows, so a layer with transparent padding would line up by its padding. Each layer is now measured by its non-transparent pixels (brush_alpha_bounds), mapped through its transform, so flips and rotations count. A layer showing nothing is left alone. A hidden layer inside a selected folder moves with the folder but doesn't count toward its box. Moves are whole pixels.

Align works on edges or centers, against the selection, the canvas when one thing is selected, or the selected layers' combined box. Distribute works on centers or spacing, for three or more layers. Both are in the Layer menu (Align and Distribute submenus). In the Move tool's bar they're one pull-down, not upstream's ten icons: at a 1512 pt window (a 14" laptop) eight of the ten were scrolled out of sight past the bar's edge.

Layer commands (TASK-17)

Ported from commit aea6e20 of closed upstream PR robbietilton#163, taking only these commands. Ungroup Layers was already in the fork.

  • Apply Layer Mask bakes an enabled mask into the layer's pixels at their own resolution. It isn't offered for folders, adjustments, live text or shapes.
  • Merge Visible combines everything showing into one layer at the top of the visible stack and leaves hidden layers alone. It has no shortcut, because ⇧⌘E is Export PNG.
  • Flatten Image combines everything showing into one "Background" layer and drops hidden layers.
  • Copy, Paste and Clear Layer Style act on a layer's effects. The copied style is shared across the app, so it pastes into other documents too.
  • Show/Hide All Other Layers solos a layer and keeps the folders around it showing.
  • Intersect: a layer's pixels or mask can now be intersected with the selection, as well as added to or subtracted from it. Intersecting with nothing in common deselects.

All of these are in the Layer menu and in the Layers panel's right-click menu. A right-click on a thumbnail also offers Select / Add to / Subtract from / Intersect with Selection for its pixels or mask, and Cmd-Shift-Option-click on a thumbnail intersects, as in Photoshop. The original commit's separate menus for each part of a row are not ported; the fork keeps its one row menu and adds a selection section when the click lands on a thumbnail.

Edit › Stroke (TASK-19)

Ported from upstream PR robbietilton#190 (issue robbietilton#177). The sheet asks for width, foreground or background color, opacity, and Inside, Center or Outside, then draws a line along the selection's outline on the layer or its mask. The line isn't clipped to the selection, and only the tiles it crosses are touched. Upstream painted masks with the color's red channel; they now take its luminance (Rec. 709). The mask palette is black and white today, so nothing visible changes yet. Also dropped the Width slider's step, which on macOS drew a tick for every pixel under it (the binding already rounds).

Testing

All with swift build --disable-keychain --build-tests, then swift test --disable-keychain --skip-build --filter …:

  • SharpenFilterTests: 5 passed (4 ported, 1 new)
  • LayerAlignTests: 8 passed (5 ported, 3 new: padding and flips, empty layer, hidden layer in a folder)
  • LayerCommandTests|LayerTests|LayerMaskTests: passed (ported tests plus new context-menu, thumbnail-menu and mask-intersect tests). The filter also matched TiledLayerTests, AdjustmentEditorTests and AdjustmentLayerTests.
  • SelectionStrokeTests: 5 passed (3 ported, plus a mask stroke and luminance); SelectionEditTests (shares paintCanvas): 19 passed
  • CanvasRotationTests: 5 passed (4 ported, 1 new); CanvasSizeTests|ImageSizeTests|HistoryTests (the restore change): 14 passed
  • Final run of every touched suite, SharpenFilterTests|LayerAlignTests|LayerCommandTests|LayerTests|LayerMaskTests|SelectionStrokeTests|SelectionEditTests|CanvasRotationTests: 101 tests in 11 suites passed

The full suite was not run.

Screenshots

Document images were rendered offscreen through the app's export path, from sessions driven by the real commands (a throwaway test, not committed). Panels and sheets were rendered offscreen with NSHostingView. The Move tool bar shots are real windows: the baseline app built from main, and this branch's make dev build, each opened on the same fixture image. Fixtures are synthetic.

Image Rotation (TASK-14)

Fixture: a soft landscape, a card with Drop Shadow, an ellipse shape with Stroke, and live text "Rotate" with Stroke.

Before After Image › Image Rotation › 90° Clockwise

After: the canvas is 400 × 600, the shadow still falls downward, and the text and shape keep their strokes and stay live.

Unsharp Mask and High Pass (TASK-15)

Before (soft landscape fixture) After Unsharp Mask (180%, 2.5 px, 0 levels) After High Pass (4 px)

Detail, 3× crop, before and after Unsharp Mask:


The Unsharp Mask panel (new; rendered offscreen during a live filter edit):

Align and Distribute (TASK-16)

Align Left Edges on three layers, each clear except for one shape placed off-center in its box. Dashed lines show each layer's bounds; the pink line marks the aligned edge.

Before After Align › Left Edges

After: the shapes' left edges meet at x = 170 while their boxes don't. Measured by the transform box, as upstream did, the boxes would line up instead.

Move tool bar, real window with the fixture open. Before (main):

After (this branch): the Align and Distribute pull-down after Flip V:

Layer commands (TASK-17)

Layers panel (the real table, rendered offscreen) with a photo, Red, a hidden layer and Green.

Before After Merge Visible After Flatten Image (from the same start)

Merge Visible names the result after the topmost visible layer and leaves the hidden one alone; Flatten Image leaves one Background layer.

Edit › Stroke (TASK-19)

Before (elliptical selection drawn dashed) After Edit › Stroke, 8 px, Center, foreground red

The Stroke sheet (new; rendered offscreen):

Manual checks still needed

Menus and sheets over a window can't be captured here:

  • Image › Image Rotation submenu; run each turn on a real project, then undo
  • Filter › Unsharp Mask… and High Pass…: open the panel, drag the sliders, check the live preview, OK and Cancel
  • Layer › Align / Distribute submenus, and the Move tool bar's Align and Distribute pull-down (contents and enabled states)
  • Layer menu: Apply Layer Mask, Merge Visible, Flatten Image, Layer Style ▸ Copy/Paste/Clear, Hide/Show All Other Layers
  • Layers panel right-click on a row, on a layer thumbnail and on a mask thumbnail; Cmd-Shift-Option-click on a thumbnail to intersect
  • Edit › Stroke…: open the sheet over the window, OK and Cancel, on pixels and with a mask targeted

Upstream (robbietilton/Compositor):

itsjavi and others added 8 commits October 7, 2026 23:14
The editor had no sharpening filter. Unsharp Mask (Amount, Radius,
Threshold) and High Pass (Radius) are Photoshop's standard ones: each
blurs the layer with its edge held, then a C kernel in AdjustPixels.c
pushes every channel away from the blur, or keeps only the detail about
middle gray. Both preview live, keep to the selection and the layer's
size, and apply as one undo step through the existing filter panel.

Ported from upstream PR robbietilton#196, with a test that the selection limits
both filters and the layer keeps its size.

robbietilton#196

Co-authored-by: Rezaul Arif <rezaulkarimarif.ctG@gmail.com>
Lining layers up is a compositing staple the editor lacked. Selected
layers now align by their edges or centers to the selection, to the
canvas when one thing is selected, or to each other, and three or more
distribute by centers or spacing, from the Layer menu and the Move
tool's bar. A selected folder moves as one, moves are whole pixels and
each command is one undo step.

Upstream measured each layer's transform box. Photoshop measures the
pixels a layer shows, so a layer with transparent padding would line
up by its padding instead of its content. Each layer is measured by
its non-transparent pixels, mapped through its transform; a layer
showing nothing is left alone, and a hidden layer inside a selected
folder moves with it without counting toward its box.

Ported from upstream PR robbietilton#191, with tests for padded, flipped, empty
and hidden layers.

robbietilton#191

Co-authored-by: Rezaul Arif <rezaulkarimarif.ctG@gmail.com>
…yle commands

Upstream took the masks, Auto Select, tabs and Ungroup parts of PR robbietilton#163
but left out its layer commands to keep menus short. They are core
compositing commands Photoshop users reach for, so they come over now,
each one undo step:

- Apply Layer Mask bakes an enabled mask into the layer's own pixels at
  their resolution (not for folders, adjustments, live text or shapes).
- Merge Visible combines everything showing into one layer at the top
  of the visible stack and leaves hidden layers alone; Flatten Image
  combines it into one "Background" layer and drops hidden ones.
- Copy, Paste and Clear Layer Style act on a layer's effects, with one
  copied style for the whole app.
- Show/Hide All Other Layers solos a layer, keeping its folders showing.
- A layer's pixels or mask can be intersected with the selection, as
  well as added to and subtracted from it.

They are in the Layer menu and the Layers panel's right-click menu. A
right-click on a thumbnail also offers Select, Add, Subtract and
Intersect for its pixels or mask, and Cmd-Shift-Option-click on a
thumbnail intersects, as in Photoshop. The per-part right-click menus
of the original commit are not ported; the fork keeps one row menu.

Ported from commit aea6e20 of closed upstream PR robbietilton#163, with its tests
and new ones for the context menu and intersecting with a mask.

robbietilton#163

Co-authored-by: cheyras <38292020+cheyras@users.noreply.github.com>
Stroking a selection's outline is a standard Photoshop command the
editor lacked (upstream issue robbietilton#177). Edit › Stroke… asks for a width,
the foreground or background color, an opacity and Inside, Center or
Outside, then paints the line on the active layer or its mask as one
undo step. The line isn't clipped to the selection, and only the
tiles it passes through are touched, so a thin stroke round a large
selection stays cheap.

Upstream painted a mask with the color's red channel. A mask now takes
the color's luminance (Rec. 709 weights), so a color never paints as
its red channel alone; the mask palette is black and white today, so
nothing changes there yet.

Ported from upstream PR robbietilton#190, with tests for stroking a mask and for
the luminance.

robbietilton#177
robbietilton#190

Co-authored-by: Rezaul Arif <rezaulkarimarif.ctG@gmail.com>
The editor could flip the canvas but not turn it (upstream issue
robbietilton#212). Image › Image Rotation turns the whole document as one undo
step: layer pixels and masks are turned whole with vImage off the main
thread, so nothing is resampled and turning back gives the same bytes;
the selection and guides turn with them, and a quarter turn swaps the
canvas's width and height. Live text and shapes keep their settings
and turn by their angle, so they stay editable. Undo and redo of a
size change now refit the view.

Rotation rebuilds the document in its own code path, from the layers
as they are, not from a project snapshot, so it keeps layer effects
(dropped by snapshot rebuilds until the effects fix lands); a test
checks that effects, live text and shapes survive a turn.

Ported from upstream PR robbietilton#195.

robbietilton#212
robbietilton#195

Co-authored-by: Rezaul Arif <rezaulkarimarif.ctG@gmail.com>
The Width slider stepped by one pixel over 1–250, and on macOS a stepped slider draws a tick for every step, which showed as a dashed line under it. Its binding already rounds to whole pixels, so the step is dropped.
Ten icon buttons after Flip V ran past the edge of the bar's scroll view in a laptop-width window (1512 pt), leaving all but two scrolled out of sight with no indicator. One Align and Distribute pull-down, with the commands in two sections, fits beside Flip V; the Layer menu still lists them all.
Plans, notes, verification and final summaries for Image Rotation, Unsharp Mask and High Pass, Align and Distribute, the layer commands and Edit › Stroke, with before/after images in backlog/assets.
@itsjavi
itsjavi merged commit 2b5e982 into main Oct 7, 2026
1 check passed
@itsjavi
itsjavi deleted the feat/image-and-layer-commands branch October 7, 2026 22:16
itsjavi added a commit that referenced this pull request Oct 7, 2026
…shortcuts-and-workflow

Conflicts: README (custom crop ratios next to Image Rotation) and the Layer
menu (Show/Hide Layer keeps its assignable shortcut beside #2's Show/Hide All
Other Layers).

The menu commands #1 and #2 added had no way to take a key, which this PR
promises for every menu command, so they're now in More Menu Commands: File ›
Export As…, Edit › Stroke…, Image › Image Rotation's three items, and Layer ›
Apply Layer Mask, Show or Hide All Other Layers, Merge Visible, Flatten
Image, Align, Distribute and Layer Style. Unsharp Mask and High Pass were
already covered by the generated Filter list.
itsjavi added a commit that referenced this pull request Oct 8, 2026
…into feat/cli

Conflict: Package.swift, the app target's dependencies (LaminaAutomation from
this PR, libwebp from #1); both kept.

#1 replaced the JPEG-only export API (JPEGOptions, ImageExporter.jpeg) with
ExportOptions and encode(_:options:), so export-document's JPEG branch uses
those now. The command still offers PNG and JPEG; #1's other formats could
join it later.
itsjavi added a commit that referenced this pull request Oct 8, 2026
…into fix/upstream-fixes

Conflict: MaskTracing. This PR made tracing throw when an outline is too
detailed and reported that from Load Mask/Layer Selection; #2 moved the
tracing into two helpers shared with its new Intersect commands. The helpers
now throw, and all four commands report the error the same way (Load beeps
when there's nothing to trace, as before).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant