Compare commits

..
3 Commits
Author SHA1 Message Date
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
TZGyn 52e36b5c5a Add drawing color picker to the result window
A compact ColorPicker appears next to the Draw button when drawing is
enabled; strokes render and export in the chosen color (defaults to
system red).

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 13:33:32 +08:00
TZGyn a74a86d001 Fix vertical offset in ScreenCaptureKit crops
The captured CGImage has a top-left origin, while the crop rect was
computed in SCDisplay space (bottom-left). Flip Y within the display's
bounds when converting to pixel coordinates so the crop lands exactly
where the user selected.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 13:27:41 +08:00
3 changed files with 136 additions and 26 deletions
+8 -8
View File
@@ -118,18 +118,18 @@ final class ScreenCapture {
@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) -> CGImage? {
// cgRect is already in SCDisplay space (Y flipped relative to AppKit). // cgRect is in SCDisplay space (bottom-left origin, Y flipped vs AppKit).
// Derive the pixel scale from the image we actually got, relative to // The captured image has a TOP-LEFT origin. So within the display,
// the display's point frame. // we must flip Y: pixelY = displayHeight - (cgY - displayMinY) - cgHeight.
let scaleX = CGFloat(image.width) / display.frame.width let scaleX = CGFloat(image.width) / display.frame.width
let scaleY = CGFloat(image.height) / display.frame.height let scaleY = CGFloat(image.height) / display.frame.height
guard scaleX > 0, scaleY > 0 else { return nil } guard scaleX > 0, scaleY > 0 else { return nil }
// pxRect is in image pixel space with a TOP-LEFT origin (SCDisplay space). let localMinX = cgRect.minX - display.frame.minX
// CGImage.cropping(to:) expects the same top-left rect, so no Y flip let localMaxY = cgRect.maxY - display.frame.minY
// is needed here — this preserves the previous CGContext behavior. let flippedY = display.frame.height - localMaxY
let pxRect = CGRect( let pxRect = CGRect(
x: (cgRect.minX - display.frame.minX) * scaleX, x: localMinX * scaleX,
y: (cgRect.minY - display.frame.minY) * scaleY, y: flippedY * scaleY,
width: cgRect.width * scaleX, width: cgRect.width * scaleX,
height: cgRect.height * scaleY height: cgRect.height * scaleY
) )
+126 -16
View File
@@ -92,8 +92,14 @@ final class CaptureModel: ObservableObject {
@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: [[CGPoint]] = []
/// Redo stack of removed strokes
@Published var redoStack: [[CGPoint]] = []
/// 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
/// Current drawing color
@Published var drawColor: NSColor = .systemRed
/// Stroke thickness in points (image space)
@Published var strokeWidth: CGFloat = 3.0
enum CaptureMode { enum CaptureMode {
case screenshot case screenshot
@@ -105,6 +111,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 {
@@ -120,8 +143,8 @@ 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))
NSColor.systemRed.setStroke() drawColor.setStroke()
ctx.setLineWidth(3) ctx.setLineWidth(strokeWidth)
ctx.setLineCap(.round) ctx.setLineCap(.round)
ctx.setLineJoin(.round) ctx.setLineJoin(.round)
for stroke in strokes { for stroke in strokes {
@@ -140,10 +163,7 @@ final class CaptureModel: ObservableObject {
func addStroke(_ points: [CGPoint]) { func addStroke(_ points: [CGPoint]) {
guard points.count > 1 else { return } guard points.count > 1 else { return }
strokes.append(points) strokes.append(points)
} redoStack = []
func clearAnnotations() {
strokes = []
} }
var displayText: String { var displayText: String {
@@ -285,15 +305,47 @@ 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.strokes.isEmpty { if model.isDrawing {
Button { ColorPicker("", selection: Binding(
model.clearAnnotations() get: { Color(nsColor: model.drawColor) },
} label: { set: { model.drawColor = NSColor($0) }
Label("Clear", systemImage: "eraser") ), supportsOpacity: false)
.labelsHidden()
.frame(width: 28)
.help("Drawing color")
}
if model.isDrawing || !model.strokes.isEmpty {
HStack(spacing: 6) {
Button {
model.undoStroke()
} label: {
Image(systemName: "arrow.uturn.backward")
}
.buttonStyle(.bordered)
.disabled(!model.canUndo)
.keyboardShortcut("z", modifiers: .command)
.help("Undo last stroke (⌘Z)")
Button {
model.redoStroke()
} label: {
Image(systemName: "arrow.uturn.forward")
}
.buttonStyle(.bordered)
.disabled(!model.canRedo)
.keyboardShortcut("z", modifiers: [.command, .shift])
.help("Redo stroke (⌘⇧Z)")
Button {
model.clearAnnotations()
} label: {
Image(systemName: "eraser")
}
.buttonStyle(.bordered)
.disabled(model.strokes.isEmpty)
.help("Remove all drawn annotations")
} }
.buttonStyle(.bordered)
.foregroundStyle(.secondary)
.help("Remove all drawn annotations")
} }
Button { Button {
@@ -326,6 +378,64 @@ struct ResultView: View {
Divider() Divider()
// Draw toolbar (only when drawing)
if model.isDrawing {
HStack(spacing: 14) {
Label("Draw", systemImage: "pencil.tip")
.font(.caption)
.foregroundStyle(.secondary)
Divider().frame(height: 18)
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)
.help("Undo last stroke (⌘Z)")
Button {
model.redoStroke()
} label: {
Label("Redo", systemImage: "arrow.uturn.forward")
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(!model.canRedo)
.help("Redo stroke (⌘⇧Z)")
}
.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)
@@ -429,7 +539,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(.red), lineWidth: 2.5) context.stroke(path, with: .color(Color(nsColor: model.drawColor)), lineWidth: model.strokeWidth)
} }
.allowsHitTesting(false) .allowsHitTesting(false)
@@ -443,7 +553,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(.red), 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.1</string> <string>1.4.0</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>31</string> <string>40</string>
<key>LSUIElement</key> <key>LSUIElement</key>
<true/> <true/>
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>