Compare commits

...
Author SHA1 Message Date
kua-agentandClaude Fable 5 f2088bbed8 fix: picker first-mouse acceptance + event-based drag coords; in-process picker selftest
Root cause: RegionPickerView lacked acceptsFirstMouse — as an LSUIElement accessory app
Shotdeck is never active when the hotkey fires, so the user's first click on the overlay
was refused and the drag never started. Also plumbs the monitored event's location through
the controller (hardware-cursor reads made the chain untestable). Adds PickerSelfTest
(SHOTDECK_PICKER_SELFTEST): posts synthetic mouse events through the app's own queue,
asserts the exact CaptureRegion, saves a mid-drag overlay bitmap. Coordinator ran it:
PICKER-SELFTEST PASS rect=(200.0, 729.0, 400.0, 300.0); overlay bitmap shows dim+punch+chip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 21:46:54 +04:00
kua-agent 31013802d7 Merge pull request 'fix: settings form alignment — grouped form, aligned labels' (#13) from fix/settings-form-alignment-20260901 into feat/shotdeck-20260830 2026-09-01 09:13:09 +00:00
kua-agent e1de0ad519 fix: settings form alignment — grouped form, aligned labels 2026-09-01 13:11:25 +04:00
kua-agent bb6c1cd0b4 Merge pull request 'test harness: offscreen panel snapshots (SHOTDECK_SNAPSHOT_DIR)' (#12) from feat/panel-snapshot-20260901 into feat/shotdeck-20260830 2026-09-01 09:06:36 +00:00
kua-agent 9278f703e0 Merge pull request 'installer: make-dmg.sh — signed DMG with /Applications symlink' (#11) from feat/dmg-installer-20260901 into feat/shotdeck-20260830 2026-09-01 09:06:28 +00:00
kua-agent 3b269102a0 test harness: offscreen panel snapshots via SHOTDECK_SNAPSHOT_DIR 2026-09-01 13:01:45 +04:00
6 changed files with 581 additions and 43 deletions
+295
View File
@@ -0,0 +1,295 @@
import AppKit
import CoreGraphics
import Darwin
import Foundation
import ImageIO
import PDFKit
import SwiftUI
import ShotdeckCore
/// Headless offscreen renderer for `MenuBarView` / `SettingsView`.
/// Driven by `SHOTDECK_SNAPSHOT_DIR`; never touches the real Application Support spool.
enum PanelSnapshot {
private static let panelWidth: CGFloat = 340
/// Called from `main.swift` before `ShotdeckApp.main()`. Returns immediately when the
/// env var is unset; otherwise writes the six panel PNGs, prints each path, and `exit`s.
@MainActor
static func runIfRequested() {
guard let raw = ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"],
!raw.isEmpty
else { return }
let app = NSApplication.shared
app.setActivationPolicy(.prohibited)
Task { @MainActor in
do {
try await captureAll(to: URL(fileURLWithPath: raw, isDirectory: true))
exit(0)
} catch {
fputs("PanelSnapshot failed: \(error)\n", stderr)
exit(1)
}
}
app.run()
exit(0)
}
@MainActor
private static func captureAll(to directory: URL) async throws {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let (model, root) = try makeIsolatedModel()
defer { try? FileManager.default.removeItem(at: root) }
let region = sampleRegion()
// 01 Screen Recording missing (no public mutator; snapshot-only seam).
model.snapshotSetScreenRecordingGranted(false)
model.replaceRegion(nil)
try renderMenuBar(model: model, to: directory, name: "01-no-permission")
// 02 granted, no region picked.
model.snapshotSetScreenRecordingGranted(true)
model.replaceRegion(nil)
try renderMenuBar(model: model, to: directory, name: "02-no-region")
// 03 region set, empty session.
model.replaceRegion(region)
try renderMenuBar(model: model, to: directory, name: "03-empty-session")
// 04 three real PNGs in the temp spool so SessionStrip thumbnails decode.
let swatches: [(CGFloat, CGFloat, CGFloat)] = [
(0.85, 0.22, 0.18),
(0.18, 0.62, 0.32),
(0.16, 0.38, 0.82),
]
for (red, green, blue) in swatches {
let png = try makePNGData(width: 192, height: 108, red: red, green: green, blue: blue)
_ = try await model.spool.append(
pngData: png,
pixelWidth: 192,
pixelHeight: 108,
scale: 2.0,
capturedAt: Date()
)
}
model.replaceSession(try await model.spool.currentSession())
try renderMenuBar(model: model, to: directory, name: "04-captures-present")
// 05 two inspected PDFs in the temp ledger, one marked / one not.
try await seedReturns(model: model)
try renderMenuBar(model: model, to: directory, name: "05-returns-present")
// 06 SettingsView against the same isolated model.
try render(
SettingsView().environment(model),
to: directory.appendingPathComponent("panel-06-settings.png")
)
}
@MainActor
private static func renderMenuBar(model: AppModel, to directory: URL, name: String) throws {
try render(
MenuBarView().environment(model),
to: directory.appendingPathComponent("panel-\(name).png")
)
}
@MainActor
private static func render(_ view: some View, to url: URL) throws {
let wrapped = view
.frame(width: panelWidth, alignment: .topLeading)
.fixedSize(horizontal: false, vertical: true)
.background(Color(nsColor: .windowBackgroundColor))
let hosting = NSHostingView(rootView: wrapped)
hosting.wantsLayer = true
hosting.appearance = NSAppearance(named: .aqua)
let window = NSWindow(
contentRect: NSRect(x: -10_000, y: -10_000, width: panelWidth, height: 64),
styleMask: [.borderless],
backing: .buffered,
defer: false
)
window.isReleasedWhenClosed = false
window.appearance = NSAppearance(named: .aqua)
window.backgroundColor = .windowBackgroundColor
window.isOpaque = true
window.alphaValue = 0
window.contentView = hosting
window.orderBack(nil)
hosting.layoutSubtreeIfNeeded()
var size = hosting.fittingSize
if size.height < 1 {
size.height = hosting.intrinsicContentSize.height
}
if size.height < 1 { size.height = 240 }
size.width = panelWidth
size.height = ceil(size.height)
hosting.setFrameSize(size)
window.setContentSize(size)
hosting.layoutSubtreeIfNeeded()
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.05))
let bounds = hosting.bounds
guard let rep = hosting.bitmapImageRepForCachingDisplay(in: bounds) else {
throw SnapshotError.renderFailed(url.lastPathComponent)
}
hosting.cacheDisplay(in: bounds, to: rep)
guard let png = rep.representation(using: .png, properties: [:]) else {
throw SnapshotError.encodeFailed(url.lastPathComponent)
}
try png.write(to: url)
print(url.path)
fflush(stdout)
window.contentView = nil
window.close()
}
@MainActor
private static func makeIsolatedModel() throws -> (model: AppModel, root: URL) {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("shotdeck-panel-snapshot-\(UUID().uuidString)", isDirectory: true)
let paths = try AppSupportPaths(
root: root,
outbox: root.appendingPathComponent("outbox", isDirectory: true),
watchFolder: root.appendingPathComponent("watch", isDirectory: true)
)
let ledger = try ReturnLedger(paths: paths)
let model = AppModel(
paths: paths,
spool: try SpoolStore(paths: paths),
composer: PDFComposer(),
capturer: ScreenCapturer(),
hotkeys: HotkeyCenter(),
picker: RegionPickerController(),
ledger: ledger,
watcher: ReturnWatcher(paths: paths, ledger: ledger)
)
model.setFolderURLs(outbox: paths.outbox, watch: paths.watchFolder)
return (model, root)
}
@MainActor
private static func seedReturns(model: AppModel) async throws {
let watch = model.paths.watchFolder
let unmarkedURL = watch.appendingPathComponent("Shotdeck-20260901-120000.pdf")
let markedURL = watch.appendingPathComponent("Shotdeck-20260901-120100.pdf")
try writeShotdeckPDF(to: unmarkedURL, marked: false)
try writeShotdeckPDF(to: markedURL, marked: true)
let unmarked = try AnnotationInspector.inspect(fileURL: unmarkedURL)
let marked = try AnnotationInspector.inspect(fileURL: markedURL)
try await model.ledger.record(unmarked)
try await model.ledger.record(marked)
model.setReturns(
all: try await model.ledger.all(),
commented: try await model.ledger.commented()
)
}
private static func sampleRegion() -> CaptureRegion {
CaptureRegion(
displayID: CGMainDisplayID(),
rect: CGRect(x: 120, y: 80, width: 800, height: 600),
capturedScale: 2.0
)
}
private static func makePNGData(
width: Int,
height: Int,
red: CGFloat,
green: CGFloat,
blue: CGFloat
) throws -> Data {
let colorSpace = CGColorSpaceCreateDeviceRGB()
guard let context = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: width * 4,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else {
throw SnapshotError.pngGenerationFailed
}
context.setFillColor(red: red, green: green, blue: blue, alpha: 1)
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
guard let image = context.makeImage() else {
throw SnapshotError.pngGenerationFailed
}
let buffer = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(
buffer,
"public.png" as CFString,
1,
nil
) else {
throw SnapshotError.pngGenerationFailed
}
CGImageDestinationAddImage(destination, image, nil)
guard CGImageDestinationFinalize(destination) else {
throw SnapshotError.pngGenerationFailed
}
return buffer as Data
}
private static func writeShotdeckPDF(to url: URL, marked: Bool) throws {
let document = PDFDocument()
let page = PDFPage()
page.setBounds(CGRect(x: 0, y: 0, width: 612, height: 792), for: .mediaBox)
document.insert(page, at: 0)
document.documentAttributes = [
PDFDocumentAttribute.creatorAttribute: "Shotdeck",
PDFDocumentAttribute.subjectAttribute: UUID().uuidString,
]
if marked {
let annotation = PDFAnnotation(
bounds: CGRect(x: 72, y: 400, width: 220, height: 36),
forType: .highlight,
withProperties: nil
)
page.addAnnotation(annotation)
}
guard document.write(to: url) else {
throw SnapshotError.pdfWriteFailed(url.lastPathComponent)
}
}
}
extension AppModel {
/// `screenRecordingGranted` is `public private(set)` with no seam mutator.
/// Snapshot-only: the KeyPath setter exists at runtime; compile-time access is file-private.
func snapshotSetScreenRecordingGranted(_ granted: Bool) {
// private(set) types this as KeyPath; the setter still exists on the @Observable storage.
let writable: ReferenceWritableKeyPath<AppModel, Bool> = unsafeBitCast(
\AppModel.screenRecordingGranted, to: ReferenceWritableKeyPath<AppModel, Bool>.self
)
self[keyPath: writable] = granted
}
}
private enum SnapshotError: Error, CustomStringConvertible {
case renderFailed(String)
case encodeFailed(String)
case pngGenerationFailed
case pdfWriteFailed(String)
var description: String {
switch self {
case .renderFailed(let name): return "bitmapImageRepForCachingDisplay failed for \(name)"
case .encodeFailed(let name): return "PNG encode failed for \(name)"
case .pngGenerationFailed: return "CoreGraphics PNG generation failed"
case .pdfWriteFailed(let name): return "could not write \(name)"
}
}
}
+172
View File
@@ -0,0 +1,172 @@
import AppKit
import Darwin
import Foundation
import ShotdeckCore
/// In-process self-test for the region picker, driven by `SHOTDECK_PICKER_SELFTEST`.
/// Posts synthetic mouse events through `NSApp.postEvent` only never CGEventPost/taps.
@MainActor
enum PickerSelfTest {
private static let pointA = NSPoint(x: 200, y: 300)
private static let pointB = NSPoint(x: 600, y: 600)
private static let dragSteps = 6
/// Called from `main.swift` before `ShotdeckApp.main()`. Returns immediately when the
/// env var is unset; otherwise waits for launch, drives the production picker, and `exit`s.
static func runIfRequested() {
guard let raw = ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"],
!raw.isEmpty
else { return }
let output = URL(fileURLWithPath: raw, isDirectory: true)
// Hop onto a plain main-queue turn after NSApp starts. Nested run loops
// from a Swift Task do not drain NSApp's event queue.
DispatchQueue.main.async {
MainActor.assumeIsolated {
execute(outputDirectory: output)
}
}
}
private static func execute(outputDirectory: URL) {
do {
try FileManager.default.createDirectory(
at: outputDirectory,
withIntermediateDirectories: true
)
} catch {
fail(expected: expectedLabel(), got: "could not create output dir: \(error)")
}
guard let screen = NSScreen.screens.first(where: { $0.frame.contains(pointA) })
?? NSScreen.screens.first
else {
fail(expected: expectedLabel(), got: "no NSScreen")
}
let appKitRect = CGRect(
x: min(pointA.x, pointB.x),
y: min(pointA.y, pointB.y),
width: abs(pointB.x - pointA.x),
height: abs(pointB.y - pointA.y)
).intersection(screen.frame)
let expected = CaptureRegion.fromAppKit(rect: appKitRect, on: screen)
let picker = RegionPickerController()
var result: CaptureRegion??
picker.pick { region in
result = .some(region)
}
guard let overlay = overlayWindow(containing: pointA) else {
fail(expected: format(expected.rect), got: "no overlay window after pick()")
}
overlay.makeKey()
var eventNumber = 1
func post(_ type: NSEvent.EventType, at screenPoint: NSPoint, clickCount: Int) {
let locationInWindow = overlay.convertPoint(fromScreen: screenPoint)
guard let event = NSEvent.mouseEvent(
with: type,
location: locationInWindow,
modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: overlay.windowNumber,
context: nil,
eventNumber: eventNumber,
clickCount: clickCount,
pressure: 1
) else {
fail(expected: format(expected.rect), got: "NSEvent.mouseEvent(\(type.rawValue)) returned nil")
}
eventNumber += 1
NSApp.postEvent(event, atStart: false)
// Local monitors run during NSApp.sendEvent (production dispatch),
// not during nextEvent dequeue. Drive that same path here.
NSApp.sendEvent(event)
}
post(.leftMouseDown, at: pointA, clickCount: 1)
for step in 1...(dragSteps / 2) {
post(.leftMouseDragged, at: interpolate(step), clickCount: 0)
}
writeOverlayBitmap(overlay, to: outputDirectory.appendingPathComponent("overlay-middrag.png"))
for step in ((dragSteps / 2) + 1)...dragSteps {
post(.leftMouseDragged, at: interpolate(step), clickCount: 0)
}
post(.leftMouseUp, at: pointB, clickCount: 1)
guard let wrapped = result else {
fail(expected: format(expected.rect), got: "completion never fired")
}
guard let got = wrapped else {
fail(expected: format(expected.rect), got: "nil")
}
if got.rect != expected.rect {
fail(expected: format(expected.rect), got: format(got.rect))
}
print("PICKER-SELFTEST PASS rect=\(format(got.rect))")
fflush(stdout)
exit(0)
}
private static func interpolate(_ step: Int) -> NSPoint {
let t = CGFloat(step) / CGFloat(dragSteps)
return NSPoint(
x: pointA.x + (pointB.x - pointA.x) * t,
y: pointA.y + (pointB.y - pointA.y) * t
)
}
private static func overlayWindow(containing point: NSPoint) -> RegionPickerWindow? {
let overlays = NSApp.windows.compactMap { $0 as? RegionPickerWindow }
return overlays.first(where: { $0.coveringScreen.frame.contains(point) }) ?? overlays.first
}
private static func writeOverlayBitmap(_ overlay: RegionPickerWindow, to url: URL) {
guard let view = overlay.contentView else {
fail(expected: expectedLabel(), got: "overlay has no contentView")
}
overlay.layoutIfNeeded()
view.layoutSubtreeIfNeeded()
view.display()
let bounds = view.bounds
guard let rep = view.bitmapImageRepForCachingDisplay(in: bounds) else {
fail(expected: expectedLabel(), got: "bitmapImageRepForCachingDisplay failed")
}
view.cacheDisplay(in: bounds, to: rep)
guard let png = rep.representation(using: .png, properties: [:]) else {
fail(expected: expectedLabel(), got: "PNG encode failed")
}
do {
try png.write(to: url)
} catch {
fail(expected: expectedLabel(), got: "could not write \(url.path): \(error)")
}
print(url.path)
fflush(stdout)
}
private static func expectedLabel() -> String {
format(CGRect(
x: min(pointA.x, pointB.x),
y: min(pointA.y, pointB.y),
width: abs(pointB.x - pointA.x),
height: abs(pointB.y - pointA.y)
))
}
private static func format(_ rect: CGRect) -> String {
"(\(rect.origin.x), \(rect.origin.y), \(rect.width), \(rect.height))"
}
private static func fail(expected: String, got: String) -> Never {
print("PICKER-SELFTEST FAIL expected=\(expected) got=\(got)")
fflush(stdout)
exit(1)
}
}
+41 -19
View File
@@ -48,20 +48,27 @@ public final class RegionPickerController {
matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp, .keyDown] matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp, .keyDown]
) { [weak self] event in ) { [weak self] event in
guard let self else { return event } guard let self else { return event }
// NSEvent is not Sendable; lift the two Sendable fields the handler // NSEvent is not Sendable; lift Sendable fields the handler needs.
// needs. Mouse geometry is read from NSEvent.mouseLocation on the // Prefer the event's window-local point converted to global AppKit
// main thread inside the isolated methods, per spec. // coordinates; NSEvent.mouseLocation is only a fallback.
// keyCode is only valid on key events reading it on a mouse event raises.
let type = event.type let type = event.type
let keyCode = event.keyCode let keyCode: UInt16 = (type == .keyDown) ? event.keyCode : 0
let locationInWindow = event.locationInWindow
let windowNumber = event.windowNumber
let consume = MainActor.assumeIsolated { let consume = MainActor.assumeIsolated {
self.handle(type: type, keyCode: keyCode) let location = self.globalAppKitLocation(
locationInWindow: locationInWindow,
windowNumber: windowNumber
)
return self.handle(type: type, keyCode: keyCode, location: location)
} }
return consume ? nil : event return consume ? nil : event
} }
} }
/// Returns `true` when the event should be swallowed. /// Returns `true` when the event should be swallowed.
private func handle(type: NSEvent.EventType, keyCode: UInt16) -> Bool { private func handle(type: NSEvent.EventType, keyCode: UInt16, location: NSPoint?) -> Bool {
switch type { switch type {
case .keyDown: case .keyDown:
if keyCode == 53 { // kVK_Escape if keyCode == 53 { // kVK_Escape
@@ -70,21 +77,21 @@ public final class RegionPickerController {
} }
return false return false
case .leftMouseDown: case .leftMouseDown:
handleMouseDown() handleMouseDown(location: location)
return false return false
case .leftMouseDragged: case .leftMouseDragged:
handleMouseDragged() handleMouseDragged(location: location)
return false return false
case .leftMouseUp: case .leftMouseUp:
handleMouseUp() handleMouseUp(location: location)
return false return false
default: default:
return false return false
} }
} }
private func handleMouseDown() { private func handleMouseDown(location: NSPoint?) {
let point = NSEvent.mouseLocation let point = location ?? NSEvent.mouseLocation
dragStart = point dragStart = point
startScreen = NSScreen.screens.first(where: { $0.frame.contains(point) }) startScreen = NSScreen.screens.first(where: { $0.frame.contains(point) })
?? NSScreen.screens.first ?? NSScreen.screens.first
@@ -99,17 +106,17 @@ public final class RegionPickerController {
} }
} }
private func handleMouseDragged() { private func handleMouseDragged(location: NSPoint?) {
guard dragStart != nil, startScreen != nil else { return } guard dragStart != nil, startScreen != nil else { return }
applyLiveSelection() applyLiveSelection(current: location)
} }
private func handleMouseUp() { private func handleMouseUp(location: NSPoint?) {
guard let startScreen else { guard let startScreen else {
dragStart = nil dragStart = nil
return return
} }
guard let rect = currentClampedRect(), rect.width >= 8, rect.height >= 8 else { guard let rect = currentClampedRect(current: location), rect.width >= 8, rect.height >= 8 else {
// Mis-click: reset drag state, leave every window open and fully dimmed. // Mis-click: reset drag state, leave every window open and fully dimmed.
dragStart = nil dragStart = nil
self.startScreen = nil self.startScreen = nil
@@ -122,8 +129,8 @@ public final class RegionPickerController {
finish(region: region) finish(region: region)
} }
private func applyLiveSelection() { private func applyLiveSelection(current: NSPoint?) {
guard let startScreen, let rect = currentClampedRect() else { return } guard let startScreen, let rect = currentClampedRect(current: current) else { return }
let local = Self.localRect(from: rect, on: startScreen) let local = Self.localRect(from: rect, on: startScreen)
let text = "\(Int(rect.width)) x \(Int(rect.height))" let text = "\(Int(rect.width)) x \(Int(rect.height))"
window(for: startScreen)?.updateSelection(localRect: local, sizeText: text) window(for: startScreen)?.updateSelection(localRect: local, sizeText: text)
@@ -134,9 +141,9 @@ public final class RegionPickerController {
/// Normalize the drag (so bottom-right up-left is not misjudged) then clamp /// Normalize the drag (so bottom-right up-left is not misjudged) then clamp
/// to the screen the gesture started on never selects across displays. /// to the screen the gesture started on never selects across displays.
private func currentClampedRect() -> CGRect? { private func currentClampedRect(current: NSPoint?) -> CGRect? {
guard let dragStart, let startScreen else { return nil } guard let dragStart, let startScreen else { return nil }
let current = NSEvent.mouseLocation let current = current ?? NSEvent.mouseLocation
let normalized = CGRect( let normalized = CGRect(
x: min(dragStart.x, current.x), x: min(dragStart.x, current.x),
y: min(dragStart.y, current.y), y: min(dragStart.y, current.y),
@@ -146,6 +153,21 @@ public final class RegionPickerController {
return normalized.intersection(startScreen.frame) return normalized.intersection(startScreen.frame)
} }
/// Convert a monitored event's `locationInWindow` into global AppKit coordinates
/// (bottom-left origin, matching `NSEvent.mouseLocation` / `NSScreen.frame`).
private func globalAppKitLocation(locationInWindow: NSPoint, windowNumber: Int) -> NSPoint? {
if let window = windows.first(where: { $0.windowNumber == windowNumber }) {
return window.convertPoint(toScreen: locationInWindow)
}
if windowNumber != 0, let window = NSApp.window(withWindowNumber: windowNumber) {
return window.convertPoint(toScreen: locationInWindow)
}
if windowNumber == 0 {
return locationInWindow
}
return nil
}
private func window(for screen: NSScreen) -> RegionPickerWindow? { private func window(for screen: NSScreen) -> RegionPickerWindow? {
windows.first { $0.coveringScreen === screen } windows.first { $0.coveringScreen === screen }
} }
@@ -29,6 +29,7 @@ final class RegionPickerWindow: NSPanel {
becomesKeyOnlyIfNeeded = false becomesKeyOnlyIfNeeded = false
animationBehavior = .none animationBehavior = .none
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
acceptsMouseMovedEvents = true
contentView = pickerView contentView = pickerView
} }
@@ -53,6 +54,8 @@ private final class RegionPickerView: NSView {
override var isOpaque: Bool { false } override var isOpaque: Bool { false }
override var acceptsFirstResponder: Bool { true } override var acceptsFirstResponder: Bool { true }
// Accessory-app trap: first click on an inactive overlay is first-mouse and is dropped unless accepted.
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
override func draw(_ dirtyRect: NSRect) { override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect) super.draw(dirtyRect)
+61 -21
View File
@@ -5,39 +5,79 @@ import ShotdeckCore
struct SettingsView: View { struct SettingsView: View {
@Environment(AppModel.self) private var model @Environment(AppModel.self) private var model
private let labelWidth: CGFloat = 104
var body: some View { var body: some View {
Form { Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) {
Section("Hotkey") { GridRow {
LabeledContent("Capture", value: "⌥⇧2 — fixed in this version") Text("Hotkey")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
.gridCellColumns(2)
} }
Section("Folders") {
LabeledContent("Watch folder") { GridRow(alignment: .firstTextBaseline) {
HStack(spacing: 8) { fieldLabel("Capture")
Text(model.watchFolderURL.path) Text("⌥⇧2 — fixed in this version")
.lineLimit(1)
.truncationMode(.middle)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Button("Choose…") { model.chooseWatchFolder() }
}
}
LabeledContent("Output folder") {
HStack(spacing: 8) {
Text(model.outboxURL.path)
.lineLimit(1) .lineLimit(1)
.truncationMode(.middle) .frame(maxWidth: .infinity, alignment: .leading)
.foregroundStyle(.secondary) .frame(minHeight: 22, alignment: .leading)
Button("Choose…") { model.chooseOutboxFolder() } }
GridRow {
Text("Folders")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
.gridCellColumns(2)
.padding(.top, 6)
}
GridRow(alignment: .center) {
fieldLabel("Watch folder")
folderValue(path: model.watchFolderURL.path) {
model.chooseWatchFolder()
} }
} }
GridRow(alignment: .center) {
fieldLabel("Output folder")
folderValue(path: model.outboxURL.path) {
model.chooseOutboxFolder()
} }
Section { }
GridRow {
Button("Reveal spool folder") { model.openSpoolFolder() } Button("Reveal spool folder") { model.openSpoolFolder() }
.gridCellColumns(2)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 4)
} }
} }
.padding() .padding(16)
.frame(width: 360) .frame(minWidth: 320, idealWidth: 360, maxWidth: 360, alignment: .leading)
.controlSize(.small) .controlSize(.small)
} }
private func fieldLabel(_ title: String) -> some View {
Text(title)
.lineLimit(1)
.frame(width: labelWidth, alignment: .trailing)
.gridColumnAlignment(.trailing)
.frame(minHeight: 22, alignment: .trailing)
}
private func folderValue(path: String, choose: @escaping () -> Void) -> some View {
HStack(spacing: 8) {
Text(path)
.lineLimit(1)
.truncationMode(.middle)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
Button("Choose…") { choose() }
}
.frame(minHeight: 22)
}
} }
extension AppModel: SettingsWindowPresenting { extension AppModel: SettingsWindowPresenting {
+6
View File
@@ -4,6 +4,12 @@ import ShotdeckCore
// SwiftPM treats a file named main.swift as top-level code, which forbids `@main`. // SwiftPM treats a file named main.swift as top-level code, which forbids `@main`.
// App.main() is the equivalent entry point. // App.main() is the equivalent entry point.
if ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil {
MainActor.assumeIsolated { PanelSnapshot.runIfRequested() }
}
if ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil {
MainActor.assumeIsolated { PickerSelfTest.runIfRequested() }
}
ShotdeckApp.main() ShotdeckApp.main()
struct ShotdeckApp: App { struct ShotdeckApp: App {