Initial commit

This commit is contained in:
Alex E. Tafoya
2026-09-17 15:15:45 -05:00
commit 87fe7927c4
36 changed files with 5113 additions and 0 deletions
@@ -0,0 +1,403 @@
// EditorWindowController.swift
// GreenshotMac
//
// Port of Greenshot's ImageEditorForm.
import AppKit
@MainActor
final class EditorWindowController: NSWindowController, NSWindowDelegate,
EditorCanvasDelegate, StyleBarDelegate {
private static var openEditors: [EditorWindowController] = []
/// The one-click palette in the toolbar, matching Greenshot's swatch row.
private static let palette: [NSColor] = [
.systemGreen, .systemRed, .systemYellow, .systemBlue,
.systemPurple, .cyan, .black, .white
]
let surface: Surface
private let canvas: EditorCanvasView
private let scrollView = NSScrollView()
private let toolBar = NSStackView()
private let styleBar = StyleBarView()
private var toolButtons: [ToolKind: NSButton] = [:]
private var deleteButton: NSButton?
// MARK: - Lifecycle
@discardableResult
static func open(with capture: Capture) -> EditorWindowController {
let controller = EditorWindowController(capture: capture)
openEditors.append(controller)
controller.showWindow(nil)
controller.window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return controller
}
init(capture: Capture) {
surface = Surface(capture: capture)
canvas = EditorCanvasView(surface: surface)
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 window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: contentWidth, height: contentHeight),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false)
window.title = capture.details.title.isEmpty
? "Screenshot"
: "Screenshot — \(capture.details.title)"
window.center()
window.isReleasedWhenClosed = false
super.init(window: window)
window.delegate = self
buildInterface()
canvas.delegate = self
select(tool: .select)
}
required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? {
surface.undoManager
}
func windowWillClose(_ notification: Notification) {
canvas.endTextEditing()
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)
// 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())
// 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)
}
toolBar.addArrangedSubview(NSBox.separator())
// 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(_:))),
("1.magnifyingglass", "Actual size (⌘0)", #selector(actualSize(_:))),
("arrow.up.left.and.down.right.magnifyingglass", "Fit to window", #selector(fitToWindow(_:)))
]
for (symbol, tip, action) in zoomControls {
let button = NSButton()
EditorWindowController.configure(button, symbol: symbol, fallbackTitle: tip)
button.bezelStyle = .texturedRounded
button.toolTip = tip
button.target = self
button.action = action
toolBar.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])
stack.orientation = .vertical
stack.spacing = 0
stack.alignment = .leading
stack.distribution = .fill
stack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(stack)
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),
styleBar.leadingAnchor.constraint(equalTo: stack.leadingAnchor),
styleBar.trailingAnchor.constraint(equalTo: stack.trailingAnchor),
scrollView.leadingAnchor.constraint(equalTo: stack.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: stack.trailingAnchor)
])
window.makeFirstResponder(canvas)
}
private func destinationButton(for destination: DestinationID) -> NSButton {
let button = NSButton()
EditorWindowController.configure(button, symbol: destination.symbolName, fallbackTitle: destination.pickerTitle)
button.bezelStyle = .texturedRounded
button.toolTip = destination.title
button.target = self
button.action = #selector(destinationButtonClicked(_:))
button.tag = DestinationID.allCases.firstIndex(of: destination) ?? 0
return button
}
/// 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.
private static func configure(_ button: NSButton, symbol: String, fallbackTitle: String) {
if let image = NSImage(systemSymbolName: symbol, accessibilityDescription: fallbackTitle) {
button.image = image
button.imagePosition = .imageOnly
} else {
button.title = fallbackTitle
button.imagePosition = .noImage
button.font = NSFont.systemFont(ofSize: 11)
}
}
private static func swatchImage(_ color: NSColor) -> NSImage {
let size = NSSize(width: 14, height: 14)
let image = NSImage(size: size)
image.lockFocus()
let path = NSBezierPath(ovalIn: NSRect(origin: .zero, size: size).insetBy(dx: 0.5, dy: 0.5))
color.setFill()
path.fill()
NSColor.separatorColor.setStroke()
path.lineWidth = 1
path.stroke()
image.unlockFocus()
return image
}
// MARK: - Tools
func select(tool: ToolKind) {
canvas.tool = tool
for (kind, button) in toolButtons {
button.state = kind == tool ? .on : .off
}
updateStyleBar()
}
@objc private func toolButtonClicked(_ sender: NSButton) {
let tool = ToolKind.allCases[min(sender.tag, ToolKind.allCases.count - 1)]
select(tool: tool)
}
@objc private func destinationButtonClicked(_ sender: NSButton) {
let destination = DestinationID.allCases[min(sender.tag, DestinationID.allCases.count - 1)]
export(to: destination, anchor: sender)
}
@objc private func colorSwatchClicked(_ sender: NSButton) {
guard sender.tag >= 0, sender.tag < EditorWindowController.palette.count else { return }
let color = EditorWindowController.palette[sender.tag]
// 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
if isHighlight {
style.highlightColor = color
} else {
style.lineColor = color
}
}
if isHighlight {
Configuration.shared.editorHighlightColor = color
} else {
Configuration.shared.editorLineColor = color
}
updateStyleBar()
}
private func updateStyleBar() {
let capabilities: StyleCapabilities
let style: ElementStyle
if let selection = surface.selection, !(selection is CropElement) {
capabilities = selection.capabilities
style = selection.style
} else {
capabilities = capabilitiesForTool(canvas.tool)
style = canvas.style
}
styleBar.update(capabilities: capabilities,
style: style,
showCrop: surface.cropElement != nil)
deleteButton?.isEnabled = surface.selection != nil
}
private func capabilitiesForTool(_ tool: ToolKind) -> StyleCapabilities {
switch tool {
case .select: return []
case .rectangle, .ellipse: return [.lineColor, .fillColor, .lineThickness, .shadow]
case .line, .arrow, .freehand: return [.lineColor, .lineThickness, .shadow]
case .text: return [.lineColor, .fillColor, .font, .shadow]
case .counter: return [.lineColor, .fillColor, .font, .shadow]
case .highlight: return [.highlightColor]
case .obfuscate: return [.obfuscation]
case .crop: return []
}
}
// MARK: - Canvas delegate
func canvasDidChangeSelection(_ canvas: EditorCanvasView) {
updateStyleBar()
}
func canvasDidChangeContent(_ canvas: EditorCanvasView) {
canvas.invalidateIntrinsicContentSize()
window?.isDocumentEdited = surface.hasUnsavedChanges
updateStyleBar()
}
func canvasDidRequestToolChange(_ canvas: EditorCanvasView, to tool: ToolKind) {
select(tool: tool)
}
// MARK: - Style bar delegate
func styleBar(_ bar: StyleBarView, didChange mutate: @escaping (inout ElementStyle) -> Void) {
canvas.applyStyleToSelection(mutate)
}
func styleBarDidApplyCrop(_ bar: StyleBarView) {
applyCrop(nil)
}
// MARK: - Actions
@IBAction func applyCrop(_ sender: Any?) {
guard let crop = surface.cropElement else { return }
surface.applyCrop(to: crop.normalizedFrame)
canvas.refreshFrame()
select(tool: .select)
}
func export(to destination: DestinationID, anchor: NSView? = nil) {
canvas.endTextEditing()
let capture = surface.renderedCapture()
Destinations.export(capture, to: destination, anchor: anchor ?? canvas)
if destination == .file || destination == .fileWithDialog {
surface.hasUnsavedChanges = false
window?.isDocumentEdited = false
}
}
@IBAction func saveDocument(_ sender: Any?) { export(to: .file) }
@IBAction func saveDocumentAs(_ sender: Any?) { export(to: .fileWithDialog) }
@IBAction func copy(_ sender: Any?) { export(to: .clipboard) }
@IBAction func printDocument(_ sender: Any?) { export(to: .printer) }
@IBAction func shareDocument(_ sender: Any?) { export(to: .share, anchor: canvas) }
@IBAction func sendAsEmail(_ sender: Any?) { export(to: .email) }
@IBAction func zoomIn(_ sender: Any?) { canvas.zoom = min(8, canvas.zoom * 1.25) }
@IBAction func zoomOut(_ sender: Any?) { canvas.zoom = max(0.1, canvas.zoom / 1.25) }
@IBAction func actualSize(_ sender: Any?) { canvas.zoom = 1 }
@IBAction func fitToWindow(_ sender: Any?) {
let available = scrollView.contentView.bounds.size
guard available.width > 1, available.height > 1,
surface.imageSize.width > 1, surface.imageSize.height > 1 else { return }
let fit = min(available.width / surface.imageSize.width,
available.height / surface.imageSize.height)
canvas.zoom = min(1, max(0.1, fit))
}
@IBAction func selectTool(_ sender: NSMenuItem) {
guard let raw = sender.representedObject as? String, let tool = ToolKind(rawValue: raw) else { return }
select(tool: tool)
}
@IBAction func deleteSelection(_ sender: Any?) {
surface.removeSelection()
canvas.needsDisplay = true
updateStyleBar()
}
@IBAction func duplicateSelection(_ sender: Any?) {
guard let selection = surface.selection else { return }
surface.duplicate(selection)
canvas.needsDisplay = true
}
@IBAction func bringToFront(_ sender: Any?) {
guard let selection = surface.selection else { return }
surface.bringToFront(selection)
}
@IBAction func sendToBack(_ sender: Any?) {
guard let selection = surface.selection else { return }
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
}
}