Initial commit

This commit is contained in:
Alex E. Tafoya
2026-09-17 15:15:45 -05:00
commit 87fe7927c4
36 changed files with 5113 additions and 0 deletions
+348
View File
@@ -0,0 +1,348 @@
// Elements.swift
// GreenshotMac
//
// Port of Greenshot's DrawableContainer hierarchy. Every element lives in
// image point coordinates with a top-left origin, matching the flipped canvas.
import AppKit
import CoreImage
enum ToolKind: String, CaseIterable {
case select
case rectangle
case ellipse
case line
case arrow
case freehand
case text
case highlight
case obfuscate
case counter
case crop
var title: String {
switch self {
case .select: return "Select"
case .rectangle: return "Rectangle"
case .ellipse: return "Ellipse"
case .line: return "Line"
case .arrow: return "Arrow"
case .freehand: return "Freehand"
case .text: return "Text"
case .highlight: return "Highlight"
case .obfuscate: return "Obfuscate"
case .counter: return "Step counter"
case .crop: return "Crop"
}
}
var symbolName: String {
switch self {
case .select: return "cursorarrow"
case .rectangle: return "rectangle"
case .ellipse: return "circle"
case .line: return "line.diagonal"
case .arrow: return "arrow.up.right"
case .freehand: return "scribble"
case .text: return "textformat"
case .highlight: return "highlighter"
case .obfuscate: return "square.grid.3x3.fill"
case .counter: return "1.circle"
case .crop: return "crop"
}
}
var keyEquivalent: String {
switch self {
case .select: return "v"
case .rectangle: return "r"
case .ellipse: return "e"
case .line: return "l"
case .arrow: return "a"
case .freehand: return "f"
case .text: return "t"
case .highlight: return "h"
case .obfuscate: return "o"
case .counter: return "c"
case .crop: return "k"
}
}
}
enum ObfuscateMode: String, CaseIterable {
case pixelate
case blur
var title: String { self == .pixelate ? "Pixelate" : "Blur" }
}
/// The drawing fields Greenshot keeps per element (FieldType in the original).
struct ElementStyle {
var lineColor: NSColor = Configuration.shared.editorLineColor
var fillColor: NSColor = Configuration.shared.editorFillColor
var lineThickness: CGFloat = CGFloat(Configuration.shared.editorLineThickness)
var highlightColor: NSColor = Configuration.shared.editorHighlightColor
var fontFamily: String = Configuration.shared.editorFontFamily
var fontSize: CGFloat = CGFloat(Configuration.shared.editorFontSize)
var bold: Bool = false
var italic: Bool = false
var blurRadius: CGFloat = CGFloat(Configuration.shared.editorBlurRadius)
var pixelSize: CGFloat = CGFloat(Configuration.shared.editorPixelSize)
var obfuscateMode: ObfuscateMode = .pixelate
var dropShadow: Bool = Configuration.shared.editorDropShadow
var font: NSFont {
var traits: NSFontDescriptor.SymbolicTraits = []
if bold { traits.insert(.bold) }
if italic { traits.insert(.italic) }
let descriptor = NSFontDescriptor(name: fontFamily, size: fontSize).withSymbolicTraits(traits)
return NSFont(descriptor: descriptor, size: fontSize)
?? NSFont.systemFont(ofSize: fontSize)
}
}
/// Which style fields a tool exposes in the style bar.
struct StyleCapabilities: OptionSet, Hashable {
let rawValue: Int
static let lineColor = StyleCapabilities(rawValue: 1 << 0)
static let fillColor = StyleCapabilities(rawValue: 1 << 1)
static let lineThickness = StyleCapabilities(rawValue: 1 << 2)
static let font = StyleCapabilities(rawValue: 1 << 3)
static let obfuscation = StyleCapabilities(rawValue: 1 << 4)
static let highlightColor = StyleCapabilities(rawValue: 1 << 5)
static let shadow = StyleCapabilities(rawValue: 1 << 6)
}
// MARK: - Base element
class Element {
var frame: CGRect
var style: ElementStyle
init(frame: CGRect, style: ElementStyle) {
self.frame = frame
self.style = style
}
var capabilities: StyleCapabilities { [.lineColor, .fillColor, .lineThickness, .shadow] }
/// Elements that read the pixels underneath them (obfuscate, highlight) need the base image.
var needsBaseImage: Bool { false }
var isResizable: Bool { true }
func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {}
func hitTest(_ point: CGPoint) -> Bool {
frame.insetBy(dx: -4, dy: -4).contains(point)
}
func move(by delta: CGVector) {
frame = frame.offsetBy(dx: delta.dx, dy: delta.dy)
}
func resize(to rect: CGRect) {
frame = rect
}
func copyElement() -> Element {
let copy = Element(frame: frame, style: style)
return copy
}
// MARK: Drawing helpers
func applyShadow(_ context: CGContext) {
guard style.dropShadow else { return }
context.setShadow(offset: CGSize(width: 1.5, height: 1.5),
blur: 3,
color: NSColor.black.withAlphaComponent(0.5).cgColor)
}
var normalizedFrame: CGRect {
CGRect(x: min(frame.minX, frame.maxX),
y: min(frame.minY, frame.maxY),
width: abs(frame.width),
height: abs(frame.height))
}
}
// MARK: - Shapes
final class RectangleElement: Element {
override func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {
context.saveGState()
applyShadow(context)
let rect = normalizedFrame.insetBy(dx: style.lineThickness / 2, dy: style.lineThickness / 2)
if style.fillColor.alphaComponent > 0 {
context.setFillColor(style.fillColor.cgColor)
context.fill(rect)
}
if style.lineThickness > 0 {
context.setStrokeColor(style.lineColor.cgColor)
context.setLineWidth(style.lineThickness)
context.stroke(rect)
}
context.restoreGState()
}
override func copyElement() -> Element { RectangleElement(frame: frame, style: style) }
}
final class EllipseElement: Element {
override func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {
context.saveGState()
applyShadow(context)
let rect = normalizedFrame.insetBy(dx: style.lineThickness / 2, dy: style.lineThickness / 2)
if style.fillColor.alphaComponent > 0 {
context.setFillColor(style.fillColor.cgColor)
context.fillEllipse(in: rect)
}
if style.lineThickness > 0 {
context.setStrokeColor(style.lineColor.cgColor)
context.setLineWidth(style.lineThickness)
context.strokeEllipse(in: rect)
}
context.restoreGState()
}
override func hitTest(_ point: CGPoint) -> Bool {
let rect = normalizedFrame.insetBy(dx: -4, dy: -4)
guard rect.width > 0, rect.height > 0 else { return false }
let dx = (point.x - rect.midX) / (rect.width / 2)
let dy = (point.y - rect.midY) / (rect.height / 2)
return dx * dx + dy * dy <= 1
}
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.
class LineElement: Element {
var start: CGPoint { frame.origin }
var end: CGPoint { CGPoint(x: frame.origin.x + frame.width, y: frame.origin.y + frame.height) }
override var capabilities: StyleCapabilities { [.lineColor, .lineThickness, .shadow] }
override func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {
context.saveGState()
applyShadow(context)
context.setStrokeColor(style.lineColor.cgColor)
context.setLineWidth(max(1, style.lineThickness))
context.setLineCap(.round)
context.move(to: start)
context.addLine(to: end)
context.strokePath()
context.restoreGState()
}
override func hitTest(_ point: CGPoint) -> Bool {
distance(from: point, toSegment: start, end) <= max(6, style.lineThickness + 4)
}
func distance(from point: CGPoint, toSegment a: CGPoint, _ b: CGPoint) -> CGFloat {
let dx = b.x - a.x
let dy = b.y - a.y
if dx == 0 && dy == 0 { return hypot(point.x - a.x, point.y - a.y) }
var t = ((point.x - a.x) * dx + (point.y - a.y) * dy) / (dx * dx + dy * dy)
t = max(0, min(1, t))
return hypot(point.x - (a.x + t * dx), point.y - (a.y + t * dy))
}
override func copyElement() -> Element { LineElement(frame: frame, style: style) }
}
final class ArrowElement: LineElement {
override func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {
context.saveGState()
applyShadow(context)
let width = max(1, style.lineThickness)
let headLength = max(10, width * 4)
let angle = atan2(end.y - start.y, end.x - start.x)
let length = hypot(end.x - start.x, end.y - start.y)
let shaftEnd = CGPoint(x: end.x - cos(angle) * min(headLength * 0.8, length),
y: end.y - sin(angle) * min(headLength * 0.8, length))
context.setStrokeColor(style.lineColor.cgColor)
context.setFillColor(style.lineColor.cgColor)
context.setLineWidth(width)
context.setLineCap(.round)
context.move(to: start)
context.addLine(to: shaftEnd)
context.strokePath()
let spread = CGFloat.pi / 7
context.move(to: end)
context.addLine(to: CGPoint(x: end.x - cos(angle - spread) * headLength,
y: end.y - sin(angle - spread) * headLength))
context.addLine(to: CGPoint(x: end.x - cos(angle + spread) * headLength,
y: end.y - sin(angle + spread) * headLength))
context.closePath()
context.fillPath()
context.restoreGState()
}
override func copyElement() -> Element { ArrowElement(frame: frame, style: style) }
}
final class FreehandElement: Element {
private(set) var points: [CGPoint]
init(points: [CGPoint], style: ElementStyle) {
self.points = points
super.init(frame: FreehandElement.bounds(of: points), style: style)
}
override var capabilities: StyleCapabilities { [.lineColor, .lineThickness, .shadow] }
override var isResizable: Bool { false }
func append(_ point: CGPoint) {
points.append(point)
frame = FreehandElement.bounds(of: points)
}
static func bounds(of points: [CGPoint]) -> CGRect {
guard let first = points.first else { return .zero }
var rect = CGRect(origin: first, size: .zero)
for point in points.dropFirst() {
rect = rect.union(CGRect(origin: point, size: .zero))
}
return rect
}
override func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {
guard points.count > 1 else { return }
context.saveGState()
applyShadow(context)
context.setStrokeColor(style.lineColor.cgColor)
context.setLineWidth(max(1, style.lineThickness))
context.setLineCap(.round)
context.setLineJoin(.round)
context.move(to: points[0])
for point in points.dropFirst() { context.addLine(to: point) }
context.strokePath()
context.restoreGState()
}
override func hitTest(_ point: CGPoint) -> Bool {
let tolerance = max(6, style.lineThickness + 4)
for index in 1..<max(1, points.count) {
let a = points[index - 1]
let b = points[index]
let dx = b.x - a.x, dy = b.y - a.y
var t: CGFloat = 0
if dx != 0 || dy != 0 {
t = max(0, min(1, ((point.x - a.x) * dx + (point.y - a.y) * dy) / (dx * dx + dy * dy)))
}
if hypot(point.x - (a.x + t * dx), point.y - (a.y + t * dy)) <= tolerance { return true }
}
return false
}
override func move(by delta: CGVector) {
points = points.map { CGPoint(x: $0.x + delta.dx, y: $0.y + delta.dy) }
frame = FreehandElement.bounds(of: points)
}
override func copyElement() -> Element { FreehandElement(points: points, style: style) }
}