Compare commits

..
7 Commits
Author SHA1 Message Date
TZGyn c9447d2a8b Make Kapture screenshot-first with marquee text selection
The result window now works like a screenshot tool: drag on the image to
select a region, then click Get Selection Text to OCR only that area
(full-image OCR stays as Get All Text). Selection is rendered in image
coordinates so it stays accurate at any window size.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 21:06:09 +08:00
TZGyn 3fdc2fca95 Fix vertical offset when capturing the secondary display
The first ScreenCaptureKit Y-flip used the wrong reference edges, shifting
cropped selections on displays below the main screen. Convert each selection
per-display as scY = appKitY - NSScreen.maxY + SCDisplay.maxY, which
matches both the main and secondary displays.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:51:18 +08:00
TZGyn 590be2356f Fix captures on displays below the main screen
ScreenCaptureKit uses a flipped Y axis relative to AppKit NSScreen frames,
so selections on a display below the main screen produced out-of-bounds
crop rects and "Capture failed". Convert the selection into SCDisplay space
using the matching NSScreen's top edge before cropping.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:47:48 +08:00
TZGyn af32a36a91 Add Copy Diagnostics menu item for multi-display debugging
Prints each NSScreen's frame/backing scale/CG display ID and each
SCDisplay's frame/point size, and copies it to the clipboard, so
secondary-display capture failures can be debugged from the menu bar.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:44:56 +08:00
TZGyn 1327b61e2b Request capture pixels from the selection screen's backing scale
SCDisplay.width is in points, not pixels: the previous fix sent point
counts to SCStreamConfiguration, producing blurry main-screen captures and
failed secondary-screen captures. Multiply by the backing scale factor of
the screen containing the selection (defaulting to 1x, never forcing 2x).

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:37:53 +08:00
TZGyn c9a807e8a0 Fix secondary display captures after retina scaling change
The 1.1.2 retina fix overrode the stream width from the display's pixel
dimensions, which made captures on secondary monitors come out at the
wrong scale or offset. Restore requesting the display's native pixel
resolution and keep the crop scale derived from the actual returned image,
plus guard against out-of-bounds crop rects.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:34:18 +08:00
TZGyn 7bc93c2014 Anti-alias downscaled screenshots in the result window
Full-res retina captures are downscaled to fit the window; nearest-neighbor
made them look chunky. Use high-quality interpolation when shrinking, keep
pixel-crisp rendering only when upscaling small selections.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:25:23 +08:00
4 changed files with 193 additions and 31 deletions
+7
View File
@@ -48,6 +48,13 @@ struct MenuView: View {
Divider() Divider()
Button("Copy Diagnostics") {
let diag = CaptureController.shared.diagnostics()
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(diag, forType: .string)
}
Button(LoginItem.isEnabled ? "Disable Launch at Login" : "Launch at Login") { Button(LoginItem.isEnabled ? "Disable Launch at Login" : "Launch at Login") {
if !LoginItem.toggle() { if !LoginItem.toggle() {
let alert = NSAlert() let alert = NSAlert()
+65 -19
View File
@@ -10,6 +10,38 @@ final class CaptureController: NSObject {
private var previousApp: NSRunningApplication? private var previousApp: NSRunningApplication?
private var overlayIDs: [Int] = [] private var overlayIDs: [Int] = []
/// Diagnostic snapshot for "Copy Diagnostics" menu item.
func diagnostics() -> String {
var lines: [String] = []
lines.append("Kapture diagnostics")
for (i, s) in NSScreen.screens.enumerated() {
let scale = s.backingScaleFactor
let cgID = (s.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0
lines.append("screen[\(i)]: frame=\(s.frame) scale=\(scale) backingSize=\(s.frame.width*scale)x\(s.frame.height*scale) cgID=\(cgID)")
}
if #available(macOS 14.0, *) {
Task {
if let content = try? await SCShareableContent.current {
for (i, d) in content.displays.enumerated() {
lines.append("SCDisplay[\(i)]: frame=\(d.frame) width=\(d.width)x\(d.height) id=\(d.displayID)")
}
} else {
lines.append("SCShareableContent: unavailable (no Screen Recording permission?)")
}
let text = lines.joined(separator: "\n")
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(text, forType: .string)
}
} else {
let text = lines.joined(separator: "\n")
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(text, forType: .string)
}
return lines.joined(separator: "\n")
}
func begin() { func begin() {
guard !isSelecting else { return } guard !isSelecting else { return }
guard PermissionManager.isGranted else { guard PermissionManager.isGranted else {
@@ -85,31 +117,31 @@ final class ScreenCapture {
private init() {} private init() {}
@available(macOS 14.0, *) @available(macOS 14.0, *)
private func crop(_ image: CGImage, to rect: NSRect, display: SCDisplay) -> CGImage? { private func crop(_ image: CGImage, to cgRect: CGRect, display: SCDisplay) -> CGImage? {
// Scale from the actual captured image dimensions, which reflect the // cgRect is already in SCDisplay space (Y flipped relative to AppKit).
// stream's pixel output (retina => 2x, non-retina => 1x). // Derive the pixel scale from the image we actually got, relative to
// the display's point frame.
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
let cgRect = CGRect( guard scaleX > 0, scaleY > 0 else { return nil }
x: rect.minX,
y: rect.minY,
width: rect.width,
height: rect.height
)
let pxRect = CGRect( let pxRect = CGRect(
x: (cgRect.minX - display.frame.minX) * scaleX, x: (cgRect.minX - display.frame.minX) * scaleX,
y: (cgRect.minY - display.frame.minY) * scaleY, y: (cgRect.minY - display.frame.minY) * scaleY,
width: cgRect.width * scaleX, width: cgRect.width * scaleX,
height: cgRect.height * scaleY height: cgRect.height * scaleY
) )
// Guard against negative offsets when the selection reaches beyond the
// display's frame (e.g. secondary monitor coordinate quirks).
let safeRect = pxRect.intersection(CGRect(x: 0, y: 0, width: CGFloat(image.width), height: CGFloat(image.height)))
guard safeRect.width >= 1, safeRect.height >= 1 else { return nil }
guard let cropCtx = CGContext( guard let cropCtx = CGContext(
data: nil, width: Int(pxRect.width), height: Int(pxRect.height), data: nil, width: Int(safeRect.width), height: Int(safeRect.height),
bitsPerComponent: 8, bytesPerRow: 0, bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(), space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return nil } ) else { return nil }
cropCtx.interpolationQuality = .high cropCtx.interpolationQuality = .high
cropCtx.draw(image, in: CGRect(x: -pxRect.minX, y: -pxRect.minY, width: CGFloat(image.width), height: CGFloat(image.height))) cropCtx.draw(image, in: CGRect(x: -safeRect.minX, y: -safeRect.minY, width: CGFloat(image.width), height: CGFloat(image.height)))
return cropCtx.makeImage() return cropCtx.makeImage()
} }
@@ -135,20 +167,34 @@ final class ScreenCapture {
let excluded = content.windows.filter { excludedSet.contains($0.windowID) } let excluded = content.windows.filter { excludedSet.contains($0.windowID) }
let filter = SCContentFilter(display: display, excludingWindows: excluded) let filter = SCContentFilter(display: display, excludingWindows: excluded)
let config = SCStreamConfiguration() let config = SCStreamConfiguration()
// width/height are in points. Use the display's backing scale so // SCDisplay.width/height are in POINTS; SCStreamConfiguration
// retina captures come out at full pixel resolution. // width/height are output PIXELS. Multiply by the backing scale
let backingScale = NSScreen.screens.first(where: { $0.frame.contains( // of the screen containing the selection so retina displays are
NSPoint(x: rect.midX, y: rect.midY) // captured at full resolution and 1x displays stay 1x.
) })?.backingScaleFactor ?? 2 guard let screen = NSScreen.screens.first(where: { $0.frame.contains(NSPoint(x: rect.midX, y: rect.midY)) }) else {
config.width = Int(display.frame.width * backingScale) completion(nil)
config.height = Int(display.frame.height * backingScale) return
}
let backingScale = screen.backingScaleFactor
config.width = Int(CGFloat(display.width) * backingScale)
config.height = Int(CGFloat(display.height) * backingScale)
config.showsCursor = true config.showsCursor = true
// ScreenCaptureKit uses a different origin than AppKit for
// screens stacked around the main display: per-display,
// SCDisplay y = appKitY - NSScreen.maxY + SCDisplay.maxY.
let scRect = CGRect(
x: rect.minX,
y: rect.minY - screen.frame.maxY + display.frame.maxY,
width: rect.width,
height: rect.height
)
guard let image = try? await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) else { guard let image = try? await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) else {
completion(nil) completion(nil)
return return
} }
completion(self.crop(image, to: rect, display: display)) completion(self.crop(image, to: scRect, display: display))
} }
} 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 {
+119 -10
View File
@@ -88,6 +88,8 @@ final class CaptureModel: ObservableObject {
@Published var extractedText: String? @Published var extractedText: String?
@Published var isOCRRunning = false @Published var isOCRRunning = false
@Published var statusMessage: String? @Published var statusMessage: String?
/// Drag selection rect in IMAGE point space (nil = none)
@Published var selection: CGRect?
enum CaptureMode { enum CaptureMode {
case screenshot case screenshot
@@ -98,24 +100,43 @@ final class CaptureModel: ObservableObject {
self.image = image self.image = image
} }
var hasSelection: Bool { selection != nil && selection!.width >= 2 && selection!.height >= 2 }
var displayText: String { var displayText: String {
if isOCRRunning { if isOCRRunning {
return "Recognizing text…" return "Recognizing text…"
} }
return extractedText ?? "Text appears here — click “Get Text”." return extractedText ?? "Select text in the screenshot, then click “Get Text”."
} }
func runOCR() { func cropImage(to rectInImage: CGRect) -> CGImage? {
return image.cropping(to: rectInImage)
}
func runOCR(over selectionOnly: CGRect? = nil) {
guard !isOCRRunning else { return } guard !isOCRRunning else { return }
isOCRRunning = true isOCRRunning = true
statusMessage = nil statusMessage = nil
OCRService.shared.recognize(image) { [weak self] text in
let targetImage: CGImage
if let sel = selectionOnly {
guard let cropped = image.cropping(to: sel) else {
self.isOCRRunning = false
self.statusMessage = "Selection too small."
return
}
targetImage = cropped
} else {
targetImage = image
}
OCRService.shared.recognize(targetImage) { [weak self] text in
Task { @MainActor in Task { @MainActor in
guard let self else { return } guard let self else { return }
self.isOCRRunning = false self.isOCRRunning = false
self.mode = .text self.mode = .text
if text.isEmpty { if text.isEmpty {
self.extractedText = "No text found in the screenshot." self.extractedText = "No text found in the selection."
} else { } else {
self.extractedText = text self.extractedText = text
let pasteboard = NSPasteboard.general let pasteboard = NSPasteboard.general
@@ -127,6 +148,11 @@ final class CaptureModel: ObservableObject {
} }
} }
func runOCRSelection() {
guard let sel = selection, hasSelection else { return }
runOCR(over: sel)
}
func copyText() { func copyText() {
guard let text = extractedText else { return } guard let text = extractedText else { return }
let pasteboard = NSPasteboard.general let pasteboard = NSPasteboard.general
@@ -185,18 +211,21 @@ struct ResultView: View {
Spacer() Spacer()
Button("Copy Image") { model.copyImage() } Button("Copy Image") { model.copyImage() }
Button("Save PNG…") { model.savePNG() } Button("Save PNG…") { model.savePNG() }
Button("Get Text") { model.runOCR() } if model.hasSelection {
.disabled(model.isOCRRunning || model.extractedText != nil) Button("Get Selection Text") { model.runOCRSelection() }
.disabled(model.isOCRRunning)
.help("Recognize text inside the selected region")
}
Button("Get All Text") { model.runOCR() }
.disabled(model.isOCRRunning)
.help("Recognize all text in the screenshot")
Button("Close") { onClose() } Button("Close") { onClose() }
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.top, 10) .padding(.top, 10)
if model.mode == .screenshot { if model.mode == .screenshot {
Image(nsImage: NSImage(cgImage: model.image, size: .zero)) ScreenshotPane(model: model)
.resizable()
.interpolation(.none)
.aspectRatio(contentMode: .fit)
.border(Color.secondary.opacity(0.4)) .border(Color.secondary.opacity(0.4))
.padding(.horizontal, 12) .padding(.horizontal, 12)
} else { } else {
@@ -237,3 +266,83 @@ struct ResultView: View {
.frame(minWidth: 480, minHeight: 380) .frame(minWidth: 480, minHeight: 380)
} }
} }
struct ScreenshotPane: View {
@ObservedObject var model: CaptureModel
private var imageSize: CGSize {
CGSize(width: model.image.width, height: model.image.height)
}
private func geometry(for geo: GeometryProxy) -> (scale: CGFloat, drawSize: CGSize, origin: CGPoint) {
let size = imageSize
let scale = min(geo.size.width / size.width, geo.size.height / size.height)
let drawSize = CGSize(width: size.width * scale, height: size.height * scale)
let origin = CGPoint(
x: (geo.size.width - drawSize.width) / 2,
y: (geo.size.height - drawSize.height) / 2
)
return (scale, drawSize, origin)
}
private func toImagePoint(_ viewPoint: CGPoint, scale: CGFloat, origin: CGPoint) -> CGPoint {
CGPoint(
x: (viewPoint.x - origin.x) / scale,
y: (viewPoint.y - origin.y) / scale
)
}
private func clampSelection(_ rect: CGRect) -> CGRect {
rect.intersection(CGRect(origin: .zero, size: imageSize))
}
var body: some View {
GeometryReader { geo in
let (scale, drawSize, origin) = geometry(for: geo)
ZStack {
Image(nsImage: NSImage(cgImage: model.image, size: .zero))
.resizable()
.interpolation(imageSize.width >= geo.size.width ? .high : .none)
.aspectRatio(contentMode: .fit)
.frame(width: drawSize.width, height: drawSize.height)
.position(x: origin.x + drawSize.width / 2, y: origin.y + drawSize.height / 2)
if let sel = model.selection {
let viewRect = CGRect(
x: origin.x + sel.minX * scale,
y: origin.y + sel.minY * scale,
width: sel.width * scale,
height: sel.height * scale
)
Rectangle()
.fill(Color.accentColor.opacity(0.15))
.border(Color.accentColor, width: 1.5)
.frame(width: viewRect.width, height: viewRect.height)
.position(x: viewRect.midX, y: viewRect.midY)
.allowsHitTesting(false)
}
}
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
let start = toImagePoint(value.startLocation, scale: scale, origin: origin)
let current = toImagePoint(value.location, scale: scale, origin: origin)
let rect = CGRect(
x: min(start.x, current.x),
y: min(start.y, current.y),
width: abs(current.x - start.x),
height: abs(current.y - start.y)
)
model.selection = clampSelection(rect)
}
.onEnded { _ in
if let s = model.selection, (s.width < 2 || s.height < 2) {
model.selection = nil
}
}
)
}
}
}
+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.1.2</string> <string>1.2.0</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>12</string> <string>20</string>
<key>LSUIElement</key> <key>LSUIElement</key>
<true/> <true/>
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>