diff --git a/Sources/ZoomItMacCore/Annotations/AnnotationController.swift b/Sources/ZoomItMacCore/Annotations/AnnotationController.swift index f84f02e..9ac7fda 100644 --- a/Sources/ZoomItMacCore/Annotations/AnnotationController.swift +++ b/Sources/ZoomItMacCore/Annotations/AnnotationController.swift @@ -6,7 +6,7 @@ final class AnnotationController { var currentStyle: AnnotationStyle = .default // Typing mode state, mirroring ZoomIt's font scaling and justification. - static let defaultFontSize: CGFloat = 36 + static let defaultFontSize: CGFloat = 20 var typingFontSize: CGFloat = AnnotationController.defaultFontSize var typingRightAligned: Bool = false /// PostScript/font family name used for typing mode. Empty means the @@ -32,7 +32,7 @@ final class AnnotationController { if !name.isEmpty, let font = NSFont(name: name, size: size) { return font } - return NSFont.systemFont(ofSize: size, weight: .semibold) + return NSFont.systemFont(ofSize: size, weight: .regular) } func reset() { diff --git a/Sources/ZoomItMacCore/App/AppController.swift b/Sources/ZoomItMacCore/App/AppController.swift index 6387ead..345ef91 100644 --- a/Sources/ZoomItMacCore/App/AppController.swift +++ b/Sources/ZoomItMacCore/App/AppController.swift @@ -36,6 +36,10 @@ final class AppController: NSObject { modeCoordinator.handle(.activateStaticZoom) } + @objc func activateDrawWithoutZoom() { + modeCoordinator.handle(.activateDrawWithoutZoom) + } + @objc func activateLiveZoom() { modeCoordinator.handle(.activateLiveZoom) } @@ -94,6 +98,12 @@ final class AppController: NSObject { alert.addButton(withTitle: screenGranted ? "Screen Recording Settings…" : "Grant Screen Recording…") alert.addButton(withTitle: micStatus == .notDetermined ? "Grant Microphone…" : "Microphone Settings…") alert.addButton(withTitle: camStatus == .notDetermined ? "Grant Camera…" : "Camera Settings…") + // Use a standard macOS-style rounded-square icon so the dialog matches + // the look of system permission prompts and the icon top lines up with + // the message text. + if let icon = ZoomItAppIcon.standardIcon() { + alert.icon = icon + } alert.window.animationBehavior = .none NSApp.activate(ignoringOtherApps: true) diff --git a/Sources/ZoomItMacCore/App/AppDelegate.swift b/Sources/ZoomItMacCore/App/AppDelegate.swift index 0bcc24c..27964d2 100644 --- a/Sources/ZoomItMacCore/App/AppDelegate.swift +++ b/Sources/ZoomItMacCore/App/AppDelegate.swift @@ -94,7 +94,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { let config = NSImage.SymbolConfiguration(paletteColors: [.systemRed]) let image = NSImage(systemSymbolName: "record.circle.fill", accessibilityDescription: "Recording")? .withSymbolConfiguration(config) - image?.size = NSSize(width: 18, height: 18) + image?.size = NSSize(width: Self.menuBarIconGlyph, height: Self.menuBarIconGlyph) button.image = image } else { button.image = Self.menuBarIcon() @@ -116,22 +116,13 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { } let menu = NSMenu() - let staticZoomItem = NSMenuItem(title: "Static Zoom", action: #selector(AppController.activateStaticZoom), keyEquivalent: "") - menu.addItem(staticZoomItem) - let liveZoomItem = NSMenuItem(title: "Live Zoom", action: #selector(AppController.activateLiveZoom), keyEquivalent: "") - menu.addItem(liveZoomItem) - let recordItem = NSMenuItem(title: "Record Screen", action: #selector(AppController.toggleRecording), keyEquivalent: "") - menu.addItem(recordItem) - let panoramaItem = NSMenuItem(title: "Panorama Capture", action: #selector(AppController.startPanorama), keyEquivalent: "") - menu.addItem(panoramaItem) - let breakItem = NSMenuItem(title: "Break Timer", action: #selector(AppController.toggleBreakTimer), keyEquivalent: "") - menu.addItem(breakItem) - menu.addItem(.separator()) - let settingsItem = NSMenuItem(title: "Settings…", action: #selector(AppController.showSettings), keyEquivalent: ",") - menu.addItem(settingsItem) - menu.addItem(NSMenuItem(title: "Check Permissions", action: #selector(AppController.checkPermissions), keyEquivalent: "")) - menu.addItem(.separator()) - menu.addItem(NSMenuItem(title: "Quit", action: #selector(AppController.quit), keyEquivalent: "q")) + for entry in Self.statusMenuEntries() { + if entry.isSeparator { + menu.addItem(.separator()) + } else { + menu.addItem(NSMenuItem(title: entry.title, action: entry.action, keyEquivalent: entry.keyEquivalent)) + } + } for item in menu.items { item.target = controller @@ -141,12 +132,72 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { return item } + /// A single status-bar menu entry (or a separator when `action` is nil). + struct StatusMenuEntry { + let title: String + let action: Selector? + let keyEquivalent: String + + var isSeparator: Bool { action == nil } + + static let separator = StatusMenuEntry(title: "", action: nil, keyEquivalent: "") + + init(title: String, action: Selector?, keyEquivalent: String) { + self.title = title + self.action = action + self.keyEquivalent = keyEquivalent + } + } + + /// The status-bar menu. It broadly follows the Windows ZoomIt tray order + /// (Options first, then the modes, then Check Permissions and Quit), with + /// Panorama Capture as a macOS-only addition after Record and the Break + /// Timer placed at the end of the mode group (below Panorama Capture). + static func statusMenuEntries() -> [StatusMenuEntry] { + [ + StatusMenuEntry(title: "Settings…", action: #selector(AppController.showSettings), keyEquivalent: ","), + .separator, + StatusMenuEntry(title: "Draw", action: #selector(AppController.activateDrawWithoutZoom), keyEquivalent: ""), + StatusMenuEntry(title: "Static Zoom", action: #selector(AppController.activateStaticZoom), keyEquivalent: ""), + StatusMenuEntry(title: "Live Zoom", action: #selector(AppController.activateLiveZoom), keyEquivalent: ""), + StatusMenuEntry(title: "Record Screen", action: #selector(AppController.toggleRecording), keyEquivalent: ""), + StatusMenuEntry(title: "Panorama Capture", action: #selector(AppController.startPanorama), keyEquivalent: ""), + StatusMenuEntry(title: "Break Timer", action: #selector(AppController.toggleBreakTimer), keyEquivalent: ""), + .separator, + StatusMenuEntry(title: "Check Permissions", action: #selector(AppController.checkPermissions), keyEquivalent: ""), + StatusMenuEntry(title: "Quit", action: #selector(AppController.quit), keyEquivalent: "q") + ] + } + /// Loads the bundled black template version of the Windows ZoomIt icon and /// sizes it for the menu bar. As a template image it is tinted by the system /// (black on a light menu bar, white on a dark one). private static func menuBarIcon() -> NSImage? { - guard let image = loadZoomItIcon() else { return nil } - image.size = NSSize(width: 18, height: 18) + guard let source = loadZoomItIcon() else { return nil } + return Self.menuBarImage(from: source) + } + + /// The square point size of the menu-bar item's image slot. + static let menuBarIconCanvas: CGFloat = 18 + /// The glyph is drawn smaller than the canvas so ZoomIt's icon carries the + /// same interior padding as system menu-bar icons; a full-bleed image made + /// it look oversized and misaligned next to them. + static let menuBarIconGlyph: CGFloat = 15 + + /// Renders `source` centered inside a padded, square template image so it + /// matches the size and vertical alignment of other menu-bar icons. + static func menuBarImage(from source: NSImage) -> NSImage { + let canvas = NSSize(width: menuBarIconCanvas, height: menuBarIconCanvas) + let image = NSImage(size: canvas) + image.lockFocus() + let rect = NSRect( + x: (canvas.width - menuBarIconGlyph) / 2, + y: (canvas.height - menuBarIconGlyph) / 2, + width: menuBarIconGlyph, + height: menuBarIconGlyph + ) + source.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1) + image.unlockFocus() image.isTemplate = true return image } diff --git a/Sources/ZoomItMacCore/App/AppIcon.swift b/Sources/ZoomItMacCore/App/AppIcon.swift index 46a76be..2f8dfaf 100644 --- a/Sources/ZoomItMacCore/App/AppIcon.swift +++ b/Sources/ZoomItMacCore/App/AppIcon.swift @@ -18,6 +18,26 @@ enum ZoomItAppIcon { loadImage(named: "ZoomItIcon") } + /// A standard macOS-style app icon: the (full-bleed) artwork inset within a + /// rounded square with a margin, matching the silhouette of other app icons + /// so it looks right in pickers and the permissions dialog. The margin also + /// brings the visible top of the icon down to line up with dialog text. + static func standardIcon(size: CGFloat = 128) -> NSImage? { + guard let source = loadColorIcon() ?? loadTemplateIcon() else { return nil } + let canvas = NSSize(width: size, height: size) + let image = NSImage(size: canvas) + image.lockFocus() + // Apple's large-icon grid leaves roughly a 10% margin and uses a + // continuous-corner radius near 22% of the icon body. + let margin = (size * 0.10).rounded() + let body = NSRect(x: margin, y: margin, width: size - 2 * margin, height: size - 2 * margin) + let radius = body.width * 0.2237 + NSBezierPath(roundedRect: body, xRadius: radius, yRadius: radius).addClip() + source.draw(in: body, from: .zero, operation: .sourceOver, fraction: 1) + image.unlockFocus() + return image + } + private static func loadColorIcon() -> NSImage? { loadImage(named: "ZoomItColorIcon") } diff --git a/Sources/ZoomItMacCore/App/IdleSleepAssertion.swift b/Sources/ZoomItMacCore/App/IdleSleepAssertion.swift new file mode 100644 index 0000000..a0a8342 --- /dev/null +++ b/Sources/ZoomItMacCore/App/IdleSleepAssertion.swift @@ -0,0 +1,60 @@ +import Foundation +import IOKit.pwr_mgt + +/// Prevents the display from idle-sleeping — and therefore the screen saver +/// from starting — while held. Windows ZoomIt keeps the screen saver from +/// kicking in during the break timer; this is the macOS equivalent using an +/// `IOPMAssertion`. +/// +/// The IOKit calls are injectable so the begin/end lifecycle can be unit +/// tested without touching real power management. +final class IdleSleepAssertion { + private let create: (String) -> IOPMAssertionID? + private let release: (IOPMAssertionID) -> Void + private var assertionID: IOPMAssertionID? + + /// True while the assertion is held (display sleep / screen saver blocked). + var isActive: Bool { assertionID != nil } + + init( + create: @escaping (String) -> IOPMAssertionID? = IdleSleepAssertion.systemCreate, + release: @escaping (IOPMAssertionID) -> Void = IdleSleepAssertion.systemRelease + ) { + self.create = create + self.release = release + } + + deinit { + if let assertionID { + release(assertionID) + } + } + + /// Acquires the assertion if not already held. Idempotent. + func begin(reason: String) { + guard assertionID == nil else { return } + assertionID = create(reason) + } + + /// Releases the assertion if held. Idempotent. + func end() { + guard let assertionID else { return } + release(assertionID) + self.assertionID = nil + } + + private static func systemCreate(_ reason: String) -> IOPMAssertionID? { + var id: IOPMAssertionID = IOPMAssertionID(0) + let result = IOPMAssertionCreateWithName( + kIOPMAssertionTypePreventUserIdleDisplaySleep as CFString, + IOPMAssertionLevel(kIOPMAssertionLevelOn), + reason as CFString, + &id + ) + return result == kIOReturnSuccess ? id : nil + } + + private static func systemRelease(_ id: IOPMAssertionID) { + IOPMAssertionRelease(id) + } +} diff --git a/Sources/ZoomItMacCore/Capture/PanoramaController.swift b/Sources/ZoomItMacCore/Capture/PanoramaController.swift index bff5ecb..ea11b22 100644 --- a/Sources/ZoomItMacCore/Capture/PanoramaController.swift +++ b/Sources/ZoomItMacCore/Capture/PanoramaController.swift @@ -3,8 +3,8 @@ import AppKit /// Draws a blue capture border plus an instruction banner around the panorama /// region. It lives in a click-through, non-shareable window so it never -/// appears in the captured frames (mirroring the Windows excluded-from-capture -/// SelectRectangle border). +/// appears in the captured frames. Blue keeps it distinct from the orange +/// screen-recording border. @MainActor private final class PanoramaBorderView: NSView { override var isFlipped: Bool { true } @@ -53,6 +53,13 @@ final class PanoramaController { private(set) var isCapturing = false private var stopRequested = false + /// Set when the user presses Escape during the scrolling capture so the run + /// is discarded (matches Windows ZoomIt, where Escape cancels panorama). + private var captureCancelled = false + /// Event monitors that watch for Escape while capturing, so the user can + /// cancel even while another app is frontmost (they scroll it). + private var escapeGlobalMonitor: Any? + private var escapeLocalMonitor: Any? /// True from the moment a panorama is initiated (region selection) until it /// finishes, so a second trigger can't stack a new selection. private var isActive = false @@ -153,7 +160,8 @@ final class PanoramaController { let view = SnipSelectionView( frame: CGRect(origin: .zero, size: display.frame.size), - image: frame.image + image: frame.image, + borderColor: .systemBlue ) var holder: NSWindow? = window var cursorLease: CrosshairCursorLease? @@ -180,6 +188,8 @@ final class PanoramaController { showBorder(display: display, region: region) isCapturing = true stopRequested = false + captureCancelled = false + installEscapeMonitor() onStateChange?(true) Task { @MainActor in @@ -219,6 +229,15 @@ final class PanoramaController { try? await Task.sleep(nanoseconds: 16_000_000) } + // Escape cancels the whole panorama (Windows behaviour): discard the + // captured frames instead of stitching them. + if captureCancelled { + isCapturing = false + onStateChange?(false) + showCompletion(message: "Panorama cancelled") + return + } + if stopRequested && frames.count < maxFrames && totalBytes + frameBytes <= maxFrameBytes { let image = try? await SCScreenshotManager.captureImage(contentFilter: filter, configuration: configuration) if let image, let frame = Self.makeFrame(from: image) { @@ -550,6 +569,7 @@ final class PanoramaController { } private func hideOverlays() { + removeEscapeMonitor() borderWindow?.orderOut(nil) borderWindow = nil bannerWindow?.orderOut(nil) @@ -561,6 +581,46 @@ final class PanoramaController { stitchTask = nil } + /// Requests cancellation of an in-progress scrolling capture (Escape). + private func cancelCapture() { + guard Self.shouldCancelOnEscape(isCapturing: isCapturing, alreadyCancelled: captureCancelled) else { return } + captureCancelled = true + stopRequested = true + } + + /// Decides whether an Escape press should cancel the panorama. Escape only + /// cancels while actively capturing and ignores repeats once cancelled. + static func shouldCancelOnEscape(isCapturing: Bool, alreadyCancelled: Bool) -> Bool { + isCapturing && !alreadyCancelled + } + + /// Watches for Escape during capture. A global monitor handles the common + /// case where the user is scrolling another (frontmost) app; a local + /// monitor covers the case where ZoomIt itself is frontmost. + private func installEscapeMonitor() { + removeEscapeMonitor() + escapeGlobalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.keyDown]) { [weak self] event in + guard event.keyCode == 53 else { return } + Task { @MainActor in self?.cancelCapture() } + } + escapeLocalMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { [weak self] event in + guard event.keyCode == 53 else { return event } + Task { @MainActor in self?.cancelCapture() } + return nil + } + } + + private func removeEscapeMonitor() { + if let monitor = escapeGlobalMonitor { + NSEvent.removeMonitor(monitor) + escapeGlobalMonitor = nil + } + if let monitor = escapeLocalMonitor { + NSEvent.removeMonitor(monitor) + escapeLocalMonitor = nil + } + } + // MARK: - CGImage <-> Frame /// Convert a captured CGImage into a top-down RGBA frame for stitching. diff --git a/Sources/ZoomItMacCore/Capture/RecordingController.swift b/Sources/ZoomItMacCore/Capture/RecordingController.swift index 5a88cd8..7f66ed9 100644 --- a/Sources/ZoomItMacCore/Capture/RecordingController.swift +++ b/Sources/ZoomItMacCore/Capture/RecordingController.swift @@ -1132,12 +1132,60 @@ final class RecordingController { self.clipEditor = editor editor.present(tempURL: url, suggestedName: suggestedFilename(), onSave: { [weak self] editedURL in self?.clipEditor = nil - self?.savePanel(for: editedURL) + self?.saveTrimmedClip(editedURL: editedURL, originalURL: url) }, onCancel: { [weak self] in self?.clipEditor = nil }) } + /// Action for saving a clip opened from an existing file (the Trim + /// workflow). If the editor exported a new temp file, that temp is moved + /// into place; if it returned the user's own original (no edits), the + /// original is copied so it is preserved — matching Windows ZoomIt, which + /// never deletes the source file. + enum TrimSaveAction: Equatable { case move, copy } + + static func trimSaveAction(editedURL: URL, originalURL: URL) -> TrimSaveAction { + editedURL == originalURL ? .copy : .move + } + + /// Saves a clip that was opened from an existing file, always preserving the + /// user's original source file. + private func saveTrimmedClip(editedURL: URL, originalURL: URL) { + let panel = NSSavePanel() + panel.nameFieldStringValue = suggestedFilename() + panel.allowedContentTypes = [.mpeg4Movie] + panel.canCreateDirectories = true + NSApp.activate(ignoringOtherApps: true) + + let action = Self.trimSaveAction(editedURL: editedURL, originalURL: originalURL) + if panel.runModal() == .OK, let destination = panel.url { + if destination != originalURL { + try? FileManager.default.removeItem(at: destination) + } + do { + switch action { + case .move: + // Move the exported temp file into place; original untouched. + if destination != editedURL { + try FileManager.default.moveItem(at: editedURL, to: destination) + } + case .copy: + // No edits: copy the user's original, preserving the source. + if destination != editedURL { + try FileManager.default.copyItem(at: editedURL, to: destination) + } + } + } catch { + let alert = NSAlert(error: error) + alert.runModal() + } + } else if action == .move { + // Discard the temp export; never delete the user's original file. + try? FileManager.default.removeItem(at: editedURL) + } + } + private func selectRegion(on display: DisplayDescriptor, completion: @escaping (CGRect?) -> Void) { Task { @MainActor in do { diff --git a/Sources/ZoomItMacCore/Capture/SnipController.swift b/Sources/ZoomItMacCore/Capture/SnipController.swift index 684ed5c..9be3038 100644 --- a/Sources/ZoomItMacCore/Capture/SnipController.swift +++ b/Sources/ZoomItMacCore/Capture/SnipController.swift @@ -62,6 +62,10 @@ final class CrosshairCursorLease { @MainActor final class SnipSelectionView: NSView { private let image: CGImage + /// Colour of the selection rectangle border. Defaults to white (snip/record + /// selectors); the panorama selector uses blue to stay distinct from the + /// orange screen-recording border. + private let borderColor: NSColor /// Called with the selected rectangle in view points (top-left origin), or /// nil if the selection was cancelled or empty. var onComplete: ((CGRect?) -> Void)? @@ -69,8 +73,9 @@ final class SnipSelectionView: NSView { private var anchorPoint: CGPoint? private var selectionRect: CGRect = .zero - init(frame frameRect: CGRect, image: CGImage) { + init(frame frameRect: CGRect, image: CGImage, borderColor: NSColor = .white) { self.image = image + self.borderColor = borderColor super.init(frame: frameRect) } @@ -127,7 +132,7 @@ final class SnipSelectionView: NSView { drawImage(in: context) context.restoreGState() - context.setStrokeColor(NSColor.white.cgColor) + context.setStrokeColor(borderColor.cgColor) context.setLineWidth(1) context.stroke(selectionRect.insetBy(dx: 0.5, dy: 0.5)) } @@ -170,6 +175,15 @@ final class SnipSelectionView: NSView { onComplete?(nil) } } + + /// Renders the selector with a fixed selection rectangle into a bitmap so + /// tests can inspect the border colour without synthesizing mouse events. + func renderForTesting(selection: CGRect) -> NSBitmapImageRep? { + selectionRect = selection + guard let rep = bitmapImageRepForCachingDisplay(in: bounds) else { return nil } + cacheDisplay(in: bounds, to: rep) + return rep + } } /// What to do with a selected region: copy the image, save it to a file, or diff --git a/Sources/ZoomItMacCore/Capture/VideoClipEditorController.swift b/Sources/ZoomItMacCore/Capture/VideoClipEditorController.swift index 9d1bb20..854713b 100644 --- a/Sources/ZoomItMacCore/Capture/VideoClipEditorController.swift +++ b/Sources/ZoomItMacCore/Capture/VideoClipEditorController.swift @@ -395,6 +395,30 @@ final class VideoClipEditorController: NSObject, NSWindowDelegate, VideoTimeline let index = transitionPopup.indexOfSelectedItem guard Transition.allCases.indices.contains(index) else { return } selectedTransition = Transition.allCases[index] + // The popup is a single global control. Apply the newly chosen + // transition to the existing append boundaries and rebuild the preview + // so switching (e.g. Fade to Black -> Fade to White) takes effect + // immediately instead of keeping the transition captured at append time. + guard !joinTransitions.isEmpty else { return } + let isAppendJoin = joinKinds.map { kind -> Bool in + if case .append = kind { return true } + return false + } + let updated = Self.updatedJoinTransitions(current: joinTransitions, + isAppendJoin: isAppendJoin, + newTransition: selectedTransition) + guard updated != joinTransitions else { return } + pushUndoSnapshot() + joinTransitions = updated + rebuildPlayer() + syncTimeline() + } + + /// Recomputes join transitions when the (global) transition popup changes: + /// every append boundary adopts the newly chosen transition, while + /// delete-seam joins keep their existing transition. + static func updatedJoinTransitions(current: [Transition], isAppendJoin: [Bool], newTransition: Transition) -> [Transition] { + zip(current, isAppendJoin).map { $0.1 ? newTransition : $0.0 } } private func syncDeleteButton() { diff --git a/Sources/ZoomItMacCore/Capture/WebcamOverlayController.swift b/Sources/ZoomItMacCore/Capture/WebcamOverlayController.swift index e420d8b..c118336 100644 --- a/Sources/ZoomItMacCore/Capture/WebcamOverlayController.swift +++ b/Sources/ZoomItMacCore/Capture/WebcamOverlayController.swift @@ -8,6 +8,32 @@ struct WebcamRecordingFrame: @unchecked Sendable { let cornerRadius: CGFloat } +/// The webcam overlay's content view. It lets the user click and drag the +/// picture-in-picture to reposition it (matching Windows ZoomIt), moving the +/// hosting window and reporting the new screen frame so the recorded +/// composite follows. +private final class DraggableWebcamView: NSView { + /// Called with the window's new screen frame whenever the overlay is moved. + var onMoved: ((CGRect) -> Void)? + /// The point grabbed within the window at mouse-down (bottom-left origin). + private var grabOffset: CGSize = .zero + + override func mouseDown(with event: NSEvent) { + let location = event.locationInWindow + grabOffset = CGSize(width: location.x, height: location.y) + } + + override func mouseDragged(with event: NSEvent) { + guard let window else { return } + let origin = WebcamOverlayController.draggedWindowOrigin( + mouseOnScreen: NSEvent.mouseLocation, + grabOffset: grabOffset + ) + window.setFrameOrigin(origin) + onMoved?(window.frame) + } +} + private final class WebcamFrameOutput: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate, @unchecked Sendable { private let lock = NSLock() private let ciContext = CIContext(options: [.useSoftwareRenderer: false]) @@ -183,13 +209,20 @@ final class WebcamOverlayController { window.level = NSWindow.Level(rawValue: NSWindow.Level.screenSaver.rawValue + 1) window.backgroundColor = .clear window.isOpaque = false - window.ignoresMouseEvents = true + // Allow click-and-drag repositioning of the picture-in-picture, like + // Windows ZoomIt. The recorder excludes this window from the screen + // capture and composites the camera at `recordingFrame`, which is kept + // in sync as the overlay is dragged. + window.ignoresMouseEvents = false window.hasShadow = false window.sharingType = .readOnly window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] window.isReleasedWhenClosed = false - let contentView = NSView(frame: CGRect(origin: .zero, size: frame.size)) + let contentView = DraggableWebcamView(frame: CGRect(origin: .zero, size: frame.size)) + contentView.onMoved = { [weak self] newFrame in + self?.recordingFrame = newFrame + } contentView.wantsLayer = true previewLayer.frame = contentView.bounds applyShape(shape, to: previewLayer, size: size, bounds: contentView.bounds) @@ -232,6 +265,12 @@ final class WebcamOverlayController { return WebcamRecordingFrame(image: image, frame: recordingFrame, cornerRadius: recordingCornerRadius) } + /// Computes the overlay window's new bottom-left screen origin while + /// dragging, keeping the point the user grabbed under the cursor. + static func draggedWindowOrigin(mouseOnScreen: CGPoint, grabOffset: CGSize) -> CGPoint { + CGPoint(x: mouseOnScreen.x - grabOffset.width, y: mouseOnScreen.y - grabOffset.height) + } + private func applyShape(_ shape: Shape, to layer: CALayer, size: Size, bounds: CGRect) { layer.masksToBounds = true layer.cornerRadius = cornerRadius(for: shape, size: size, bounds: bounds) diff --git a/Sources/ZoomItMacCore/Core/ModeCoordinator.swift b/Sources/ZoomItMacCore/Core/ModeCoordinator.swift index 5f16673..72fb9e5 100644 --- a/Sources/ZoomItMacCore/Core/ModeCoordinator.swift +++ b/Sources/ZoomItMacCore/Core/ModeCoordinator.swift @@ -331,9 +331,15 @@ final class ModeCoordinator { } } + /// At the zoom-out floor (1x), decides whether the overlay should exit. + /// Matches Windows ZoomIt: static zoom stays active at 1x, while live zoom + /// (and its typing sub-mode) still exits when zoomed all the way out. + static func exitsOnZoomOutFloor(mode: AppMode) -> Bool { + mode != .staticZoom + } + private func zoomIn() { guard mode == .staticZoom || mode == .liveZoom || mode == .typing, !isExiting else { return } - let settings = settingsStore.load() let current = viewportController.targetZoomFactor guard current < settings.maximumZoomFactor else { return } @@ -345,12 +351,16 @@ final class ModeCoordinator { private func zoomOutOrExit() { guard mode == .staticZoom || mode == .liveZoom || mode == .typing, !isExiting else { return } - let settings = settingsStore.load() let current = viewportController.targetZoomFactor - // At 1x there is nothing left to zoom out of, so exit the overlay. + // At 1x there is nothing left to zoom out of. guard current > settings.minimumZoomFactor else { - animateExit() + // Static zoom matches Windows ZoomIt: it stays active at 1x instead + // of exiting when the user zooms all the way out. Only Esc (or right + // click) exits static zoom. Live zoom still exits at 1x. + if Self.exitsOnZoomOutFloor(mode: mode) { + animateExit() + } return } diff --git a/Sources/ZoomItMacCore/Overlay/BreakTimerController.swift b/Sources/ZoomItMacCore/Overlay/BreakTimerController.swift index d9dc5bc..aea82e6 100644 --- a/Sources/ZoomItMacCore/Overlay/BreakTimerController.swift +++ b/Sources/ZoomItMacCore/Overlay/BreakTimerController.swift @@ -57,6 +57,16 @@ enum BreakTimerLayout { } return CGPoint(x: x, y: y) } + + /// Draws a background image into the (flipped, top-left origin) break timer + /// view. The break timer view uses a flipped coordinate system, which would + /// otherwise render images upside down, so this always passes + /// `respectFlipped: true` to keep the image right-side up like Windows. + @MainActor + static func drawBackground(_ image: NSImage, in rect: CGRect, fraction: CGFloat) { + image.draw(in: rect, from: .zero, operation: .sourceOver, fraction: fraction, + respectFlipped: true, hints: nil) + } } @MainActor @@ -67,6 +77,9 @@ final class BreakTimerController { private var window: NSWindow? private weak var timerView: BreakTimerView? private var onFinished: (() -> Void)? + /// Keeps the display awake / screen saver suppressed while the break timer + /// is on screen, matching Windows ZoomIt. + private let idleSleepAssertion = IdleSleepAssertion() init(displayManager: DisplayManager, captureService: ScreenCaptureService, settingsStore: SettingsStore) { self.displayManager = displayManager @@ -119,6 +132,7 @@ final class BreakTimerController { self.window = window self.timerView = timerView self.onFinished = onFinished + idleSleepAssertion.begin(reason: "ZoomIt break timer") timerView.start() } @@ -128,6 +142,7 @@ final class BreakTimerController { private func close(notify: Bool) { timerView?.prepareForClose() + idleSleepAssertion.end() guard let window else { return } window.orderOut(nil) @@ -297,17 +312,19 @@ private final class BreakTimerView: NSView { } private func drawBackgroundImage(_ image: NSImage, in bounds: CGRect) { + // This view is flipped (top-left origin), so the image must be drawn + // with flip awareness or it renders upside down (matches Windows). if settings.breakBackgroundMode == 1 { - image.draw(in: bounds, from: .zero, operation: .sourceOver, fraction: 0.31) + BreakTimerLayout.drawBackground(image, in: bounds, fraction: 0.31) return } if settings.breakBackgroundStretch { - image.draw(in: bounds, from: .zero, operation: .sourceOver, fraction: 1) + BreakTimerLayout.drawBackground(image, in: bounds, fraction: 1) } else { let size = image.size let origin = CGPoint(x: (bounds.width - size.width) / 2, y: (bounds.height - size.height) / 2) - image.draw(in: CGRect(origin: origin, size: size), from: .zero, operation: .sourceOver, fraction: 1) + BreakTimerLayout.drawBackground(image, in: CGRect(origin: origin, size: size), fraction: 1) } } diff --git a/Sources/ZoomItMacCore/Overlay/ZoomCanvasView.swift b/Sources/ZoomItMacCore/Overlay/ZoomCanvasView.swift index 2250349..1e94aa4 100644 --- a/Sources/ZoomItMacCore/Overlay/ZoomCanvasView.swift +++ b/Sources/ZoomItMacCore/Overlay/ZoomCanvasView.swift @@ -443,6 +443,7 @@ final class ZoomCanvasView: NSView { private func handleDrawingShortcut(_ event: NSEvent) -> Void? { guard let key = event.charactersIgnoringModifiers?.lowercased() else { return nil } let shift = event.modifierFlags.contains(.shift) + let control = event.modifierFlags.contains(.control) switch key { case "r": commandSink(shift ? .setHighlightColor(.red) : .setColor(.red)) @@ -452,22 +453,19 @@ final class ZoomCanvasView: NSView { case "o": commandSink(shift ? .setHighlightColor(.orange) : .setColor(.orange)) case "p": commandSink(shift ? .setHighlightColor(.pink) : .setColor(.pink)) case "w": - // Shift+W highlights white; otherwise W blanks the screen (in - // drawing mode) or selects the white pen. - if shift { - commandSink(.setHighlightColor(.white)) - } else if isDrawingMode { - toggleBlankScreen(.white) - } else { - commandSink(.setColor(.white)) + // Ctrl+W blanks the screen white as a sketch pad (matches the Draw + // tab help); Shift+W selects the white highlighter; plain W selects + // the white pen. + switch Self.whiteBlackKeyAction(control: control, shift: shift, isDrawingMode: isDrawingMode) { + case .blankScreen: toggleBlankScreen(.white) + case .highlightColor: commandSink(.setHighlightColor(.white)) + case .penColor: commandSink(.setColor(.white)) } case "k": - if shift { - commandSink(.setHighlightColor(.black)) - } else if isDrawingMode { - toggleBlankScreen(.black) - } else { - commandSink(.setColor(.black)) + switch Self.whiteBlackKeyAction(control: control, shift: shift, isDrawingMode: isDrawingMode) { + case .blankScreen: toggleBlankScreen(.black) + case .highlightColor: commandSink(.setHighlightColor(.black)) + case .penColor: commandSink(.setColor(.black)) } case "f": commandSink(.setTool(.pen)) case "l": commandSink(.setTool(.line)) @@ -493,6 +491,17 @@ final class ZoomCanvasView: NSView { needsDisplay = true } + enum WhiteBlackKeyAction: Equatable { case blankScreen, highlightColor, penColor } + + /// Decides what the W/K keys do while drawing: Ctrl blanks the screen (white + /// or black sketch pad), Shift selects the highlighter of that shade, and + /// plain selects the solid pen colour. + static func whiteBlackKeyAction(control: Bool, shift: Bool, isDrawingMode: Bool) -> WhiteBlackKeyAction { + if control && isDrawingMode { return .blankScreen } + if shift { return .highlightColor } + return .penColor + } + private func drawTypingCaret(in context: CGContext, source: CGRect) { guard let caret = annotationController.typingCaret() else { return } let color = annotationController.currentStyle.color.nsColor diff --git a/Sources/ZoomItMacCore/SelfTest/SelfTestRunner.swift b/Sources/ZoomItMacCore/SelfTest/SelfTestRunner.swift index 693525e..17efc81 100644 --- a/Sources/ZoomItMacCore/SelfTest/SelfTestRunner.swift +++ b/Sources/ZoomItMacCore/SelfTest/SelfTestRunner.swift @@ -10,6 +10,21 @@ enum SelfTestError: Error, CustomStringConvertible { } } +/// A flipped (top-left origin) host view that draws a background image through +/// `BreakTimerLayout.drawBackground`, mirroring the real break timer view. Used +/// to verify images are not rendered upside down in a flipped context. +private final class FlippedBackgroundHostView: NSView { + var image: NSImage? + override var isFlipped: Bool { true } + override func draw(_ dirtyRect: NSRect) { + NSColor.black.setFill() + bounds.fill() + if let image { + BreakTimerLayout.drawBackground(image, in: bounds, fraction: 1) + } + } +} + @MainActor public enum SelfTestRunner { public static func run() throws { @@ -30,6 +45,22 @@ public enum SelfTestRunner { try testDemoTypeTypingDelayRange() try testDemoTypeUserDrivenStepStopsAtEnd() try testBreakTimerLayout() + try testBreakTimerBackgroundNotFlipped() + try testPanoramaSelectionBorderColor() + try testPanoramaEscapeCancel() + try testIdleSleepAssertionLifecycle() + try testStatusMenuOrderMatchesWindows() + try testClipTransitionUpdatesOnChange() + try testWebcamOverlayDragOrigin() + try testTrimSavePreservesOriginal() + try testSettingsWindowStaysOnTop() + try testZoomAndLiveZoomAreSeparateTabs() + try testBlankScreenUsesControlKeys() + try testTypeTabFontSampleUsesSelectedFont() + try testMenuBarIconIsPaddedTemplate() + try testStandardIconIsRoundedSquareWithMargin() + try testDefaultTypingFontIsSystem20pt() + try testStaticZoomStaysAtOneX() try testPanoramaStitching() try testPanoramaTopSeamUsesSingleFramePixels() try testPanoramaVerticalSeamKeepsSingleFrame() @@ -339,6 +370,386 @@ public enum SelfTestRunner { try expect(DemoTypeController.completedUserDrivenEntryOffsetForTesting("abc", startOffset: 0) == 0, "Expected scripts without [end] to wrap after EOF") } + private static func testStaticZoomStaysAtOneX() throws { + // Windows ZoomIt keeps static zoom active when the user zooms all the + // way out to 1x; only Esc/right-click exits. Live zoom still exits at + // the floor. + try expect(ModeCoordinator.exitsOnZoomOutFloor(mode: .staticZoom) == false, + "Expected static zoom to stay active at 1x instead of exiting") + try expect(ModeCoordinator.exitsOnZoomOutFloor(mode: .liveZoom), + "Expected live zoom to exit when zoomed out to 1x") + try expect(ModeCoordinator.exitsOnZoomOutFloor(mode: .typing), + "Expected typing (live zoom sub-mode) to exit when zoomed out to 1x") + } + + /// The break timer view uses a flipped coordinate system. Drawing a + /// background image there without flip awareness renders it upside down. + /// Verify BreakTimerLayout.drawBackground keeps a vertically asymmetric + /// image right-side up when drawn through a real flipped view. + private static func testBreakTimerBackgroundNotFlipped() throws { + let dim = 16 + // Source image: top half red, bottom half blue in its natural (image) + // orientation. NSImage.lockFocus uses a bottom-left origin, so the red + // upper half is filled at the higher y range. + let source = NSImage(size: NSSize(width: dim, height: dim)) + source.lockFocus() + NSColor.red.setFill() + NSRect(x: 0, y: dim / 2, width: dim, height: dim / 2).fill() + NSColor.blue.setFill() + NSRect(x: 0, y: 0, width: dim, height: dim / 2).fill() + source.unlockFocus() + + let host = FlippedBackgroundHostView(frame: NSRect(x: 0, y: 0, width: dim, height: dim)) + host.image = source + guard let rep = host.bitmapImageRepForCachingDisplay(in: host.bounds) else { + throw SelfTestError.failure("Could not create caching bitmap for flipped host view") + } + host.cacheDisplay(in: host.bounds, to: rep) + + // Sample in the rep's real pixel space (it may be Retina 2x). Row 0 is + // the top of the rendered view. With flip-aware drawing the top of the + // image (red) must appear at the top; a regression would show blue there. + let midX = rep.pixelsWide / 2 + guard let top = rep.colorAt(x: midX, y: 1), + let bottom = rep.colorAt(x: midX, y: rep.pixelsHigh - 2) else { + throw SelfTestError.failure("Could not sample break timer background pixels") + } + try expect(top.redComponent > 0.5 && top.blueComponent < 0.5, + "Expected break timer background top to stay red (right-side up), got \(top)") + try expect(bottom.blueComponent > 0.5 && bottom.redComponent < 0.5, + "Expected break timer background bottom to stay blue (right-side up), got \(bottom)") + } + + /// The panorama region rectangle is drawn in blue to stay distinct from the + /// orange screen-recording border. The shared selection view defaults to + /// white (snip/record) but the panorama selector requests blue; verify the + /// requested border colour is actually rendered. + private static func testPanoramaSelectionBorderColor() throws { + let dim = 40 + // A solid grey backing image for the selector. + guard let context = CGContext( + data: nil, width: dim, height: dim, bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + throw SelfTestError.failure("Could not create selector backing context") + } + context.setFillColor(NSColor(white: 0.5, alpha: 1).cgColor) + context.fill(CGRect(x: 0, y: 0, width: dim, height: dim)) + guard let image = context.makeImage() else { + throw SelfTestError.failure("Could not create selector backing image") + } + + let selection = CGRect(x: 8, y: 8, width: 24, height: 24) + + func borderIsBlue(_ view: SnipSelectionView) throws -> Bool { + guard let rep = view.renderForTesting(selection: selection) else { + throw SelfTestError.failure("Selector render returned no bitmap") + } + let scaleX = rep.pixelsWide / dim + let scaleY = rep.pixelsHigh / dim + // Sample the middle of the top border edge of the selection rect. + let px = Int(selection.midX) * scaleX + let py = Int(selection.minY) * scaleY + guard let c = rep.colorAt(x: px, y: py) else { + throw SelfTestError.failure("Could not sample selector border pixel") + } + return c.blueComponent > 0.5 && c.redComponent < 0.4 + } + + let blueView = SnipSelectionView(frame: CGRect(x: 0, y: 0, width: dim, height: dim), image: image, borderColor: .systemBlue) + try expect(try borderIsBlue(blueView), "Expected panorama selection border to render blue") + + let whiteView = SnipSelectionView(frame: CGRect(x: 0, y: 0, width: dim, height: dim), image: image) + try expect(try !borderIsBlue(whiteView), "Expected default snip selection border to remain non-blue (white)") + } + + /// Escape during the scrolling panorama capture must cancel the run, but + /// only while it is actively capturing, and repeated Escapes are ignored. + private static func testPanoramaEscapeCancel() throws { + try expect(PanoramaController.shouldCancelOnEscape(isCapturing: true, alreadyCancelled: false), + "Expected Escape to cancel an active panorama capture") + try expect(PanoramaController.shouldCancelOnEscape(isCapturing: false, alreadyCancelled: false) == false, + "Expected Escape to be ignored when not capturing") + try expect(PanoramaController.shouldCancelOnEscape(isCapturing: true, alreadyCancelled: true) == false, + "Expected a repeated Escape to be ignored once already cancelled") + } + + /// The break timer suppresses the screen saver by holding a display-sleep + /// assertion. Verify the assertion is acquired once on begin, released on + /// end, and that both operations are idempotent. + private static func testIdleSleepAssertionLifecycle() throws { + var created = 0 + var released = 0 + let assertion = IdleSleepAssertion( + create: { _ in created += 1; return IOPMAssertionID(created) }, + release: { _ in released += 1 } + ) + + try expect(assertion.isActive == false, "Expected assertion to start inactive") + + assertion.begin(reason: "test") + try expect(assertion.isActive, "Expected assertion active after begin") + try expect(created == 1, "Expected exactly one assertion created") + + // begin is idempotent: a second begin must not create another. + assertion.begin(reason: "test") + try expect(created == 1, "Expected begin to be idempotent (no second create)") + + assertion.end() + try expect(assertion.isActive == false, "Expected assertion inactive after end") + try expect(released == 1, "Expected exactly one assertion released") + + // end is idempotent: a second end must not release again. + assertion.end() + try expect(released == 1, "Expected end to be idempotent (no second release)") + } + + /// The menu-bar menu broadly follows the Windows ZoomIt tray order (Options + /// first, modes, then Check Permissions and Quit), with Panorama as a + /// macOS-only extra after Record and the Break Timer placed below Panorama + /// Capture. + private static func testStatusMenuOrderMatchesWindows() throws { + let titles = AppDelegate.statusMenuEntries() + .filter { !$0.isSeparator } + .map(\.title) + + // Confirm the items appear in the expected relative order. + let expectedOrder = [ + "Settings…", // Options + "Draw", + "Static Zoom", // Zoom + "Live Zoom", + "Record Screen", // Record + "Panorama Capture", // macOS-only, after Record + "Break Timer", // moved below Panorama Capture + "Check Permissions", + "Quit" + ] + + let positions = expectedOrder.map { titles.firstIndex(of: $0) } + for (label, index) in zip(expectedOrder, positions) { + try expect(index != nil, "Expected status menu to contain '\(label)'") + } + let resolved = positions.compactMap { $0 } + try expect(resolved == resolved.sorted(), + "Expected status menu items to follow the expected order, got \(titles)") + + // Break Timer must come after Panorama Capture. + if let breakIndex = titles.firstIndex(of: "Break Timer"), + let panoramaIndex = titles.firstIndex(of: "Panorama Capture") { + try expect(breakIndex > panoramaIndex, + "Expected Break Timer to be below Panorama Capture, got \(titles)") + } else { + throw SelfTestError.failure("Expected both Break Timer and Panorama Capture menu items") + } + + // Options must be first and Quit last, as on Windows. + try expect(titles.first == "Settings…", "Expected Options/Settings to be the first menu item") + try expect(titles.last == "Quit", "Expected Quit to be the last menu item") + } + + /// Changing the clip transition popup from Fade to Black to Fade to White + /// must update the existing append boundary (previously it stayed black + /// because the transition was captured only at append time). Delete-seam + /// joins keep their own transition. + private static func testClipTransitionUpdatesOnChange() throws { + typealias Transition = VideoClipEditorController.Transition + + // One append boundary starting as Fade to Black; switch to Fade to White. + let updated = VideoClipEditorController.updatedJoinTransitions( + current: [.fadeBlack], + isAppendJoin: [true], + newTransition: .fadeWhite + ) + try expect(updated == [.fadeWhite], "Expected append boundary to switch to Fade to White, got \(updated)") + + // Mixed: an append boundary adopts the new transition, a delete seam + // (not an append) keeps its existing value. + let mixed = VideoClipEditorController.updatedJoinTransitions( + current: [.fadeBlack, Transition.none], + isAppendJoin: [true, false], + newTransition: .fadeWhite + ) + try expect(mixed == [.fadeWhite, Transition.none], + "Expected only the append boundary to change, got \(mixed)") + } + + /// Dragging the webcam picture-in-picture must keep the grabbed point under + /// the cursor: the new window origin is the cursor position minus the grab + /// offset within the window. + private static func testWebcamOverlayDragOrigin() throws { + // Window was at origin (100, 200) with size 160x120; the user grabbed a + // point 40,30 inside it, so grabOffset = (40, 30). Grab point on screen + // was (140, 230). + let grabOffset = CGSize(width: 40, height: 30) + + // No movement: cursor still at the original grab point -> origin unchanged. + let unchanged = WebcamOverlayController.draggedWindowOrigin(mouseOnScreen: CGPoint(x: 140, y: 230), grabOffset: grabOffset) + try expect(unchanged == CGPoint(x: 100, y: 200), "Expected unchanged origin when cursor hasn't moved, got \(unchanged)") + + // Move the cursor by (+50, -70); the window origin should move the same. + let moved = WebcamOverlayController.draggedWindowOrigin(mouseOnScreen: CGPoint(x: 190, y: 160), grabOffset: grabOffset) + try expect(moved == CGPoint(x: 150, y: 130), "Expected dragged origin to track the cursor, got \(moved)") + } + + /// Trimming an existing video and saving under a new name must NOT delete + /// the user's original file (it did, because the source was moved). When no + /// edits were made the editor returns the original URL and we copy it; + /// otherwise it returns an exported temp file that we move. + private static func testTrimSavePreservesOriginal() throws { + let original = URL(fileURLWithPath: "/tmp/original.mp4") + + // No edits: editor hands back the original URL -> copy (preserve source). + try expect(RecordingController.trimSaveAction(editedURL: original, originalURL: original) == .copy, + "Expected an unedited trim save to copy the original, preserving it") + + // Edited: editor exported a temp file -> move it (original untouched). + let exported = URL(fileURLWithPath: "/tmp/ZoomIt-edit-1234.mp4") + try expect(RecordingController.trimSaveAction(editedURL: exported, originalURL: original) == .move, + "Expected an edited trim save to move the exported temp file") + } + + /// The Settings dialog must stay on top like the Windows Options dialog so + /// it can't get hidden behind other windows (which would leave ZoomIt's + /// hotkeys suspended and the app apparently unresponsive). + private static func testSettingsWindowStaysOnTop() throws { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 100, height: 100), + styleMask: [.titled, .closable], + backing: .buffered, + defer: true + ) + // Sanity: a normal window is at the normal level and hides on deactivate + // is off by default; ensure our configuration changes the level. + SettingsWindowController.configureAlwaysOnTop(window) + try expect(window.level == .floating, "Expected settings window to float above other windows") + try expect(window.hidesOnDeactivate == false, "Expected settings window not to hide when the app deactivates") + } + + /// Windows keeps static-zoom and live-zoom settings on separate tabs (the + /// Zoom tab is static-only). Verify the Mac Options dialog exposes a + /// distinct "Live Zoom" tab immediately after "Zoom". + private static func testZoomAndLiveZoomAreSeparateTabs() throws { + let titles = SettingsWindowController.settingsTabTitles + guard let zoomIndex = titles.firstIndex(of: "Zoom") else { + throw SelfTestError.failure("Expected a Zoom tab in the Options dialog") + } + try expect(titles.contains("Live Zoom"), "Expected a separate Live Zoom tab") + try expect(titles.firstIndex(of: "Live Zoom") == zoomIndex + 1, + "Expected Live Zoom to be its own tab right after Zoom, got \(titles)") + } + + /// The blank-screen sketch pad is triggered with Ctrl+W / Ctrl+K while + /// drawing (matching the corrected Draw-tab help), leaving plain W/K for the + /// white/black pen and Shift+W/K for the highlighter. + private static func testBlankScreenUsesControlKeys() throws { + typealias Action = ZoomCanvasView.WhiteBlackKeyAction + try expect(ZoomCanvasView.whiteBlackKeyAction(control: true, shift: false, isDrawingMode: true) == .blankScreen, + "Expected Ctrl+W/Ctrl+K to blank the screen while drawing") + try expect(ZoomCanvasView.whiteBlackKeyAction(control: false, shift: false, isDrawingMode: true) == .penColor, + "Expected plain W/K to select the pen colour, not blank the screen") + try expect(ZoomCanvasView.whiteBlackKeyAction(control: false, shift: true, isDrawingMode: true) == .highlightColor, + "Expected Shift+W/K to select the highlighter") + try expect(ZoomCanvasView.whiteBlackKeyAction(control: true, shift: false, isDrawingMode: false) == .penColor, + "Expected Ctrl+W/K outside drawing mode to fall back to the pen colour") + } + + /// The Type tab's "Sample" preview must render in the selected typing font + /// (it previously always used the system font, so font changes weren't + /// visible). Also verify the preview size is clamped to a legible range. + private static func testTypeTabFontSampleUsesSelectedFont() throws { + // A concrete named font should be reflected in the preview font. + let courier = SettingsWindowController.fontSamplePreviewFont(name: "Courier", size: 24) + try expect(courier.fontName.lowercased().contains("courier"), + "Expected the font sample preview to use the selected font, got \(courier.fontName)") + + // Preview size clamps: very large selections shrink to <= 36pt, very + // small ones grow to >= 12pt, so the sample stays legible. + let big = SettingsWindowController.fontSamplePreviewFont(name: "Courier", size: 200) + try expect(big.pointSize <= 36, "Expected large font preview to clamp to 36pt, got \(big.pointSize)") + let small = SettingsWindowController.fontSamplePreviewFont(name: "Courier", size: 4) + try expect(small.pointSize >= 12, "Expected small font preview to clamp to 12pt, got \(small.pointSize)") + } + + /// The menu-bar icon was a full-bleed image, making it look larger than and + /// misaligned with system icons. It must now render into a padded, square + /// template image so the glyph carries interior padding and stays centered. + private static func testMenuBarIconIsPaddedTemplate() throws { + // A fully-filled opaque source glyph (edge to edge). + let dim = 32 + let source = NSImage(size: NSSize(width: dim, height: dim)) + source.lockFocus() + NSColor.black.setFill() + NSRect(x: 0, y: 0, width: dim, height: dim).fill() + source.unlockFocus() + + let icon = AppDelegate.menuBarImage(from: source) + try expect(icon.isTemplate, "Expected the menu-bar icon to be a template image so it tints with the menu bar") + try expect(icon.size == NSSize(width: AppDelegate.menuBarIconCanvas, height: AppDelegate.menuBarIconCanvas), + "Expected the menu-bar icon to use the padded canvas size, got \(icon.size)") + // The glyph must be inset (smaller than the canvas), giving it padding. + try expect(AppDelegate.menuBarIconGlyph < AppDelegate.menuBarIconCanvas, + "Expected the glyph to be inset within the canvas for padding") + + // The canvas corners should be transparent padding even though the + // source filled its bounds edge to edge. + guard let tiff = icon.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff) else { + throw SelfTestError.failure("Could not rasterize menu-bar icon") + } + let corner = rep.colorAt(x: 0, y: 0) + try expect((corner?.alphaComponent ?? 1) < 0.01, + "Expected the menu-bar icon corner to be transparent padding, got alpha \(corner?.alphaComponent ?? -1)") + // The centre should carry the glyph (opaque). + let center = rep.colorAt(x: rep.pixelsWide / 2, y: rep.pixelsHigh / 2) + try expect((center?.alphaComponent ?? 0) > 0.5, + "Expected the menu-bar icon centre to contain the glyph, got alpha \(center?.alphaComponent ?? -1)") + } + + /// The permissions-dialog / picker icon must be a standard macOS-style + /// rounded square with a margin (the raw artwork is full-bleed edge to + /// edge, which looks oversized and misaligns the dialog text). Verify the + /// produced icon is square, has transparent margin/corners, and an opaque + /// centre. + private static func testStandardIconIsRoundedSquareWithMargin() throws { + let size: CGFloat = 128 + guard let icon = ZoomItAppIcon.standardIcon(size: size) else { + throw SelfTestError.failure("Expected a standard icon to be produced") + } + try expect(icon.size == NSSize(width: size, height: size), + "Expected a square standard icon of \(size)pt, got \(icon.size)") + + guard let tiff = icon.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff) else { + throw SelfTestError.failure("Could not rasterize standard icon") + } + // Corner should be transparent (rounded + margin), unlike the full-bleed + // source artwork which reaches every edge. + let corner = rep.colorAt(x: 0, y: 0) + try expect((corner?.alphaComponent ?? 1) < 0.01, + "Expected standard icon corner to be transparent margin, got alpha \(corner?.alphaComponent ?? -1)") + // The centre must carry the artwork. + let center = rep.colorAt(x: rep.pixelsWide / 2, y: rep.pixelsHigh / 2) + try expect((center?.alphaComponent ?? 0) > 0.5, + "Expected standard icon centre to contain artwork, got alpha \(center?.alphaComponent ?? -1)") + } + + /// The default typing font should be the default Mac font (an empty font + /// name resolves to the system font) at 20pt. + private static func testDefaultTypingFontIsSystem20pt() throws { + try expect(AppSettings.defaults.typingFontName.isEmpty, + "Expected the default typing font name to be empty (the default Mac system font)") + try expect(AppSettings.defaults.typingFontSize == 20, + "Expected the default typing font size to be 20pt, got \(AppSettings.defaults.typingFontSize)") + try expect(AnnotationController.defaultFontSize == 20, + "Expected the annotation controller default font size to be 20pt") + + // An empty name resolves to the system font at the requested size. + let resolved = AnnotationController.typingFont(named: "", size: 20) + let system = NSFont.systemFont(ofSize: 20, weight: .regular) + try expect(resolved.fontName == system.fontName, + "Expected the default typing font to resolve to the regular (non-bold) system font, got \(resolved.fontName)") + try expect(resolved.pointSize == 20, "Expected the default typing font to be 20pt, got \(resolved.pointSize)") + } + private static func testBreakTimerLayout() throws { try expect(BreakTimerLayout.timerText(for: 601) == "10:01", "Expected positive break timer text to format as minutes and seconds") try expect(BreakTimerLayout.timerText(for: 0) == "0:00", "Expected zero break timer text") diff --git a/Sources/ZoomItMacCore/Settings/SettingsStore.swift b/Sources/ZoomItMacCore/Settings/SettingsStore.swift index fd759bc..4a2b763 100644 --- a/Sources/ZoomItMacCore/Settings/SettingsStore.swift +++ b/Sources/ZoomItMacCore/Settings/SettingsStore.swift @@ -116,7 +116,7 @@ struct AppSettings: Equatable { smoothImage: true, launchAtLogin: false, typingFontName: "", - typingFontSize: 36, + typingFontSize: 20, // Control+1 (kVK_ANSI_1 = 18, NSEvent.ModifierFlags.control = 1 << 18). hotKeyCode: 18, hotKeyModifiers: 1 << 18, diff --git a/Sources/ZoomItMacCore/Settings/SettingsWindowController.swift b/Sources/ZoomItMacCore/Settings/SettingsWindowController.swift index 2aecb70..1382f4b 100644 --- a/Sources/ZoomItMacCore/Settings/SettingsWindowController.swift +++ b/Sources/ZoomItMacCore/Settings/SettingsWindowController.swift @@ -37,9 +37,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { private var window: NSWindow? - // Draw tab controls that need live updates. - private weak var penWidthLabel: NSTextField? - // Type tab controls. private weak var fontSampleLabel: NSTextField? @@ -150,22 +147,37 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { // MARK: - Window construction + /// The Options dialog tabs, in order. Zoom and Live Zoom are separate tabs + /// (matching Windows ZoomIt, whose Zoom tab holds static-zoom settings only). + static let settingsTabTitles = [ + "General", "Zoom", "Live Zoom", "Draw", "Type", + "DemoType", "Break", "Snip", "Record", "Panorama" + ] + + private func viewForTab(_ title: String) -> NSView { + switch title { + case "General": return makeGeneralTab() + case "Zoom": return makeZoomTab() + case "Live Zoom": return makeLiveZoomTab() + case "Draw": return makeDrawTab() + case "Type": return makeTypeTab() + case "DemoType": return makeDemoTypeTab() + case "Break": return makeBreakTab() + case "Snip": return makeSnipTab() + case "Record": return makeRecordTab() + case "Panorama": return makePanoramaTab() + default: return NSView() + } + } + private func makeWindow() -> NSWindow { // Build each tab's content and measure the tallest one so every tab can // share a single height. Equal-height tabs keep the tab view a constant // size, which in turn keeps the footer anchored near the bottom no // matter which tab is selected. - let tabs: [(String, NSView)] = [ - ("General", makeGeneralTab()), - ("Zoom", makeZoomTab()), - ("Draw", makeDrawTab()), - ("Type", makeTypeTab()), - ("DemoType", makeDemoTypeTab()), - ("Break", makeBreakTab()), - ("Snip", makeSnipTab()), - ("Record", makeRecordTab()), - ("Panorama", makePanoramaTab()) - ] + let tabs: [(String, NSView)] = Self.settingsTabTitles.map { title in + (title, viewForTab(title)) + } var maxContentHeight: CGFloat = 0 for (_, content) in tabs { @@ -241,6 +253,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { window.contentView = container window.isReleasedWhenClosed = false window.delegate = self + Self.configureAlwaysOnTop(window) // Size the window to fit the (now equal-height) tabs plus the footer. container.layoutSubtreeIfNeeded() @@ -249,6 +262,15 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { return window } + /// Configures the settings window to behave like the Windows Options dialog: + /// it floats above other windows so it can never get lost behind them. If it + /// did, ZoomIt's hotkeys (suspended while the dialog is open) would stay + /// suspended and the app would appear broken with no obvious way to recover. + static func configureAlwaysOnTop(_ window: NSWindow) { + window.level = .floating + window.hidesOnDeactivate = false + } + private func makeTabItem(label: String, view: NSView) -> NSTabViewItem { let item = NSTabViewItem(identifier: label) item.label = label @@ -344,6 +366,30 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { return stack } + /// Lays out label/control rows in a grid so the leading labels form a + /// right-aligned column and the controls line up in consistent columns. + /// Rows may have differing numbers of cells; trailing cells are left empty. + private func makeFormGrid(_ rows: [[NSView]], rowSpacing: CGFloat = 10, columnSpacing: CGFloat = 8) -> NSGridView { + let grid = NSGridView(views: rows) + grid.translatesAutoresizingMaskIntoConstraints = false + grid.rowSpacing = rowSpacing + grid.columnSpacing = columnSpacing + // Center controls vertically within each row so labels line up with + // popups, steppers and checkboxes regardless of their heights. + grid.rowAlignment = .none + for r in 0.. 0 { + // Right-align the leading label column so the controls in column 1 + // share a common left edge. + grid.column(at: 0).xPlacement = .trailing + } + return grid + } + // MARK: - General tab private func makeGeneralTab() -> NSView { @@ -393,18 +439,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { self.hotKeyButton = hotKeyButton let hotKeyRow = makeRow([makeLabel("Zoom toggle:"), hotKeyButton]) - let liveHelp = makeLabel( - "Live zoom magnifies the live screen so motion and updates stay visible while zoomed. Use the same zoom and pan controls.", - wraps: true - ) - - let liveHotKeyButton = NSButton(title: liveHotKeyDisplayString(), target: self, action: #selector(toggleLiveHotKeyRecording(_:))) - liveHotKeyButton.bezelStyle = .rounded - liveHotKeyButton.setButtonType(.momentaryPushIn) - liveHotKeyButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 140).isActive = true - self.liveHotKeyButton = liveHotKeyButton - let liveHotKeyRow = makeRow([makeLabel("Live zoom toggle:"), liveHotKeyButton]) - let magHelp = makeLabel("Specify the initial level of magnification when zooming in:", wraps: true) let magPopup = NSPopUpButton(frame: .zero, pullsDown: false) @@ -426,7 +460,27 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { let smoothCheck = NSButton(checkboxWithTitle: "Smooth zoomed image", target: self, action: #selector(smoothImageChanged(_:))) smoothCheck.state = settings.smoothImage ? .on : .off - return makeColumn([help, hotKeyRow, liveHelp, liveHotKeyRow, magHelp, magRow, animateCheck, smoothCheck]) + return makeColumn([help, hotKeyRow, magHelp, magRow, animateCheck, smoothCheck]) + } + + // MARK: - Live Zoom tab + + /// Live zoom lives on its own tab so the Zoom tab holds only static-zoom + /// settings, matching the Windows ZoomIt options dialog. + private func makeLiveZoomTab() -> NSView { + let liveHelp = makeLabel( + "Live zoom magnifies the live screen so motion and updates stay visible while zoomed. Use the same zoom and pan controls.", + wraps: true + ) + + let liveHotKeyButton = NSButton(title: liveHotKeyDisplayString(), target: self, action: #selector(toggleLiveHotKeyRecording(_:))) + liveHotKeyButton.bezelStyle = .rounded + liveHotKeyButton.setButtonType(.momentaryPushIn) + liveHotKeyButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 140).isActive = true + self.liveHotKeyButton = liveHotKeyButton + let liveHotKeyRow = makeRow([makeLabel("Live zoom toggle:"), liveHotKeyButton]) + + return makeColumn([liveHelp, liveHotKeyRow]) } @objc private func zoomLevelChanged(_ sender: NSPopUpButton) { @@ -754,20 +808,10 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { let screenSection = makeSectionLabel("Screen") let screenHelp = makeLabel( - "Press W or K to blank the screen white or black as a sketch pad.", + "Press Ctrl+W or Ctrl+K to blank the screen white or black as a sketch pad.", wraps: true ) - let slider = NSSlider(value: Double(settings.rootPenWidth), minValue: 1, maxValue: 20, target: self, action: #selector(penWidthChanged(_:))) - slider.translatesAutoresizingMaskIntoConstraints = false - slider.numberOfTickMarks = 20 - slider.allowsTickMarkValuesOnly = true - slider.widthAnchor.constraint(equalToConstant: 200).isActive = true - - let widthValue = makeLabel("\(Int(settings.rootPenWidth))") - penWidthLabel = widthValue - let widthRow = makeRow([makeLabel("Default pen width:"), slider, widthValue]) - let drawHotKeyButton = NSButton(title: drawHotKeyDisplayString(), target: self, action: #selector(toggleDrawHotKeyRecording(_:))) drawHotKeyButton.bezelStyle = .rounded drawHotKeyButton.setButtonType(.momentaryPushIn) @@ -778,7 +822,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { return makeColumn([ help, penSection, - makeIndentedColumn([penHelp, widthRow]), + makeIndentedColumn([penHelp]), colorsSection, makeIndentedColumn([colorsHelp]), highlightSection, @@ -791,12 +835,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { ], spacing: 6) } - @objc private func penWidthChanged(_ sender: NSSlider) { - settings.rootPenWidth = CGFloat(sender.intValue) - penWidthLabel?.stringValue = "\(sender.intValue)" - persist() - } - // MARK: - Type tab private func makeTypeTab() -> NSView { @@ -845,21 +883,20 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { let durationLabel = makeLabel("") breakDurationLabel = durationLabel updateBreakDurationLabel() - let timerRow = makeRow([makeLabel("Break timer:"), hotKeyButton, makeLabel("Duration:"), durationStepper, durationLabel]) + let durationControls = makeRow([durationStepper, durationLabel]) let expiredCheck = NSButton(checkboxWithTitle: "Show expired time after 0:00", target: self, action: #selector(breakShowExpiredChanged(_:))) expiredCheck.state = settings.breakShowExpiredTime ? .on : .off let textColorPopup = makeColorPopup(selected: settings.breakTextColorRGB, action: #selector(breakTextColorChanged(_:))) let backgroundColorPopup = makeColorPopup(selected: settings.breakBackgroundColorRGB, action: #selector(breakBackgroundColorChanged(_:))) - let colorRow = makeRow([makeLabel("Timer color:"), textColorPopup, makeLabel("Background:"), backgroundColorPopup]) let positionPopup = makeIndexedPopup( titles: ["Top-left", "Top", "Top-right", "Left", "Center", "Right", "Bottom-left", "Bottom", "Bottom-right"], selected: settings.breakTimerPosition, action: #selector(breakPositionChanged(_:)) ) - positionPopup.widthAnchor.constraint(equalToConstant: 130).isActive = true + positionPopup.widthAnchor.constraint(equalToConstant: 150).isActive = true let opacityPopup = NSPopUpButton(frame: .zero, pullsDown: false) opacityPopup.translatesAutoresizingMaskIntoConstraints = false for value in stride(from: 10, through: 100, by: 10) { @@ -869,16 +906,13 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { opacityPopup.selectItem(withTitle: "\(min(max(settings.breakOpacity, 10), 100))%") opacityPopup.target = self opacityPopup.action = #selector(breakOpacityChanged(_:)) - let layoutRow = makeRow([makeLabel("Position:"), positionPopup, makeLabel("Opacity:"), opacityPopup]) let soundCheck = NSButton(checkboxWithTitle: "Play sound at 0:00", target: self, action: #selector(breakPlaySoundChanged(_:))) soundCheck.state = settings.breakPlaySound ? .on : .off - let behaviorRow = makeRow([expiredCheck, soundCheck]) let soundField = makePathField(settings.breakSoundFile) breakSoundFileField = soundField let soundBrowse = NSButton(title: "Browse…", target: self, action: #selector(chooseBreakSoundFile(_:))) soundBrowse.bezelStyle = .rounded - let soundFileRow = makeRow([makeLabel("Sound:"), soundField, soundBrowse]) let backgroundModePopup = makeIndexedPopup( titles: ["No image", "Faded desktop", "Image file"], @@ -895,20 +929,25 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { let stretchCheck = NSButton(checkboxWithTitle: "Stretch image", target: self, action: #selector(breakBackgroundStretchChanged(_:))) stretchCheck.state = settings.breakBackgroundStretch ? .on : .off breakBackgroundStretchCheckbox = stretchCheck - let backgroundModeRow = makeRow([makeLabel("Background:"), backgroundModePopup, stretchCheck]) - let backgroundFileRow = makeRow([makeLabel("Image file:"), backgroundField, backgroundBrowse]) + + // A single grid keeps every label/control pair in aligned columns: + // column 0 = right-aligned labels, column 1 = primary control, + // column 2 = secondary label, column 3 = secondary control. + let grid = makeFormGrid([ + [makeLabel("Break timer:"), hotKeyButton, makeLabel("Duration:"), durationControls], + [makeLabel("Timer color:"), textColorPopup, makeLabel("Background:"), backgroundColorPopup], + [makeLabel("Position:"), positionPopup, makeLabel("Opacity:"), opacityPopup], + [makeLabel("Backdrop:"), backgroundModePopup, stretchCheck], + [makeLabel("Image file:"), backgroundField, backgroundBrowse], + [makeLabel("Sound:"), soundField, soundBrowse] + ]) updateBreakBackgroundControlsEnabled() return makeColumn([ help, - timerRow, - behaviorRow, - colorRow, - layoutRow, - soundFileRow, - backgroundModeRow, - backgroundFileRow - ], spacing: 8) + grid, + makeRow([expiredCheck, soundCheck]) + ], spacing: 12) } private func updateBreakDurationLabel() { @@ -1375,11 +1414,20 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { } private func updateFontSample() { - let font = currentTypingFont() - fontSampleLabel?.font = NSFont.systemFont(ofSize: 18) + let font = Self.fontSamplePreviewFont(name: settings.typingFontName, size: settings.typingFontSize) + // Render the sample in the actually-selected font so choosing a new font + // is reflected immediately (it previously always used the system font). + fontSampleLabel?.font = font fontSampleLabel?.stringValue = "Sample — \(font.displayName ?? font.fontName) \(Int(settings.typingFontSize))pt" } + /// Font used for the Type tab's live "Sample" preview. Uses the selected + /// typing font, clamped to a legible on-screen preview size. + static func fontSamplePreviewFont(name: String, size: CGFloat) -> NSFont { + let previewSize = min(max(size, 12), 36) + return AnnotationController.typingFont(named: name, size: previewSize) + } + @objc private func selectFont(_ sender: NSButton) { let manager = NSFontManager.shared manager.target = self