Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
991004f
Fix: static zoom stays active at 1x instead of exiting (match Windows…
Jul 18, 2026
c6a5135
Fix: break timer background image drawn upside down in flipped view
Jul 18, 2026
ae33073
Fix: panorama region rectangle drawn in yellow to match Windows ZoomIt
Jul 18, 2026
c80d9c4
Fix: Escape cancels/exits panorama during scrolling capture (match Wi…
Jul 18, 2026
3bae648
Fix: block screen saver / display sleep while break timer is active
Jul 18, 2026
c8bf30d
Fix: reorder menu-bar menu to match Windows ZoomIt; add Draw item
Jul 18, 2026
32fbeca
Fix: changing clip transition (e.g. fade to white) now updates existi…
Jul 18, 2026
6de2bdc
Fix: webcam overlay can be click-dragged to reposition (match Windows)
Jul 18, 2026
2c7415e
Fix: trimming an existing video no longer deletes the original file
Jul 18, 2026
a029a45
Fix: Settings window floats on top so it can't get lost behind other …
Jul 18, 2026
d8fe3d6
Fix: split live zoom into its own Options tab so Zoom tab is static-o…
Jul 18, 2026
0d6fbe9
Fix: Draw tab matches Windows - remove default pen width, blank scree…
Jul 18, 2026
127f2c9
Fix: Type tab Sample now renders in the selected font
Jul 18, 2026
37e513d
Fix: pad menu-bar icon so it matches system icon size and alignment
Jul 18, 2026
3e4e821
Move Break Timer menu item below Panorama Capture
Jul 18, 2026
603361e
Use standard rounded-square icon in permissions dialog; align icon to…
Jul 19, 2026
d5a8e89
Default typing font to the system (Mac default) font at 20pt
Jul 19, 2026
2b03ed4
Align Break tab controls in a grid (timer, colors, position, opacity,…
Jul 19, 2026
3537cda
Default typing font is now regular (non-bold) weight
Jul 20, 2026
d63c320
Change panorama region rectangle back to blue (distinct from orange r…
Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Sources/ZoomItMacCore/Annotations/AnnotationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() {
Expand Down
10 changes: 10 additions & 0 deletions Sources/ZoomItMacCore/App/AppController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ final class AppController: NSObject {
modeCoordinator.handle(.activateStaticZoom)
}

@objc func activateDrawWithoutZoom() {
modeCoordinator.handle(.activateDrawWithoutZoom)
}

@objc func activateLiveZoom() {
modeCoordinator.handle(.activateLiveZoom)
}
Expand Down Expand Up @@ -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)

Expand Down
89 changes: 70 additions & 19 deletions Sources/ZoomItMacCore/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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
}
Expand Down
20 changes: 20 additions & 0 deletions Sources/ZoomItMacCore/App/AppIcon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
60 changes: 60 additions & 0 deletions Sources/ZoomItMacCore/App/IdleSleepAssertion.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
66 changes: 63 additions & 3 deletions Sources/ZoomItMacCore/Capture/PanoramaController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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?
Expand All @@ -180,6 +188,8 @@ final class PanoramaController {
showBorder(display: display, region: region)
isCapturing = true
stopRequested = false
captureCancelled = false
installEscapeMonitor()
onStateChange?(true)

Task { @MainActor in
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -550,6 +569,7 @@ final class PanoramaController {
}

private func hideOverlays() {
removeEscapeMonitor()
borderWindow?.orderOut(nil)
borderWindow = nil
bannerWindow?.orderOut(nil)
Expand All @@ -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
}
}
Comment on lines +606 to +611

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.
Expand Down
Loading