Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Compositor/CompositorApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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 },
Expand Down
12 changes: 11 additions & 1 deletion Compositor/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
45 changes: 45 additions & 0 deletions Compositor/Document/EditorSession+Navigator.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
52 changes: 52 additions & 0 deletions Compositor/Rendering/NavigatorGeometry.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
152 changes: 152 additions & 0 deletions Compositor/UI/NavigatorPanel.swift
Original file line number Diff line number Diff line change
@@ -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<Double> = 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<Double> = 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")
}
}
Loading