diff --git a/CHANGELOG.md b/CHANGELOG.md index e9e10c032b..66b407891d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Settings > Editor grouped into Display, Gutter and Editing. - Editor Font and Data Grid Font moved to Settings > Editor and Settings > Data, and kept per Mac. - Editor font size range of 10 to 24 points everywhere, including zoom. +- Edit > Data shown only in data file windows. - Delta updates from the last five releases, about 6 MB instead of the full 34 MB download. ### Removed diff --git a/TablePro/Core/Menu/EditDataMenuGroup.swift b/TablePro/Core/Menu/EditDataMenuGroup.swift new file mode 100644 index 0000000000..75d14c664c --- /dev/null +++ b/TablePro/Core/Menu/EditDataMenuGroup.swift @@ -0,0 +1,51 @@ +// +// EditDataMenuGroup.swift +// TablePro +// + +import AppKit + +/// Edit > Data only acts on a data file window, so it shows only while one would receive its +/// commands. A hidden submenu still matches its items' key equivalents (measured on macOS 27), so +/// every item in the group is hidden, not only the submenu. +@MainActor +internal enum EditDataMenuGroup { + internal static let separatorIdentifier = NSUserInterfaceItemIdentifier("edit.data.separator") + internal static let submenuIdentifier = NSUserInterfaceItemIdentifier("edit.data") + + private static let probeAction = #selector(DataFileSplitViewController.dataFileShowProperties(_:)) + + internal static func isShown(forReceiver receiver: Any?) -> Bool { + receiver is DataFileSplitViewController + } + + internal static func update(in menu: NSMenu) { + let receiver = NSApp.target(forAction: probeAction, to: nil, from: nil) + setShown(isShown(forReceiver: receiver), in: menu) + } + + internal static func setShown(_ shown: Bool, in menu: NSMenu) { + let group = menu.items.filter { $0.identifier == separatorIdentifier || $0.identifier == submenuIdentifier } + // Runs on every modified keystroke, so nothing is written when the group already matches. + guard group.contains(where: { $0.isHidden == shown }) else { return } + for item in group { + setHidden(!shown, item) + } + } + + private static func setHidden(_ hidden: Bool, _ item: NSMenuItem) { + item.isHidden = hidden + for child in item.submenu?.items ?? [] { + setHidden(hidden, child) + } + } +} + +/// AppKit calls `menuNeedsUpdate` before the Edit menu opens and, because this delegate has no +/// `menuHasKeyEquivalent`, before it matches a key equivalent too. +@MainActor +internal final class EditMenuDelegate: NSObject, NSMenuDelegate { + func menuNeedsUpdate(_ menu: NSMenu) { + EditDataMenuGroup.update(in: menu) + } +} diff --git a/TablePro/Core/Menu/EditMenuBuilder.swift b/TablePro/Core/Menu/EditMenuBuilder.swift index 8515e82b60..20ab0a33c4 100644 --- a/TablePro/Core/Menu/EditMenuBuilder.swift +++ b/TablePro/Core/Menu/EditMenuBuilder.swift @@ -9,13 +9,16 @@ import AppKit /// them to the Edit menu itself. @MainActor enum EditMenuBuilder { + /// Retained for the menu's lifetime, which is the app's: `NSMenu.delegate` is unowned. + private static let delegate = EditMenuDelegate() + /// A key of its own: French names this menu "Édition" but an Edit button "Modifier". static var title: String { String(localized: "menu.edit", defaultValue: "Edit", comment: "The Edit menu in the menu bar") } static func build(keyboard: KeyboardSettings) -> NSMenuItem { - MenuItemFactory.menu(title, items: [ + let edit = MenuItemFactory.menu(title, items: [ MenuItemFactory.item( String(localized: "Undo"), action: Selector(("undo:")), @@ -101,9 +104,20 @@ enum EditMenuBuilder { shortcut: .restorePreviousValues, keyboard: keyboard ), - MenuItemFactory.separator, + dataSeparator(), tabularEditingSubmenu(keyboard: keyboard) ]) + if let menu = edit.submenu { + menu.delegate = delegate + EditDataMenuGroup.setShown(false, in: menu) + } + return edit + } + + private static func dataSeparator() -> NSMenuItem { + let separator = MenuItemFactory.separator + separator.identifier = EditDataMenuGroup.separatorIdentifier + return separator } private static func copySpecialSubmenu(keyboard: KeyboardSettings) -> NSMenuItem { @@ -172,7 +186,7 @@ enum EditMenuBuilder { } private static func tabularEditingSubmenu(keyboard: KeyboardSettings) -> NSMenuItem { - MenuItemFactory.submenu(String(localized: "Data"), items: [ + let data = MenuItemFactory.submenu(String(localized: "Data"), items: [ MenuItemFactory.item( String(localized: "Insert Row Above"), action: #selector(DataFileSplitViewController.dataFileInsertRowAbove(_:)) @@ -261,6 +275,8 @@ enum EditMenuBuilder { action: #selector(DataFileSplitViewController.dataFileImportIntoTable(_:)) ) ]) + data.identifier = EditDataMenuGroup.submenuIdentifier + return data } private static func changeCaseSubmenu() -> NSMenuItem { diff --git a/TableProTests/Core/Menu/EditDataMenuGroupTests.swift b/TableProTests/Core/Menu/EditDataMenuGroupTests.swift new file mode 100644 index 0000000000..b6b0bf95cd --- /dev/null +++ b/TableProTests/Core/Menu/EditDataMenuGroupTests.swift @@ -0,0 +1,110 @@ +// +// EditDataMenuGroupTests.swift +// TableProTests +// + +import AppKit +import Carbon.HIToolbox +@testable import TablePro +import Testing + +private final class FiringTarget: NSObject { + var fired = 0 + @objc func fire(_ sender: Any?) { fired += 1 } +} + +@MainActor +struct EditDataMenuGroupTests { + private func editMenu() throws -> NSMenu { + try #require(EditMenuBuilder.build(keyboard: KeyboardSettings()).submenu) + } + + private func flatten(_ items: [NSMenuItem]) -> [NSMenuItem] { + items.flatMap { [$0] + flatten($0.submenu?.items ?? []) } + } + + private func isGroupRoot(_ item: NSMenuItem) -> Bool { + item.identifier == EditDataMenuGroup.separatorIdentifier || item.identifier == EditDataMenuGroup.submenuIdentifier + } + + private func group(in menu: NSMenu) -> [NSMenuItem] { + flatten(menu.items.filter(isGroupRoot)) + } + + private func headerRowItem(in menu: NSMenu) throws -> NSMenuItem { + let identifier = MenuItemFactory.identifier(for: .toggleHeaderRow) + return try #require(group(in: menu).first { $0.identifier == identifier }) + } + + private func commandShiftH() throws -> NSEvent { + let event = try #require(CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_ANSI_H), keyDown: true)) + event.flags = [.maskCommand, .maskShift] + return try #require(NSEvent(cgEvent: event)) + } + + @Test("Edit > Data and the separator above it are built hidden, down to every leaf") + func builtHidden() throws { + let menu = try editMenu() + let roots = menu.items.filter(isGroupRoot) + #expect(roots.count == 2) + #expect(roots.last?.title == String(localized: "Data")) + #expect(group(in: menu).count > 20) + #expect(group(in: menu).allSatisfy { $0.isHidden }) + } + + @Test("Showing and hiding the group leaves every other Edit item visible") + func togglesOnlyTheGroup() throws { + let menu = try editMenu() + let others = flatten(menu.items.filter { !isGroupRoot($0) }) + #expect(!others.isEmpty) + + EditDataMenuGroup.setShown(true, in: menu) + #expect(group(in: menu).allSatisfy { !$0.isHidden }) + #expect(others.allSatisfy { !$0.isHidden }) + + EditDataMenuGroup.setShown(false, in: menu) + #expect(group(in: menu).allSatisfy { $0.isHidden }) + #expect(others.allSatisfy { !$0.isHidden }) + } + + @Test("Only a data file window's controller shows the group") + func onlyADataFileReceiverShowsTheGroup() { + #expect(EditDataMenuGroup.isShown(forReceiver: DataFileSplitViewController(document: DataFileDocument()))) + #expect(!EditDataMenuGroup.isShown(forReceiver: nil)) + #expect(!EditDataMenuGroup.isShown(forReceiver: NSObject())) + } + + @Test("A hidden group's shortcut does not match and a shown one does") + func hiddenShortcutDoesNotMatch() throws { + let menu = try editMenu() + menu.delegate = nil + let target = FiringTarget() + let item = try headerRowItem(in: menu) + item.target = target + item.action = #selector(FiringTarget.fire(_:)) + + #expect(!menu.performKeyEquivalent(with: try commandShiftH())) + #expect(target.fired == 0) + + EditDataMenuGroup.setShown(true, in: menu) + #expect(menu.performKeyEquivalent(with: try commandShiftH())) + #expect(target.fired == 1) + } + + /// The test host has no data file window, so the delegate must hide a group left shown, and it + /// has to do so before AppKit matches the chord. + @Test("The Edit menu's delegate updates the group before a shortcut is matched") + func delegateRunsBeforeKeyEquivalentMatching() throws { + let menu = try editMenu() + #expect(menu.delegate is EditMenuDelegate) + let target = FiringTarget() + let item = try headerRowItem(in: menu) + item.target = target + item.action = #selector(FiringTarget.fire(_:)) + EditDataMenuGroup.setShown(true, in: menu) + + #expect(!menu.performKeyEquivalent(with: try commandShiftH())) + #expect(target.fired == 0) + #expect(group(in: menu).allSatisfy { $0.isHidden }) + } +} diff --git a/TableProUITests/SingleWindowMenuContractUITests.swift b/TableProUITests/SingleWindowMenuContractUITests.swift index 1b722c7a6a..befcf86f04 100644 --- a/TableProUITests/SingleWindowMenuContractUITests.swift +++ b/TableProUITests/SingleWindowMenuContractUITests.swift @@ -111,6 +111,20 @@ final class SingleWindowMenuContractUITests: UITestCase { XCTAssertFalse(menuItems["Hide Workspace Rail"].exists, "The invented noun must be gone") } + /// Edit > Data acts only on a data file window, so a connection window leaves it out. + /// `DataFileWindowUITests` opens the same submenu from a data file window. + func testConnectionWindowLeavesOutEditData() throws { + let app = try launchWithSampleDatabase() + let menuItems = app.menuBars.menuItems + + XCTAssertTrue( + menuItems["Restore Previous Values…"].waitToExist(timeout: 5), + "The Edit menu must be listed for the checks below to mean anything" + ) + XCTAssertFalse(menuItems["Column Statistics…"].exists, "A connection window must not list Edit > Data") + XCTAssertFalse(menuItems["Import into Table…"].exists, "A connection window must not list Edit > Data") + } + /// Launching shows the welcome window and nothing else. A second main window appearing here /// is the shape of the bug the single-window model exists to prevent. func testLaunchOpensExactlyOneWindow() throws {