diff --git a/Compositor/CompositorApp.swift b/Compositor/CompositorApp.swift index 0c171105a..34d23524d 100644 --- a/Compositor/CompositorApp.swift +++ b/Compositor/CompositorApp.swift @@ -4,6 +4,7 @@ import Sparkle @main struct CompositorApp: App { @NSApplicationDelegateAdaptor(CompositorApplicationDelegate.self) private var applicationDelegate + @AppStorage("navigator.visible") private var showsNavigator = true private var session: EditorSession { applicationDelegate.session } var body: some Scene { Window("Compositor", id: "editor") { @@ -113,6 +114,7 @@ struct CompositorApp: App { if let preview = session.previewZoom { preview(.zoomOut) } else { session.zoomKeyboard(by: -1) } } .configuredKeyboardShortcut("-").disabled(session.document == nil) + Toggle("Navigator", isOn: $showsNavigator) Toggle("Pixel Grid (800% and above)", isOn: Binding(get: { session.showsPixelGrid }, set: { session.showsPixelGrid = $0 })) Toggle("Snap", isOn: Binding(get: { session.snappingEnabled }, diff --git a/Compositor/ContentView.swift b/Compositor/ContentView.swift index 4c551854e..5b63f4cf7 100644 --- a/Compositor/ContentView.swift +++ b/Compositor/ContentView.swift @@ -4,6 +4,8 @@ import UniformTypeIdentifiers struct ContentView: View { /// The Layers panel's width, remembered across launches. @AppStorage("layersPanelWidth") private var layersPanelWidth = 252.0 + @AppStorage("navigator.visible") private var showsNavigator = true + @State private var navigatorColumnHeight = Double.infinity @Bindable var session: EditorSession var applicationDelegate: CompositorApplicationDelegate? = nil @Environment(\.openWindow) private var openWindow @@ -109,7 +111,15 @@ struct ContentView: View { } } PanelResizeEdge(width: $layersPanelWidth, range: LayersPanel.widths) - LayersPanel(session: session, width: layersPanelWidth) + VStack(spacing: 0) { + if showsNavigator { + NavigatorPanel(session: session, columnHeight: navigatorColumnHeight) + Divider() + } + LayersPanel(session: session, width: layersPanelWidth) + } + .frame(width: layersPanelWidth) + .onGeometryChange(for: Double.self) { $0.size.height } action: { navigatorColumnHeight = $0 } } Divider() // Keeps its own height however short the window gets; the tools scroll instead. diff --git a/Compositor/Document/EditorSession+Navigator.swift b/Compositor/Document/EditorSession+Navigator.swift new file mode 100644 index 000000000..ad31716ba --- /dev/null +++ b/Compositor/Document/EditorSession+Navigator.swift @@ -0,0 +1,45 @@ +import CoreGraphics + +extension EditorSession { + /// The whole document for the Navigator, no more than `maxSide` pixels on its longer side (never enlarged). It is + /// the same live composite the Magic Wand samples, so adjustments, masks and clipping show as on the canvas. + /// + /// The document is placed at the picture's size rather than drawn full size into a scaled context: adjustment and + /// clipping surfaces take the size of what they draw, so a scaled context would still render them at full size. + /// A bare session draws it, so a transform or preview in progress here (whose placement is full size) stays out; + /// the picture shows the document as it is. + func navigatorImage(maxSide: Int) -> CGImage? { + guard let document, document.width > 0, document.height > 0, maxSide > 0 else { return nil } + let scale = min(1, CGFloat(maxSide) / CGFloat(max(document.width, document.height))) + let width = max(1, Int((CGFloat(document.width) * scale).rounded())) + let height = max(1, Int((CGFloat(document.height) * scale).rounded())) + guard let context = try? BrushRaster.context(width: width, height: height, mask: false) else { return nil } + context.interpolationQuality = .medium + let small = document.placed(scaleX: CGFloat(width) / CGFloat(document.width), + y: CGFloat(height) / CGFloat(document.height), width: width, height: height) + EditorSession().drawLiveComposite(small, in: context) + return context.makeImage() + } +} + +private extension CanvasDocument { + /// This document drawn `x` × `y` times its size into `width` × `height`: every layer, folder and mask placement + /// scaled, and blurs with them; the pixels untouched. + func placed(scaleX x: CGFloat, y: CGFloat, width: Int, height: Int) -> CanvasDocument { + func scaled(_ transform: LayerTransform) -> LayerTransform { + var result = transform + result.origin = CGPoint(x: transform.origin.x * x, y: transform.origin.y * y) + result.size = CGSize(width: transform.size.width * x, height: transform.size.height * y) + return result + } + return CanvasDocument(id: id, width: width, height: height, layers: layers.map { layer in + var small = layer + small.transform = scaled(layer.transform) + if let placement = layer.mask?.placement { small.mask?.placement = scaled(placement) } + // Blurs are measured in document pixels, so they shrink with everything else. + if let radius = layer.adjustment?.blurRadius { small.adjustment?.blurRadius = radius * x } + if let distance = layer.adjustment?.motionDistance { small.adjustment?.motionDistance = distance * x } + return small + }, resolution: resolution) + } +} diff --git a/Compositor/Rendering/NavigatorGeometry.swift b/Compositor/Rendering/NavigatorGeometry.swift new file mode 100644 index 000000000..6c2fb4cf1 --- /dev/null +++ b/Compositor/Rendering/NavigatorGeometry.swift @@ -0,0 +1,52 @@ +import CoreGraphics + +/// Where the whole document sits in the Navigator's box (fitted, centred, its proportions kept), and the +/// conversions between that picture and document pixels. +struct NavigatorGeometry: Equatable { + let documentSize: CGSize + let box: CGSize + + var imageRect: CGRect { + guard documentSize.width > 0, documentSize.height > 0, box.width > 0, box.height > 0 else { return .zero } + let scale = min(box.width / documentSize.width, box.height / documentSize.height) + let size = CGSize(width: documentSize.width * scale, height: documentSize.height * scale) + return CGRect(x: (box.width - size.width) / 2, y: (box.height - size.height) / 2, width: size.width, height: size.height) + } + + /// Box points per document pixel. + private var scale: CGFloat { documentSize.width > 0 ? imageRect.width / documentSize.width : 0 } + + func thumbnailRect(for documentRect: CGRect) -> CGRect { + let image = imageRect + return CGRect(x: image.minX + documentRect.minX * scale, y: image.minY + documentRect.minY * scale, + width: documentRect.width * scale, height: documentRect.height * scale) + } + + /// The document pixel under a point in the box, held to the document's edges. + func documentPoint(for point: CGPoint) -> CGPoint { + guard scale > 0 else { return .zero } + let image = imageRect + return CGPoint(x: min(max((point.x - image.minX) / scale, 0), documentSize.width), + y: min(max((point.y - image.minY) / scale, 0), documentSize.height)) + } +} + +extension CanvasViewport { + /// The part of the document the canvas shows, in document pixels. It reaches past the document's edges when the + /// canvas shows more than the document. + func visibleDocumentRect(documentSize: CGSize) -> CGRect { + let topLeft = documentPoint(from: .zero, documentSize: documentSize) + let bottomRight = documentPoint(from: CGPoint(x: viewSize.width, y: viewSize.height), documentSize: documentSize) + return CGRect(x: min(topLeft.x, bottomRight.x), y: min(topLeft.y, bottomRight.y), + width: abs(bottomRight.x - topLeft.x), height: abs(bottomRight.y - topLeft.y)) + } + + /// Scrolls so `point` (document pixels) is in the middle of the canvas, at the same zoom. Goes through + /// `translate(by:)` so the view stops following Fit, as any scroll the person makes does. + mutating func centerView(on point: CGPoint, documentSize: CGSize) { + let scale = pointsPerPixel + let target = CGSize(width: documentSize.width * scale / 2 - point.x * scale, + height: documentSize.height * scale / 2 - point.y * scale) + translate(by: CGSize(width: target.width - pan.width, height: target.height - pan.height)) + } +} diff --git a/Compositor/UI/NavigatorPanel.swift b/Compositor/UI/NavigatorPanel.swift new file mode 100644 index 000000000..871ec5885 --- /dev/null +++ b/Compositor/UI/NavigatorPanel.swift @@ -0,0 +1,152 @@ +import AppKit +import SwiftUI + +/// The Navigator, above the Layers panel: the whole document in small, with a red box around what the canvas shows. +/// Clicking or dragging in it moves the view there; its zoom row zooms the canvas. Its picture is redrawn a moment +/// after edits settle, and only while the panel is open. +struct NavigatorPanel: View { + @Bindable var session: EditorSession + /// The height of the column the Navigator shares with the Layers panel. + var columnHeight = Double.infinity + @AppStorage("navigator.expanded") private var expanded = true + @AppStorage("navigator.height") private var height = 160.0 + @State private var picture: CGImage? + @State private var dragStartHeight: Double? + + static let heights: ClosedRange = 90...420 + /// Pixels on the picture's longer side: sharp on a Retina display at the tallest height. + static let picturePixels = 900 + static let zooms: ClosedRange = 0.01...32 + + static func clampedHeight(_ value: Double) -> Double { min(heights.upperBound, max(heights.lowerBound, value)) } + /// What the rest of the column keeps below and around the picture: the Navigator's header and zoom row, and room + /// for the Layers panel to show its list. + static let reservedHeight = 380.0 + /// The picture's height in a column `column` points tall: the stored height, shortened so Layers keeps its room. + static func pictureHeight(_ stored: Double, column: Double) -> Double { + max(heights.lowerBound, min(clampedHeight(stored), column - reservedHeight)) + } + /// The slider moves in doublings (log2 of the zoom), so 1%–3200% spread evenly along it. + static func slider(forZoom zoom: Double) -> Double { log2(min(zooms.upperBound, max(zooms.lowerBound, zoom))) } + static func zoom(forSlider value: Double) -> Double { min(zooms.upperBound, max(zooms.lowerBound, pow(2, value))) } + + var body: some View { + VStack(spacing: 0) { + header + if expanded { + preview.frame(height: shownHeight) + zoomRow + resizeHandle + } + } + .task(id: RenderKey(document: session.document, revision: session.brushRevision, expanded: expanded)) { + guard expanded, session.document != nil else { picture = nil; return } + // Edits settle first, so a stroke or a drag redraws the picture once, afterwards. + try? await Task.sleep(for: .milliseconds(250)) + guard !Task.isCancelled else { return } + picture = session.navigatorImage(maxSide: Self.picturePixels) + } + } + + private var shownHeight: Double { Self.pictureHeight(height, column: columnHeight) } + + /// What the picture is drawn from. The layers rather than the whole document, so selecting, placing guides and + /// the like don't redraw it. + struct RenderKey: Equatable { + let documentID: UUID? + let size: CGSize + let layers: [ImageLayer] + let revision: Int + let expanded: Bool + + init(document: CanvasDocument?, revision: Int, expanded: Bool) { + documentID = document?.id + size = document?.size ?? .zero + layers = document?.layers ?? [] + self.revision = revision + self.expanded = expanded + } + } + + private var header: some View { + Button { expanded.toggle() } label: { + HStack(spacing: 6) { + Image(systemName: expanded ? "chevron.down" : "chevron.right") + .font(.system(size: 10, weight: .semibold)).frame(width: 12) + Text("Navigator").font(.system(size: 12, weight: .semibold)) + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.horizontal, 18).padding(.vertical, 10) + .accessibilityLabel(expanded ? "Collapse Navigator" : "Expand Navigator") + } + + private var preview: some View { + GeometryReader { proxy in + let geometry = NavigatorGeometry(documentSize: session.document?.size ?? .zero, box: proxy.size) + let image = geometry.imageRect + ZStack(alignment: .topLeading) { + Color(white: 0.11) + if let picture { + Image(decorative: picture, scale: 1).resizable().interpolation(.medium) + .frame(width: image.width, height: image.height) + .offset(x: image.minX, y: image.minY) + } + if let document = session.document { + let shown = geometry.thumbnailRect(for: session.viewport.visibleDocumentRect(documentSize: document.size)) + .intersection(image) + if !shown.isNull, !shown.isEmpty { + Rectangle().strokeBorder(Color.red, lineWidth: 1.5) + .frame(width: max(3, shown.width), height: max(3, shown.height)) + .offset(x: shown.minX, y: shown.minY) + .allowsHitTesting(false) + } + } else { + Text("No document").font(.caption).foregroundStyle(.secondary) + .frame(width: proxy.size.width, height: proxy.size.height) + } + } + .contentShape(Rectangle()) + .gesture(DragGesture(minimumDistance: 0).onChanged { value in + guard let document = session.document else { return } + session.viewport.centerView(on: geometry.documentPoint(for: value.location), documentSize: document.size) + }) + } + .clipped() + .accessibilityElement() + .accessibilityLabel("Navigator") + .accessibilityHint("Click or drag to move the view") + } + + private var zoomRow: some View { + HStack(spacing: 8) { + Button { session.zoomKeyboard(by: -1) } label: { Image(systemName: "minus.magnifyingglass") } + .help("Zoom out") + Slider(value: Binding(get: { Self.slider(forZoom: Double(session.viewport.zoom)) }, + set: { session.zoom(to: CGFloat(Self.zoom(forSlider: $0))) }), + in: Self.slider(forZoom: Self.zooms.lowerBound)...Self.slider(forZoom: Self.zooms.upperBound)) + Button { session.zoomKeyboard(by: 1) } label: { Image(systemName: "plus.magnifyingglass") } + .help("Zoom in") + Text(session.viewport.zoom, format: .percent.precision(.fractionLength(0...1))) + .font(.caption.monospacedDigit()).frame(width: 52, alignment: .trailing) + } + .buttonStyle(.borderless).controlSize(.small) + .padding(.horizontal, 12).padding(.vertical, 6) + .disabled(session.document == nil) + } + + /// Drag the bottom edge to make the picture taller or shorter. Measured on the screen, as the handle moves with + /// the edge it drags. + private var resizeHandle: some View { + Color.clear.frame(height: 6).contentShape(Rectangle()) + .pointerStyle(.rowResize) + .gesture(DragGesture(minimumDistance: 1, coordinateSpace: .global).onChanged { value in + let start = dragStartHeight ?? shownHeight + dragStartHeight = start + height = Self.clampedHeight(start + value.translation.height) + }.onEnded { _ in dragStartHeight = nil }) + .accessibilityLabel("Resize Navigator") + } +} diff --git a/CompositorTests/NavigatorTests.swift b/CompositorTests/NavigatorTests.swift new file mode 100644 index 000000000..76cda8eff --- /dev/null +++ b/CompositorTests/NavigatorTests.swift @@ -0,0 +1,153 @@ +import AppKit +import SwiftUI +import Testing +@testable import Compositor + +@MainActor +struct NavigatorTests { + @Test func documentFitsTheBoxCentred() { + let geometry = NavigatorGeometry(documentSize: CGSize(width: 400, height: 200), box: CGSize(width: 200, height: 200)) + #expect(geometry.imageRect == CGRect(x: 0, y: 50, width: 200, height: 100)) + #expect(geometry.thumbnailRect(for: CGRect(x: 100, y: 50, width: 200, height: 100)) == CGRect(x: 50, y: 75, width: 100, height: 50)) + #expect(geometry.documentPoint(for: CGPoint(x: 100, y: 100)) == CGPoint(x: 200, y: 100)) + // Points outside the picture land on the document's edge. + #expect(geometry.documentPoint(for: CGPoint(x: -20, y: 199)) == CGPoint(x: 0, y: 200)) + #expect(NavigatorGeometry(documentSize: .zero, box: CGSize(width: 10, height: 10)).imageRect == .zero) + } + + /// A 400 × 300 document in an 800 × 600 view at 1× backing. + private func viewport() -> CanvasViewport { + var viewport = CanvasViewport() + viewport.resize(to: CGSize(width: 800, height: 600), backingScale: 1, documentSize: CGSize(width: 400, height: 300)) + return viewport + } + + @Test func visibleRectFollowsZoomAndPan() { + let size = CGSize(width: 400, height: 300) + var viewport = viewport() + viewport.setZoom(4, anchoredAt: viewport.center, documentSize: size) + let visible = viewport.visibleDocumentRect(documentSize: size) + #expect(abs(visible.width - 200) < 0.001 && abs(visible.height - 150) < 0.001, "800 × 600 points at 4× show 200 × 150 pixels") + #expect(abs(visible.midX - 200) < 0.001 && abs(visible.midY - 150) < 0.001) + } + + @Test func centringKeepsZoomAndStopsFollowingFit() { + let size = CGSize(width: 400, height: 300) + var viewport = viewport() // still following Fit, at the fitted zoom (1.68) + let zoom = viewport.zoom + viewport.centerView(on: CGPoint(x: 50, y: 60), documentSize: size) + let visible = viewport.visibleDocumentRect(documentSize: size) + #expect(abs(visible.midX - 50) < 0.001 && abs(visible.midY - 60) < 0.001) + #expect(viewport.zoom == zoom) + // A window resize afterwards keeps the view where the person put it. Still following Fit, it would re-fit + // to about 2.01 here. + viewport.resize(to: CGSize(width: 1000, height: 700), backingScale: 1, documentSize: size) + #expect(viewport.zoom == zoom) + } + + private func paint(_ width: Int, _ height: Int) throws -> ImportedImage { + let context = try BrushRaster.context(width: width, height: height, mask: false) + context.setFillColor(CGColor(srgbRed: 1, green: 0, blue: 0, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + let image = try #require(context.makeImage()) + return ImportedImage(image: image, thumbnail: image, name: "Red") + } + + private func rgba(_ image: CGImage, _ x: Int, _ y: Int) throws -> [Int] { + let color = try #require(NSBitmapImageRep(cgImage: image).colorAt(x: x, y: y)) + return [color.redComponent, color.greenComponent, color.blueComponent, color.alphaComponent].map { Int(($0 * 255).rounded()) } + } + + @Test func pictureFitsTheLongerSideAndShowsTheComposite() throws { + let session = EditorSession() + #expect(session.navigatorImage(maxSide: 100) == nil, "nothing to draw without a document") + session.createDocument(width: 400, height: 200) + session.document?.layers.append(ImageLayer(asset: try paint(200, 200), origin: .zero)) + let picture = try #require(session.navigatorImage(maxSide: 100)) + #expect(picture.width == 100 && picture.height == 50) + #expect(try rgba(picture, 25, 25) == [255, 0, 0, 255]) + #expect(try rgba(picture, 75, 25)[3] == 0, "the empty half stays transparent") + // Adjustment layers apply, as on the canvas. + let red = try #require(session.document?.layers.last?.id) + session.selectLayers([red], primary: red) + session.addAdjustment(.invert) + let inverted = try #require(session.navigatorImage(maxSide: 100)) + #expect(try rgba(inverted, 25, 25) == [0, 255, 255, 255]) + } + + @Test func smallDocumentsAreNotEnlarged() throws { + let session = EditorSession() + session.createDocument(width: 40, height: 30) + let picture = try #require(session.navigatorImage(maxSide: 900)) + #expect(picture.width == 40 && picture.height == 30) + } + + @Test func heightStaysInRangeAndSliderIsLogarithmic() { + #expect(NavigatorPanel.clampedHeight(20) == 90 && NavigatorPanel.clampedHeight(999) == 420) + #expect(NavigatorPanel.clampedHeight(200) == 200) + #expect(abs(NavigatorPanel.zoom(forSlider: NavigatorPanel.slider(forZoom: 0.25)) - 0.25) < 0.0001) + #expect(abs(NavigatorPanel.slider(forZoom: 2) - 1) < 0.0001, "the slider moves in doublings: 2× is one step past 100%") + #expect(NavigatorPanel.zoom(forSlider: -100) == 0.01 && NavigatorPanel.zoom(forSlider: 100) == 32) + } + + @Test func panelLaysOutWithAndWithoutADocument() { + let session = EditorSession() + // Its own defaults, so what the person set in the app (collapsed, say) can't change the result. + let defaults = UserDefaults(suiteName: "NavigatorTests-\(UUID().uuidString)")! + let empty = NSHostingView(rootView: NavigatorPanel(session: session).frame(width: 252).defaultAppStorage(defaults)) + #expect(empty.fittingSize.width == 252) + session.createDocument(width: 400, height: 300) + let full = NSHostingView(rootView: NavigatorPanel(session: session).frame(width: 252).defaultAppStorage(defaults)) + #expect(full.fittingSize.height > 160, "the header, the 160 pt picture and the zoom row") + } + + /// Past the largest surface Compositor allocates (200 megapixels). Drawn at full size, the Invert surface can't be + /// made and the picture comes out empty; drawn at the picture's size it is cheap. + @Test func hugeDocumentsWithAdjustmentsStillShow() throws { + let session = EditorSession() + session.createDocument(width: 20_000, height: 12_000) + var layer = ImageLayer(asset: try paint(200, 120), origin: .zero) + layer.transform.size = CGSize(width: 20_000, height: 12_000) + session.document?.layers.append(layer) + session.selectLayers([layer.id], primary: layer.id) + session.addAdjustment(.invert) + let picture = try #require(session.navigatorImage(maxSide: 100)) + #expect(picture.width == 100 && picture.height == 60) + #expect(try rgba(picture, 50, 30) == [0, 255, 255, 255]) + } + + /// A blur measured in document pixels shrinks with the picture, so it looks as it does on the canvas. + @Test func blursShrinkWithThePicture() throws { + let session = EditorSession() + session.createDocument(width: 2000, height: 2000) + var layer = ImageLayer(asset: try paint(100, 200), origin: .zero) + layer.transform.size = CGSize(width: 1000, height: 2000) + session.document?.layers.append(layer) + session.selectLayers([layer.id], primary: layer.id) + session.addAdjustment(.gaussianBlur) + let blur = try #require(session.document?.layers.lastIndex { $0.adjustment != nil }) + session.document?.layers[blur].adjustment?.blurRadius = 100 + let picture = try #require(session.navigatorImage(maxSide: 100)) + // 5 blur radii from every edge at the picture's scale: solid red. With the radius left at 100 it would be + // half blended with the empty half. + #expect(try rgba(picture, 25, 50)[3] >= 250) + } + + @Test func selectionChangesDoNotRedrawThePicture() throws { + var document = CanvasDocument(width: 100, height: 100, layers: [ImageLayer(asset: try paint(10, 10), origin: .zero)]) + let key = NavigatorPanel.RenderKey(document: document, revision: 0, expanded: true) + document.selection = DocumentSelection(path: CGPath(rect: CGRect(x: 0, y: 0, width: 5, height: 5), transform: nil), + antialiased: true, feather: 0) + #expect(NavigatorPanel.RenderKey(document: document, revision: 0, expanded: true) == key) + document.layers[0].isVisible = false + #expect(NavigatorPanel.RenderKey(document: document, revision: 0, expanded: true) != key) + } + + /// The picture gives way so the Layers panel keeps its room, however short the window. + @Test func pictureLeavesRoomForLayers() { + #expect(NavigatorPanel.pictureHeight(160, column: .infinity) == 160) + #expect(NavigatorPanel.pictureHeight(420, column: 500) == 500 - NavigatorPanel.reservedHeight) + #expect(NavigatorPanel.pictureHeight(300, column: 200) == 90, "never smaller than the shortest height") + #expect(NavigatorPanel.pictureHeight(999, column: .infinity) == 420) + } +} diff --git a/README.md b/README.md index 9540063cf..54195c2f8 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ brew install --cask robbietilton-compositor - Rulers (⌘R), guides dragged from them, a layout grid with adjustable spacing and subdivisions, and Snap To for guides, grid, layers and document bounds - Crop with snapping, ratios including 3:4 and 9:16, and Option for symmetric cropping; with a selection, the crop starts at it - Canvas Size, Image Size and Trim +- A Navigator above the Layers panel: the whole document in small with a red box around the view; click or drag to move there, and zoom from its slider - Sharp high-quality downsampling when zoomed out, and a pixel grid when zoomed in - Import JPEG, PNG, HEIC, TIFF, SVG, camera RAW (with a develop step first) and Photoshop PSD and PSB (8-bit RGB; not CMYK). Photoshop folders, masks, blend modes, fill rectangles/ellipses, and simple horizontal text stay editable; other vectors and vertical text become pixels. A conversion report is shown before anything is applied. - Large documents: the memory budget scales with your Mac, and a Photoshop file too big to open has its layers cropped to the canvas instead