// EditorWindowController.swift // GreenshotMac // // 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 @MainActor final class EditorWindowController: NSWindowController, NSWindowDelegate, EditorCanvasDelegate, StyleBarDelegate { private static var openEditors: [EditorWindowController] = [] /// The one-click palette at the foot of the tool rail. private static let palette: [NSColor] = [ .systemRed, .systemOrange, .systemYellow, .systemGreen, .systemBlue, .systemPurple, .black, .white ] let surface: Surface private let canvas: EditorCanvasView private let scrollView = NSScrollView() private let optionsBar = NSStackView() private let styleBar = StyleBarView() private var toolButtons: [ToolKind: NSButton] = [:] private var deleteButton: NSButton? private let currentColorWell = NSColorWell() private let fillColorWell = NSColorWell() // 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(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], backing: .buffered, defer: false) window.title = capture.details.title.isEmpty ? "Screenshot" : "Screenshot — \(capture.details.title)" window.center() window.isReleasedWhenClosed = false Theme.styleWindowChrome(window) 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 } } // MARK: - Interface private func buildInterface() { guard let window, let contentView = window.contentView else { return } let chrome = buildOptionsChrome() let rail = buildToolRail() scrollView.documentView = canvas scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = true scrollView.allowsMagnification = false scrollView.borderType = .noBorder scrollView.backgroundColor = .underPageBackgroundColor scrollView.drawsBackground = true scrollView.translatesAutoresizingMaskIntoConstraints = false 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)) } let spacer = NSView() spacer.translatesAutoresizingMaskIntoConstraints = false spacer.setContentHuggingPriority(.init(1), for: .horizontal) optionsBar.addArrangedSubview(spacer) 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) Theme.styleButton(button) button.toolTip = tip button.target = self button.action = action optionsBar.addArrangedSubview(button) } styleBar.delegate = self let stack = NSStackView(views: [optionsBar, styleBar]) stack.orientation = .vertical stack.spacing = 0 stack.alignment = .leading stack.translatesAutoresizingMaskIntoConstraints = false let chrome = Theme.backdrop(material: .headerView) Theme.fill(stack, in: chrome) NSLayoutConstraint.activate([ optionsBar.leadingAnchor.constraint(equalTo: stack.leadingAnchor), optionsBar.trailingAnchor.constraint(equalTo: stack.trailingAnchor), styleBar.leadingAnchor.constraint(equalTo: stack.leadingAnchor), 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 // Line and fill colour, the way an editor shows foreground and background swatches. currentColorWell.color = Configuration.shared.editorLineColor currentColorWell.target = self currentColorWell.action = #selector(currentColorChanged) currentColorWell.toolTip = "Line colour" currentColorWell.translatesAutoresizingMaskIntoConstraints = false fillColorWell.color = Configuration.shared.editorFillColor fillColorWell.target = self fillColorWell.action = #selector(fillColorChanged) fillColorWell.toolTip = "Fill colour" fillColorWell.translatesAutoresizingMaskIntoConstraints = false for well in [currentColorWell, fillColorWell] { NSLayoutConstraint.activate([ well.widthAnchor.constraint(equalToConstant: 26), well.heightAnchor.constraint(equalToConstant: 24) ]) } let wells = NSStackView(views: [currentColorWell, fillColorWell]) wells.orientation = .horizontal wells.spacing = 4 wells.translatesAutoresizingMaskIntoConstraints = false 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(), wells, 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) Theme.styleButton(button) 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 button. private static func configure(_ button: NSButton, symbol: String, fallbackTitle: String) { 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 { button.title = fallbackTitle button.imagePosition = .noImage button.font = NSFont.systemFont(ofSize: 11) } } private static func swatchImage(_ color: NSColor) -> NSImage { let size = NSSize(width: 16, height: 16) let image = NSImage(size: size) image.lockFocus() 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() 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 } apply(color: EditorWindowController.palette[sender.tag]) } @objc private func currentColorChanged() { apply(color: currentColorWell.color) } @objc private func fillColorChanged() { let color = fillColorWell.color Configuration.shared.editorFillColor = color canvas.applyStyleToSelection { $0.fillColor = 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 if isHighlight { style.highlightColor = color } else { style.lineColor = color } } if isHighlight { Configuration.shared.editorHighlightColor = color } else { Configuration.shared.editorLineColor = color } currentColorWell.color = 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, toolName: canvas.tool.title) deleteButton?.isEnabled = surface.selection != nil currentColorWell.color = capabilities.contains(.highlightColor) ? style.highlightColor : style.lineColor fillColorWell.color = style.fillColor fillColorWell.isEnabled = capabilities.contains(.fillColor) } 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 .dropper: return [] 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) } func canvasDidPickColor(_ canvas: EditorCanvasView, color: NSColor) { apply(color: color) } // 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) } }