diff --git a/Compositor/CompositorApp.swift b/Compositor/CompositorApp.swift index 0c171105a..82ca0e1b0 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("toolRail.columns") private var toolRailColumns = 1 private var session: EditorSession { applicationDelegate.session } var body: some Scene { Window("Compositor", id: "editor") { @@ -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 }, diff --git a/Compositor/ContentView.swift b/Compositor/ContentView.swift index 4c551854e..f5b3e6952 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 + /// 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 @@ -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 { @@ -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) }, diff --git a/Compositor/UI/IndicatorlessScrollView.swift b/Compositor/UI/IndicatorlessScrollView.swift index 44add0612..37093a3da 100644 --- a/Compositor/UI/IndicatorlessScrollView.swift +++ b/Compositor/UI/IndicatorlessScrollView.swift @@ -40,7 +40,8 @@ struct IndicatorlessScrollView: 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 } diff --git a/Compositor/UI/ToolRail.swift b/Compositor/UI/ToolRail.swift new file mode 100644 index 000000000..372287f16 --- /dev/null +++ b/Compositor/UI/ToolRail.swift @@ -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..= 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 : []) + } +} diff --git a/CompositorTests/ToolRailTests.swift b/CompositorTests/ToolRailTests.swift new file mode 100644 index 000000000..f243bf77c --- /dev/null +++ b/CompositorTests/ToolRailTests.swift @@ -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) + } + } +} diff --git a/README.md b/README.md index 9540063cf..e1e382efd 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 +- 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