Compare commits

..
2 Commits
Author SHA1 Message Date
TZGyn cac85402a3 Remember the Actual Size view choice
Persist the magnifier state in UserDefaults so every new result window
opens in the same mode and the choice survives relaunches.

Generated with OpenCode

Assisted-by: Kimi:k3
2026-10-26 21:14:51 +08:00
TZGyn d4b0970ed2 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
2026-10-26 20:57:12 +08:00
2 changed files with 88 additions and 9 deletions
+86 -7
View File
@@ -6,6 +6,12 @@ final class ResultWindow {
static let shared = ResultWindow() static let shared = ResultWindow()
private var windows: [NSWindow] = [] 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<ResultView>] = [:]
private var nextOrigin = NSPoint(x: 60, y: 60) private var nextOrigin = NSPoint(x: 60, y: 60)
private var openCount = 0 private var openCount = 0
@@ -13,15 +19,21 @@ final class ResultWindow {
func show(image: CGImage) { func show(image: CGImage) {
let model = CaptureModel(image: image) let model = CaptureModel(image: image)
let w = NSWindow() let w = NSWindow()
w.contentViewController = NSHostingController(rootView: ResultView(model: model) { let host = NSHostingController(rootView: ResultView(model: model) {
w.close() w.close()
}) })
w.contentView = host.view
hosts[ObjectIdentifier(w)] = host
w.title = "Kapture — Screenshot" + (openCount > 0 ? " \(openCount + 1)" : "") w.title = "Kapture — Screenshot" + (openCount > 0 ? " \(openCount + 1)" : "")
w.level = .floating w.level = .floating
w.styleMask = [.titled, .closable, .resizable] w.styleMask = [.titled, .closable, .resizable]
w.setContentSize(NSSize(width: 694, height: 520)) w.setContentSize(NSSize(width: 694, height: 520))
w.isReleasedWhenClosed = false w.isReleasedWhenClosed = false
w.setFrameOrigin(nextOrigin) 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) w.makeKeyAndOrderFront(nil)
windows.append(w) windows.append(w)
@@ -40,6 +52,7 @@ final class ResultWindow {
Task { @MainActor in Task { @MainActor in
guard let self else { return } guard let self else { return }
self.windows.removeAll { $0 === window } self.windows.removeAll { $0 === window }
self.hosts[ObjectIdentifier(window)] = nil
self.openCount = max(0, self.openCount - 1) self.openCount = max(0, self.openCount - 1)
} }
} }
@@ -107,7 +120,11 @@ final class CaptureModel: ObservableObject {
/// Whether the pen is active (drag draws instead of selecting) /// Whether the pen is active (drag draws instead of selecting)
@Published var tool: Tool = .select @Published var tool: Tool = .select
/// Actual-size view: 1 image pixel per screen pixel, scrollable. /// Actual-size view: 1 image pixel per screen pixel, scrollable.
@Published var isActualSize = false /// Persisted so the choice carries over to new windows and launches.
@Published var isActualSize: Bool = UserDefaults.standard.bool(forKey: CaptureModel.actualSizeKey) {
didSet { UserDefaults.standard.set(isActualSize, forKey: CaptureModel.actualSizeKey) }
}
private static let actualSizeKey = "actualSizeView"
/// Current drawing color /// Current drawing color
@Published var drawColor: NSColor = .systemRed @Published var drawColor: NSColor = .systemRed
/// Stroke thickness in points (image space) /// Stroke thickness in points (image space)
@@ -525,8 +542,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 { struct ResultView: View {
@ObservedObject var model: CaptureModel @ObservedObject var model: CaptureModel
let onClose: () -> Void let onClose: () -> Void
@@ -714,7 +784,7 @@ struct ResultView: View {
? "Adjust the crop — Return to apply, Esc to cancel." ? "Adjust the crop — Return to apply, Esc to cancel."
: (model.hasSelection : (model.hasSelection
? "Region selected — OCR it, or adjust it with Crop." ? "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")) : "Selectable text"))
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -871,15 +941,21 @@ struct ScreenshotPane: View {
model.cropRect = newRect 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 { var body: some View {
GeometryReader { geo in GeometryReader { geo in
if model.isActualSize { if model.isActualSize {
// Actual size: 1 image pixel per screen pixel (view scale = // 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. // 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 drawSize = CGSize(width: imageSize.width * scale, height: imageSize.height * scale)
let origin = CGPoint( let origin = CGPoint(
x: max(0, (geo.size.width - drawSize.width) / 2), x: max(0, (geo.size.width - drawSize.width) / 2),
@@ -898,6 +974,9 @@ struct ScreenshotPane: View {
.frame(width: geo.size.width, height: geo.size.height) .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 /// The image card with annotations, selection and crop overlays, plus
+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.11.1</string> <string>1.11.3</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>111</string> <string>113</string>
<key>LSUIElement</key> <key>LSUIElement</key>
<true/> <true/>
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>