diff --git a/CHANGELOG.md b/CHANGELOG.md index fbe5730..abde5cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ First release. with the Mac. - A window follows its device: it shows when the device has shut down, offers a reboot, and reattaches on its own when the device comes back. +- The menu bar follows the simulator it replaces: the same menus in the same order, the same + shortcuts, and the standard macOS ones that were missing. The zoom shortcuts are Apple's now, + ⌘1 Physical Size, ⌘2 Point Accurate, ⌘3 Pixel Accurate, ⌘4 Fit Screen, which changes three of the + four this app used before. - The `odhub` command line tool beside the app, for listing devices and driving input from a script. One menu item puts it on your PATH, without a password on most machines. - If Xcode is missing or its simulator frameworks will not load, the app says what is missing and diff --git a/engine/Sources/OpenDeviceHubViewer/ScaleMode.swift b/engine/Sources/OpenDeviceHubViewer/ScaleMode.swift index 0114a43..0fc451d 100644 --- a/engine/Sources/OpenDeviceHubViewer/ScaleMode.swift +++ b/engine/Sources/OpenDeviceHubViewer/ScaleMode.swift @@ -31,7 +31,7 @@ public enum ScaleMode: String, Sendable, Hashable, Codable, CaseIterable, Expres public var displayName: String { switch self { - case .fit: return "Fit" + case .fit: return "Fit Screen" case .pointAccurate: return "Point Accurate" case .pixelAccurate: return "Pixel Accurate" case .physicalSize: return "Physical Size" diff --git a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift index 94fe022..20a429b 100644 --- a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift +++ b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift @@ -92,6 +92,10 @@ public enum ViewerMenu { let appItem = NSMenuItem() let appMenu = NSMenu() + let aboutItem = target.item("About \(Brand.productName)", #selector(MenuTarget.about), "", []) + aboutItem.icon("info.circle") + appMenu.addItem(aboutItem) + appMenu.addItem(.separator()) if actions.checkForUpdates != nil { appMenu.addItem(target.item("Check for Updates\u{2026}", #selector(MenuTarget.checkForUpdates), "", [])) appMenu.addItem(.separator()) @@ -102,6 +106,25 @@ public enum ViewerMenu { appMenu.addItem(item) appMenu.addItem(.separator()) } + let servicesItem = NSMenuItem(title: "Services", action: nil, keyEquivalent: "") + let servicesMenu = NSMenu(title: "Services") + servicesItem.submenu = servicesMenu + application.servicesMenu = servicesMenu + appMenu.addItem(servicesItem) + servicesItem.icon("gearshape.2") + appMenu.addItem(.separator()) + + appMenu.addItem(withTitle: "Hide \(Brand.productName)", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h") + .icon("rectangle.dashed") + let hideOthers = appMenu.addItem(withTitle: "Hide Others", action: #selector(NSApplication.hideOtherApplications(_:)), keyEquivalent: "h") + hideOthers.keyEquivalentModifierMask = [.command, .option] + // The closest public symbol. The one macOS draws here has no public equivalent, so this is + // deliberately an approximation rather than a match. + hideOthers.icon("rectangle.on.rectangle.dashed") + appMenu.addItem(withTitle: "Show All", action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "") + .icon("macwindow.on.rectangle") + appMenu.addItem(.separator()) + appMenu.addItem(withTitle: "Quit \(Brand.productName)", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") appItem.submenu = appMenu bar.addItem(appItem) @@ -114,28 +137,29 @@ public enum ViewerMenu { fileMenu.addItem(open) fileMenu.addItem(.separator()) fileMenu.addItem(withTitle: "Close Window", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") + .icon("xmark") fileItem.submenu = fileMenu bar.addItem(fileItem) } let editItem = NSMenuItem() let editMenu = NSMenu(title: "Edit") - editMenu.addItem(target.item("Copy Screenshot", #selector(MenuTarget.copyScreenshot), "c", [])) - editMenu.addItem(target.item("Paste to Device", #selector(MenuTarget.paste), "v", [])) + editMenu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z").icon("arrow.uturn.backward") + editMenu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z").icon("arrow.uturn.forward") + editMenu.addItem(.separator()) + editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x").icon("scissors") + editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c").icon("doc.on.doc") + editMenu.addItem(target.item("Copy Screen", #selector(MenuTarget.copyScreenshot), "c", [.command, .control])) + let pasteItem = target.item("Paste", #selector(MenuTarget.paste), "v", []) + pasteItem.icon("doc.on.clipboard") + editMenu.addItem(pasteItem) + editMenu.addItem(.separator()) + editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") editItem.submenu = editMenu bar.addItem(editItem) let viewItem = NSMenuItem() let viewMenu = NSMenu(title: "View") - let scaleShortcuts: [(ScaleMode, String)] = [ - (.pointAccurate, "1"), (.pixelAccurate, "2"), (.physicalSize, "3"), (.fit, "4"), - ] - for (mode, key) in scaleShortcuts { - let item = target.item(mode.displayName, #selector(MenuTarget.scale(_:)), key, []) - item.representedObject = mode.rawValue - viewMenu.addItem(item) - } - viewMenu.addItem(.separator()) viewMenu.addItem(target.item("Show Device Bezel", #selector(MenuTarget.bezel), "b", [])) viewMenu.addItem(target.item("Keep on Top", #selector(MenuTarget.keepOnTop), "t", [])) viewMenu.addItem(.separator()) @@ -172,7 +196,7 @@ public enum ViewerMenu { let debugItem = NSMenuItem() let debugMenu = NSMenu(title: "Debug") - let memoryWarning = target.item("Simulate Memory Warning", #selector(MenuTarget.memoryWarning), "", []) + let memoryWarning = target.item("Simulate Memory Warning", #selector(MenuTarget.memoryWarning), "m", [.command, .shift]) // Unavailable items stay visible but disabled, with the reason in a tooltip, rather than // disappearing and leaving the menu looking arbitrary. disable(memoryWarning, unless: capabilities.contains(.memoryWarning), reason: "not available on this Xcode") @@ -187,18 +211,70 @@ public enum ViewerMenu { debugMenu.addItem(shake) debugMenu.addItem(.separator()) - debugMenu.addItem(target.item("Open System Log", #selector(MenuTarget.systemLog), "", [])) + debugMenu.addItem(target.item("Open System Log\u{2026}", #selector(MenuTarget.systemLog), "/", [])) debugMenu.addItem(target.item("Open App Data in Finder", #selector(MenuTarget.appData), "", [])) debugMenu.addItem(.separator()) debugMenu.addItem(target.item("Show Click to Frame Latency", #selector(MenuTarget.latency(_:)), "l", [.command, .shift])) debugItem.submenu = debugMenu bar.addItem(debugItem) + let windowItem = NSMenuItem() + let windowMenu = NSMenu(title: "Window") + windowMenu.addItem(withTitle: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m") + .icon("minus.rectangle") + windowMenu.addItem(withTitle: "Zoom", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "") + windowMenu.addItem(.separator()) + let scaleShortcuts: [(ScaleMode, String)] = [ + (.physicalSize, "1"), (.pointAccurate, "2"), (.pixelAccurate, "3"), (.fit, "4"), + ] + for (mode, key) in scaleShortcuts { + let item = target.item(mode.displayName, #selector(MenuTarget.scale(_:)), key, []) + item.representedObject = mode.rawValue + windowMenu.addItem(item) + } + windowMenu.addItem(.separator()) + windowMenu.addItem(withTitle: "Bring All to Front", action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "") + .icon("square.3.layers.3d") + windowItem.submenu = windowMenu + bar.addItem(windowItem) + application.windowsMenu = windowMenu + + let helpItem = NSMenuItem() + let helpMenu = NSMenu(title: "Help") + helpItem.submenu = helpMenu + bar.addItem(helpItem) + application.helpMenu = helpMenu + application.mainMenu = bar return target } } +/// The icons macOS draws on standard commands, set by hand. +/// +/// Simulator.app's `MainMenu.nib` carries no images at all, yet its Undo, Cut, Copy and application +/// menu rows have them, so on macOS 26 AppKit decorates nib loaded items itself. It does not do that +/// for a menu built in code: measured here, our Undo and Cut came out bare while the three rows +/// AppKit inserted on its own, AutoFill, Start Dictation and Emoji & Symbols, arrived with icons. So +/// matching the app we replace means naming the symbols. +/// +/// Only the standard commands get one. Our own commands stay bare, which is Simulator.app's own +/// seam: `Copy Screen` sits between two icon bearing rows with nothing, and the whole of Device, +/// I/O, Features and Debug has none. +/// Setting `image` is not enough on macOS 27. `preferredImageVisibility` is new there and defaults +/// to `.automatic`, which for a menu built in code resolves to hidden: the image is present, +/// template and correctly sized, and simply is not drawn. +private extension NSMenuItem { + @discardableResult + func icon(_ symbol: String) -> NSMenuItem { + image = NSImage(systemSymbolName: symbol, accessibilityDescription: title) + if #available(macOS 27.0, *) { + preferredImageVisibility = .visible + } + return self + } +} + private func disable(_ item: NSMenuItem, unless available: Bool, reason: String) { guard !available else { return } item.action = nil @@ -272,6 +348,14 @@ public final class MenuTarget: NSObject, NSMenuDelegate { actions.setScaleMode(mode) } + @objc func about() { + NSApplication.shared.orderFrontStandardAboutPanel(options: [ + .applicationName: Brand.productName, + .applicationVersion: Brand.version, + .version: Brand.buildNumber, + ]) + } + @objc func bezel() { actions.toggleBezel() } @objc func keepOnTop() { actions.toggleKeepOnTop() } @objc func saveScreenshot() { actions.saveScreenshot() }