Compare commits

..
4 Commits
Author SHA1 Message Date
TZGyn 04b84e188a Retry OCR on upscaled images when nothing is read
Tiny UI text (around 7pt) reads as nothing at native size — verified
locally. When a pass returns fewer than three characters, OCR retries at
2x and 3x and keeps the fullest result. The smoke test covers a tiny-text
case so the fallback stays working.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-30 11:44:26 +08:00
TZGyn 2e971d5f8d Darken the screen in frozen capture mode
Frozen mode showed the still image at full brightness until a selection
existed, unlike live mode's dark veil. It now draws the same 35% veil over
everything and re-draws the selected area from the snapshot, keeping the
selection bright. The hint text matches the mode.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-25 17:43:59 +08:00
TZGyn e4e20c51d3 Add frozen-screen capture on Ctrl+Shift+Cmd+S
A second hotkey snapshots every display the moment it is pressed and
shows that still image as the overlay, so the pointer and hover states
(tooltips, open menus) are captured. Selection is marquee-only — no
window detection — and the result is cropped from the snapshot rather
than recaptured, keeping the frame exactly as it was.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-25 17:40:12 +08:00
TZGyn 26700c59f4 Make exported stroke thickness match the preview
Strokes were sized in view points but exported in image pixels, so on
retina captures the saved/copied annotation looked several times thinner.
Stroke widths are now converted into image pixels when drawn and scaled
back for the preview, keeping preview and export identical.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-23 17:28:38 +08:00
7 changed files with 304 additions and 61 deletions
+17 -8
View File
@@ -33,6 +33,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
HotkeyManager.shared.onTrigger = { CaptureController.shared.begin() } HotkeyManager.shared.onTrigger = { CaptureController.shared.begin() }
HotkeyManager.shared.onFrozenTrigger = { CaptureController.shared.begin(frozen: true) }
HotkeyManager.shared.register() HotkeyManager.shared.register()
} }
} }
@@ -44,6 +45,9 @@ struct MenuView: View {
Button("Capture & OCR") { CaptureController.shared.begin() } Button("Capture & OCR") { CaptureController.shared.begin() }
.keyboardShortcut("s", modifiers: [.command, .shift]) .keyboardShortcut("s", modifiers: [.command, .shift])
Button("Capture Frozen Screen") { CaptureController.shared.begin(frozen: true) }
.keyboardShortcut("s", modifiers: [.command, .shift, .control])
Button("Extract from Image File…") { ResultWindow.shared.showOpenPanel() } Button("Extract from Image File…") { ResultWindow.shared.showOpenPanel() }
Divider() Divider()
@@ -119,8 +123,8 @@ enum LoginItem {
} }
} }
func renderTestImage(_ text: String, fontSize: CGFloat = 56) -> CGImage? { func renderTestImage(_ text: String, fontSize: CGFloat = 56, width: CGFloat = 1900, height: CGFloat = 220) -> CGImage? {
let size = NSSize(width: 1900, height: 220) let size = NSSize(width: width, height: height)
guard let rep = NSBitmapImageRep( guard let rep = NSBitmapImageRep(
bitmapDataPlanes: nil, pixelsWide: Int(size.width), pixelsHigh: Int(size.height), bitmapDataPlanes: nil, pixelsWide: Int(size.width), pixelsHigh: Int(size.height),
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
@@ -134,15 +138,20 @@ func renderTestImage(_ text: String, fontSize: CGFloat = 56) -> CGImage? {
.font: NSFont.systemFont(ofSize: fontSize), .font: NSFont.systemFont(ofSize: fontSize),
.foregroundColor: NSColor.black, .foregroundColor: NSColor.black,
] ]
text.draw(at: NSPoint(x: 60, y: 75), withAttributes: attrs) // Centre the text vertically so tiny canvases still contain it.
let textHeight = ("Ag" as NSString).size(withAttributes: attrs).height
let y = max(4, (size.height - textHeight) / 2)
text.draw(at: NSPoint(x: 20, y: y), withAttributes: attrs)
NSGraphicsContext.restoreGraphicsState() NSGraphicsContext.restoreGraphicsState()
return rep.cgImage return rep.cgImage
} }
func runOCRSelfTest() { func runOCRSelfTest() {
let cases: [(expected: String, contains: String)] = [ let cases: [(expected: String, contains: String, fontSize: CGFloat, width: CGFloat, height: CGFloat)] = [
("Kapture OCR test 1234, hello world", "Kapture OCR test 1234"), ("Kapture OCR test 1234, hello world", "Kapture OCR test 1234", 56, 1900, 220),
("你好世界,这是一个测试", "你好世界"), ("你好世界,这是一个测试", "你好世界", 56, 1900, 220),
// Tiny text: needs the upscale retry to read anything at all.
("会话导出并发冲突报错 export fail", "导出", 7, 320, 40),
] ]
var index = 0 var index = 0
@@ -150,9 +159,9 @@ func runOCRSelfTest() {
guard index < cases.count else { exit(0) } guard index < cases.count else { exit(0) }
let current = cases[index] let current = cases[index]
index += 1 index += 1
guard let image = renderTestImage(current.expected) else { exit(1) } guard let image = renderTestImage(current.expected, fontSize: current.fontSize, width: current.width, height: current.height) else { exit(1) }
OCRService.shared.recognize(image) { recognized in OCRService.shared.recognize(image) { recognized in
print("case: \(current.expected)") print("case: \(current.expected) @\(Int(current.fontSize))pt")
print("got: \(recognized)") print("got: \(recognized)")
let ok = recognized.contains(current.contains) let ok = recognized.contains(current.contains)
print(ok ? "PASS" : "FAIL") print(ok ? "PASS" : "FAIL")
+122 -3
View File
@@ -76,13 +76,19 @@ final class CaptureController: NSObject {
// MARK: - Session lifecycle // MARK: - Session lifecycle
func begin() { /// Frozen mode: the screen is snapshotted at hotkey press, so the pointer
/// and any hover state (tooltips, open menus) end up in the capture.
private(set) var isFrozenMode = false
private var frozenSnapshots: [ScreenCapture.DisplaySnapshot] = []
func begin(frozen: Bool = false) {
guard !isSelecting else { return } guard !isSelecting else { return }
guard PermissionManager.isGranted else { guard PermissionManager.isGranted else {
PermissionManager.request() PermissionManager.request()
return return
} }
isSelecting = true isSelecting = true
isFrozenMode = frozen
previousApp = NSWorkspace.shared.frontmostApplication previousApp = NSWorkspace.shared.frontmostApplication
// With the event tap driving input we must NOT activate: stealing // With the event tap driving input we must NOT activate: stealing
// focus turns the target app into background mode (dimmed chrome), // focus turns the target app into background mode (dimmed chrome),
@@ -92,12 +98,31 @@ final class CaptureController: NSObject {
if !usesTap { if !usesTap {
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
} }
CaptureController.log("begin: usesTap=\(usesTap) active=\(NSApp.isActive)") CaptureController.log("begin: frozen=\(frozen) usesTap=\(usesTap) active=\(NSApp.isActive)")
if frozen {
ScreenCapture.shared.snapshotAllDisplays(includeCursor: true) { [weak self] snapshots in
DispatchQueue.main.async {
guard let self, self.isSelecting else { return }
self.frozenSnapshots = snapshots
CaptureController.log("frozen: captured \(snapshots.count) displays")
self.showOverlays()
}
}
} else {
showOverlays()
}
}
private func showOverlays() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
guard let self, self.isSelecting else { return } guard let self, self.isSelecting else { return }
self.overlays = NSScreen.screens.map { screen in self.overlays = NSScreen.screens.map { screen in
let panel = OverlayWindow(screen: screen, controller: self) let panel = OverlayWindow(screen: screen, controller: self)
(panel.contentView as? OverlayView)?.controller = self if let view = panel.contentView as? OverlayView {
view.controller = self
view.frozenImage = self.frozenSnapshots.first(where: { $0.frame == screen.frame })?.image
}
return panel return panel
} }
// orderFrontRegardless: show without taking key/focus. // orderFrontRegardless: show without taking key/focus.
@@ -126,11 +151,30 @@ final class CaptureController: NSObject {
private func finishSelection(_ rect: NSRect) { private func finishSelection(_ rect: NSRect) {
let ids = overlayIDs let ids = overlayIDs
let frozen = frozenSnapshots
let wasFrozen = isFrozenMode
hideOverlays() hideOverlays()
guard rect.width >= 2, rect.height >= 2 else { guard rect.width >= 2, rect.height >= 2 else {
cancelSelection() cancelSelection()
return return
} }
if wasFrozen {
let midpoint = NSPoint(x: rect.midX, y: rect.midY)
let snapshot = frozen.first(where: { $0.frame.contains(midpoint) }) ?? frozen.first
isSelecting = false
restorePreviousApp()
DispatchQueue.main.async { [self] in
if let snapshot, let image = ScreenCapture.cropFrozen(snapshot, to: rect) {
CaptureController.log("frozen: cropped \(Int(rect.width))x\(Int(rect.height)) -> \(image.width)x\(image.height)")
ResultWindow.shared.show(image: image)
} else {
ResultWindow.shared.show(image: ResultWindow.failureImage("Could not crop the frozen screen."))
}
}
return
}
DispatchQueue.main.async { [self] in DispatchQueue.main.async { [self] in
capture(rect, excludingWindowIDs: ids) capture(rect, excludingWindowIDs: ids)
} }
@@ -149,6 +193,8 @@ final class CaptureController: NSObject {
marqueeRect = nil marqueeRect = nil
hoverWindow = nil hoverWindow = nil
marqueeStart = nil marqueeStart = nil
frozenSnapshots = []
isFrozenMode = false
} }
private func restorePreviousApp() { private func restorePreviousApp() {
@@ -306,6 +352,8 @@ final class CaptureController: NSObject {
} }
let appKitPoint = cgPointToAppKit(NSPoint(x: event.location.x, y: event.location.y)) let appKitPoint = cgPointToAppKit(NSPoint(x: event.location.x, y: event.location.y))
if type == .mouseMoved { if type == .mouseMoved {
// Frozen mode is marquee-only: no window hover detection.
if isFrozenMode { return event }
let window = topmostWindowContaining(appKitPoint) let window = topmostWindowContaining(appKitPoint)
if window?.frame != hoverFrame { if window?.frame != hoverFrame {
hoverWindow = window hoverWindow = window
@@ -345,6 +393,9 @@ final class CaptureController: NSObject {
if let rect = marqueeRect, dragDistance >= 4, rect.width >= 2, rect.height >= 2 { if let rect = marqueeRect, dragDistance >= 4, rect.width >= 2, rect.height >= 2 {
CaptureController.log("tap: marquee complete \(rect)") CaptureController.log("tap: marquee complete \(rect)")
finishSelection(rect) finishSelection(rect)
} else if isFrozenMode {
CaptureController.log("tap: frozen click without drag — cancel")
cancelSelection()
} else if let hover = hoverWindow { } else if let hover = hoverWindow {
// An overlapping window from the same app means this is an // An overlapping window from the same app means this is an
// owned popup — desktopIndependentWindow would composite the // owned popup — desktopIndependentWindow would composite the
@@ -506,6 +557,74 @@ final class ScreenCapture {
return result return result
} }
/// Screen frame (AppKit global points) + full-resolution image per display.
struct DisplaySnapshot {
let frame: NSRect
let image: CGImage
}
/// Snapshot every display right now. Used by frozen mode: the images are
/// taken before the overlay appears, so the pointer and hover states
/// (tooltips, menus) are baked in.
func snapshotAllDisplays(includeCursor: Bool, completion: @escaping ([DisplaySnapshot]) -> Void) {
if #available(macOS 14.0, *) {
Task {
guard let content = try? await SCShareableContent.current else {
completion([])
return
}
var out: [DisplaySnapshot] = []
for display in content.displays {
guard let screen = NSScreen.screens.first(where: {
(($0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0) == display.displayID
}) else { continue }
let config = SCStreamConfiguration()
let scale = screen.backingScaleFactor
config.width = Int(CGFloat(display.width) * scale)
config.height = Int(CGFloat(display.height) * scale)
config.showsCursor = includeCursor
let filter = SCContentFilter(display: display, excludingWindows: [])
if let image = try? await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) {
out.append(DisplaySnapshot(frame: screen.frame, image: image))
}
}
completion(out)
}
} else {
var out: [DisplaySnapshot] = []
for screen in NSScreen.screens {
let cgRect = CGRect(
x: screen.frame.minX,
y: (NSScreen.screens.first(where: { $0.frame.origin == .zero })?.frame.height ?? screen.frame.height) - screen.frame.maxY,
width: screen.frame.width,
height: screen.frame.height
)
if let image = CGWindowListCreateImage(cgRect, .optionOnScreenOnly, kCGNullWindowID, [.bestResolution, .boundsIgnoreFraming]) {
out.append(DisplaySnapshot(frame: screen.frame, image: image))
}
}
completion(out)
}
}
/// Crop a selection out of a frozen snapshot (top-left pixel space).
static func cropFrozen(_ snapshot: DisplaySnapshot, to rect: NSRect) -> CGImage? {
let scaleX = CGFloat(snapshot.image.width) / snapshot.frame.width
let scaleY = CGFloat(snapshot.image.height) / snapshot.frame.height
guard scaleX > 0, scaleY > 0 else { return nil }
let localX = rect.minX - snapshot.frame.minX
let localTopY = snapshot.frame.maxY - rect.maxY
let px = CGRect(
x: localX * scaleX,
y: localTopY * scaleY,
width: rect.width * scaleX,
height: rect.height * scaleY
)
let safe = px.intersection(CGRect(x: 0, y: 0, width: CGFloat(snapshot.image.width), height: CGFloat(snapshot.image.height))).integral
guard safe.width >= 1, safe.height >= 1 else { return nil }
return snapshot.image.cropping(to: safe)
}
@available(macOS 14.0, *) @available(macOS 14.0, *)
private func displayContaining(_ rect: NSRect, in content: SCShareableContent) -> SCDisplay? { private func displayContaining(_ rect: NSRect, in content: SCShareableContent) -> SCDisplay? {
let mid = NSPoint(x: rect.midX, y: rect.midY) let mid = NSPoint(x: rect.midX, y: rect.midY)
+33 -12
View File
@@ -4,11 +4,14 @@ import AppKit
final class HotkeyManager { final class HotkeyManager {
static let shared = HotkeyManager() static let shared = HotkeyManager()
/// ⌘⇧S — live capture (ScreenCaptureKit at capture time).
var onTrigger: (() -> Void)? var onTrigger: (() -> Void)?
/// ⌃⇧⌘S — frozen capture (snapshot at hotkey press).
var onFrozenTrigger: (() -> Void)?
private var hotKeyRef: EventHotKeyRef? private var hotKeyRefs: [EventHotKeyRef?] = []
private var eventHandlerRef: EventHandlerRef? private var eventHandlerRef: EventHandlerRef?
private var hotKeyID = EventHotKeyID(signature: 0x4B505452, id: 1) private let signature: OSType = 0x4B505452
private init() {} private init() {}
@@ -17,18 +20,36 @@ final class HotkeyManager {
eventClass: OSType(kEventClassKeyboard), eventClass: OSType(kEventClassKeyboard),
eventKind: UInt32(kEventHotKeyPressed) eventKind: UInt32(kEventHotKeyPressed)
) )
InstallEventHandler(GetApplicationEventTarget(), { _, _, _ in InstallEventHandler(GetApplicationEventTarget(), { _, event, _ in
HotkeyManager.shared.onTrigger?() var hotKeyID = EventHotKeyID()
let status = GetEventParameter(
event,
EventParamName(kEventParamDirectObject),
EventParamType(typeEventHotKeyID),
nil,
MemoryLayout<EventHotKeyID>.size,
nil,
&hotKeyID
)
guard status == noErr else { return noErr }
DispatchQueue.main.async {
switch hotKeyID.id {
case 1: HotkeyManager.shared.onTrigger?()
case 2: HotkeyManager.shared.onFrozenTrigger?()
default: break
}
}
return noErr return noErr
}, 1, &eventType, nil, &eventHandlerRef) }, 1, &eventType, nil, &eventHandlerRef)
RegisterEventHotKey( registerHotkey(id: 1, keyCode: UInt32(kVK_ANSI_S), modifiers: UInt32(cmdKey | shiftKey))
UInt32(kVK_ANSI_S), registerHotkey(id: 2, keyCode: UInt32(kVK_ANSI_S), modifiers: UInt32(cmdKey | shiftKey | controlKey))
UInt32(cmdKey | shiftKey), }
hotKeyID,
GetApplicationEventTarget(), private func registerHotkey(id: UInt32, keyCode: UInt32, modifiers: UInt32) {
0, var ref: EventHotKeyRef?
&hotKeyRef let hotKeyID = EventHotKeyID(signature: signature, id: id)
) RegisterEventHotKey(keyCode, modifiers, hotKeyID, GetApplicationEventTarget(), 0, &ref)
hotKeyRefs.append(ref)
} }
} }
+35 -3
View File
@@ -8,6 +8,25 @@ final class OCRService {
func recognize(_ cgImage: CGImage, completion: @escaping (String) -> Void) { func recognize(_ cgImage: CGImage, completion: @escaping (String) -> Void) {
queue.async { queue.async {
let text = Self.perform(cgImage)
// Tiny text (small UI labels) often reads as nothing at native
// size; retry upscaled and keep whichever pass read more.
guard text.count < 3 else {
completion(text)
return
}
var best = text
for factor in [CGFloat(2), CGFloat(3)] {
guard let upscaled = Self.upscaled(cgImage, factor: factor) else { continue }
let candidate = Self.perform(upscaled)
if candidate.count > best.count { best = candidate }
if best.count >= 3 { break }
}
completion(best)
}
}
private static func perform(_ cgImage: CGImage) -> String {
let request = VNRecognizeTextRequest() let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate request.recognitionLevel = .accurate
request.usesLanguageCorrection = true request.usesLanguageCorrection = true
@@ -22,8 +41,7 @@ final class OCRService {
do { do {
try handler.perform([request]) try handler.perform([request])
} catch { } catch {
completion("") return ""
return
} }
let observations = request.results ?? [] let observations = request.results ?? []
@@ -35,7 +53,21 @@ final class OCRService {
return a.boundingBox.minX < b.boundingBox.minX return a.boundingBox.minX < b.boundingBox.minX
} }
.compactMap { $0.topCandidates(1).first?.string } .compactMap { $0.topCandidates(1).first?.string }
completion(lines.joined(separator: "\n")) return lines.joined(separator: "\n")
} }
private static func upscaled(_ cgImage: CGImage, factor: CGFloat) -> CGImage? {
let width = Int(CGFloat(cgImage.width) * factor)
let height = Int(CGFloat(cgImage.height) * factor)
guard width > 0, height > 0, width <= 20000, height <= 20000 else { return nil }
guard let ctx = CGContext(
data: nil, width: width, height: height,
bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return nil }
ctx.interpolationQuality = .high
ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height)))
return ctx.makeImage()
} }
} }
+61 -1
View File
@@ -30,6 +30,9 @@ final class OverlayWindow: NSPanel {
final class OverlayView: NSView { final class OverlayView: NSView {
weak var controller: CaptureController? weak var controller: CaptureController?
/// In frozen mode: the still image to display for this screen (already
/// full resolution). Nil = live mode (dim the real screen).
var frozenImage: CGImage?
override var acceptsFirstResponder: Bool { true } override var acceptsFirstResponder: Bool { true }
@@ -56,12 +59,47 @@ final class OverlayView: NSView {
) )
} }
/// Draw the frozen snapshot filling the screen, then replace its centre
/// hole with the live... no: the image IS the content, drawn opaque.
private func drawFrozen(_ image: CGImage) {
guard let ctx = NSGraphicsContext.current?.cgContext else { return }
ctx.saveGState()
ctx.interpolationQuality = .high
// Image is in pixels, view in points — draw into the view bounds.
ctx.draw(image, in: bounds)
ctx.restoreGState()
}
private func drawSizeLabel(for sel: NSRect, in vf: NSRect) {
let label = "\(Int(sel.width)) × \(Int(sel.height))"
let attrs: [NSAttributedString.Key: Any] = [
.font: NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium),
.foregroundColor: NSColor.white,
]
let labelSize = label.size(withAttributes: attrs)
let labelRect = NSRect(
x: vf.maxX - labelSize.width - 8,
y: vf.minY - labelSize.height - 10,
width: labelSize.width + 8,
height: labelSize.height + 6
)
NSColor.black.withAlphaComponent(0.6).setFill()
NSBezierPath(roundedRect: labelRect, xRadius: 4, yRadius: 4).fill()
label.draw(at: NSPoint(x: labelRect.minX + 4, y: labelRect.minY + 3), withAttributes: attrs)
}
override func draw(_ dirtyRect: NSRect) { override func draw(_ dirtyRect: NSRect) {
if let frozenImage {
drawFrozen(frozenImage)
} else {
NSColor.black.withAlphaComponent(0.35).setFill() NSColor.black.withAlphaComponent(0.35).setFill()
NSBezierPath(rect: bounds).fill() NSBezierPath(rect: bounds).fill()
}
// Esc hint — the only way out once clicks are consumed by the tap. // Esc hint — the only way out once clicks are consumed by the tap.
let hint = "Drag to select · hover + click a window to capture it · Esc to cancel" let hint = frozenImage != nil
? "Frozen screen · drag to select · Esc to cancel"
: "Drag to select · hover + click a window to capture it · Esc to cancel"
let hintAttrs: [NSAttributedString.Key: Any] = [ let hintAttrs: [NSAttributedString.Key: Any] = [
.font: NSFont.monospacedSystemFont(ofSize: 12, weight: .medium), .font: NSFont.monospacedSystemFont(ofSize: 12, weight: .medium),
.foregroundColor: NSColor.white.withAlphaComponent(0.85), .foregroundColor: NSColor.white.withAlphaComponent(0.85),
@@ -79,6 +117,28 @@ final class OverlayView: NSView {
guard let controller else { return } guard let controller else { return }
// Frozen mode: same look as live mode — a dark veil over everything,
// with the selection showing the frozen pixels undimmed.
if let frozenImage {
NSColor.black.withAlphaComponent(0.35).setFill()
NSBezierPath(rect: bounds).fill()
if let sel = controller.marqueeRect, sel.width > 0, sel.height > 0 {
let vf = viewRect(sel)
NSGraphicsContext.current?.saveGraphicsState()
NSBezierPath(rect: vf).addClip()
drawFrozen(frozenImage)
NSGraphicsContext.current?.restoreGraphicsState()
let border = NSBezierPath(rect: vf)
border.lineWidth = 1.5
NSColor.controlAccentColor.setStroke()
border.stroke()
drawSizeLabel(for: sel, in: vf)
}
return
}
// Highlight the hovered window (rounded, like real window corners) // Highlight the hovered window (rounded, like real window corners)
if let frame = controller.hoverFrame { if let frame = controller.hoverFrame {
let vf = viewRect(frame) let vf = viewRect(frame)
+9 -7
View File
@@ -195,14 +195,15 @@ final class CaptureModel: ObservableObject {
return rep.cgImage ?? image return rep.cgImage ?? image
} }
func addStroke(_ points: [CGPoint]) { /// `widthPx` is the stroke thickness in IMAGE PIXELS (the preview
/// converts the slider value so exports match what was drawn).
func addStroke(_ points: [CGPoint], widthPx: CGFloat) {
guard points.count > 1 else { return } guard points.count > 1 else { return }
if tool == .redact { if tool == .redact {
// Redaction: a filled black bar from start to end, as thick as the // Redaction: a filled black bar from start to end.
// current stroke width (drawn as a rect for clean edges). strokes.append(Stroke(points: points, width: max(widthPx, 12), color: .black, isRedaction: true))
strokes.append(Stroke(points: points, width: max(strokeWidth, 12), color: .black, isRedaction: true))
} else { } else {
strokes.append(Stroke(points: points, width: strokeWidth, color: drawColor)) strokes.append(Stroke(points: points, width: widthPx, color: drawColor))
} }
redoStack = [] redoStack = []
} }
@@ -575,7 +576,8 @@ struct ScreenshotPane: View {
for point in stroke.points.dropFirst() { for point in stroke.points.dropFirst() {
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: stroke.color)), lineWidth: stroke.width) // stroke.width is in image pixels; scale to view points
context.stroke(path, with: .color(Color(nsColor: stroke.color)), lineWidth: stroke.width * scale)
} }
} }
} }
@@ -636,7 +638,7 @@ struct ScreenshotPane: View {
.onEnded { _ in .onEnded { _ in
if model.tool != .select { if model.tool != .select {
if currentStroke.count > 1 { if currentStroke.count > 1 {
model.addStroke(currentStroke) model.addStroke(currentStroke, widthPx: model.strokeWidth / max(scale, 0.01))
} }
currentStroke = [] currentStroke = []
} else { } else {
+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.9.1</string> <string>1.10.2</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>91</string> <string>102</string>
<key>LSUIElement</key> <key>LSUIElement</key>
<true/> <true/>
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>