diff --git a/Sources/ZoomItMacCore/App/AppController.swift b/Sources/ZoomItMacCore/App/AppController.swift index 345ef91..03dc8b9 100644 --- a/Sources/ZoomItMacCore/App/AppController.swift +++ b/Sources/ZoomItMacCore/App/AppController.swift @@ -64,6 +64,136 @@ final class AppController: NSObject { presentPermissionsDialog() } + @objc func showShortcuts() { + presentShortcutsDialog() + } + + /// Shows a summary of every global keyboard shortcut ZoomIt currently + /// responds to. Values are read live from the settings store so any user + /// customization is reflected. The list also documents the in-mode keys + /// (colors, shapes, undo, etc.) that are handled once an overlay is active. + private func presentShortcutsDialog() { + let settings = settingsStore.load() + + func describe(code: Int, modifiers: UInt) -> String { + guard code != 0 else { return "None" } + return SettingsWindowController.describe( + keyCode: code, + modifiers: NSEvent.ModifierFlags(rawValue: modifiers) + ) + } + + let zoom = describe(code: settings.hotKeyCode, modifiers: settings.hotKeyModifiers) + let draw = describe(code: settings.drawHotKeyCode, modifiers: settings.drawHotKeyModifiers) + let live = describe(code: settings.liveHotKeyCode, modifiers: settings.liveHotKeyModifiers) + let snipCopy = describe(code: settings.snipHotKeyCode, modifiers: settings.snipHotKeyModifiers) + let snipSave = describe( + code: settings.snipHotKeyCode, + modifiers: settings.snipHotKeyModifiers ^ NSEvent.ModifierFlags.shift.rawValue + ) + let snipOcr = describe(code: settings.snipOcrHotKeyCode, modifiers: settings.snipOcrHotKeyModifiers) + let record = describe(code: settings.recordHotKeyCode, modifiers: settings.recordHotKeyModifiers) + let recordRegion = describe( + code: settings.recordHotKeyCode, + modifiers: settings.recordHotKeyModifiers ^ NSEvent.ModifierFlags.shift.rawValue + ) + let demo = describe(code: settings.demoTypeHotKeyCode, modifiers: settings.demoTypeHotKeyModifiers) + let demoReset = describe( + code: settings.demoTypeHotKeyCode, + modifiers: settings.demoTypeHotKeyModifiers ^ NSEvent.ModifierFlags.shift.rawValue + ) + let panoramaCopy = describe(code: settings.panoramaHotKeyCode, modifiers: settings.panoramaHotKeyModifiers) + let panoramaSave = describe( + code: settings.panoramaHotKeyCode, + modifiers: settings.panoramaHotKeyModifiers ^ NSEvent.ModifierFlags.shift.rawValue + ) + let breakTimer = describe(code: settings.breakHotKeyCode, modifiers: settings.breakHotKeyModifiers) + + let global = """ + Global shortcuts + Static Zoom: \(zoom) + Live Zoom: \(live) + Draw w/out Zoom: \(draw) + Snip → Clipboard: \(snipCopy) + Snip → File: \(snipSave) + Snip → OCR: \(snipOcr) + Record Screen: \(record) + Record Region: \(recordRegion) + Panorama → Clipbd: \(panoramaCopy) + Panorama → File: \(panoramaSave) + DemoType Start: \(demo) + DemoType Reset: \(demoReset) + Break Timer: \(breakTimer) + """ + + let inMode = """ + While zoomed / drawing + Zoom in / out: Option+Up / Option+Down (Live Zoom) + Draw: Left mouse button + Exit draw: Right mouse button + Undo: Command+Z or Control+Z + Erase all: E + Pen width: Mouse wheel, [ / ], or Shift+Up/Down + Colors: R G B O Y P W K + Highlighter: Shift + color key + Line: Hold Shift while dragging + Rectangle: Hold Control while dragging + Ellipse: Hold Tab while dragging + Arrow: Hold Shift+Control while dragging + Blank screen: Control+W (white) / Control+K (black) + Type text: T (left) / Shift+T (right) + Font size: + / − while typing + Exit: Esc + """ + + let alert = NSAlert() + alert.messageText = "ZoomIt Keyboard Shortcuts" + alert.informativeText = "Customize global shortcuts in Settings…" + alert.addButton(withTitle: "Done") + alert.addButton(withTitle: "Open Settings…") + if let icon = ZoomItAppIcon.standardIcon() { + alert.icon = icon + } + + // Render the shortcut tables in a monospaced font inside an accessory + // view so the two columns line up. NSAlert's informativeText uses the + // proportional system font, which makes space-padded columns wobble. + let body = global + "\n\n" + inMode + let font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) + let attributed = NSAttributedString( + string: body, + attributes: [ + .font: font, + .foregroundColor: NSColor.labelColor + ] + ) + + let label = NSTextField(labelWithAttributedString: attributed) + label.isSelectable = true + label.lineBreakMode = .byClipping + label.usesSingleLineMode = false + label.translatesAutoresizingMaskIntoConstraints = true + + // Size the accessory view to fit the intrinsic text size so NSAlert + // grows the whole dialog around it instead of clipping it. + let fitting = label.sizeThatFits( + NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + ) + let container = NSView(frame: NSRect(origin: .zero, size: fitting)) + container.translatesAutoresizingMaskIntoConstraints = true + container.addSubview(label) + label.frame = container.bounds + label.autoresizingMask = [.width, .height] + alert.accessoryView = container + + alert.window.animationBehavior = .none + NSApp.activate(ignoringOtherApps: true) + + if alert.runModal() == .alertSecondButtonReturn { + settingsWindowController.show() + } + } + /// Shows the permission status dialog and acts on the chosen button, then /// re-presents itself so the user can grant or open settings for several /// permissions in one sitting and watch the status refresh. For the diff --git a/Sources/ZoomItMacCore/App/AppDelegate.swift b/Sources/ZoomItMacCore/App/AppDelegate.swift index 364ee0d..ac6f790 100644 --- a/Sources/ZoomItMacCore/App/AppDelegate.swift +++ b/Sources/ZoomItMacCore/App/AppDelegate.swift @@ -178,6 +178,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { StatusMenuEntry(title: "Panorama Capture", action: #selector(AppController.startPanorama), keyEquivalent: ""), StatusMenuEntry(title: "Break Timer", action: #selector(AppController.toggleBreakTimer), keyEquivalent: ""), .separator, + StatusMenuEntry(title: "Keyboard Shortcuts", action: #selector(AppController.showShortcuts), keyEquivalent: ""), StatusMenuEntry(title: "Check Permissions", action: #selector(AppController.checkPermissions), keyEquivalent: ""), StatusMenuEntry(title: "Quit", action: #selector(AppController.quit), keyEquivalent: "q") ] diff --git a/Sources/ZoomItMacCore/SelfTest/SelfTestRunner.swift b/Sources/ZoomItMacCore/SelfTest/SelfTestRunner.swift index 2397e50..f9773cd 100644 --- a/Sources/ZoomItMacCore/SelfTest/SelfTestRunner.swift +++ b/Sources/ZoomItMacCore/SelfTest/SelfTestRunner.swift @@ -52,6 +52,7 @@ public enum SelfTestRunner { try testPanoramaEscapeCancel() try testIdleSleepAssertionLifecycle() try testStatusMenuOrderMatchesWindows() + try testStatusMenuActionsAreWired() try testClipTransitionUpdatesOnChange() try testWebcamOverlayDragOrigin() try testTrimSavePreservesOriginal() @@ -570,6 +571,7 @@ public enum SelfTestRunner { "Record Screen", // Record "Panorama Capture", // macOS-only, after Record "Break Timer", // moved below Panorama Capture + "Keyboard Shortcuts", "Check Permissions", "Quit" ] @@ -596,6 +598,20 @@ public enum SelfTestRunner { try expect(titles.last == "Quit", "Expected Quit to be the last menu item") } + /// Every non-separator status-menu entry must have a non-nil action that + /// AppController actually responds to; otherwise the menu item would be + /// permanently disabled at runtime. Regression guard for wiring up new + /// entries such as "Keyboard Shortcuts". + private static func testStatusMenuActionsAreWired() throws { + for entry in AppDelegate.statusMenuEntries() where !entry.isSeparator { + guard let action = entry.action else { + throw SelfTestError.failure("Expected menu entry '\(entry.title)' to have an action") + } + try expect(AppController.instancesRespond(to: action), + "Expected AppController to respond to \(action) for menu entry '\(entry.title)'") + } + } + /// 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