Compare commits

...
8 Commits
Author SHA1 Message Date
TZGyn 5f137f89bf Fix window hover detection; add redaction tool
Window detection was broken by wrong CG-to-AppKit coordinate conversion
of the cursor and highlight rects — both now go through the same
cgToAppKit mapping. The draw toolbar gains a Pen/Redact toggle: redact
drags cover content with a solid black bar (participates in undo/redo
and export like pen strokes).

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 10:39:26 +08:00
TZGyn b998ea210e Document brew trust step for the Gitea tap 2026-09-09 10:36:20 +08:00
TZGyn 86826f7ecc Point Homebrew install at the Gitea-hosted tap
brew tap without a URL resolves to GitHub, where the tap no longer exists;
the full Gitea URL is required.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 10:32:47 +08:00
TZGyn 969cda6f14 Add window detection capture with rounded corners
Hovering during selection now highlights the window under the cursor;
clicking captures it with a rounded-corner alpha mask matching macOS
window chrome. Dragging still switches to manual marquee selection.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 10:29:24 +08:00
TZGyn de642ad692 Fix flipped drawing when copying/saving annotated images
The strokes were stored in top-left image coordinates but drawn into an
AppKit CGContext with a bottom-left origin, so exports came out mirrored
and offset. Flip Y in the context before stroking so annotations land
where they were drawn.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-24 09:08:37 +08:00
TZGyn 8b27263962 Make stroke thickness per-stroke; restore Clear button
Each drawn stroke now stores its own width and color, so changing the
thickness slider no longer retroactively resizes existing strokes. The
Clear button is back in the draw toolbar.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 14:00:00 +08:00
TZGyn 1961d0dc9c Move draw controls into the draw toolbar
The color picker, undo/redo, and clear eraser now live in the dedicated
draw top bar instead of the main toolbar, keeping the primary actions
(Copy/Save/Get Text) uncluttered. Keyboard shortcuts (⌘Z/⌘⇧Z) remain.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 13:55:00 +08:00
TZGyn 41cb06dcb5 Add draw toolbar with thickness, undo/redo, and color picker
When drawing is enabled a dedicated top bar shows a thickness slider,
color picker, and undo/redo buttons (⌘Z / ⌘⇧Z). Strokes render and export
with the chosen width and color.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 13:39:06 +08:00
5 changed files with 341 additions and 59 deletions
+5 -2
View File
@@ -25,11 +25,14 @@ No network calls, no API keys — everything runs locally.
## Install with Homebrew ## Install with Homebrew
```bash ```bash
brew tap TZGyn/tap brew tap TZGyn/tap https://gitea.tzgyn.dev/TZGyn/homebrew-tap.git
brew trust TZGyn/tap # Homebrew 6+ requires trusting third-party taps brew trust TZGyn/tap
brew install --cask kapture brew install --cask kapture
``` ```
The tap is hosted on Gitea, so the full URL is required. Homebrew may print
an "Unsupported tap" warning — it's harmless.
Releases are published as zips on GitHub Releases; `release.sh` packages a Releases are published as zips on GitHub Releases; `release.sh` packages a
new one (`./release.sh 1.0.1 && git tag v1.0.1 && git push origin v1.0.1`). new one (`./release.sh 1.0.1 && git tag v1.0.1 && git push origin v1.0.1`).
The cask lives in the [TZGyn/homebrew-tap](https://github.com/TZGyn/homebrew-tap) The cask lives in the [TZGyn/homebrew-tap](https://github.com/TZGyn/homebrew-tap)
+44 -7
View File
@@ -59,13 +59,25 @@ final class CaptureController: NSObject {
OverlayWindow( OverlayWindow(
screen: screen, screen: screen,
onComplete: { [weak self] rect in self?.finishSelection(rect) }, onComplete: { [weak self] rect in self?.finishSelection(rect) },
onCancel: { [weak self] in self?.cancelSelection() } onCancel: { [weak self] in self?.cancelSelection() },
onWindowPick: { [weak self] rect in self?.finishWindowPick(rect) }
) )
} }
overlays.forEach { $0.makeKeyAndOrderFront(nil) } overlays.forEach { $0.makeKeyAndOrderFront(nil) }
overlayIDs = overlays.map { $0.windowNumber } overlayIDs = overlays.map { $0.windowNumber }
} }
/// A window was picked from hover — capture it with rounded corners.
private func finishWindowPick(_ rect: NSRect) {
let ids = overlayIDs
hideOverlays()
isSelecting = false
restorePreviousApp()
DispatchQueue.main.async { [self] in
capture(rect, excludingWindowIDs: ids, roundedCorners: true)
}
}
private func finishSelection(_ rect: NSRect) { private func finishSelection(_ rect: NSRect) {
let ids = overlayIDs let ids = overlayIDs
hideOverlays() hideOverlays()
@@ -94,8 +106,8 @@ final class CaptureController: NSObject {
previousApp = nil previousApp = nil
} }
private func capture(_ rect: NSRect, excludingWindowIDs ids: [Int]) { private func capture(_ rect: NSRect, excludingWindowIDs ids: [Int], roundedCorners: Bool = false) {
ScreenCapture.shared.capture(rect, excludingWindowIDs: ids) { [weak self] image in ScreenCapture.shared.capture(rect, excludingWindowIDs: ids, roundedCorners: roundedCorners) { [weak self] image in
DispatchQueue.main.async { DispatchQueue.main.async {
self?.isSelecting = false self?.isSelecting = false
self?.restorePreviousApp() self?.restorePreviousApp()
@@ -117,7 +129,7 @@ final class ScreenCapture {
private init() {} private init() {}
@available(macOS 14.0, *) @available(macOS 14.0, *)
private func crop(_ image: CGImage, to cgRect: CGRect, display: SCDisplay) -> CGImage? { private func crop(_ image: CGImage, to cgRect: CGRect, display: SCDisplay, roundedCorners: Bool = false) -> CGImage? {
// cgRect is in SCDisplay space (bottom-left origin, Y flipped vs AppKit). // cgRect is in SCDisplay space (bottom-left origin, Y flipped vs AppKit).
// The captured image has a TOP-LEFT origin. So within the display, // The captured image has a TOP-LEFT origin. So within the display,
// we must flip Y: pixelY = displayHeight - (cgY - displayMinY) - cgHeight. // we must flip Y: pixelY = displayHeight - (cgY - displayMinY) - cgHeight.
@@ -141,7 +153,32 @@ final class ScreenCapture {
// CGContext, which resamples and loses quality on retina captures. // CGContext, which resamples and loses quality on retina captures.
let rounded = safeRect.integral let rounded = safeRect.integral
guard rounded.width >= 1, rounded.height >= 1 else { return nil } guard rounded.width >= 1, rounded.height >= 1 else { return nil }
return image.cropping(to: rounded) guard var result = image.cropping(to: rounded) else { return nil }
// Apply a rounded-corner alpha mask for window captures, matching
// macOS window chrome (≈10pt radius, scaled to image pixels).
if roundedCorners {
let radius = 10 * scaleX
let maskRect = CGRect(x: 0, y: 0, width: result.width, height: result.height)
guard let maskCtx = CGContext(
data: nil, width: result.width, height: result.height,
bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return result }
maskCtx.setFillColor(CGColor(gray: 1, alpha: 1))
maskCtx.fill(maskRect)
maskCtx.setBlendMode(.clear)
maskCtx.setFillColor(CGColor(gray: 0, alpha: 1))
maskCtx.addPath(CGPath(roundedRect: maskRect, cornerWidth: radius, cornerHeight: radius, transform: nil))
maskCtx.fillPath()
maskCtx.setBlendMode(.normal)
if let mask = maskCtx.makeImage(),
let masked = result.masking(mask) {
result = masked
}
}
return result
} }
@available(macOS 14.0, *) @available(macOS 14.0, *)
@@ -154,7 +191,7 @@ final class ScreenCapture {
return content.displays.first(where: { $0.displayID == cgID }) ?? content.displays.first return content.displays.first(where: { $0.displayID == cgID }) ?? content.displays.first
} }
func capture(_ rect: NSRect, excludingWindowIDs excludedIDs: [Int], completion: @escaping (CGImage?) -> Void) { func capture(_ rect: NSRect, excludingWindowIDs excludedIDs: [Int], roundedCorners: Bool = false, completion: @escaping (CGImage?) -> Void) {
if #available(macOS 14.0, *) { if #available(macOS 14.0, *) {
Task { Task {
guard let content = try? await SCShareableContent.current, guard let content = try? await SCShareableContent.current,
@@ -193,7 +230,7 @@ final class ScreenCapture {
completion(nil) completion(nil)
return return
} }
completion(self.crop(image, to: scRect, display: display)) completion(self.crop(image, to: scRect, display: display, roundedCorners: roundedCorners))
} }
} else { } else {
guard let primary = NSScreen.screens.first(where: { $0.frame.origin == .zero }) ?? NSScreen.screens.first else { guard let primary = NSScreen.screens.first(where: { $0.frame.origin == .zero }) ?? NSScreen.screens.first else {
+126 -6
View File
@@ -1,8 +1,8 @@
import AppKit import AppKit
final class OverlayWindow: NSPanel { final class OverlayWindow: NSPanel {
init(screen: NSScreen, onComplete: @escaping (NSRect) -> Void, onCancel: @escaping () -> Void) { init(screen: NSScreen, onComplete: @escaping (NSRect) -> Void, onCancel: @escaping () -> Void, onWindowPick: @escaping (NSRect) -> Void) {
let content = OverlayView(onComplete: onComplete, onCancel: onCancel) let content = OverlayView(onComplete: onComplete, onCancel: onCancel, onWindowPick: onWindowPick)
super.init( super.init(
contentRect: screen.frame, contentRect: screen.frame,
styleMask: [.borderless, .nonactivatingPanel], styleMask: [.borderless, .nonactivatingPanel],
@@ -25,13 +25,21 @@ final class OverlayWindow: NSPanel {
final class OverlayView: NSView { final class OverlayView: NSView {
private let onComplete: (NSRect) -> Void private let onComplete: (NSRect) -> Void
private let onCancel: () -> Void private let onCancel: () -> Void
private let onWindowPick: (NSRect) -> Void
private var startPoint: NSPoint? private var startPoint: NSPoint?
private(set) var selection: NSRect? private(set) var selection: NSRect?
private var isDragging = false
init(onComplete: @escaping (NSRect) -> Void, onCancel: @escaping () -> Void) { // Window detection
private var trackedWindows: [(id: CGWindowID, frame: NSRect)] = []
private var hoveredWindowFrame: NSRect?
private var excludedIDs: [Int] = []
init(onComplete: @escaping (NSRect) -> Void, onCancel: @escaping () -> Void, onWindowPick: @escaping (NSRect) -> Void) {
self.onComplete = onComplete self.onComplete = onComplete
self.onCancel = onCancel self.onCancel = onCancel
self.onWindowPick = onWindowPick
super.init(frame: .zero) super.init(frame: .zero)
} }
@@ -47,6 +55,80 @@ final class OverlayView: NSView {
addCursorRect(bounds, cursor: .crosshair) addCursorRect(bounds, cursor: .crosshair)
} }
// MARK: - Window detection
/// Rebuild the list of capturable windows intersecting this screen.
/// Called lazily on mouse moves; cheap enough for interactive hover.
private func refreshTrackedWindows() {
guard let window = window else { return }
let screenFrame = window.convertToScreen(NSRect(origin: .zero, size: bounds.size))
// Exclude this overlay's own window (the other overlays don't intersect
// this screen, so the on-screen filter handles them).
let ownID = window.windowNumber
guard let list = CGWindowListCopyWindowInfo(
[ .optionOnScreenOnly, .excludeDesktopElements ], kCGNullWindowID
) as? [[String: Any]] else { trackedWindows = []; return }
var found: [(CGWindowID, NSRect)] = []
for info in list {
guard let cid = info[kCGWindowNumber as String] as? CGWindowID,
let bounds = info[kCGWindowBounds as String] as? [String: CGFloat],
let layer = info[kCGWindowLayer as String] as? Int,
layer == 0
else { continue }
// Skip our own overlays
if Int(cid) == ownID { continue }
let rect = NSRect(
x: bounds["X"] ?? 0,
y: bounds["Y"] ?? 0,
width: bounds["Width"] ?? 0,
height: bounds["Height"] ?? 0
)
guard rect.width >= 80, rect.height >= 60 else { continue }
// Skip tiny/utility windows
guard rect.width < screenFrame.width || rect.height < screenFrame.height else { continue }
// Only windows intersecting this screen
guard rect.intersects(screenFrame) else { continue }
found.append((cid, rect))
}
trackedWindows = found
}
private func windowFrame(at point: NSPoint) -> NSRect? {
guard let window = window else { return nil }
// convertToScreen gives AppKit-global coordinates; convert tracked
// CG frames to the same space and pick the frontmost containing it.
let screenPoint = window.convertToScreen(NSRect(origin: point, size: .zero)).origin
for (_, frame) in trackedWindows {
if Self.cgToAppKit(rect: frame).contains(screenPoint) {
return frame
}
}
return nil
}
/// Convert a CGWindowList (top-left origin, CG global space) frame into
/// AppKit screen coordinates (bottom-left origin) for capture.
private static func cgToAppKit(rect: NSRect) -> NSRect {
guard let mainScreen = NSScreen.screens.first else { return rect }
let y = mainScreen.frame.height - rect.origin.y - rect.height
return NSRect(x: rect.origin.x, y: y, width: rect.width, height: rect.height)
}
// MARK: - Events
override func mouseMoved(with event: NSEvent) {
guard !isDragging else { return }
refreshTrackedWindows()
let p = convert(event.locationInWindow, from: nil)
let frame = windowFrame(at: p)
if frame != hoveredWindowFrame {
hoveredWindowFrame = frame
needsDisplay = true
}
}
override func mouseDown(with event: NSEvent) { override func mouseDown(with event: NSEvent) {
let p = convert(event.locationInWindow, from: nil) let p = convert(event.locationInWindow, from: nil)
startPoint = p startPoint = p
@@ -56,6 +138,8 @@ final class OverlayView: NSView {
override func mouseDragged(with event: NSEvent) { override func mouseDragged(with event: NSEvent) {
guard let start = startPoint else { return } guard let start = startPoint else { return }
isDragging = true
hoveredWindowFrame = nil
let p = convert(event.locationInWindow, from: nil) let p = convert(event.locationInWindow, from: nil)
selection = NSRect( selection = NSRect(
x: min(start.x, p.x), x: min(start.x, p.x),
@@ -67,9 +151,20 @@ final class OverlayView: NSView {
} }
override func mouseUp(with event: NSEvent) { override func mouseUp(with event: NSEvent) {
guard let sel = selection else { return } if isDragging {
if sel.width >= 2, sel.height >= 2 { guard let sel = selection else { return }
onComplete(window!.convertToScreen(sel)) if sel.width >= 2, sel.height >= 2 {
onComplete(window!.convertToScreen(sel))
} else {
onCancel()
}
return
}
// No drag: it was a click. If a window is hovered, capture it with
// rounded corners; otherwise treat as an empty marquee (cancel).
if let frame = hoveredWindowFrame {
onWindowPick(Self.cgToAppKit(rect: frame))
} else { } else {
onCancel() onCancel()
} }
@@ -87,6 +182,31 @@ final class OverlayView: NSView {
NSColor.black.withAlphaComponent(0.35).setFill() NSColor.black.withAlphaComponent(0.35).setFill()
NSBezierPath(rect: bounds).fill() NSBezierPath(rect: bounds).fill()
// Highlight the hovered window (rounded, like the real window corners)
if let frame = hoveredWindowFrame {
// Convert the CG frame to AppKit-global, then to this view's space.
guard let window = window else { return }
let screenFrame = window.convertToScreen(NSRect(origin: .zero, size: bounds.size))
let appKitFrame = Self.cgToAppKit(rect: frame)
let viewFrame = NSRect(
x: appKitFrame.origin.x - screenFrame.origin.x,
y: appKitFrame.origin.y - screenFrame.origin.y,
width: frame.width,
height: frame.height
)
NSGraphicsContext.current?.saveGraphicsState()
NSGraphicsContext.current?.compositingOperation = .clear
NSColor.black.setFill()
NSBezierPath(roundedRect: viewFrame, xRadius: 10, yRadius: 10).fill()
NSGraphicsContext.current?.restoreGraphicsState()
let border = NSBezierPath(roundedRect: viewFrame, xRadius: 10, yRadius: 10)
border.lineWidth = 2
NSColor.controlAccentColor.setStroke()
border.stroke()
return
}
guard let sel = selection, sel.width > 0, sel.height > 0 else { return } guard let sel = selection, sel.width > 0, sel.height > 0 else { return }
NSGraphicsContext.current?.saveGraphicsState() NSGraphicsContext.current?.saveGraphicsState()
+164 -42
View File
@@ -91,11 +91,24 @@ final class CaptureModel: ObservableObject {
/// Drag selection rect in IMAGE point space (nil = none) /// Drag selection rect in IMAGE point space (nil = none)
@Published var selection: CGRect? @Published var selection: CGRect?
/// Freehand drawing strokes in IMAGE point space /// Freehand drawing strokes in IMAGE point space
@Published var strokes: [[CGPoint]] = [] @Published var strokes: [Stroke] = []
/// Redo stack of removed strokes
@Published var redoStack: [Stroke] = []
/// Whether the pen is active (drag draws instead of selecting) /// Whether the pen is active (drag draws instead of selecting)
@Published var isDrawing = false @Published var isDrawing = false
/// Whether drawing redacts (black bar) instead of pen strokes
@Published var isRedacting = false
/// Current drawing color /// Current drawing color
@Published var drawColor: NSColor = .systemRed @Published var drawColor: NSColor = .systemRed
/// Stroke thickness in points (image space)
@Published var strokeWidth: CGFloat = 3.0
struct Stroke {
let points: [CGPoint]
let width: CGFloat
let color: NSColor
var isRedaction: Bool = false
}
enum CaptureMode { enum CaptureMode {
case screenshot case screenshot
@@ -107,6 +120,23 @@ final class CaptureModel: ObservableObject {
} }
var hasSelection: Bool { selection != nil && selection!.width >= 2 && selection!.height >= 2 } var hasSelection: Bool { selection != nil && selection!.width >= 2 && selection!.height >= 2 }
var canUndo: Bool { !strokes.isEmpty }
var canRedo: Bool { !redoStack.isEmpty }
func undoStroke() {
guard let last = strokes.popLast() else { return }
redoStack.append(last)
}
func redoStroke() {
guard let last = redoStack.popLast() else { return }
strokes.append(last)
}
func clearAnnotations() {
strokes = []
redoStack = []
}
/// Image with drawn strokes flattened in, for copy/save/export. /// Image with drawn strokes flattened in, for copy/save/export.
var annotatedImage: CGImage { var annotatedImage: CGImage {
@@ -122,30 +152,55 @@ final class CaptureModel: ObservableObject {
let ctx = NSGraphicsContext.current!.cgContext let ctx = NSGraphicsContext.current!.cgContext
// CGImage draws with a flipped Y in NSGraphicsContext // CGImage draws with a flipped Y in NSGraphicsContext
ctx.draw(image, in: NSRect(origin: .zero, size: size)) ctx.draw(image, in: NSRect(origin: .zero, size: size))
drawColor.setStroke() // Strokes are stored in TOP-LEFT image coordinates (from the SwiftUI
ctx.setLineWidth(3) // canvas), but the CGContext uses a BOTTOM-LEFT origin. Flip Y so the
// strokes land where they were drawn.
ctx.saveGState()
ctx.translateBy(x: 0, y: size.height)
ctx.scaleBy(x: 1, y: -1)
ctx.setLineCap(.round) ctx.setLineCap(.round)
ctx.setLineJoin(.round) ctx.setLineJoin(.round)
for stroke in strokes { for stroke in strokes {
guard stroke.count > 1 else { continue } guard stroke.points.count > 1 else { continue }
ctx.beginPath() if stroke.isRedaction {
ctx.move(to: stroke[0]) // Redaction bar: filled black rect covering the stroke bounds,
for point in stroke.dropFirst() { // padded so it fully covers text.
ctx.addLine(to: point) let xs = stroke.points.map { $0.x }
let ys = stroke.points.map { $0.y }
let bar = NSRect(
x: xs.min()! - 2,
y: ys.min()! - 2,
width: (xs.max()! - xs.min()!) + 4,
height: max(stroke.width, ys.max()! - ys.min()!) + 4
)
NSColor.black.setFill()
NSBezierPath(rect: bar).fill()
} else {
stroke.color.setStroke()
ctx.setLineWidth(stroke.width)
ctx.beginPath()
ctx.move(to: stroke.points[0])
for point in stroke.points.dropFirst() {
ctx.addLine(to: point)
}
ctx.strokePath()
} }
ctx.strokePath()
} }
ctx.restoreGState()
NSGraphicsContext.restoreGraphicsState() NSGraphicsContext.restoreGraphicsState()
return rep.cgImage ?? image return rep.cgImage ?? image
} }
func addStroke(_ points: [CGPoint]) { func addStroke(_ points: [CGPoint]) {
guard points.count > 1 else { return } guard points.count > 1 else { return }
strokes.append(points) if isRedacting {
} // Redaction: a filled black bar from start to end, as thick as the
// current stroke width (drawn as a rect for clean edges).
func clearAnnotations() { strokes.append(Stroke(points: points, width: max(strokeWidth, 12), color: .black, isRedaction: true))
strokes = [] } else {
strokes.append(Stroke(points: points, width: strokeWidth, color: drawColor))
}
redoStack = []
} }
var displayText: String { var displayText: String {
@@ -287,27 +342,6 @@ struct ResultView: View {
.foregroundStyle(model.isDrawing ? .primary : .secondary) .foregroundStyle(model.isDrawing ? .primary : .secondary)
.help("Toggle drawing on the screenshot") .help("Toggle drawing on the screenshot")
if model.isDrawing {
ColorPicker("", selection: Binding(
get: { Color(nsColor: model.drawColor) },
set: { model.drawColor = NSColor($0) }
), supportsOpacity: false)
.labelsHidden()
.frame(width: 28)
.help("Drawing color")
}
if !model.strokes.isEmpty {
Button {
model.clearAnnotations()
} label: {
Label("Clear", systemImage: "eraser")
}
.buttonStyle(.bordered)
.foregroundStyle(.secondary)
.help("Remove all drawn annotations")
}
Button { Button {
model.copyImage() model.copyImage()
} label: { } label: {
@@ -338,6 +372,82 @@ struct ResultView: View {
Divider() Divider()
// Draw toolbar (only when drawing)
if model.isDrawing {
HStack(spacing: 14) {
Picker("", selection: $model.isRedacting) {
Label("Pen", systemImage: "pencil.tip").tag(false)
Label("Redact", systemImage: "rectangle.fill").tag(true)
}
.pickerStyle(.segmented)
.fixedSize()
.help("Pen draws strokes; Redact covers text with a black bar")
Divider().frame(height: 18)
if !model.isRedacting {
HStack(spacing: 6) {
Image(systemName: "circle.fill").font(.system(size: 4))
Image(systemName: "circle.fill").font(.system(size: 7))
Image(systemName: "circle.fill").font(.system(size: 10))
}
.foregroundStyle(.secondary)
Slider(value: $model.strokeWidth, in: 1...12, step: 0.5)
.frame(width: 120)
.help("Stroke thickness")
Divider().frame(height: 18)
ColorPicker("", selection: Binding(
get: { Color(nsColor: model.drawColor) },
set: { model.drawColor = NSColor($0) }
), supportsOpacity: false)
.labelsHidden()
.frame(width: 28)
.help("Drawing color")
}
Spacer()
Button {
model.undoStroke()
} label: {
Label("Undo", systemImage: "arrow.uturn.backward")
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(!model.canUndo)
.keyboardShortcut("z", modifiers: .command)
.help("Undo last stroke (⌘Z)")
Button {
model.redoStroke()
} label: {
Label("Redo", systemImage: "arrow.uturn.forward")
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(!model.canRedo)
.keyboardShortcut("z", modifiers: [.command, .shift])
.help("Redo stroke (⌘⇧Z)")
Button {
model.clearAnnotations()
} label: {
Label("Clear", systemImage: "eraser")
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(model.strokes.isEmpty)
.help("Remove all drawn annotations")
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(.bar)
Divider()
}
// Content // Content
if model.mode == .screenshot { if model.mode == .screenshot {
ScreenshotPane(model: model) ScreenshotPane(model: model)
@@ -433,15 +543,27 @@ struct ScreenshotPane: View {
// Completed strokes // Completed strokes
Canvas { context, _ in Canvas { context, _ in
var path = Path()
for stroke in model.strokes { for stroke in model.strokes {
guard stroke.count > 1 else { continue } guard stroke.points.count > 1 else { continue }
path.move(to: CGPoint(x: origin.x + stroke[0].x * scale, y: origin.y + stroke[0].y * scale)) if stroke.isRedaction {
for point in stroke.dropFirst() { let xs = stroke.points.map { $0.x }
path.addLine(to: CGPoint(x: origin.x + point.x * scale, y: origin.y + point.y * scale)) let ys = stroke.points.map { $0.y }
let bar = CGRect(
x: origin.x + (xs.min()! - 2) * scale,
y: origin.y + (ys.min()! - 2) * scale,
width: ((xs.max()! - xs.min()!) + 4) * scale,
height: (max(stroke.width, ys.max()! - ys.min()!) + 4) * scale
)
context.fill(Path(bar), with: .color(.black))
} else {
var path = Path()
path.move(to: CGPoint(x: origin.x + stroke.points[0].x * scale, y: origin.y + stroke.points[0].y * scale))
for point in stroke.points.dropFirst() {
path.addLine(to: CGPoint(x: origin.x + point.x * scale, y: origin.y + point.y * scale))
}
context.stroke(path, with: .color(Color(nsColor: stroke.color)), lineWidth: stroke.width)
} }
} }
context.stroke(path, with: .color(Color(nsColor: model.drawColor)), lineWidth: 2.5)
} }
.allowsHitTesting(false) .allowsHitTesting(false)
@@ -455,7 +577,7 @@ struct ScreenshotPane: View {
path.addLine(to: CGPoint(x: origin.x + point.x * scale, y: origin.y + point.y * scale)) path.addLine(to: CGPoint(x: origin.x + point.x * scale, y: origin.y + point.y * scale))
} }
} }
context.stroke(path, with: .color(Color(nsColor: model.drawColor)), lineWidth: 2.5) context.stroke(path, with: .color(Color(nsColor: model.drawColor)), lineWidth: model.strokeWidth)
} }
.allowsHitTesting(false) .allowsHitTesting(false)
} }
+2 -2
View File
@@ -108,9 +108,9 @@ cat > "$APP/Contents/Info.plist" <<'PLIST'
<key>CFBundleIconFile</key> <key>CFBundleIconFile</key>
<string>Icon</string> <string>Icon</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.3.3</string> <string>1.5.1</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>33</string> <string>51</string>
<key>LSUIElement</key> <key>LSUIElement</key>
<true/> <true/>
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>