diff --git a/Compositor/CompositorApp.swift b/Compositor/CompositorApp.swift index 0c171105a..bebe91829 100644 --- a/Compositor/CompositorApp.swift +++ b/Compositor/CompositorApp.swift @@ -281,6 +281,11 @@ struct CompositorApp: App { } } CommandMenu("Filter") { + Button(session.lastFilter.map { "Last Filter: " + $0.rawValue } ?? "Last Filter") { + Task { await session.repeatLastFilter() } + } + .configuredKeyboardShortcut("f").disabled(!session.canRepeatLastFilter) + Divider() ForEach(FilterKind.allCases.filter { $0 != .contentAwareFill && !$0.isImageAdjustment }, id: \.self) { kind in Button("\(kind.rawValue)…") { session.beginFilter(kind) } .disabled(!(kind == .vignette ? session.canVignette : session.canAdjustColors) || session.hueSaturation != nil) diff --git a/Compositor/ContentView.swift b/Compositor/ContentView.swift index 4c551854e..441ee078c 100644 --- a/Compositor/ContentView.swift +++ b/Compositor/ContentView.swift @@ -246,7 +246,8 @@ struct ContentView: View { content: SelectionAmountSheet(session: session, operation: operation)) } else { selectionAmountPanel.close() } } - .onChange(of: session.filterEdit == nil) { _, closed in + // Last Filter applies without the panel. + .onChange(of: session.filterEdit == nil || session.filterEdit?.repeating == true) { _, closed in if closed { filterPanel.close() } else { filterPanel.onClose = { session.cancelFilter() } diff --git a/Compositor/Document/EditorSession.swift b/Compositor/Document/EditorSession.swift index 5681cf63b..a09e83ac0 100644 --- a/Compositor/Document/EditorSession.swift +++ b/Compositor/Document/EditorSession.swift @@ -253,6 +253,8 @@ final class EditorSession { /// The open filter (Filter menu), and the settings the next one starts from. var filterEdit: FilterEdit? var filterSettings = FilterSettings() + /// What Filter › Last Filter runs again. + var lastFilter: FilterKind? @ObservationIgnored var hueSaturationTask: Task? /// The newest preview request while one is already rendering. @ObservationIgnored var hueSaturationPending: HueSaturationJob? diff --git a/Compositor/Document/Filters.swift b/Compositor/Document/Filters.swift index ecc53cad9..cc3395eda 100644 --- a/Compositor/Document/Filters.swift +++ b/Compositor/Document/Filters.swift @@ -23,6 +23,8 @@ nonisolated enum FilterKind: String, CaseIterable, Sendable { case blackWhite = "Black & White" case colorBalance = "Color Balance" var isAutomatic: Bool { self == .contentAwareFill || self == .removeBackground } + /// The Filter menu's own filters, which Last Filter can run again; not Content-Aware Fill or the Image menu's. + var repeatsAsLastFilter: Bool { self != .contentAwareFill && !isImageAdjustment } /// Color adjustments: in the Image menu (and editable as adjustment layers), not under Filter. var isImageAdjustment: Bool { self == .curves || self == .exposure || self == .gradientMap || self == .grain @@ -273,6 +275,8 @@ nonisolated enum PixelFilter { @Observable final class FilterEdit { let kind: FilterKind + /// Filter › Last Filter, applied straight away with the settings it had. + var repeating = false let layerID: UUID let original: ImportedImage let transform: LayerTransform @@ -476,7 +480,8 @@ extension EditorSession { var canContentAwareFill: Bool { canAdjustColors && !isMaskSelected && selection?.isEmpty == false && filterEdit == nil && hueSaturation == nil } - func beginFilter(_ kind: FilterKind) { + /// `repeating` is Last Filter: the settings go on as they are, without a preview or the panel. + func beginFilter(_ kind: FilterKind, repeating: Bool = false) { if kind == .contentAwareFill && !canContentAwareFill { return } guard filterEdit == nil, hueSaturation == nil, kind == .vignette ? canVignette : canAdjustColors else { NSSound.beep(); return } if gradientEdit != nil { @@ -508,11 +513,28 @@ extension EditorSession { let edit = try FilterEdit(kind: kind, layer: layer, selection: selection?.clip(canvas: document.size), settings: settings, growingTo: area) if fillsCanvas { edit.canvas = canvas } edit.startedEmpty = startedEmpty + edit.repeating = repeating filterEdit = edit - updateFilter(edit.settings, preview: true) + // An automatic filter commits what its preview made, so it still needs one. + updateFilter(edit.settings, preview: !repeating || kind.isAutomatic) } catch { brushError = error.localizedDescription } } + /// Filter › Last Filter (⌘F): the last filter applied, again, with the same settings and no panel, as in Photoshop. + var canRepeatLastFilter: Bool { + guard let lastFilter, filterEdit == nil, hueSaturation == nil else { return false } + return lastFilter == .vignette ? canVignette : canAdjustColors + } + func repeatLastFilter() async { + guard let kind = lastFilter, canRepeatLastFilter else { NSSound.beep(); return } + if gradientEdit != nil { await commitGradient() } + beginFilter(kind, repeating: true) + guard let edit = filterEdit, edit.repeating else { return } + await commitFilter() + // Nothing to apply (a zero amount), or it couldn't be: don't leave it open with no panel to close it. + if filterEdit === edit { cancelFilter() } + } + func updateFilter(_ settings: FilterSettings, preview: Bool) { guard let edit = filterEdit, !edit.committing else { return } edit.settings = settings.normalized @@ -611,6 +633,7 @@ extension EditorSession { edit.committing = true edit.previewTask?.cancel() if edit.kind != .cameraRaw { filterSettings = edit.settings } + if edit.kind.repeatsAsLastFilter { lastFilter = edit.kind } isProjectBusy = true // The preview stays up until the result is on the layer, so the canvas never flashes the original. defer { filterEdit = nil; isProjectBusy = false; brushRevision += 1 } diff --git a/Compositor/UI/KeyboardShortcuts.swift b/Compositor/UI/KeyboardShortcuts.swift index 8a844a5a1..a39bf2c94 100644 --- a/Compositor/UI/KeyboardShortcuts.swift +++ b/Compositor/UI/KeyboardShortcuts.swift @@ -85,7 +85,7 @@ struct ShortcutDefinition: Identifiable { entry("Fill with Background", "\u{7f}", 1, menu: true), entry("Content-Aware Fill", "\u{7f}", 8, menu: true), entry("Select All", "a", 1, menu: true), entry("Deselect", "d", 1, menu: true), entry("Inverse Selection", "i", 9, menu: true), entry("Select Subject", "a", 3, menu: true), - entry("Curves", "m", 1, menu: true), entry("Levels", "l", 1, menu: true), + entry("Last Filter", "f", 1, menu: true), entry("Curves", "m", 1, menu: true), entry("Levels", "l", 1, menu: true), entry("Hue/Saturation", "u", 1, menu: true), entry("Invert Pixels / Mask", "i", 1, menu: true), entry("Canvas Size", "c", 3, menu: true), entry("Image Size", "i", 3, menu: true), entry("Transform Layer / Selection", "t", 1, menu: true), entry("Duplicate / Layer via Copy", "j", 1, menu: true), diff --git a/CompositorTests/LastFilterTests.swift b/CompositorTests/LastFilterTests.swift new file mode 100644 index 000000000..63265c72b --- /dev/null +++ b/CompositorTests/LastFilterTests.swift @@ -0,0 +1,69 @@ +import AppKit +import Testing +@testable import Compositor + +@MainActor +struct LastFilterTests { + private func session() throws -> EditorSession { + let session = EditorSession() + session.createDocument(width: 40, height: 20) + let context = try BrushRaster.context(width: 40, height: 20, mask: false) + context.setFillColor(CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: 20, height: 20)) + let image = try #require(context.makeImage()) + session.insert(ImportedImage(image: image, thumbnail: image, name: "Half")) + return session + } + + @Test func runsTheLastFilterAgainWithItsSettingsAsOneUndoStep() async throws { + let session = try session() + #expect(!session.canRepeatLastFilter) + session.beginFilter(.gaussianBlur) + session.updateFilter(FilterSettings(radius: 3), preview: true) + await session.commitFilter() + let once = try #require(session.activeLayer) + #expect(session.lastFilter == .gaussianBlur && session.canRepeatLastFilter) + + let count = session.history.undoCount + await session.repeatLastFilter() + let twice = try #require(session.activeLayer) + #expect(session.filterEdit == nil && session.history.undoCount == count + 1) + #expect(twice.asset?.image !== once.asset?.image) + // Blurred again by 3, it spreads another 3 × 3 past the edge the first blur left. + #expect(twice.transform.size.width > once.transform.size.width) + session.undo() + #expect(session.activeLayer?.asset?.image === once.asset?.image) + } + + @Test func aCancelledFilterOrAnImageAdjustmentIsNotTheLastFilter() async throws { + let session = try session() + session.beginFilter(.addNoise) + await session.commitFilter() + session.beginFilter(.gaussianBlur) + session.cancelFilter() + session.beginFilter(.curves) + session.cancelFilter() + #expect(session.lastFilter == .addNoise) + // Nothing to do (no distortion to remove) closes without an undo step and without leaving it open. + session.lastFilter = .lensCorrection + let count = session.history.undoCount + await session.repeatLastFilter() + #expect(session.filterEdit == nil && session.history.undoCount == count) + } + + /// Every menu shortcut belongs to one command: two share one only by accident, and then the first menu wins. + @Test func noTwoMenuShortcutsAreTheSame() { + let chords = ShortcutDefinition.all.filter(\.isMenu).map(\.original) + #expect(Set(chords).count == chords.count) + } + + /// ⌘F in the app's real menu bar: Last Filter, and nothing else (a system Find or Full Screen would win). + @Test func commandFIsLastFilterAlone() throws { + let menu = try #require(NSApp.mainMenu) + func items(_ menu: NSMenu) -> [NSMenuItem] { menu.items.flatMap { [$0] + ($0.submenu.map(items) ?? []) } } + let commandF = items(menu).filter { + $0.keyEquivalent == "f" && $0.keyEquivalentModifierMask.intersection(.deviceIndependentFlagsMask) == .command + } + #expect(commandF.map(\.title) == ["Last Filter"]) + } +} diff --git a/README.md b/README.md index 9540063cf..3f98deaa1 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ brew install --cask robbietilton-compositor - Gaussian Blur and Motion Blur that spread past a layer's edges - Add Noise, Vignette, Bloom / Glow, Tonal Contrast, Lens Correction and Remove Background - Live previews, limited to the selection when there is one +- Last Filter (⌘F) runs the last filter again with the same settings ### Canvas and files - Multiple projects in tabs