second commit
This commit is contained in:
@@ -21,6 +21,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
NSApp.setActivationPolicy(.accessory)
|
NSApp.setActivationPolicy(.accessory)
|
||||||
|
Theme.apply()
|
||||||
|
NotificationCenter.default.addObserver(forName: .appearanceChanged,
|
||||||
|
object: nil,
|
||||||
|
queue: .main) { _ in
|
||||||
|
MainActor.assumeIsolated { Theme.apply() }
|
||||||
|
}
|
||||||
statusMenu = StatusMenuController()
|
statusMenu = StatusMenuController()
|
||||||
MainMenuBuilder.install()
|
MainMenuBuilder.install()
|
||||||
HotkeyBindings.shared.registerAll()
|
HotkeyBindings.shared.registerAll()
|
||||||
|
|||||||
@@ -329,8 +329,12 @@ final class OverlayView: NSView {
|
|||||||
box.origin.x = min(max(4, box.origin.x), bounds.width - box.width - 4)
|
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)
|
box.origin.y = min(max(4, box.origin.y), bounds.height - box.height - 4)
|
||||||
|
|
||||||
NSColor(calibratedWhite: 0, alpha: 0.75).setFill()
|
let chip = NSBezierPath(roundedRect: box, xRadius: Theme.controlRadius, yRadius: Theme.controlRadius)
|
||||||
NSBezierPath(roundedRect: box, xRadius: 5, yRadius: 5).fill()
|
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)
|
(text as NSString).draw(at: CGPoint(x: box.minX + 6, y: box.minY + 4), withAttributes: attributes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -110,7 +110,8 @@ final class Configuration {
|
|||||||
Keys.editorHighlightColor: "#FFFF00",
|
Keys.editorHighlightColor: "#FFFF00",
|
||||||
Keys.editorDropShadow: false,
|
Keys.editorDropShadow: false,
|
||||||
Keys.showMagnifier: true,
|
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 editorDropShadow = "EditorDropShadow"
|
||||||
static let showMagnifier = "ShowMagnifier"
|
static let showMagnifier = "ShowMagnifier"
|
||||||
static let showSelectionSize = "ShowSelectionSize"
|
static let showSelectionSize = "ShowSelectionSize"
|
||||||
|
static let appearanceMode = "AppearanceMode"
|
||||||
static let hotkeyRegion = "HotkeyRegion"
|
static let hotkeyRegion = "HotkeyRegion"
|
||||||
static let hotkeyFullscreen = "HotkeyFullscreen"
|
static let hotkeyFullscreen = "HotkeyFullscreen"
|
||||||
static let hotkeyWindow = "HotkeyWindow"
|
static let hotkeyWindow = "HotkeyWindow"
|
||||||
@@ -208,6 +210,11 @@ final class Configuration {
|
|||||||
set { defaults.set(newValue, forKey: Keys.showSelectionSize) }
|
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
|
// MARK: - Destinations
|
||||||
|
|
||||||
var defaultDestination: DestinationID {
|
var defaultDestination: DestinationID {
|
||||||
|
|||||||
@@ -76,6 +76,38 @@ enum ImageExporter {
|
|||||||
return url
|
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 {
|
static func format(forPathExtension pathExtension: String) -> OutputFormat {
|
||||||
switch pathExtension.lowercased() {
|
switch pathExtension.lowercased() {
|
||||||
case "jpg", "jpeg": return .jpg
|
case "jpg", "jpeg": return .jpg
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ final class DestinationPickerWindowController: NSWindowController, NSWindowDeleg
|
|||||||
window.isReleasedWhenClosed = false
|
window.isReleasedWhenClosed = false
|
||||||
window.level = .floating
|
window.level = .floating
|
||||||
window.center()
|
window.center()
|
||||||
|
Theme.styleWindowChrome(window)
|
||||||
super.init(window: window)
|
super.init(window: window)
|
||||||
window.delegate = self
|
window.delegate = self
|
||||||
buildInterface()
|
buildInterface()
|
||||||
@@ -54,15 +55,19 @@ final class DestinationPickerWindowController: NSWindowController, NSWindowDeleg
|
|||||||
private func buildInterface() {
|
private func buildInterface() {
|
||||||
guard let window, let contentView = window.contentView else { return }
|
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()
|
let preview = NSImageView()
|
||||||
preview.image = capture.nsImage
|
preview.image = capture.nsImage
|
||||||
preview.imageScaling = .scaleProportionallyDown
|
preview.imageScaling = .scaleProportionallyDown
|
||||||
preview.imageAlignment = .alignCenter
|
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.translatesAutoresizingMaskIntoConstraints = false
|
||||||
preview.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
preview.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
preview.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
preview.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
||||||
@@ -86,17 +91,19 @@ final class DestinationPickerWindowController: NSWindowController, NSWindowDeleg
|
|||||||
separator.boxType = .separator
|
separator.boxType = .separator
|
||||||
separator.translatesAutoresizingMaskIntoConstraints = false
|
separator.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
contentView.addSubview(preview)
|
let previewWell = Theme.panel(preview, radius: Theme.panelRadius, inset: 8)
|
||||||
|
|
||||||
|
contentView.addSubview(previewWell)
|
||||||
contentView.addSubview(separator)
|
contentView.addSubview(separator)
|
||||||
contentView.addSubview(buttonRow)
|
contentView.addSubview(buttonRow)
|
||||||
contentView.addSubview(rememberCheckbox)
|
contentView.addSubview(rememberCheckbox)
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
preview.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16),
|
previewWell.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16),
|
||||||
preview.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
previewWell.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
||||||
preview.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, 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.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
||||||
separator.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, 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 aspect = capture.pointSize.height / max(1, capture.pointSize.width)
|
||||||
let previewWidth: CGFloat = 528
|
let previewWidth: CGFloat = 528
|
||||||
let previewHeight = min(max(180, previewWidth * aspect), 380)
|
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.setContentSize(NSSize(width: 560, height: previewHeight + 190))
|
||||||
window.center()
|
window.center()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ protocol EditorCanvasDelegate: AnyObject {
|
|||||||
func canvasDidChangeSelection(_ canvas: EditorCanvasView)
|
func canvasDidChangeSelection(_ canvas: EditorCanvasView)
|
||||||
func canvasDidChangeContent(_ canvas: EditorCanvasView)
|
func canvasDidChangeContent(_ canvas: EditorCanvasView)
|
||||||
func canvasDidRequestToolChange(_ canvas: EditorCanvasView, to tool: ToolKind)
|
func canvasDidRequestToolChange(_ canvas: EditorCanvasView, to tool: ToolKind)
|
||||||
|
func canvasDidPickColor(_ canvas: EditorCanvasView, color: NSColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ResizeHandle: Int, CaseIterable {
|
enum ResizeHandle: Int, CaseIterable {
|
||||||
@@ -100,7 +101,6 @@ final class EditorCanvasView: NSView, NSTextViewDelegate {
|
|||||||
self.surface = surface
|
self.surface = surface
|
||||||
super.init(frame: CGRect(origin: .zero, size: surface.imageSize))
|
super.init(frame: CGRect(origin: .zero, size: surface.imageSize))
|
||||||
wantsLayer = true
|
wantsLayer = true
|
||||||
layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
|
|
||||||
surface.onChange = { [weak self] in
|
surface.onChange = { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.needsDisplay = true
|
self.needsDisplay = true
|
||||||
@@ -196,6 +196,7 @@ final class EditorCanvasView: NSView, NSTextViewDelegate {
|
|||||||
switch tool {
|
switch tool {
|
||||||
case .select: cursor = .arrow
|
case .select: cursor = .arrow
|
||||||
case .text: cursor = .iBeam
|
case .text: cursor = .iBeam
|
||||||
|
case .dropper: cursor = .crosshair
|
||||||
default: cursor = .crosshair
|
default: cursor = .crosshair
|
||||||
}
|
}
|
||||||
addCursorRect(bounds, cursor: cursor)
|
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
|
// The tool must stay on .text until editing finishes: switching tools calls
|
||||||
// endTextEditing(), which would discard the element before anything is typed.
|
// endTextEditing(), which would discard the element before anything is typed.
|
||||||
beginTextEditing(element)
|
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:
|
case .counter:
|
||||||
let element = CounterElement(center: point, number: surface.nextCounterNumber, style: style)
|
let element = CounterElement(center: point, number: surface.nextCounterNumber, style: style)
|
||||||
surface.nextCounterNumber += 1
|
surface.nextCounterNumber += 1
|
||||||
@@ -233,6 +240,7 @@ final class EditorCanvasView: NSView, NSTextViewDelegate {
|
|||||||
interaction = .none
|
interaction = .none
|
||||||
case .rectangle, .ellipse, .line, .arrow, .highlight, .obfuscate:
|
case .rectangle, .ellipse, .line, .arrow, .highlight, .obfuscate:
|
||||||
let element = makeElement(for: tool, at: point)
|
let element = makeElement(for: tool, at: point)
|
||||||
|
element.creationAnchor = point
|
||||||
surface.add(element)
|
surface.add(element)
|
||||||
surface.selection = element
|
surface.selection = element
|
||||||
interaction = .creating(element)
|
interaction = .creating(element)
|
||||||
@@ -246,8 +254,8 @@ final class EditorCanvasView: NSView, NSTextViewDelegate {
|
|||||||
switch tool {
|
switch tool {
|
||||||
case .rectangle: return RectangleElement(frame: frame, style: style)
|
case .rectangle: return RectangleElement(frame: frame, style: style)
|
||||||
case .ellipse: return EllipseElement(frame: frame, style: style)
|
case .ellipse: return EllipseElement(frame: frame, style: style)
|
||||||
case .line: return LineElement(frame: frame, style: style)
|
case .line: return LineElement(start: point, end: point, style: style)
|
||||||
case .arrow: return ArrowElement(frame: frame, style: style)
|
case .arrow: return ArrowElement(start: point, end: point, style: style)
|
||||||
case .highlight: return HighlightElement(frame: frame, style: style)
|
case .highlight: return HighlightElement(frame: frame, style: style)
|
||||||
case .obfuscate: return ObfuscateElement(frame: frame, style: style)
|
case .obfuscate: return ObfuscateElement(frame: frame, style: style)
|
||||||
default: return RectangleElement(frame: frame, style: style)
|
default: return RectangleElement(frame: frame, style: style)
|
||||||
@@ -287,6 +295,7 @@ final class EditorCanvasView: NSView, NSTextViewDelegate {
|
|||||||
surface.remove(existing)
|
surface.remove(existing)
|
||||||
}
|
}
|
||||||
let crop = CropElement(frame: CGRect(origin: point, size: .zero), style: style)
|
let crop = CropElement(frame: CGRect(origin: point, size: .zero), style: style)
|
||||||
|
crop.creationAnchor = point
|
||||||
surface.add(crop)
|
surface.add(crop)
|
||||||
surface.selection = crop
|
surface.selection = crop
|
||||||
interaction = .creating(crop)
|
interaction = .creating(crop)
|
||||||
@@ -298,20 +307,23 @@ final class EditorCanvasView: NSView, NSTextViewDelegate {
|
|||||||
|
|
||||||
switch interaction {
|
switch interaction {
|
||||||
case .creating(let element):
|
case .creating(let element):
|
||||||
|
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
|
var target = point
|
||||||
if constrain {
|
if constrain {
|
||||||
if element is LineElement {
|
let side = max(abs(point.x - anchorPoint.x), abs(point.y - anchorPoint.y))
|
||||||
target = constrainedLineEnd(from: element.frame.origin, to: point)
|
target = CGPoint(x: anchorPoint.x + side * (point.x < anchorPoint.x ? -1 : 1),
|
||||||
} else {
|
y: anchorPoint.y + side * (point.y < anchorPoint.y ? -1 : 1))
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
// 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()
|
surface.changed()
|
||||||
case .moving(let element, let origin):
|
case .moving(let element, let origin):
|
||||||
element.move(by: CGVector(dx: point.x - origin.x, dy: point.y - origin.y))
|
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 {
|
switch interaction {
|
||||||
case .creating(let element):
|
case .creating(let element):
|
||||||
let rect = element.normalizedFrame
|
let rect = element.normalizedFrame
|
||||||
let tooSmall = element is LineElement
|
let tooSmall = (element as? LineElement).map { $0.length < 4 }
|
||||||
? hypot(element.frame.width, element.frame.height) < 4
|
?? (rect.width < 4 || rect.height < 4)
|
||||||
: (rect.width < 4 || rect.height < 4)
|
element.creationAnchor = nil
|
||||||
if tooSmall {
|
if tooSmall {
|
||||||
surface.remove(element)
|
surface.remove(element)
|
||||||
} else if !(element is LineElement) {
|
} else if !(element is LineElement) {
|
||||||
element.frame = rect
|
element.frame = rect
|
||||||
if element is CropElement { surface.cropElement = element as? CropElement }
|
if element is CropElement { surface.cropElement = element as? CropElement }
|
||||||
}
|
}
|
||||||
if !(element is CropElement) {
|
// The tool stays active so several shapes can be drawn in a row.
|
||||||
delegate?.canvasDidRequestToolChange(self, to: .select)
|
|
||||||
}
|
|
||||||
case .moving, .resizing, .drawingFreehand, .none:
|
case .moving, .resizing, .drawingFreehand, .none:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -448,9 +458,6 @@ final class EditorCanvasView: NSView, NSTextViewDelegate {
|
|||||||
surface.changed()
|
surface.changed()
|
||||||
needsDisplay = true
|
needsDisplay = true
|
||||||
|
|
||||||
if tool == .text {
|
|
||||||
delegate?.canvasDidRequestToolChange(self, to: .select)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func textDidEndEditing(_ notification: Notification) {
|
func textDidEndEditing(_ notification: Notification) {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// EditorWindowController.swift
|
// EditorWindowController.swift
|
||||||
// GreenshotMac
|
// 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
|
import AppKit
|
||||||
|
|
||||||
@@ -11,19 +12,20 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate,
|
|||||||
|
|
||||||
private static var openEditors: [EditorWindowController] = []
|
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] = [
|
private static let palette: [NSColor] = [
|
||||||
.systemGreen, .systemRed, .systemYellow, .systemBlue,
|
.systemRed, .systemOrange, .systemYellow, .systemGreen,
|
||||||
.systemPurple, .cyan, .black, .white
|
.systemBlue, .systemPurple, .black, .white
|
||||||
]
|
]
|
||||||
|
|
||||||
let surface: Surface
|
let surface: Surface
|
||||||
private let canvas: EditorCanvasView
|
private let canvas: EditorCanvasView
|
||||||
private let scrollView = NSScrollView()
|
private let scrollView = NSScrollView()
|
||||||
private let toolBar = NSStackView()
|
private let optionsBar = NSStackView()
|
||||||
private let styleBar = StyleBarView()
|
private let styleBar = StyleBarView()
|
||||||
private var toolButtons: [ToolKind: NSButton] = [:]
|
private var toolButtons: [ToolKind: NSButton] = [:]
|
||||||
private var deleteButton: NSButton?
|
private var deleteButton: NSButton?
|
||||||
|
private let currentColorWell = NSColorWell()
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
@@ -43,8 +45,8 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate,
|
|||||||
|
|
||||||
let size = surface.imageSize
|
let size = surface.imageSize
|
||||||
let maxSize = (NSScreen.main?.visibleFrame.size ?? CGSize(width: 1400, height: 900))
|
let maxSize = (NSScreen.main?.visibleFrame.size ?? CGSize(width: 1400, height: 900))
|
||||||
let contentWidth = min(max(720, size.width), maxSize.width - 80)
|
let contentWidth = min(max(760, size.width + 120), maxSize.width - 80)
|
||||||
let contentHeight = min(max(400, size.height + 96), maxSize.height - 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),
|
let window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: contentWidth, height: contentHeight),
|
||||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||||
@@ -55,6 +57,7 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate,
|
|||||||
: "Screenshot — \(capture.details.title)"
|
: "Screenshot — \(capture.details.title)"
|
||||||
window.center()
|
window.center()
|
||||||
window.isReleasedWhenClosed = false
|
window.isReleasedWhenClosed = false
|
||||||
|
Theme.styleWindowChrome(window)
|
||||||
super.init(window: window)
|
super.init(window: window)
|
||||||
|
|
||||||
window.delegate = self
|
window.delegate = self
|
||||||
@@ -74,73 +77,79 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate,
|
|||||||
EditorWindowController.openEditors.removeAll { $0 === self }
|
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
|
// MARK: - Interface
|
||||||
|
|
||||||
private func buildInterface() {
|
private func buildInterface() {
|
||||||
guard let window, let contentView = window.contentView else { return }
|
guard let window, let contentView = window.contentView else { return }
|
||||||
|
|
||||||
toolBar.orientation = .horizontal
|
let chrome = buildOptionsChrome()
|
||||||
toolBar.spacing = 4
|
let rail = buildToolRail()
|
||||||
toolBar.alignment = .centerY
|
|
||||||
toolBar.edgeInsets = NSEdgeInsets(top: 6, left: 12, bottom: 6, right: 12)
|
|
||||||
|
|
||||||
// Destinations first, as in the Greenshot toolbar.
|
scrollView.documentView = canvas
|
||||||
for destination in [DestinationID.file, .email, .clipboard, .fileWithDialog, .printer, .share] {
|
scrollView.hasVerticalScroller = true
|
||||||
toolBar.addArrangedSubview(destinationButton(for: destination))
|
scrollView.hasHorizontalScroller = true
|
||||||
}
|
scrollView.allowsMagnification = false
|
||||||
toolBar.addArrangedSubview(NSBox.separator())
|
scrollView.borderType = .noBorder
|
||||||
|
scrollView.backgroundColor = .underPageBackgroundColor
|
||||||
|
scrollView.drawsBackground = true
|
||||||
|
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
// Drawing tools.
|
let railDivider = Theme.divider(axis: .vertical)
|
||||||
for tool in ToolKind.allCases {
|
let chromeDivider = Theme.divider(axis: .horizontal)
|
||||||
let button = NSButton()
|
|
||||||
EditorWindowController.configure(button, symbol: tool.symbolName, fallbackTitle: tool.title)
|
contentView.addSubview(chrome)
|
||||||
button.bezelStyle = .texturedRounded
|
contentView.addSubview(chromeDivider)
|
||||||
button.setButtonType(.pushOnPushOff)
|
contentView.addSubview(rail)
|
||||||
button.toolTip = "\(tool.title) (\(tool.keyEquivalent.uppercased()))"
|
contentView.addSubview(railDivider)
|
||||||
button.target = self
|
contentView.addSubview(scrollView)
|
||||||
button.action = #selector(toolButtonClicked(_:))
|
|
||||||
button.tag = ToolKind.allCases.firstIndex(of: tool) ?? 0
|
NSLayoutConstraint.activate([
|
||||||
toolButtons[tool] = button
|
// Options chrome spans the top.
|
||||||
toolBar.addArrangedSubview(button)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
toolBar.addArrangedSubview(NSBox.separator())
|
/// 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
|
||||||
|
|
||||||
// Delete the selected element.
|
for destination in [DestinationID.file, .fileWithDialog, .clipboard, .email, .printer, .share] {
|
||||||
let delete = NSButton()
|
optionsBar.addArrangedSubview(destinationButton(for: destination))
|
||||||
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())
|
let spacer = NSView()
|
||||||
|
spacer.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
spacer.setContentHuggingPriority(.init(1), for: .horizontal)
|
||||||
|
optionsBar.addArrangedSubview(spacer)
|
||||||
|
|
||||||
// Zoom controls.
|
|
||||||
let zoomControls: [(String, String, Selector)] = [
|
let zoomControls: [(String, String, Selector)] = [
|
||||||
("minus.magnifyingglass", "Zoom out (⌘−)", #selector(zoomOut(_:))),
|
("minus.magnifyingglass", "Zoom out (⌘−)", #selector(zoomOut(_:))),
|
||||||
("plus.magnifyingglass", "Zoom in (⌘+)", #selector(zoomIn(_:))),
|
("plus.magnifyingglass", "Zoom in (⌘+)", #selector(zoomIn(_:))),
|
||||||
@@ -150,51 +159,173 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate,
|
|||||||
for (symbol, tip, action) in zoomControls {
|
for (symbol, tip, action) in zoomControls {
|
||||||
let button = NSButton()
|
let button = NSButton()
|
||||||
EditorWindowController.configure(button, symbol: symbol, fallbackTitle: tip)
|
EditorWindowController.configure(button, symbol: symbol, fallbackTitle: tip)
|
||||||
button.bezelStyle = .texturedRounded
|
Theme.styleButton(button)
|
||||||
button.toolTip = tip
|
button.toolTip = tip
|
||||||
button.target = self
|
button.target = self
|
||||||
button.action = action
|
button.action = action
|
||||||
toolBar.addArrangedSubview(button)
|
optionsBar.addArrangedSubview(button)
|
||||||
}
|
}
|
||||||
|
|
||||||
styleBar.delegate = self
|
styleBar.delegate = self
|
||||||
|
|
||||||
scrollView.documentView = canvas
|
let stack = NSStackView(views: [optionsBar, styleBar])
|
||||||
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.orientation = .vertical
|
||||||
stack.spacing = 0
|
stack.spacing = 0
|
||||||
stack.alignment = .leading
|
stack.alignment = .leading
|
||||||
stack.distribution = .fill
|
|
||||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
contentView.addSubview(stack)
|
|
||||||
|
|
||||||
|
let chrome = Theme.backdrop(material: .headerView)
|
||||||
|
Theme.fill(stack, in: chrome)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
optionsBar.leadingAnchor.constraint(equalTo: stack.leadingAnchor),
|
||||||
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
optionsBar.trailingAnchor.constraint(equalTo: stack.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.leadingAnchor.constraint(equalTo: stack.leadingAnchor),
|
||||||
styleBar.trailingAnchor.constraint(equalTo: stack.trailingAnchor),
|
styleBar.trailingAnchor.constraint(equalTo: stack.trailingAnchor)
|
||||||
scrollView.leadingAnchor.constraint(equalTo: stack.leadingAnchor),
|
])
|
||||||
scrollView.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 {
|
private func destinationButton(for destination: DestinationID) -> NSButton {
|
||||||
let button = NSButton()
|
let button = NSButton()
|
||||||
EditorWindowController.configure(button, symbol: destination.symbolName, fallbackTitle: destination.pickerTitle)
|
EditorWindowController.configure(button, symbol: destination.symbolName, fallbackTitle: destination.pickerTitle)
|
||||||
button.bezelStyle = .texturedRounded
|
Theme.styleButton(button)
|
||||||
button.toolTip = destination.title
|
button.toolTip = destination.title
|
||||||
button.target = self
|
button.target = self
|
||||||
button.action = #selector(destinationButtonClicked(_:))
|
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,
|
/// 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) {
|
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.image = image
|
||||||
button.imagePosition = .imageOnly
|
button.imagePosition = .imageOnly
|
||||||
} else {
|
} else {
|
||||||
@@ -216,10 +349,11 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate,
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func swatchImage(_ color: NSColor) -> NSImage {
|
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)
|
let image = NSImage(size: size)
|
||||||
image.lockFocus()
|
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()
|
color.setFill()
|
||||||
path.fill()
|
path.fill()
|
||||||
NSColor.separatorColor.setStroke()
|
NSColor.separatorColor.setStroke()
|
||||||
@@ -251,8 +385,14 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate,
|
|||||||
|
|
||||||
@objc private func colorSwatchClicked(_ sender: NSButton) {
|
@objc private func colorSwatchClicked(_ sender: NSButton) {
|
||||||
guard sender.tag >= 0, sender.tag < EditorWindowController.palette.count else { return }
|
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.
|
// The highlighter has its own colour field; everything else uses the line colour.
|
||||||
let isHighlight = canvas.tool == .highlight || surface.selection is HighlightElement
|
let isHighlight = canvas.tool == .highlight || surface.selection is HighlightElement
|
||||||
canvas.applyStyleToSelection { style in
|
canvas.applyStyleToSelection { style in
|
||||||
@@ -267,6 +407,7 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate,
|
|||||||
} else {
|
} else {
|
||||||
Configuration.shared.editorLineColor = color
|
Configuration.shared.editorLineColor = color
|
||||||
}
|
}
|
||||||
|
currentColorWell.color = color
|
||||||
updateStyleBar()
|
updateStyleBar()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +425,7 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate,
|
|||||||
style: style,
|
style: style,
|
||||||
showCrop: surface.cropElement != nil)
|
showCrop: surface.cropElement != nil)
|
||||||
deleteButton?.isEnabled = surface.selection != nil
|
deleteButton?.isEnabled = surface.selection != nil
|
||||||
|
currentColorWell.color = capabilities.contains(.highlightColor) ? style.highlightColor : style.lineColor
|
||||||
}
|
}
|
||||||
|
|
||||||
private func capabilitiesForTool(_ tool: ToolKind) -> StyleCapabilities {
|
private func capabilitiesForTool(_ tool: ToolKind) -> StyleCapabilities {
|
||||||
@@ -295,6 +437,7 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate,
|
|||||||
case .counter: return [.lineColor, .fillColor, .font, .shadow]
|
case .counter: return [.lineColor, .fillColor, .font, .shadow]
|
||||||
case .highlight: return [.highlightColor]
|
case .highlight: return [.highlightColor]
|
||||||
case .obfuscate: return [.obfuscation]
|
case .obfuscate: return [.obfuscation]
|
||||||
|
case .dropper: return []
|
||||||
case .crop: return []
|
case .crop: return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,6 +458,10 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate,
|
|||||||
select(tool: tool)
|
select(tool: tool)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func canvasDidPickColor(_ canvas: EditorCanvasView, color: NSColor) {
|
||||||
|
apply(color: color)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Style bar delegate
|
// MARK: - Style bar delegate
|
||||||
|
|
||||||
func styleBar(_ bar: StyleBarView, didChange mutate: @escaping (inout ElementStyle) -> Void) {
|
func styleBar(_ bar: StyleBarView, didChange mutate: @escaping (inout ElementStyle) -> Void) {
|
||||||
@@ -391,13 +538,3 @@ final class EditorWindowController: NSWindowController, NSWindowDelegate,
|
|||||||
surface.sendToBack(selection)
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ enum ToolKind: String, CaseIterable {
|
|||||||
case highlight
|
case highlight
|
||||||
case obfuscate
|
case obfuscate
|
||||||
case counter
|
case counter
|
||||||
|
case dropper
|
||||||
case crop
|
case crop
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
@@ -32,6 +33,7 @@ enum ToolKind: String, CaseIterable {
|
|||||||
case .highlight: return "Highlight"
|
case .highlight: return "Highlight"
|
||||||
case .obfuscate: return "Obfuscate"
|
case .obfuscate: return "Obfuscate"
|
||||||
case .counter: return "Step counter"
|
case .counter: return "Step counter"
|
||||||
|
case .dropper: return "Colour picker"
|
||||||
case .crop: return "Crop"
|
case .crop: return "Crop"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,15 +41,16 @@ enum ToolKind: String, CaseIterable {
|
|||||||
var symbolName: String {
|
var symbolName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .select: return "cursorarrow"
|
case .select: return "cursorarrow"
|
||||||
case .rectangle: return "rectangle"
|
case .rectangle: return "square"
|
||||||
case .ellipse: return "circle"
|
case .ellipse: return "circle"
|
||||||
case .line: return "line.diagonal"
|
case .line: return "line.diagonal"
|
||||||
case .arrow: return "arrow.up.right"
|
case .arrow: return "arrow.up.forward"
|
||||||
case .freehand: return "scribble"
|
case .freehand: return "scribble.variable"
|
||||||
case .text: return "textformat"
|
case .text: return "character.textbox"
|
||||||
case .highlight: return "highlighter"
|
case .highlight: return "highlighter"
|
||||||
case .obfuscate: return "square.grid.3x3.fill"
|
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"
|
case .crop: return "crop"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,6 +67,7 @@ enum ToolKind: String, CaseIterable {
|
|||||||
case .highlight: return "h"
|
case .highlight: return "h"
|
||||||
case .obfuscate: return "o"
|
case .obfuscate: return "o"
|
||||||
case .counter: return "c"
|
case .counter: return "c"
|
||||||
|
case .dropper: return "i"
|
||||||
case .crop: return "k"
|
case .crop: return "k"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,6 +122,8 @@ struct StyleCapabilities: OptionSet, Hashable {
|
|||||||
class Element {
|
class Element {
|
||||||
var frame: CGRect
|
var frame: CGRect
|
||||||
var style: ElementStyle
|
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) {
|
init(frame: CGRect, style: ElementStyle) {
|
||||||
self.frame = frame
|
self.frame = frame
|
||||||
@@ -217,13 +223,61 @@ final class EllipseElement: Element {
|
|||||||
override func copyElement() -> Element { EllipseElement(frame: frame, style: style) }
|
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 {
|
class LineElement: Element {
|
||||||
var start: CGPoint { frame.origin }
|
private(set) var start: CGPoint
|
||||||
var end: CGPoint { CGPoint(x: frame.origin.x + frame.width, y: frame.origin.y + frame.height) }
|
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 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) {
|
override func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {
|
||||||
context.saveGState()
|
context.saveGState()
|
||||||
applyShadow(context)
|
applyShadow(context)
|
||||||
@@ -249,7 +303,7 @@ class LineElement: Element {
|
|||||||
return hypot(point.x - (a.x + t * dx), point.y - (a.y + t * dy))
|
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 {
|
final class ArrowElement: LineElement {
|
||||||
@@ -282,7 +336,7 @@ final class ArrowElement: LineElement {
|
|||||||
context.restoreGState()
|
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 {
|
final class FreehandElement: Element {
|
||||||
|
|||||||
@@ -32,10 +32,19 @@ private struct GeneralPreferences: View {
|
|||||||
@AppStorage(Configuration.Keys.showMagnifier) private var magnifier = true
|
@AppStorage(Configuration.Keys.showMagnifier) private var magnifier = true
|
||||||
@AppStorage(Configuration.Keys.showSelectionSize) private var selectionSize = true
|
@AppStorage(Configuration.Keys.showSelectionSize) private var selectionSize = true
|
||||||
@AppStorage(Configuration.Keys.windowCaptureMode) private var windowMode = WindowCaptureMode.window.rawValue
|
@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
|
@State private var launchAtLogin = LaunchAtLogin.isEnabled
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
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) {
|
Picker("After capture:", selection: $destination) {
|
||||||
ForEach(DestinationID.allCases.filter { $0 != .openWith }, id: \.rawValue) { item in
|
ForEach(DestinationID.allCases.filter { $0 != .openWith }, id: \.rawValue) { item in
|
||||||
Text(item.title).tag(item.rawValue)
|
Text(item.title).tag(item.rawValue)
|
||||||
|
|||||||
Reference in New Issue
Block a user