Skip to content

Commit 61bbeab

Browse files
committed
fix(macos): move Pin off Cmd-P and test menu chords
Pin on Top is now Cmd-Option-P. Shortcuts live in a portable catalog so the protocol suite can reject collisions, including a reserved Cmd-,.
1 parent caeb26c commit 61bbeab

4 files changed

Lines changed: 176 additions & 45 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import Foundation
2+
3+
/// Portable catalog of macOS app menu shortcuts. The Cocoa host applies these
4+
/// chords; the protocol suite rejects duplicates so Cmd+P cannot silently mean
5+
/// both Pin and Print.
6+
public struct MenuShortcutSpec: Equatable, Sendable {
7+
public let menu: String
8+
public let title: String
9+
public let key: String
10+
public let command: Bool
11+
public let shift: Bool
12+
public let option: Bool
13+
public let control: Bool
14+
public let selector: String
15+
16+
public init(
17+
menu: String,
18+
title: String,
19+
key: String,
20+
command: Bool = true,
21+
shift: Bool = false,
22+
option: Bool = false,
23+
control: Bool = false,
24+
selector: String
25+
) {
26+
self.menu = menu
27+
self.title = title
28+
self.key = key
29+
self.command = command
30+
self.shift = shift
31+
self.option = option
32+
self.control = control
33+
self.selector = selector
34+
}
35+
36+
public var chordIdentity: String {
37+
[
38+
command ? "cmd" : nil,
39+
control ? "ctrl" : nil,
40+
option ? "opt" : nil,
41+
shift ? "shift" : nil,
42+
key.isEmpty ? nil : key.lowercased(),
43+
].compactMap { $0 }.joined(separator: "+")
44+
}
45+
}
46+
47+
public let headlessMenuShortcuts: [MenuShortcutSpec] = [
48+
.init(menu: "Headless", title: "Hide Headless", key: "h", selector: "hide:"),
49+
.init(
50+
menu: "Headless", title: "Hide Others", key: "h", option: true,
51+
selector: "hideOtherApplications:"
52+
),
53+
.init(menu: "Headless", title: "Quit Headless", key: "q", selector: "terminate:"),
54+
.init(menu: "File", title: "New Window", key: "n", selector: "newWindow:"),
55+
.init(menu: "File", title: "Open Location…", key: "l", selector: "openLocation:"),
56+
.init(
57+
menu: "File", title: "Save Snapshot to Desktop", key: "s", shift: true,
58+
selector: "saveSnapshot:"
59+
),
60+
.init(menu: "File", title: "Close Window", key: "w", selector: "performClose:"),
61+
.init(menu: "Edit", title: "Undo", key: "z", selector: "undo:"),
62+
.init(menu: "Edit", title: "Redo", key: "z", shift: true, selector: "redo:"),
63+
.init(menu: "Edit", title: "Cut", key: "x", selector: "cut:"),
64+
.init(menu: "Edit", title: "Copy", key: "c", selector: "copy:"),
65+
.init(menu: "Edit", title: "Paste", key: "v", selector: "paste:"),
66+
.init(menu: "Edit", title: "Select All", key: "a", selector: "selectAll:"),
67+
.init(
68+
menu: "Edit", title: "Copy Current URL", key: "c", shift: true, selector: "copyPageURL:"
69+
),
70+
.init(menu: "View", title: "Reload Page", key: "r", selector: "reloadPage:"),
71+
.init(
72+
menu: "View", title: "Reload Ignoring Cache", key: "r", shift: true,
73+
selector: "hardReloadPage:"
74+
),
75+
.init(menu: "View", title: "Zoom In", key: "=", selector: "zoomInPage:"),
76+
.init(menu: "View", title: "Zoom Out", key: "-", selector: "zoomOutPage:"),
77+
.init(menu: "View", title: "Actual Size", key: "0", selector: "resetZoom:"),
78+
.init(
79+
menu: "View", title: "Enter Full Screen", key: "f", control: true,
80+
selector: "toggleFullScreen:"
81+
),
82+
.init(menu: "History", title: "Back", key: "[", selector: "goBackAction:"),
83+
.init(menu: "History", title: "Forward", key: "]", selector: "goForwardAction:"),
84+
.init(menu: "Window", title: "Minimize", key: "m", selector: "performMiniaturize:"),
85+
.init(
86+
menu: "Window", title: "Pin on Top", key: "p", option: true, selector: "togglePin:"
87+
),
88+
.init(menu: "Help", title: "Headless Help", key: "?", selector: "showHelpPage:"),
89+
]

apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1863,6 +1863,35 @@ struct ProtocolTests {
18631863
try expect(checked >= 30, "expected to check every command line, checked \(checked)")
18641864
}
18651865

1866+
static func menuShortcutsHaveUniqueChords() throws {
1867+
var seen: [String: String] = [:]
1868+
for spec in headlessMenuShortcuts {
1869+
try expect(!spec.selector.isEmpty, "\(spec.title) is missing a selector")
1870+
try expect(!spec.key.isEmpty, "\(spec.title) should not be in the keyed catalog without a chord")
1871+
if let previous = seen[spec.chordIdentity] {
1872+
throw TestFailure(
1873+
description: "\(spec.title) collides with \(previous) on \(spec.chordIdentity)"
1874+
)
1875+
}
1876+
seen[spec.chordIdentity] = spec.title
1877+
}
1878+
let pin = headlessMenuShortcuts.first { $0.title == "Pin on Top" }
1879+
try expect(pin?.key == "p" && pin?.command == true && pin?.option == true && pin?.shift == false,
1880+
"Pin on Top should be Cmd-Option-P, not Cmd-P")
1881+
try expect(
1882+
!headlessMenuShortcuts.contains { $0.key == "," },
1883+
"Cmd-, is reserved for a future Settings window"
1884+
)
1885+
let snapshot = headlessMenuShortcuts.first { $0.title == "Save Snapshot to Desktop" }
1886+
try expect(snapshot?.key == "s" && snapshot?.shift == true,
1887+
"snapshot capture should stay Cmd-Shift-S")
1888+
let p0 = try String(contentsOfFile: "docs/P0.md", encoding: .utf8)
1889+
try expect(
1890+
p0.contains("Cmd-Option-P") && p0.contains("Cmd-Shift-S"),
1891+
"P0 should document the Pin and snapshot chords"
1892+
)
1893+
}
1894+
18661895
static func sharedHostCoreDispatch() throws {
18671896
let root = "/tmp/headless-host-core-test-\(UUID().uuidString)"
18681897
defer { try? FileManager.default.removeItem(atPath: root) }
@@ -1990,6 +2019,7 @@ struct ProtocolTests {
19902019
("single-source contract constants", singleSourceContractConstants),
19912020
("shared host core dispatch", sharedHostCoreDispatch),
19922021
("docs command reference matches help", docsCommandReferenceMatchesHelp),
2022+
("menu shortcuts have unique chords", menuShortcutsHaveUniqueChords),
19932023
]
19942024

19952025
var failures = 0

apps/headless/docs/P0.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ new host. Settings and overrides never reorder an already-running host. Direct
3838
GUI launches and windows created from the app's menu retain normal foreground
3939
behavior. Startup presentation configuration is unsupported on Linux.
4040

41+
## macOS app shortcuts
42+
43+
Menu chords live in `MenuShortcuts.swift` and are tested for uniqueness.
44+
Pin on Top is Cmd-Option-P so it does not take Cmd-P (Print). Snapshot is
45+
Cmd-Shift-S. Cmd-, is reserved for a future Settings window and is not wired.
46+
4147
## Security boundaries
4248

4349
- Socket access is limited to the current operating-system user.

apps/headless/main.swift

Lines changed: 51 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -941,87 +941,93 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
941941

942942
// MARK: Menu
943943

944+
private func menuItem(
945+
_ title: String, action: Selector?, target: AnyObject? = nil
946+
) -> NSMenuItem {
947+
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
948+
item.target = target
949+
if let spec = headlessMenuShortcuts.first(where: { $0.title == title }) {
950+
item.keyEquivalent = spec.key
951+
var mask: NSEvent.ModifierFlags = []
952+
if spec.command { mask.insert(.command) }
953+
if spec.shift { mask.insert(.shift) }
954+
if spec.option { mask.insert(.option) }
955+
if spec.control { mask.insert(.control) }
956+
item.keyEquivalentModifierMask = mask
957+
}
958+
return item
959+
}
960+
944961
private func buildMenu() {
945962
let main = NSMenu()
946963

947964
let appMenu = NSMenu()
948965
appMenu.addItem(withTitle: "About Headless",
949966
action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")
950967
appMenu.addItem(.separator())
951-
appMenu.addItem(withTitle: "Hide Headless", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
952-
let hideOthers = appMenu.addItem(withTitle: "Hide Others",
953-
action: #selector(NSApplication.hideOtherApplications(_:)), keyEquivalent: "h")
954-
hideOthers.keyEquivalentModifierMask = [.command, .option]
968+
appMenu.addItem(menuItem("Hide Headless", action: #selector(NSApplication.hide(_:))))
969+
appMenu.addItem(menuItem("Hide Others", action: #selector(NSApplication.hideOtherApplications(_:))))
955970
appMenu.addItem(withTitle: "Show All", action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "")
956971
appMenu.addItem(.separator())
957-
appMenu.addItem(withTitle: "Quit Headless", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
972+
appMenu.addItem(menuItem("Quit Headless", action: #selector(NSApplication.terminate(_:))))
958973
main.addItem(withTitle: "Headless", action: nil, keyEquivalent: "").submenu = appMenu
959974

960975
let fileMenu = NSMenu(title: "File")
961-
let newWin = fileMenu.addItem(withTitle: "New Window", action: #selector(newWindow(_:)), keyEquivalent: "n")
962-
newWin.target = self
963-
fileMenu.addItem(withTitle: "Open Location…",
964-
action: #selector(BrowserWindowController.openLocation(_:)), keyEquivalent: "l")
976+
fileMenu.addItem(menuItem("New Window", action: #selector(newWindow(_:)), target: self))
977+
fileMenu.addItem(menuItem("Open Location…", action: #selector(BrowserWindowController.openLocation(_:))))
965978
fileMenu.addItem(.separator())
966-
let snap = fileMenu.addItem(withTitle: "Save Snapshot to Desktop",
967-
action: #selector(BrowserWindowController.saveSnapshot(_:)), keyEquivalent: "s")
968-
snap.keyEquivalentModifierMask = [.command, .shift]
979+
fileMenu.addItem(menuItem(
980+
"Save Snapshot to Desktop",
981+
action: #selector(BrowserWindowController.saveSnapshot(_:))
982+
))
969983
fileMenu.addItem(.separator())
970-
fileMenu.addItem(withTitle: "Close Window", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w")
984+
fileMenu.addItem(menuItem("Close Window", action: #selector(NSWindow.performClose(_:))))
971985
main.addItem(withTitle: "File", action: nil, keyEquivalent: "").submenu = fileMenu
972986

973987
let editMenu = NSMenu(title: "Edit")
974-
editMenu.addItem(withTitle: "Undo", action: NSSelectorFromString("undo:"), keyEquivalent: "z")
975-
editMenu.addItem(withTitle: "Redo", action: NSSelectorFromString("redo:"), keyEquivalent: "Z")
988+
editMenu.addItem(menuItem("Undo", action: NSSelectorFromString("undo:")))
989+
editMenu.addItem(menuItem("Redo", action: NSSelectorFromString("redo:")))
976990
editMenu.addItem(.separator())
977-
editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
978-
editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
979-
editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
980-
editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
991+
editMenu.addItem(menuItem("Cut", action: #selector(NSText.cut(_:))))
992+
editMenu.addItem(menuItem("Copy", action: #selector(NSText.copy(_:))))
993+
editMenu.addItem(menuItem("Paste", action: #selector(NSText.paste(_:))))
994+
editMenu.addItem(menuItem("Select All", action: #selector(NSText.selectAll(_:))))
981995
editMenu.addItem(.separator())
982-
let copyURL = editMenu.addItem(withTitle: "Copy Current URL",
983-
action: #selector(BrowserWindowController.copyPageURL(_:)), keyEquivalent: "c")
984-
copyURL.keyEquivalentModifierMask = [.command, .shift]
996+
editMenu.addItem(menuItem(
997+
"Copy Current URL",
998+
action: #selector(BrowserWindowController.copyPageURL(_:))
999+
))
9851000
main.addItem(withTitle: "Edit", action: nil, keyEquivalent: "").submenu = editMenu
9861001

9871002
let viewMenu = NSMenu(title: "View")
988-
viewMenu.addItem(withTitle: "Reload Page",
989-
action: #selector(BrowserWindowController.reloadPage(_:)), keyEquivalent: "r")
990-
let hardReload = viewMenu.addItem(withTitle: "Reload Ignoring Cache",
991-
action: #selector(BrowserWindowController.hardReloadPage(_:)), keyEquivalent: "r")
992-
hardReload.keyEquivalentModifierMask = [.command, .shift]
1003+
viewMenu.addItem(menuItem("Reload Page", action: #selector(BrowserWindowController.reloadPage(_:))))
1004+
viewMenu.addItem(menuItem(
1005+
"Reload Ignoring Cache",
1006+
action: #selector(BrowserWindowController.hardReloadPage(_:))
1007+
))
9931008
viewMenu.addItem(.separator())
994-
viewMenu.addItem(withTitle: "Zoom In",
995-
action: #selector(BrowserWindowController.zoomInPage(_:)), keyEquivalent: "=")
996-
viewMenu.addItem(withTitle: "Zoom Out",
997-
action: #selector(BrowserWindowController.zoomOutPage(_:)), keyEquivalent: "-")
998-
viewMenu.addItem(withTitle: "Actual Size",
999-
action: #selector(BrowserWindowController.resetZoom(_:)), keyEquivalent: "0")
1009+
viewMenu.addItem(menuItem("Zoom In", action: #selector(BrowserWindowController.zoomInPage(_:))))
1010+
viewMenu.addItem(menuItem("Zoom Out", action: #selector(BrowserWindowController.zoomOutPage(_:))))
1011+
viewMenu.addItem(menuItem("Actual Size", action: #selector(BrowserWindowController.resetZoom(_:))))
10001012
viewMenu.addItem(.separator())
1001-
let fullScreen = viewMenu.addItem(withTitle: "Enter Full Screen",
1002-
action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f")
1003-
fullScreen.keyEquivalentModifierMask = [.command, .control]
1013+
viewMenu.addItem(menuItem("Enter Full Screen", action: #selector(NSWindow.toggleFullScreen(_:))))
10041014
main.addItem(withTitle: "View", action: nil, keyEquivalent: "").submenu = viewMenu
10051015

10061016
let historyMenu = NSMenu(title: "History")
1007-
historyMenu.addItem(withTitle: "Back",
1008-
action: #selector(BrowserWindowController.goBackAction(_:)), keyEquivalent: "[")
1009-
historyMenu.addItem(withTitle: "Forward",
1010-
action: #selector(BrowserWindowController.goForwardAction(_:)), keyEquivalent: "]")
1017+
historyMenu.addItem(menuItem("Back", action: #selector(BrowserWindowController.goBackAction(_:))))
1018+
historyMenu.addItem(menuItem("Forward", action: #selector(BrowserWindowController.goForwardAction(_:))))
10111019
main.addItem(withTitle: "History", action: nil, keyEquivalent: "").submenu = historyMenu
10121020

10131021
let windowMenu = NSMenu(title: "Window")
1014-
windowMenu.addItem(withTitle: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")
1022+
windowMenu.addItem(menuItem("Minimize", action: #selector(NSWindow.performMiniaturize(_:))))
10151023
windowMenu.addItem(withTitle: "Zoom", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "")
10161024
windowMenu.addItem(.separator())
1017-
windowMenu.addItem(withTitle: "Pin on Top",
1018-
action: #selector(BrowserWindowController.togglePin(_:)), keyEquivalent: "p")
1025+
windowMenu.addItem(menuItem("Pin on Top", action: #selector(BrowserWindowController.togglePin(_:))))
10191026
main.addItem(withTitle: "Window", action: nil, keyEquivalent: "").submenu = windowMenu
10201027
NSApp.windowsMenu = windowMenu
10211028

10221029
let helpMenu = NSMenu(title: "Help")
1023-
helpMenu.addItem(withTitle: "Headless Help",
1024-
action: #selector(BrowserWindowController.showHelpPage(_:)), keyEquivalent: "?")
1030+
helpMenu.addItem(menuItem("Headless Help", action: #selector(BrowserWindowController.showHelpPage(_:))))
10251031
main.addItem(withTitle: "Help", action: nil, keyEquivalent: "").submenu = helpMenu
10261032
NSApp.helpMenu = helpMenu
10271033

0 commit comments

Comments
 (0)