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
3 changes: 3 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("toolRail.columns") private var toolRailColumns = 1
private var session: EditorSession { applicationDelegate.session }
var body: some Scene {
Window("Compositor", id: "editor") {
Expand Down Expand Up @@ -113,6 +114,8 @@ struct CompositorApp: App {
if let preview = session.previewZoom { preview(.zoomOut) } else { session.zoomKeyboard(by: -1) }
}
.configuredKeyboardShortcut("-").disabled(session.document == nil)
Toggle("Tools in Two Columns", isOn: Binding(get: { toolRailColumns >= 2 },
set: { toolRailColumns = $0 ? 2 : 1 }))
Toggle("Pixel Grid (800% and above)", isOn: Binding(get: { session.showsPixelGrid },
set: { session.showsPixelGrid = $0 }))
Toggle("Snap", isOn: Binding(get: { session.snappingEnabled },
Expand Down
38 changes: 3 additions & 35 deletions 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
/// One column of tools or two, remembered across launches.
@AppStorage("toolRail.columns") private var toolRailColumns = 1
@Bindable var session: EditorSession
var applicationDelegate: CompositorApplicationDelegate? = nil
@Environment(\.openWindow) private var openWindow
Expand Down Expand Up @@ -80,7 +82,7 @@ struct ContentView: View {
VStack(spacing: 0) {
toolHeaders
HStack(spacing: 0) {
toolRail
ToolRail(session: session, columns: $toolRailColumns)
Divider()
VStack(spacing: 0) {
if session.showsRulers, session.document != nil {
Expand Down Expand Up @@ -284,40 +286,6 @@ struct ContentView: View {
if let applicationDelegate { Task { await applicationDelegate.projects.newCanvas() } }
else { session.clearProject() }
}
private var toolRail: some View {
// Scrolls when the window is too short for every tool, rather than pushing the bars above and below away.
IndicatorlessScrollView {
VStack(spacing: 10) {
ForEach(NavigationTool.allCases.filter { $0 != .idle }, id: \.self) { tool in
Button { session.selectTool(tool) } label: {
Group {
if tool == .gradient { GradientToolIcon().frame(width: 18, height: 18) }
else if tool == .cloneStamp { CloneStampToolIcon().frame(width: 18, height: 18) }
else if tool == .lasso, session.lassoKind == .polygonal { PolygonalLassoToolIcon().frame(width: 18, height: 18) }
else if tool == .wand, session.wandMode == .object { ObjectSelectionToolIcon().frame(width: 18, height: 18) }
// The Marquee's icon follows its shape: a dashed circle in Ellipse mode.
else { Image(systemName: tool == .marquee && session.marqueeKind == .ellipse ? "circle.dashed" : session.symbol(for: tool)).font(.system(size: 17)) }
}
.frame(width: 36, height: 36)
.background(session.tool == tool ? Color.white.opacity(0.12) : .clear,
in: RoundedRectangle(cornerRadius: 7))
.overlay {
RoundedRectangle(cornerRadius: 7)
.strokeBorder(session.tool == tool ? Color.white.opacity(0.14) : .clear)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain).help(tool.label).accessibilityLabel(tool.label)
.foregroundStyle(.primary)
.accessibilityAddTraits(session.tool == tool ? .isSelected : [])
}
ColorPaletteControls(session: session).padding(.top, 8)
}
.padding(.top, 16).padding(.bottom, 12)
.frame(width: 56)
}
.frame(width: 56)
}
private var welcome: some View {
NewCanvasSheet(session: session,
onCreate: { session.createNewProject(width: $0, height: $1) },
Expand Down
3 changes: 2 additions & 1 deletion Compositor/UI/IndicatorlessScrollView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ struct IndicatorlessScrollView<Content: View>: NSViewRepresentable {

func updateDocumentSize() {
let height = host.fittingSize.height
let size = NSSize(width: 56, height: height)
// As wide as the scroll view, so contents of any width (one column of tools or two) fit it.
let size = NSSize(width: contentView.bounds.width, height: height)
if host.frame.size != size { host.setFrameSize(size) }
verticalScrollElasticity = height > contentView.bounds.height + 1 ? .allowed : .none
}
Expand Down
90 changes: 90 additions & 0 deletions Compositor/UI/ToolRail.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import SwiftUI

/// How the tool rail lays out its tools: one column as it always has, or two for a rail about half as tall.
enum ToolRailLayout {
static let buttonSize: CGFloat = 36
static let columnSpacing: CGFloat = 8
static let rowSpacing: CGFloat = 10

/// 56 pt in one column; 92 in two (two buttons, the gap between them and 6 pt margins each side).
static func width(columns: Int) -> CGFloat {
columns >= 2 ? buttonSize * 2 + columnSpacing + 12 : 56
}

/// The tools in rows of `columns`, in the rail's order read left to right.
static func rows(_ tools: [NavigationTool], columns: Int) -> [[NavigationTool]] {
let count = min(2, max(1, columns))
return stride(from: 0, to: tools.count, by: count).map { Array(tools[$0..<min($0 + count, tools.count)]) }
}
}

/// The tools down the left of the window, with the colour swatches under them. The double chevron at the top
/// switches between one column and two, as Photoshop's does.
struct ToolRail: View {
@Bindable var session: EditorSession
@Binding var columns: Int

var body: some View {
// Scrolls when the window is too short for every tool, rather than pushing the bars above and below away.
IndicatorlessScrollView { ToolRailStack(session: session, columns: $columns) }
.frame(width: ToolRailLayout.width(columns: columns))
}
}

/// The rail's contents: the column toggle, the tools in rows, and the colour swatches.
struct ToolRailStack: View {
@Bindable var session: EditorSession
@Binding var columns: Int

private var tools: [NavigationTool] { NavigationTool.allCases.filter { $0 != .idle } }

var body: some View {
let width = ToolRailLayout.width(columns: columns)
let twoColumns = columns >= 2
VStack(spacing: ToolRailLayout.rowSpacing) {
Button { columns = twoColumns ? 1 : 2 } label: {
Image(systemName: twoColumns ? "chevron.left.2" : "chevron.right.2")
.font(.system(size: 9, weight: .semibold))
.frame(width: width - 16, height: 12)
.contentShape(Rectangle())
}
.buttonStyle(.plain).foregroundStyle(.secondary)
.help(twoColumns ? "Show tools in one column" : "Show tools in two columns")
.accessibilityLabel(twoColumns ? "Show tools in one column" : "Show tools in two columns")
ForEach(Array(ToolRailLayout.rows(tools, columns: columns).enumerated()), id: \.offset) { _, row in
HStack(spacing: ToolRailLayout.columnSpacing) {
ForEach(row, id: \.self) { toolButton($0) }
}
// A lone last tool stays in the left column.
.frame(width: twoColumns ? ToolRailLayout.buttonSize * 2 + ToolRailLayout.columnSpacing : ToolRailLayout.buttonSize,
alignment: .leading)
}
ColorPaletteControls(session: session).padding(.top, 8)
}
.padding(.top, 10).padding(.bottom, 12)
.frame(width: width)
}

private func toolButton(_ tool: NavigationTool) -> some View {
Button { session.selectTool(tool) } label: {
Group {
if tool == .gradient { GradientToolIcon().frame(width: 18, height: 18) }
else if tool == .cloneStamp { CloneStampToolIcon().frame(width: 18, height: 18) }
else if tool == .lasso, session.lassoKind == .polygonal { PolygonalLassoToolIcon().frame(width: 18, height: 18) }
else if tool == .wand, session.wandMode == .object { ObjectSelectionToolIcon().frame(width: 18, height: 18) }
// The Marquee's icon follows its shape: a dashed circle in Ellipse mode.
else { Image(systemName: tool == .marquee && session.marqueeKind == .ellipse ? "circle.dashed" : session.symbol(for: tool)).font(.system(size: 17)) }
}
.frame(width: ToolRailLayout.buttonSize, height: ToolRailLayout.buttonSize)
.background(session.tool == tool ? Color.white.opacity(0.12) : .clear, in: RoundedRectangle(cornerRadius: 7))
.overlay {
RoundedRectangle(cornerRadius: 7)
.strokeBorder(session.tool == tool ? Color.white.opacity(0.14) : .clear)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain).help(tool.label).accessibilityLabel(tool.label)
.foregroundStyle(.primary)
.accessibilityAddTraits(session.tool == tool ? .isSelected : [])
}
}
53 changes: 53 additions & 0 deletions CompositorTests/ToolRailTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import AppKit
import SwiftUI
import Testing
@testable import Compositor

@MainActor
struct ToolRailTests {
private let tools = NavigationTool.allCases.filter { $0 != .idle }

@Test func widthsForOneAndTwoColumns() {
#expect(ToolRailLayout.width(columns: 1) == 56)
#expect(ToolRailLayout.width(columns: 2) == 92)
#expect(ToolRailLayout.width(columns: 7) == 92, "anything past two is two")
}

@Test func rowsKeepTheOrderAndFillLeftToRight() {
let one = ToolRailLayout.rows(tools, columns: 1)
#expect(one.count == tools.count && one.allSatisfy { $0.count == 1 })
let two = ToolRailLayout.rows(tools, columns: 2)
#expect(two.count == (tools.count + 1) / 2)
#expect(two.flatMap { $0 } == tools, "same order, read row by row")
#expect(two.dropLast().allSatisfy { $0.count == 2 })
#expect(ToolRailLayout.rows([], columns: 2).isEmpty)
}

@Test func railIsAsWideAsItsColumnsAndShorterInTwo() {
let session = EditorSession()
func size(_ columns: Int) -> CGSize {
// The rail's contents: the scroll view around them would take any height it's offered.
NSHostingView(rootView: ToolRailStack(session: session, columns: .constant(columns))).fittingSize
}
#expect(size(1).width == 56)
#expect(size(2).width == 92)
#expect(size(2).height < size(1).height * 0.75)
}

/// The rail scrolls its contents in an AppKit scroll view, which has to give them the rail's whole width: at a
/// fixed one-column width the second column was cut off.
@Test func scrollingRailGivesItsContentsItsWidth() throws {
for columns in [1, 2] {
let width = ToolRailLayout.width(columns: columns)
let host = NSHostingView(rootView: ToolRail(session: EditorSession(), columns: .constant(columns)).frame(height: 300))
host.frame = CGRect(x: 0, y: 0, width: width, height: 300)
host.layoutSubtreeIfNeeded()
func scrollView(in view: NSView) -> NSScrollView? {
view as? NSScrollView ?? view.subviews.lazy.compactMap(scrollView).first
}
let scroll = try #require(scrollView(in: host))
#expect(scroll.frame.width == width)
#expect(scroll.documentView?.frame.width == width)
}
}
}
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- The tool bar can sit in one column or two (the double chevron at its top, or View > Tools in Two Columns)
- 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
Expand Down