second commit

This commit is contained in:
Alex E. Tafoya
2026-09-17 16:15:02 -05:00
parent 87fe7927c4
commit 7e3801c7c6
10 changed files with 585 additions and 151 deletions
+240 -103
View File
@@ -1,7 +1,8 @@
// EditorWindowController.swift
// GreenshotMac
//
// Port of Greenshot's ImageEditorForm.
// Port of Greenshot's ImageEditorForm, laid out like a classic image editor:
// a vertical tool rail on the left, a contextual options bar across the top.
import AppKit
@@ -11,19 +12,20 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate,
private static var openEditors: [EditorWindowController] = []
/// The one-click palette in the toolbar, matching Greenshot's swatch row.
/// The one-click palette at the foot of the tool rail.
private static let palette: [NSColor] = [
.systemGreen, .systemRed, .systemYellow, .systemBlue,
.systemPurple, .cyan, .black, .white
.systemRed, .systemOrange, .systemYellow, .systemGreen,
.systemBlue, .systemPurple, .black, .white
]
let surface: Surface
private let canvas: EditorCanvasView
private let scrollView = NSScrollView()
private let toolBar = NSStackView()
private let optionsBar = NSStackView()
private let styleBar = StyleBarView()
private var toolButtons: [ToolKind: NSButton] = [:]
private var deleteButton: NSButton?
private let currentColorWell = NSColorWell()
// MARK: - Lifecycle
@@ -43,8 +45,8 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate,
let size = surface.imageSize
let maxSize = (NSScreen.main?.visibleFrame.size ?? CGSize(width: 1400, height: 900))
let contentWidth = min(max(720, size.width), maxSize.width - 80)
let contentHeight = min(max(400, size.height + 96), maxSize.height - 80)
let contentWidth = min(max(760, size.width + 120), maxSize.width - 80)
let contentHeight = min(max(520, size.height + 96), maxSize.height - 80)
let window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: contentWidth, height: contentHeight),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
@@ -55,6 +57,7 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate,
: "Screenshot — \(capture.details.title)"
window.center()
window.isReleasedWhenClosed = false
Theme.styleWindowChrome(window)
super.init(window: window)
window.delegate = self
@@ -74,73 +77,79 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate,
EditorWindowController.openEditors.removeAll { $0 === self }
}
func windowDidResize(_ notification: Notification) {
// Nothing to do; the canvas keeps its own size. Fit-to-window is explicit.
}
// MARK: - Interface
private func buildInterface() {
guard let window, let contentView = window.contentView else { return }
toolBar.orientation = .horizontal
toolBar.spacing = 4
toolBar.alignment = .centerY
toolBar.edgeInsets = NSEdgeInsets(top: 6, left: 12, bottom: 6, right: 12)
let chrome = buildOptionsChrome()
let rail = buildToolRail()
// Destinations first, as in the Greenshot toolbar.
for destination in [DestinationID.file, .email, .clipboard, .fileWithDialog, .printer, .share] {
toolBar.addArrangedSubview(destinationButton(for: destination))
}
toolBar.addArrangedSubview(NSBox.separator())
scrollView.documentView = canvas
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = true
scrollView.allowsMagnification = false
scrollView.borderType = .noBorder
scrollView.backgroundColor = .underPageBackgroundColor
scrollView.drawsBackground = true
scrollView.translatesAutoresizingMaskIntoConstraints = false
// Drawing tools.
for tool in ToolKind.allCases {
let button = NSButton()
EditorWindowController.configure(button, symbol: tool.symbolName, fallbackTitle: tool.title)
button.bezelStyle = .texturedRounded
button.setButtonType(.pushOnPushOff)
button.toolTip = "\(tool.title) (\(tool.keyEquivalent.uppercased()))"
button.target = self
button.action = #selector(toolButtonClicked(_:))
button.tag = ToolKind.allCases.firstIndex(of: tool) ?? 0
toolButtons[tool] = button
toolBar.addArrangedSubview(button)
let railDivider = Theme.divider(axis: .vertical)
let chromeDivider = Theme.divider(axis: .horizontal)
contentView.addSubview(chrome)
contentView.addSubview(chromeDivider)
contentView.addSubview(rail)
contentView.addSubview(railDivider)
contentView.addSubview(scrollView)
NSLayoutConstraint.activate([
// Options chrome spans the top.
chrome.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
chrome.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
chrome.topAnchor.constraint(equalTo: contentView.topAnchor),
chromeDivider.topAnchor.constraint(equalTo: chrome.bottomAnchor),
chromeDivider.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
chromeDivider.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
// Tool rail down the left.
rail.topAnchor.constraint(equalTo: chromeDivider.bottomAnchor),
rail.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
rail.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
railDivider.topAnchor.constraint(equalTo: rail.topAnchor),
railDivider.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
railDivider.leadingAnchor.constraint(equalTo: rail.trailingAnchor),
// Canvas fills what is left.
scrollView.topAnchor.constraint(equalTo: chromeDivider.bottomAnchor),
scrollView.leadingAnchor.constraint(equalTo: railDivider.trailingAnchor),
scrollView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
scrollView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
])
canvas.refreshFrame()
window.makeFirstResponder(canvas)
}
/// Destinations and zoom on one row, the contextual style bar beneath: an options bar.
private func buildOptionsChrome() -> NSView {
optionsBar.orientation = .horizontal
optionsBar.spacing = 4
optionsBar.alignment = .centerY
optionsBar.edgeInsets = NSEdgeInsets(top: 6, left: 12, bottom: 6, right: 12)
optionsBar.translatesAutoresizingMaskIntoConstraints = false
for destination in [DestinationID.file, .fileWithDialog, .clipboard, .email, .printer, .share] {
optionsBar.addArrangedSubview(destinationButton(for: destination))
}
toolBar.addArrangedSubview(NSBox.separator())
let spacer = NSView()
spacer.translatesAutoresizingMaskIntoConstraints = false
spacer.setContentHuggingPriority(.init(1), for: .horizontal)
optionsBar.addArrangedSubview(spacer)
// Delete the selected element.
let delete = NSButton()
EditorWindowController.configure(delete, symbol: "minus.circle", fallbackTitle: "Delete")
delete.contentTintColor = .systemRed
delete.bezelStyle = .texturedRounded
delete.toolTip = "Delete the selected element (⌫)"
delete.target = self
delete.action = #selector(deleteSelection(_:))
delete.isEnabled = false
deleteButton = delete
toolBar.addArrangedSubview(delete)
toolBar.addArrangedSubview(NSBox.separator())
// One-click colour palette.
for (index, color) in EditorWindowController.palette.enumerated() {
let swatch = NSButton()
swatch.image = EditorWindowController.swatchImage(color)
swatch.imagePosition = .imageOnly
swatch.isBordered = false
swatch.toolTip = "Set colour"
swatch.target = self
swatch.action = #selector(colorSwatchClicked(_:))
swatch.tag = index
swatch.widthAnchor.constraint(equalToConstant: 20).isActive = true
toolBar.addArrangedSubview(swatch)
}
toolBar.addArrangedSubview(NSBox.separator())
// Zoom controls.
let zoomControls: [(String, String, Selector)] = [
("minus.magnifyingglass", "Zoom out (⌘−)", #selector(zoomOut(_:))),
("plus.magnifyingglass", "Zoom in (⌘+)", #selector(zoomIn(_:))),
@@ -150,51 +159,173 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate,
for (symbol, tip, action) in zoomControls {
let button = NSButton()
EditorWindowController.configure(button, symbol: symbol, fallbackTitle: tip)
button.bezelStyle = .texturedRounded
Theme.styleButton(button)
button.toolTip = tip
button.target = self
button.action = action
toolBar.addArrangedSubview(button)
optionsBar.addArrangedSubview(button)
}
styleBar.delegate = self
scrollView.documentView = canvas
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = true
scrollView.allowsMagnification = false
scrollView.borderType = .noBorder
scrollView.backgroundColor = .underPageBackgroundColor
scrollView.drawsBackground = true
let stack = NSStackView(views: [toolBar, styleBar, scrollView])
let stack = NSStackView(views: [optionsBar, styleBar])
stack.orientation = .vertical
stack.spacing = 0
stack.alignment = .leading
stack.distribution = .fill
stack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(stack)
let chrome = Theme.backdrop(material: .headerView)
Theme.fill(stack, in: chrome)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
stack.topAnchor.constraint(equalTo: contentView.topAnchor),
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
toolBar.leadingAnchor.constraint(equalTo: stack.leadingAnchor),
toolBar.trailingAnchor.constraint(lessThanOrEqualTo: stack.trailingAnchor),
optionsBar.leadingAnchor.constraint(equalTo: stack.leadingAnchor),
optionsBar.trailingAnchor.constraint(equalTo: stack.trailingAnchor),
styleBar.leadingAnchor.constraint(equalTo: stack.leadingAnchor),
styleBar.trailingAnchor.constraint(equalTo: stack.trailingAnchor),
scrollView.leadingAnchor.constraint(equalTo: stack.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: stack.trailingAnchor)
styleBar.trailingAnchor.constraint(equalTo: stack.trailingAnchor)
])
return chrome
}
/// The tool palette: two columns of tools, then delete, then the colour swatches.
private func buildToolRail() -> NSView {
var toolRows: [[NSView]] = []
var pair: [NSView] = []
for tool in ToolKind.allCases {
let button = makeToolButton(tool)
toolButtons[tool] = button
pair.append(button)
if pair.count == 2 {
toolRows.append(pair)
pair = []
}
}
if pair.count == 1 {
pair.append(NSView())
toolRows.append(pair)
}
let toolGrid = NSGridView(views: toolRows)
toolGrid.rowSpacing = 4
toolGrid.columnSpacing = 4
toolGrid.translatesAutoresizingMaskIntoConstraints = false
let delete = NSButton()
EditorWindowController.configure(delete, symbol: "trash", fallbackTitle: "Delete")
Theme.styleButton(delete)
delete.contentTintColor = .systemRed
delete.toolTip = "Delete the selected element (⌫)"
delete.target = self
delete.action = #selector(deleteSelection(_:))
delete.isEnabled = false
delete.translatesAutoresizingMaskIntoConstraints = false
delete.widthAnchor.constraint(equalToConstant: 32).isActive = true
delete.heightAnchor.constraint(equalToConstant: 28).isActive = true
deleteButton = delete
// Current colour, shown the way an editor shows its foreground swatch.
currentColorWell.color = Configuration.shared.editorLineColor
currentColorWell.target = self
currentColorWell.action = #selector(currentColorChanged)
currentColorWell.toolTip = "Current colour"
currentColorWell.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
currentColorWell.widthAnchor.constraint(equalToConstant: 44),
currentColorWell.heightAnchor.constraint(equalToConstant: 26)
])
window.makeFirstResponder(canvas)
var swatchRows: [[NSView]] = []
var swatchPair: [NSView] = []
for (index, color) in EditorWindowController.palette.enumerated() {
swatchPair.append(makeSwatchButton(color, index: index))
if swatchPair.count == 2 {
swatchRows.append(swatchPair)
swatchPair = []
}
}
if swatchPair.count == 1 {
swatchPair.append(NSView())
swatchRows.append(swatchPair)
}
let swatchGrid = NSGridView(views: swatchRows)
swatchGrid.rowSpacing = 4
swatchGrid.columnSpacing = 4
swatchGrid.translatesAutoresizingMaskIntoConstraints = false
let spacer = NSView()
spacer.translatesAutoresizingMaskIntoConstraints = false
spacer.setContentHuggingPriority(.init(1), for: .vertical)
spacer.setContentCompressionResistancePriority(.init(1), for: .vertical)
let stack = NSStackView(views: [toolGrid,
EditorWindowController.railDivider(),
delete,
EditorWindowController.railDivider(),
currentColorWell,
swatchGrid,
spacer])
stack.orientation = .vertical
stack.spacing = 8
stack.alignment = .centerX
stack.distribution = .fill
stack.translatesAutoresizingMaskIntoConstraints = false
stack.setHuggingPriority(.defaultHigh, for: .vertical)
let rail = Theme.backdrop(material: .sidebar)
rail.addSubview(stack)
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: rail.topAnchor, constant: 10),
stack.leadingAnchor.constraint(equalTo: rail.leadingAnchor, constant: 8),
stack.trailingAnchor.constraint(equalTo: rail.trailingAnchor, constant: -8),
stack.bottomAnchor.constraint(equalTo: rail.bottomAnchor, constant: -10)
])
return rail
}
/// A separator sized for the tool rail; an unconstrained one would collapse
/// to zero width inside a centre-aligned vertical stack.
private static func railDivider() -> NSBox {
let divider = Theme.divider(axis: .horizontal)
divider.widthAnchor.constraint(equalToConstant: 56).isActive = true
return divider
}
private func makeToolButton(_ tool: ToolKind) -> NSButton {
let button = NSButton()
EditorWindowController.configure(button, symbol: tool.symbolName, fallbackTitle: tool.title)
Theme.styleButton(button)
button.setButtonType(.pushOnPushOff)
button.toolTip = "\(tool.title) (\(tool.keyEquivalent.uppercased()))"
button.target = self
button.action = #selector(toolButtonClicked(_:))
button.tag = ToolKind.allCases.firstIndex(of: tool) ?? 0
button.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
button.widthAnchor.constraint(equalToConstant: 32),
button.heightAnchor.constraint(equalToConstant: 28)
])
return button
}
private func makeSwatchButton(_ color: NSColor, index: Int) -> NSButton {
let swatch = NSButton()
swatch.image = EditorWindowController.swatchImage(color)
swatch.imagePosition = .imageOnly
swatch.isBordered = false
swatch.toolTip = "Set colour"
swatch.target = self
swatch.action = #selector(colorSwatchClicked(_:))
swatch.tag = index
swatch.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
swatch.widthAnchor.constraint(equalToConstant: 20),
swatch.heightAnchor.constraint(equalToConstant: 20)
])
return swatch
}
private func destinationButton(for destination: DestinationID) -> NSButton {
let button = NSButton()
EditorWindowController.configure(button, symbol: destination.symbolName, fallbackTitle: destination.pickerTitle)
button.bezelStyle = .texturedRounded
Theme.styleButton(button)
button.toolTip = destination.title
button.target = self
button.action = #selector(destinationButtonClicked(_:))
@@ -203,9 +334,11 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate,
}
/// Uses the SF Symbol when the running system has it, and a short text label otherwise,
/// so a missing symbol never leaves an invisible toolbar button.
/// so a missing symbol never leaves an invisible button.
private static func configure(_ button: NSButton, symbol: String, fallbackTitle: String) {
if let image = NSImage(systemSymbolName: symbol, accessibilityDescription: fallbackTitle) {
let configuration = NSImage.SymbolConfiguration(pointSize: 14, weight: .regular)
if let image = NSImage(systemSymbolName: symbol, accessibilityDescription: fallbackTitle)?
.withSymbolConfiguration(configuration) {
button.image = image
button.imagePosition = .imageOnly
} else {
@@ -216,10 +349,11 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate,
}
private static func swatchImage(_ color: NSColor) -> NSImage {
let size = NSSize(width: 14, height: 14)
let size = NSSize(width: 16, height: 16)
let image = NSImage(size: size)
image.lockFocus()
let path = NSBezierPath(ovalIn: NSRect(origin: .zero, size: size).insetBy(dx: 0.5, dy: 0.5))
let path = NSBezierPath(roundedRect: NSRect(origin: .zero, size: size).insetBy(dx: 0.5, dy: 0.5),
xRadius: 4, yRadius: 4)
color.setFill()
path.fill()
NSColor.separatorColor.setStroke()
@@ -251,8 +385,14 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate,
@objc private func colorSwatchClicked(_ sender: NSButton) {
guard sender.tag >= 0, sender.tag < EditorWindowController.palette.count else { return }
let color = EditorWindowController.palette[sender.tag]
apply(color: EditorWindowController.palette[sender.tag])
}
@objc private func currentColorChanged() {
apply(color: currentColorWell.color)
}
private func apply(color: NSColor) {
// The highlighter has its own colour field; everything else uses the line colour.
let isHighlight = canvas.tool == .highlight || surface.selection is HighlightElement
canvas.applyStyleToSelection { style in
@@ -267,6 +407,7 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate,
} else {
Configuration.shared.editorLineColor = color
}
currentColorWell.color = color
updateStyleBar()
}
@@ -284,6 +425,7 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate,
style: style,
showCrop: surface.cropElement != nil)
deleteButton?.isEnabled = surface.selection != nil
currentColorWell.color = capabilities.contains(.highlightColor) ? style.highlightColor : style.lineColor
}
private func capabilitiesForTool(_ tool: ToolKind) -> StyleCapabilities {
@@ -295,6 +437,7 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate,
case .counter: return [.lineColor, .fillColor, .font, .shadow]
case .highlight: return [.highlightColor]
case .obfuscate: return [.obfuscation]
case .dropper: return []
case .crop: return []
}
}
@@ -315,6 +458,10 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate,
select(tool: tool)
}
func canvasDidPickColor(_ canvas: EditorCanvasView, color: NSColor) {
apply(color: color)
}
// MARK: - Style bar delegate
func styleBar(_ bar: StyleBarView, didChange mutate: @escaping (inout ElementStyle) -> Void) {
@@ -391,13 +538,3 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate,
surface.sendToBack(selection)
}
}
private extension NSBox {
static func separator() -> NSBox {
let box = NSBox()
box.translatesAutoresizingMaskIntoConstraints = false
box.boxType = .separator
box.widthAnchor.constraint(equalToConstant: 12).isActive = true
return box
}
}