From 7e3801c7c65e509a48458ed4da1f119a1e6eefc0 Mon Sep 17 00:00:00 2001 From: "Alex E. Tafoya" Date: Thu, 17 Sep 2026 16:15:02 -0500 Subject: [PATCH] second commit --- GreenshotMac/App/AppDelegate.swift | 6 + GreenshotMac/Capture/CaptureOverlay.swift | 8 +- GreenshotMac/Core/Appearance.swift | 171 +++++++++ GreenshotMac/Core/Configuration.swift | 9 +- GreenshotMac/Core/ImageExporter.swift | 32 ++ .../Destinations/DestinationPicker.swift | 29 +- GreenshotMac/Editor/EditorCanvasView.swift | 55 +-- .../Editor/EditorWindowController.swift | 343 ++++++++++++------ GreenshotMac/Editor/Elements.swift | 74 +++- .../Preferences/PreferencesView.swift | 9 + 10 files changed, 585 insertions(+), 151 deletions(-) create mode 100644 GreenshotMac/Core/Appearance.swift diff --git a/GreenshotMac/App/AppDelegate.swift b/GreenshotMac/App/AppDelegate.swift index e9fe021..c7c7ac6 100644 --- a/GreenshotMac/App/AppDelegate.swift +++ b/GreenshotMac/App/AppDelegate.swift @@ -21,6 +21,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } NSApp.setActivationPolicy(.accessory) + Theme.apply() + NotificationCenter.default.addObserver(forName: .appearanceChanged, + object: nil, + queue: .main) { _ in + MainActor.assumeIsolated { Theme.apply() } + } statusMenu = StatusMenuController() MainMenuBuilder.install() HotkeyBindings.shared.registerAll() diff --git a/GreenshotMac/Capture/CaptureOverlay.swift b/GreenshotMac/Capture/CaptureOverlay.swift index a2155b4..8632d2c 100644 --- a/GreenshotMac/Capture/CaptureOverlay.swift +++ b/GreenshotMac/Capture/CaptureOverlay.swift @@ -329,8 +329,12 @@ final class OverlayView: NSView { box.origin.x = min(max(4, box.origin.x), bounds.width - box.width - 4) box.origin.y = min(max(4, box.origin.y), bounds.height - box.height - 4) - NSColor(calibratedWhite: 0, alpha: 0.75).setFill() - NSBezierPath(roundedRect: box, xRadius: 5, yRadius: 5).fill() + let chip = NSBezierPath(roundedRect: box, xRadius: Theme.controlRadius, yRadius: Theme.controlRadius) + NSColor(calibratedWhite: 0, alpha: 0.72).setFill() + chip.fill() + NSColor(calibratedWhite: 1, alpha: 0.18).setStroke() + chip.lineWidth = 1 + chip.stroke() (text as NSString).draw(at: CGPoint(x: box.minX + 6, y: box.minY + 4), withAttributes: attributes) } diff --git a/GreenshotMac/Core/Appearance.swift b/GreenshotMac/Core/Appearance.swift new file mode 100644 index 0000000..ddea11d --- /dev/null +++ b/GreenshotMac/Core/Appearance.swift @@ -0,0 +1,171 @@ +// Appearance.swift +// GreenshotMac +// +// Appearance handling and the shared material/shape vocabulary for the UI. +// Everything here follows the system appearance by default; the user can override it. + +import AppKit + +enum AppearanceMode: String, CaseIterable { + case system + case light + case dark + + var title: String { + switch self { + case .system: return "Match system" + case .light: return "Light" + case .dark: return "Dark" + } + } + + var nsAppearance: NSAppearance? { + switch self { + case .system: return nil // nil = inherit from macOS + case .light: return NSAppearance(named: .aqua) + case .dark: return NSAppearance(named: .darkAqua) + } + } +} + +@MainActor +enum Theme { + + /// Applies the user's appearance choice to the whole app. + static func apply() { + NSApp.appearance = Configuration.shared.appearanceMode.nsAppearance + } + + // MARK: - Shapes + + /// Corner radius used for panels and preview wells. + /// macOS 27 moved back towards squarer, more consistent radii than Tahoe used. + /// + /// These are `nonisolated` because default argument expressions are evaluated + /// outside the main actor, and `panelRadius` is used as one below. + nonisolated static let panelRadius: CGFloat = 10 + /// Corner radius used for buttons and small chips. + nonisolated static let controlRadius: CGFloat = 6 + + // MARK: - Materials + + /// A translucent backdrop that picks up whatever is behind the window and adapts + /// to light/dark automatically. Used for toolbars, style bars and dialog chrome. + /// + /// On macOS 26 the system materials render with the Liquid Glass treatment; on earlier + /// systems they fall back to the classic vibrancy blur. Either way the colours come from + /// the system rather than being hard coded, so an appearance override is respected. + static func backdrop(material: NSVisualEffectView.Material = .headerView, + blending: NSVisualEffectView.BlendingMode = .withinWindow, + radius: CGFloat = 0) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = material + view.blendingMode = blending + view.state = .followsWindowActiveState + view.translatesAutoresizingMaskIntoConstraints = false + if radius > 0 { + view.wantsLayer = true + view.layer?.cornerRadius = radius + view.layer?.cornerCurve = .continuous + view.layer?.masksToBounds = true + } + return view + } + + /// Fills a container with a child view, inset by `inset` on every edge. + static func fill(_ child: NSView, in container: NSView, inset: CGFloat = 0) { + child.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(child) + NSLayoutConstraint.activate([ + child.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: inset), + child.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -inset), + child.topAnchor.constraint(equalTo: container.topAnchor, constant: inset), + child.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -inset) + ]) + } + + /// A hairline divider that reads correctly in both appearances. + /// + /// `axis` is the direction the line runs. A separator NSBox has no intrinsic size + /// across its thin edge, so that dimension must be pinned or Auto Layout is free to + /// stretch the divider over the content next to it. + static func divider(axis: NSUserInterfaceLayoutOrientation = .horizontal) -> NSBox { + let box = NSBox() + box.boxType = .separator + box.translatesAutoresizingMaskIntoConstraints = false + switch axis { + case .horizontal: + box.heightAnchor.constraint(equalToConstant: 1).isActive = true + case .vertical: + box.widthAnchor.constraint(equalToConstant: 1).isActive = true + @unknown default: + break + } + return box + } + + /// True when the person has dialled transparency down, either with the macOS 27 + /// transparency slider or the Reduce Transparency accessibility setting. + static var prefersOpaqueSurfaces: Bool { + NSWorkspace.shared.accessibilityDisplayShouldReduceTransparency + } + + /// Wraps `content` in a Liquid Glass panel on macOS 26 and later, falling back to a + /// vibrancy backdrop on older systems. Used for floating surfaces such as the + /// destination picker's preview well — not for toolbars, which macOS 27 wants to read + /// as solid bars rather than floating glass. + static func panel(_ content: NSView, + radius: CGFloat = panelRadius, + material: NSVisualEffectView.Material = .underPageBackground, + inset: CGFloat = 0) -> NSView { + if #available(macOS 26.0, *), !prefersOpaqueSurfaces { + let glass = NSGlassEffectView() + glass.translatesAutoresizingMaskIntoConstraints = false + glass.cornerRadius = radius + content.translatesAutoresizingMaskIntoConstraints = false + glass.contentView = content + return glass + } + let view = backdrop(material: material, radius: radius) + fill(content, in: view, inset: inset) + return view + } + + /// The bezel style for a chrome button: Liquid Glass where the system has it. + static func styleButton(_ button: NSButton) { + if #available(macOS 26.0, *) { + button.bezelStyle = .glass + } else { + button.bezelStyle = .texturedRounded + } + } + + /// Groups nearby glass elements so the system can merge them into one shape + /// instead of rendering each separately. + static func glassGroup(_ views: [NSView]) -> NSView? { + guard #available(macOS 26.0, *), !prefersOpaqueSurfaces else { return nil } + let container = NSGlassEffectContainerView() + container.translatesAutoresizingMaskIntoConstraints = false + let stack = NSStackView(views: views) + stack.orientation = .horizontal + stack.spacing = 4 + container.contentView = stack + return container + } + + // MARK: - Window chrome + + /// Gives a window the unified, translucent titlebar used throughout the app. + static func styleWindowChrome(_ window: NSWindow, fullSizeContent: Bool = false) { + window.titlebarAppearsTransparent = true + window.isMovableByWindowBackground = true + if fullSizeContent { + window.styleMask.insert(.fullSizeContentView) + } + } +} + +extension Notification.Name { + /// Posted when the appearance override changes, so open windows can re-apply it. + static let appearanceChanged = Notification.Name("GreenshotMacAppearanceChanged") +} diff --git a/GreenshotMac/Core/Configuration.swift b/GreenshotMac/Core/Configuration.swift index 3f6b801..c119103 100644 --- a/GreenshotMac/Core/Configuration.swift +++ b/GreenshotMac/Core/Configuration.swift @@ -110,7 +110,8 @@ final class Configuration { Keys.editorHighlightColor: "#FFFF00", Keys.editorDropShadow: false, Keys.showMagnifier: true, - Keys.showSelectionSize: true + Keys.showSelectionSize: true, + Keys.appearanceMode: AppearanceMode.system.rawValue ]) } @@ -138,6 +139,7 @@ final class Configuration { static let editorDropShadow = "EditorDropShadow" static let showMagnifier = "ShowMagnifier" static let showSelectionSize = "ShowSelectionSize" + static let appearanceMode = "AppearanceMode" static let hotkeyRegion = "HotkeyRegion" static let hotkeyFullscreen = "HotkeyFullscreen" static let hotkeyWindow = "HotkeyWindow" @@ -208,6 +210,11 @@ final class Configuration { set { defaults.set(newValue, forKey: Keys.showSelectionSize) } } + var appearanceMode: AppearanceMode { + get { AppearanceMode(rawValue: defaults.string(forKey: Keys.appearanceMode) ?? "system") ?? .system } + set { defaults.set(newValue.rawValue, forKey: Keys.appearanceMode) } + } + // MARK: - Destinations var defaultDestination: DestinationID { diff --git a/GreenshotMac/Core/ImageExporter.swift b/GreenshotMac/Core/ImageExporter.swift index 4386cf7..ad87521 100644 --- a/GreenshotMac/Core/ImageExporter.swift +++ b/GreenshotMac/Core/ImageExporter.swift @@ -76,6 +76,38 @@ enum ImageExporter { return url } + /// Reads a single pixel from an image. `point` is in image points with a top-left + /// origin; `scale` converts those points to pixels. + static func color(at point: CGPoint, in image: CGImage, scale: CGFloat) -> NSColor? { + let x = Int((point.x * scale).rounded(.down)) + let y = Int((point.y * scale).rounded(.down)) + guard x >= 0, y >= 0, x < image.width, y < image.height else { return nil } + + var pixel: [UInt8] = [0, 0, 0, 0] + guard let context = CGContext(data: &pixel, + width: 1, + height: 1, + bitsPerComponent: 8, + bytesPerRow: 4, + space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) + else { return nil } + + // Draw the image shifted so the pixel of interest lands in the 1x1 context. + context.draw(image, + in: CGRect(x: -CGFloat(x), + y: -CGFloat(image.height - 1 - y), + width: CGFloat(image.width), + height: CGFloat(image.height))) + + let alpha = CGFloat(pixel[3]) / 255 + guard alpha > 0 else { return NSColor.clear } + return NSColor(srgbRed: CGFloat(pixel[0]) / 255 / alpha, + green: CGFloat(pixel[1]) / 255 / alpha, + blue: CGFloat(pixel[2]) / 255 / alpha, + alpha: 1) + } + static func format(forPathExtension pathExtension: String) -> OutputFormat { switch pathExtension.lowercased() { case "jpg", "jpeg": return .jpg diff --git a/GreenshotMac/Destinations/DestinationPicker.swift b/GreenshotMac/Destinations/DestinationPicker.swift index 1ac040a..7db65cf 100644 --- a/GreenshotMac/Destinations/DestinationPicker.swift +++ b/GreenshotMac/Destinations/DestinationPicker.swift @@ -38,6 +38,7 @@ final class DestinationPickerWindowController: NSWindowController, NSWindowDeleg window.isReleasedWhenClosed = false window.level = .floating window.center() + Theme.styleWindowChrome(window) super.init(window: window) window.delegate = self buildInterface() @@ -54,15 +55,19 @@ final class DestinationPickerWindowController: NSWindowController, NSWindowDeleg private func buildInterface() { guard let window, let contentView = window.contentView else { return } + let background = Theme.backdrop(material: .windowBackground, blending: .behindWindow) + contentView.addSubview(background) + NSLayoutConstraint.activate([ + background.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + background.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + background.topAnchor.constraint(equalTo: contentView.topAnchor), + background.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + let preview = NSImageView() preview.image = capture.nsImage preview.imageScaling = .scaleProportionallyDown preview.imageAlignment = .alignCenter - preview.wantsLayer = true - preview.layer?.backgroundColor = NSColor.underPageBackgroundColor.cgColor - preview.layer?.borderColor = NSColor.separatorColor.cgColor - preview.layer?.borderWidth = 1 - preview.layer?.cornerRadius = 4 preview.translatesAutoresizingMaskIntoConstraints = false preview.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) preview.setContentCompressionResistancePriority(.defaultLow, for: .vertical) @@ -86,17 +91,19 @@ final class DestinationPickerWindowController: NSWindowController, NSWindowDeleg separator.boxType = .separator separator.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(preview) + let previewWell = Theme.panel(preview, radius: Theme.panelRadius, inset: 8) + + contentView.addSubview(previewWell) contentView.addSubview(separator) contentView.addSubview(buttonRow) contentView.addSubview(rememberCheckbox) NSLayoutConstraint.activate([ - preview.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16), - preview.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), - preview.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + previewWell.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16), + previewWell.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + previewWell.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), - separator.topAnchor.constraint(equalTo: preview.bottomAnchor, constant: 14), + separator.topAnchor.constraint(equalTo: previewWell.bottomAnchor, constant: 14), separator.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), separator.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), @@ -113,7 +120,7 @@ final class DestinationPickerWindowController: NSWindowController, NSWindowDeleg let aspect = capture.pointSize.height / max(1, capture.pointSize.width) let previewWidth: CGFloat = 528 let previewHeight = min(max(180, previewWidth * aspect), 380) - preview.heightAnchor.constraint(equalToConstant: previewHeight).isActive = true + previewWell.heightAnchor.constraint(equalToConstant: previewHeight + 16).isActive = true window.setContentSize(NSSize(width: 560, height: previewHeight + 190)) window.center() } diff --git a/GreenshotMac/Editor/EditorCanvasView.swift b/GreenshotMac/Editor/EditorCanvasView.swift index a6aa4b3..925e811 100644 --- a/GreenshotMac/Editor/EditorCanvasView.swift +++ b/GreenshotMac/Editor/EditorCanvasView.swift @@ -10,6 +10,7 @@ protocol EditorCanvasDelegate: AnyObject { func canvasDidChangeSelection(_ canvas: EditorCanvasView) func canvasDidChangeContent(_ canvas: EditorCanvasView) func canvasDidRequestToolChange(_ canvas: EditorCanvasView, to tool: ToolKind) + func canvasDidPickColor(_ canvas: EditorCanvasView, color: NSColor) } enum ResizeHandle: Int, CaseIterable { @@ -100,7 +101,6 @@ final class EditorCanvasView: NSView, NSTextViewDelegate { self.surface = surface super.init(frame: CGRect(origin: .zero, size: surface.imageSize)) wantsLayer = true - layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor surface.onChange = { [weak self] in guard let self else { return } self.needsDisplay = true @@ -196,6 +196,7 @@ final class EditorCanvasView: NSView, NSTextViewDelegate { switch tool { case .select: cursor = .arrow case .text: cursor = .iBeam + case .dropper: cursor = .crosshair default: cursor = .crosshair } addCursorRect(bounds, cursor: cursor) @@ -225,6 +226,12 @@ final class EditorCanvasView: NSView, NSTextViewDelegate { // The tool must stay on .text until editing finishes: switching tools calls // endTextEditing(), which would discard the element before anything is typed. beginTextEditing(element) + case .dropper: + // Sample the flattened image so annotation colours can be picked up too. + if let color = ImageExporter.color(at: point, in: surface.render(), scale: surface.scale) { + delegate?.canvasDidPickColor(self, color: color) + } + interaction = .none case .counter: let element = CounterElement(center: point, number: surface.nextCounterNumber, style: style) surface.nextCounterNumber += 1 @@ -233,6 +240,7 @@ final class EditorCanvasView: NSView, NSTextViewDelegate { interaction = .none case .rectangle, .ellipse, .line, .arrow, .highlight, .obfuscate: let element = makeElement(for: tool, at: point) + element.creationAnchor = point surface.add(element) surface.selection = element interaction = .creating(element) @@ -246,8 +254,8 @@ final class EditorCanvasView: NSView, NSTextViewDelegate { switch tool { case .rectangle: return RectangleElement(frame: frame, style: style) case .ellipse: return EllipseElement(frame: frame, style: style) - case .line: return LineElement(frame: frame, style: style) - case .arrow: return ArrowElement(frame: frame, style: style) + case .line: return LineElement(start: point, end: point, style: style) + case .arrow: return ArrowElement(start: point, end: point, style: style) case .highlight: return HighlightElement(frame: frame, style: style) case .obfuscate: return ObfuscateElement(frame: frame, style: style) default: return RectangleElement(frame: frame, style: style) @@ -287,6 +295,7 @@ final class EditorCanvasView: NSView, NSTextViewDelegate { surface.remove(existing) } let crop = CropElement(frame: CGRect(origin: point, size: .zero), style: style) + crop.creationAnchor = point surface.add(crop) surface.selection = crop interaction = .creating(crop) @@ -298,20 +307,23 @@ final class EditorCanvasView: NSView, NSTextViewDelegate { switch interaction { case .creating(let element): - var target = point - if constrain { - if element is LineElement { - target = constrainedLineEnd(from: element.frame.origin, to: point) - } else { - let side = max(abs(point.x - element.frame.origin.x), abs(point.y - element.frame.origin.y)) - target = CGPoint(x: element.frame.origin.x + side * (point.x < element.frame.origin.x ? -1 : 1), - y: element.frame.origin.y + side * (point.y < element.frame.origin.y ? -1 : 1)) + if let line = element as? LineElement { + let target = constrain ? constrainedLineEnd(from: line.start, to: point) : point + line.setEnd(target) + } else { + let anchorPoint = element.creationAnchor ?? element.frame.origin + var target = point + if constrain { + let side = max(abs(point.x - anchorPoint.x), abs(point.y - anchorPoint.y)) + target = CGPoint(x: anchorPoint.x + side * (point.x < anchorPoint.x ? -1 : 1), + y: anchorPoint.y + side * (point.y < anchorPoint.y ? -1 : 1)) } + // Build the rect from the two corners so dragging up or left works. + element.frame = CGRect(x: min(anchorPoint.x, target.x), + y: min(anchorPoint.y, target.y), + width: abs(target.x - anchorPoint.x), + height: abs(target.y - anchorPoint.y)) } - element.frame = CGRect(x: element.frame.origin.x, - y: element.frame.origin.y, - width: target.x - element.frame.origin.x, - height: target.y - element.frame.origin.y) surface.changed() case .moving(let element, let origin): element.move(by: CGVector(dx: point.x - origin.x, dy: point.y - origin.y)) @@ -341,18 +353,16 @@ final class EditorCanvasView: NSView, NSTextViewDelegate { switch interaction { case .creating(let element): let rect = element.normalizedFrame - let tooSmall = element is LineElement - ? hypot(element.frame.width, element.frame.height) < 4 - : (rect.width < 4 || rect.height < 4) + let tooSmall = (element as? LineElement).map { $0.length < 4 } + ?? (rect.width < 4 || rect.height < 4) + element.creationAnchor = nil if tooSmall { surface.remove(element) } else if !(element is LineElement) { element.frame = rect if element is CropElement { surface.cropElement = element as? CropElement } } - if !(element is CropElement) { - delegate?.canvasDidRequestToolChange(self, to: .select) - } + // The tool stays active so several shapes can be drawn in a row. case .moving, .resizing, .drawingFreehand, .none: break } @@ -448,9 +458,6 @@ final class EditorCanvasView: NSView, NSTextViewDelegate { surface.changed() needsDisplay = true - if tool == .text { - delegate?.canvasDidRequestToolChange(self, to: .select) - } } func textDidEndEditing(_ notification: Notification) { diff --git a/GreenshotMac/Editor/EditorWindowController.swift b/GreenshotMac/Editor/EditorWindowController.swift index 2417a12..e3112fc 100644 --- a/GreenshotMac/Editor/EditorWindowController.swift +++ b/GreenshotMac/Editor/EditorWindowController.swift @@ -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 - } -} diff --git a/GreenshotMac/Editor/Elements.swift b/GreenshotMac/Editor/Elements.swift index c616336..a84ed79 100644 --- a/GreenshotMac/Editor/Elements.swift +++ b/GreenshotMac/Editor/Elements.swift @@ -18,6 +18,7 @@ enum ToolKind: String, CaseIterable { case highlight case obfuscate case counter + case dropper case crop var title: String { @@ -32,6 +33,7 @@ enum ToolKind: String, CaseIterable { case .highlight: return "Highlight" case .obfuscate: return "Obfuscate" case .counter: return "Step counter" + case .dropper: return "Colour picker" case .crop: return "Crop" } } @@ -39,15 +41,16 @@ enum ToolKind: String, CaseIterable { var symbolName: String { switch self { case .select: return "cursorarrow" - case .rectangle: return "rectangle" + case .rectangle: return "square" case .ellipse: return "circle" case .line: return "line.diagonal" - case .arrow: return "arrow.up.right" - case .freehand: return "scribble" - case .text: return "textformat" + case .arrow: return "arrow.up.forward" + case .freehand: return "scribble.variable" + case .text: return "character.textbox" case .highlight: return "highlighter" case .obfuscate: return "square.grid.3x3.fill" - case .counter: return "1.circle" + case .counter: return "number.circle" + case .dropper: return "eyedropper" case .crop: return "crop" } } @@ -64,6 +67,7 @@ enum ToolKind: String, CaseIterable { case .highlight: return "h" case .obfuscate: return "o" case .counter: return "c" + case .dropper: return "i" case .crop: return "k" } } @@ -118,6 +122,8 @@ struct StyleCapabilities: OptionSet, Hashable { class Element { var frame: CGRect var style: ElementStyle + /// Where the drag that created this element started; nil once the drag has finished. + var creationAnchor: CGPoint? init(frame: CGRect, style: ElementStyle) { self.frame = frame @@ -217,13 +223,61 @@ final class EllipseElement: Element { override func copyElement() -> Element { EllipseElement(frame: frame, style: style) } } -/// A straight line; `frame` stores start in origin and end in origin + size, so the size may be negative. +/// A straight line between two explicit points. +/// +/// The end points are stored separately rather than being derived from `frame`: CGRect +/// reports `width` and `height` as absolute values, so a rect built with a negative size +/// cannot describe a line that runs right-to-left or bottom-to-top. `frame` is kept in +/// sync as the bounding box so selection and hit testing behave like every other element. class LineElement: Element { - var start: CGPoint { frame.origin } - var end: CGPoint { CGPoint(x: frame.origin.x + frame.width, y: frame.origin.y + frame.height) } + private(set) var start: CGPoint + private(set) var end: CGPoint + + init(start: CGPoint, end: CGPoint, style: ElementStyle) { + self.start = start + self.end = end + super.init(frame: LineElement.bounds(start, end), style: style) + } + + static func bounds(_ a: CGPoint, _ b: CGPoint) -> CGRect { + CGRect(x: min(a.x, b.x), y: min(a.y, b.y), + width: abs(b.x - a.x), height: abs(b.y - a.y)) + } + + /// Moves the free end while the line is being drawn. + func setEnd(_ point: CGPoint) { + end = point + frame = LineElement.bounds(start, end) + } + + var length: CGFloat { hypot(end.x - start.x, end.y - start.y) } override var capabilities: StyleCapabilities { [.lineColor, .lineThickness, .shadow] } + override func move(by delta: CGVector) { + start = CGPoint(x: start.x + delta.dx, y: start.y + delta.dy) + end = CGPoint(x: end.x + delta.dx, y: end.y + delta.dy) + frame = LineElement.bounds(start, end) + } + + /// Maps both end points into the new bounding box, preserving the line's direction. + override func resize(to rect: CGRect) { + let old = frame + let target = CGRect(x: min(rect.minX, rect.maxX), y: min(rect.minY, rect.maxY), + width: abs(rect.width), height: abs(rect.height)) + func map(_ point: CGPoint) -> CGPoint { + let fractionX = old.width > 0 ? (point.x - old.minX) / old.width : 0 + let fractionY = old.height > 0 ? (point.y - old.minY) / old.height : 0 + return CGPoint(x: target.minX + fractionX * target.width, + y: target.minY + fractionY * target.height) + } + let newStart = map(start) + let newEnd = map(end) + start = newStart + end = newEnd + frame = LineElement.bounds(start, end) + } + override func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) { context.saveGState() applyShadow(context) @@ -249,7 +303,7 @@ class LineElement: Element { return hypot(point.x - (a.x + t * dx), point.y - (a.y + t * dy)) } - override func copyElement() -> Element { LineElement(frame: frame, style: style) } + override func copyElement() -> Element { LineElement(start: start, end: end, style: style) } } final class ArrowElement: LineElement { @@ -282,7 +336,7 @@ final class ArrowElement: LineElement { context.restoreGState() } - override func copyElement() -> Element { ArrowElement(frame: frame, style: style) } + override func copyElement() -> Element { ArrowElement(start: start, end: end, style: style) } } final class FreehandElement: Element { diff --git a/GreenshotMac/Preferences/PreferencesView.swift b/GreenshotMac/Preferences/PreferencesView.swift index 06553d7..0d43cb5 100644 --- a/GreenshotMac/Preferences/PreferencesView.swift +++ b/GreenshotMac/Preferences/PreferencesView.swift @@ -32,10 +32,19 @@ private struct GeneralPreferences: View { @AppStorage(Configuration.Keys.showMagnifier) private var magnifier = true @AppStorage(Configuration.Keys.showSelectionSize) private var selectionSize = true @AppStorage(Configuration.Keys.windowCaptureMode) private var windowMode = WindowCaptureMode.window.rawValue + @AppStorage(Configuration.Keys.appearanceMode) private var appearance = AppearanceMode.system.rawValue @State private var launchAtLogin = LaunchAtLogin.isEnabled var body: some View { Form { + Picker("Appearance:", selection: $appearance) { + ForEach(AppearanceMode.allCases, id: \.rawValue) { mode in + Text(mode.title).tag(mode.rawValue) + } + } + .onChange(of: appearance) { _, _ in + NotificationCenter.default.post(name: .appearanceChanged, object: nil) + } Picker("After capture:", selection: $destination) { ForEach(DestinationID.allCases.filter { $0 != .openWith }, id: \.rawValue) { item in Text(item.title).tag(item.rawValue)