From d4b0970ed2768572fe78c88d7f175c7a7455c62d Mon Sep 17 00:00:00 2001 From: TZGyn Date: Mon, 26 Oct 2026 20:57:12 +0800 Subject: [PATCH] Fix Actual Size: activation, window sizing, and real backing scale Three bugs made the toggle appear dead: the result window never activated the LSUIElement app, so the first click was consumed; the hosting controller's Auto Layout fitting size shrank the window when the layout branch changed; and SwiftUI's displayScale environment does not reliably track the window's display on macOS. The window now activates before ordering front, the hosting view is attached directly as the contentView (no fitting-size constraints), and the actual-size scale comes from the window's backingScaleFactor (updated when it moves between displays). Generated with OpenCode Assisted-by: Kimi:k3 --- Sources/Kapture/ResultWindow.swift | 87 +++++++++++++++++++++++++++--- build-app.sh | 4 +- 2 files changed, 83 insertions(+), 8 deletions(-) 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