diff --git a/Sources/Kapture/ResultWindow.swift b/Sources/Kapture/ResultWindow.swift index 3996c26..f95965d 100644 --- a/Sources/Kapture/ResultWindow.swift +++ b/Sources/Kapture/ResultWindow.swift @@ -6,6 +6,12 @@ final class ResultWindow { static let shared = ResultWindow() private var windows: [NSWindow] = [] + /// Kept alive: the hosting controller drives the SwiftUI view we attach + /// directly as the window's contentView. Using contentViewController + /// would install Auto Layout constraints that force the window to the + /// view's fitting size — it shrank whenever the layout branch changed + /// (e.g. toggling Actual Size). + private var hosts: [ObjectIdentifier: NSHostingController] = [:] private var nextOrigin = NSPoint(x: 60, y: 60) private var openCount = 0 @@ -13,15 +19,21 @@ final class ResultWindow { func show(image: CGImage) { let model = CaptureModel(image: image) let w = NSWindow() - w.contentViewController = NSHostingController(rootView: ResultView(model: model) { + let host = NSHostingController(rootView: ResultView(model: model) { w.close() }) + w.contentView = host.view + hosts[ObjectIdentifier(w)] = host w.title = "Kapture — Screenshot" + (openCount > 0 ? " \(openCount + 1)" : "") w.level = .floating w.styleMask = [.titled, .closable, .resizable] w.setContentSize(NSSize(width: 694, height: 520)) w.isReleasedWhenClosed = false w.setFrameOrigin(nextOrigin) + // Activate so the FIRST click on any control (Actual Size, Copy, the + // mode picker…) reaches it. Without this, an inactive LSUIElement + // app's windows swallow the initial click just to activate. + NSApp.activate(ignoringOtherApps: true) w.makeKeyAndOrderFront(nil) windows.append(w) @@ -40,6 +52,7 @@ final class ResultWindow { Task { @MainActor in guard let self else { return } self.windows.removeAll { $0 === window } + self.hosts[ObjectIdentifier(window)] = nil self.openCount = max(0, self.openCount - 1) } } @@ -525,8 +538,61 @@ private struct CapIconButton: View { } } -// MARK: - Result window +/// Reports the hosting window's backing scale factor. Used by the +/// actual-size view so one image pixel maps to exactly one device pixel. +/// SwiftUI's `displayScale` environment is unreliable on macOS and does not +/// follow the window when it moves between displays of different densities. +private struct WindowScaleReader: NSViewRepresentable { + let onChange: (CGFloat) -> Void + func makeNSView(context: Context) -> ScaleProbeView { + let view = ScaleProbeView() + view.onChange = onChange + return view + } + + func updateNSView(_ nsView: ScaleProbeView, context: Context) { + nsView.onChange = onChange + } + + final class ScaleProbeView: NSView { + var onChange: ((CGFloat) -> Void)? + private var observers: [NSObjectProtocol] = [] + private var lastScale: CGFloat = 0 + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + observers.forEach(NotificationCenter.default.removeObserver) + observers = [] + report() + guard let window else { return } + for name in [NSWindow.didChangeBackingPropertiesNotification, + NSWindow.didChangeScreenNotification] { + observers.append(NotificationCenter.default.addObserver( + forName: name, object: window, queue: .main + ) { [weak self] _ in + self?.report() + }) + } + } + + private func report() { + let scale = window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2 + guard scale != lastScale else { return } + lastScale = scale + // Defer: this runs during layout, and the callback writes @State. + DispatchQueue.main.async { [weak self] in + self?.onChange?(scale) + } + } + + deinit { + observers.forEach(NotificationCenter.default.removeObserver) + } + } +} + +// MARK: - Result window struct ResultView: View { @ObservedObject var model: CaptureModel let onClose: () -> Void @@ -714,7 +780,7 @@ struct ResultView: View { ? "Adjust the crop — Return to apply, Esc to cancel." : (model.hasSelection ? "Region selected — OCR it, or adjust it with Crop." - : (model.isActualSize ? "Actual size — scroll to pan, drag to select." : "Drag to select a region, or Get All Text."))) + : (model.isActualSize ? "Actual size — 1:1 pixels, scroll to pan." : "Drag to select a region, or Get All Text."))) : "Selectable text")) .font(.caption) .foregroundStyle(.secondary) @@ -871,15 +937,21 @@ struct ScreenshotPane: View { model.cropRect = newRect } - @Environment(\.displayScale) private var displayScale + @State private var windowScale: CGFloat = 2 + + /// Image pixels to window points. Actual size is 1 image pixel per + /// device pixel, i.e. 1/windowScale points. + private var actualSizeScale: CGFloat { + 1 / max(windowScale, 0.01) + } var body: some View { GeometryReader { geo in if model.isActualSize { // Actual size: 1 image pixel per screen pixel (view scale = - // 1/displayScale). Scrollable when larger than the viewport; + // 1/windowScale). Scrollable when larger than the viewport; // centred when smaller. - let scale = 1 / max(displayScale, 0.01) + let scale = actualSizeScale let drawSize = CGSize(width: imageSize.width * scale, height: imageSize.height * scale) let origin = CGPoint( x: max(0, (geo.size.width - drawSize.width) / 2), @@ -898,6 +970,9 @@ struct ScreenshotPane: View { .frame(width: geo.size.width, height: geo.size.height) } } + .background(WindowScaleReader { scale in + windowScale = scale + }) } /// The image card with annotations, selection and crop overlays, plus diff --git a/build-app.sh b/build-app.sh index 8499b0c..bd4b74e 100755 --- a/build-app.sh +++ b/build-app.sh @@ -108,9 +108,9 @@ cat > "$APP/Contents/Info.plist" <<'PLIST' CFBundleIconFile Icon CFBundleShortVersionString - 1.11.1 + 1.11.2 CFBundleVersion - 111 + 112 LSUIElement NSHighResolutionCapable