diff --git a/Apps/Keyty/Sources/Keyty/App/Lifecycle/AppController.swift b/Apps/Keyty/Sources/Keyty/App/Lifecycle/AppController.swift index ae0beb72..3000f2b4 100644 --- a/Apps/Keyty/Sources/Keyty/App/Lifecycle/AppController.swift +++ b/Apps/Keyty/Sources/Keyty/App/Lifecycle/AppController.swift @@ -24,14 +24,17 @@ final class AppController: NSObject { self.updateService = UpdateServiceFactory.make() self.menuController = MenuController() let statusShortcutItem = self.menuController.makeStatusShortcutMenuItem() - self.statusItemController = StatusItemController( - menu: self.menuController.makeStatusMenu(shortcutItem: statusShortcutItem), - shortcutItem: statusShortcutItem - ) self.dependencies = AppDependencies( statusShortcutItem: statusShortcutItem, updateService: self.updateService ) + self.statusItemController = StatusItemController( + menu: self.menuController.makeStatusMenu( + shortcutItem: statusShortcutItem, + settings: self.dependencies.settings + ), + shortcutItem: statusShortcutItem + ) super.init() self.menuController.setAppController(self) NSApp.mainMenu = self.menuController.makeMainMenu() @@ -135,6 +138,26 @@ extension AppController { self.dependencies.captureController.toggleCapturing() } + @objc + func toggleKeyboardVisualizer(_ sender: Any?) { + self.dependencies.settings.keyboardVisualizerSettings.isEnabled.toggle() + } + + @objc + func togglePointerRingVisualizer(_ sender: Any?) { + self.dependencies.settings.pointerRingSettings.isEnabled.toggle() + } + + @objc + func togglePointerRipplesVisualizer(_ sender: Any?) { + self.dependencies.settings.pointerRipplesSettings.isEnabled.toggle() + } + + @objc + func togglePointerIconVisualizer(_ sender: Any?) { + self.dependencies.settings.pointerIconSettings.isEnabled.toggle() + } + @objc func quitApplication(_ sender: Any?) { NSApp.terminate(sender) diff --git a/Apps/Keyty/Sources/Keyty/App/Shell/MainMenu.swift b/Apps/Keyty/Sources/Keyty/App/Shell/MainMenu.swift index 7652e963..4ef700f1 100644 --- a/Apps/Keyty/Sources/Keyty/App/Shell/MainMenu.swift +++ b/Apps/Keyty/Sources/Keyty/App/Shell/MainMenu.swift @@ -9,9 +9,11 @@ import AppKit /// Builds and wires the status item menu used by the menu-bar-only app. -final class MenuController { +final class MenuController: NSObject { private weak var appController: AppController? private var controlledMenuItems: [NSMenuItem] = [] + private var visualizerMenuItems: [Visualizer: NSMenuItem] = [:] + private var settings: AppSettingsContainer? init(appController: AppController? = nil) { self.appController = appController @@ -26,11 +28,15 @@ final class MenuController { self.makeShortcutMenuItem(action: #selector(AppController.toggleCapturing(_:))) } - func makeStatusMenu(shortcutItem: NSMenuItem) -> NSMenu { + func makeStatusMenu(shortcutItem: NSMenuItem, settings: AppSettingsContainer) -> NSMenu { + self.settings = settings + let menu = NSMenu(title: AppConstants.appName) menu.addItem(shortcutItem) + menu.addItem(self.makeVisualizersMenuItem()) menu.addItem(self.makeSettingsMenuItem()) menu.addItem(self.makeQuitMenuItem()) + menu.delegate = self return menu } @@ -49,6 +55,13 @@ final class MenuController { } } +// MARK: - NSMenuDelegate +extension MenuController: NSMenuDelegate { + func menuNeedsUpdate(_ menu: NSMenu) { + self.updateVisualizerMenuItemStates() + } +} + // MARK: - Private API private extension MenuController { private func makeApplicationMenu() -> NSMenu { @@ -74,6 +87,32 @@ private extension MenuController { return self.register(item) } + private func makeVisualizersMenuItem() -> NSMenuItem { + let menu = NSMenu(title: L10n.MainMenu.visualizers) + menu.addItem(self.makeVisualizerMenuItem(.keyboard)) + menu.addItem(self.makeVisualizerMenuItem(.pointerRing)) + menu.addItem(self.makeVisualizerMenuItem(.pointerRipples)) + menu.addItem(self.makeVisualizerMenuItem(.pointerIcon)) + + let item = NSMenuItem(title: L10n.MainMenu.visualizers, action: nil, keyEquivalent: "") + item.submenu = menu + return item + } + + private func makeVisualizerMenuItem(_ visualizer: Visualizer) -> NSMenuItem { + let item = NSMenuItem(title: visualizer.title, action: visualizer.action, keyEquivalent: "") + self.visualizerMenuItems[visualizer] = self.register(item) + return item + } + + private func updateVisualizerMenuItemStates() { + guard let settings = self.settings else { return } + + self.visualizerMenuItems.forEach { visualizer, item in + item.state = visualizer.isEnabled(in: settings) ? .on : .off + } + } + private func makeShortcutMenuItem(action: Selector) -> NSMenuItem { let item = NSMenuItem(title: L10n.General.startCapturing, action: action, keyEquivalent: "S") item.keyEquivalentModifierMask = [.shift, .option] @@ -92,3 +131,51 @@ private extension MenuController { return item } } + +private extension MenuController { + enum Visualizer: CaseIterable { + case keyboard + case pointerRing + case pointerRipples + case pointerIcon + + var title: String { + switch self { + case .keyboard: + return L10n.Settings.Pane.keyboard + case .pointerRing: + return L10n.Mouse.tabRing + case .pointerRipples: + return L10n.Mouse.tabRipples + case .pointerIcon: + return L10n.Mouse.tabIcon + } + } + + var action: Selector { + switch self { + case .keyboard: + return #selector(AppController.toggleKeyboardVisualizer(_:)) + case .pointerRing: + return #selector(AppController.togglePointerRingVisualizer(_:)) + case .pointerRipples: + return #selector(AppController.togglePointerRipplesVisualizer(_:)) + case .pointerIcon: + return #selector(AppController.togglePointerIconVisualizer(_:)) + } + } + + func isEnabled(in settings: AppSettingsContainer) -> Bool { + switch self { + case .keyboard: + return settings.keyboardVisualizerSettings.isEnabled + case .pointerRing: + return settings.pointerRingSettings.isEnabled + case .pointerRipples: + return settings.pointerRipplesSettings.isEnabled + case .pointerIcon: + return settings.pointerIconSettings.isEnabled + } + } + } +} diff --git a/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings index 5f39892f..67f3a9c7 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings @@ -3,6 +3,7 @@ "main_menu.close_window" = "Fenster schließen"; "main_menu.file" = "Ablage"; "main_menu.settings" = "Einstellungen…"; +"main_menu.visualizers" = "Visualisierungen"; "main_menu.quit" = "%@ beenden"; /* About window */ diff --git a/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings index 0d113ffc..c29aecab 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings @@ -3,6 +3,7 @@ "main_menu.close_window" = "Close Window"; "main_menu.file" = "File"; "main_menu.settings" = "Settings…"; +"main_menu.visualizers" = "Visualizers"; "main_menu.quit" = "Quit %@"; /* About window */ diff --git a/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings index 9919c37c..d1da3243 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings @@ -3,6 +3,7 @@ "main_menu.close_window" = "Cerrar ventana"; "main_menu.file" = "Archivo"; "main_menu.settings" = "Ajustes…"; +"main_menu.visualizers" = "Visualizadores"; "main_menu.quit" = "Salir de %@"; /* About window */ diff --git a/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings index 19f9477d..9892ff2b 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings @@ -3,6 +3,7 @@ "main_menu.close_window" = "Fermer la fenêtre"; "main_menu.file" = "Fichier"; "main_menu.settings" = "Réglages…"; +"main_menu.visualizers" = "Visualiseurs"; "main_menu.quit" = "Quitter %@"; /* About window */ diff --git a/Apps/Keyty/Sources/Keyty/Resources/it.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/it.lproj/Localizable.strings index 852b813a..63c12a89 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/it.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/it.lproj/Localizable.strings @@ -3,6 +3,7 @@ "main_menu.close_window" = "Chiudi finestra"; "main_menu.file" = "File"; "main_menu.settings" = "Impostazioni…"; +"main_menu.visualizers" = "Visualizzatori"; "main_menu.quit" = "Esci da %@"; /* About window */ diff --git a/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings index 36b30fa5..38f4c0f0 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings @@ -3,6 +3,7 @@ "main_menu.close_window" = "ウインドウを閉じる"; "main_menu.file" = "ファイル"; "main_menu.settings" = "設定…"; +"main_menu.visualizers" = "ビジュアライザー"; "main_menu.quit" = "%@を終了"; /* About window */ diff --git a/Apps/Keyty/Sources/Keyty/Resources/ko.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/ko.lproj/Localizable.strings index dca4de68..0836c826 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/ko.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/ko.lproj/Localizable.strings @@ -3,6 +3,7 @@ "main_menu.close_window" = "창 닫기"; "main_menu.file" = "파일"; "main_menu.settings" = "설정…"; +"main_menu.visualizers" = "시각화 도구"; "main_menu.quit" = "%@ 종료"; /* About window */ diff --git a/Apps/Keyty/Sources/Keyty/Resources/nl.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/nl.lproj/Localizable.strings index bd9ff92e..4284b371 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/nl.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/nl.lproj/Localizable.strings @@ -3,6 +3,7 @@ "main_menu.close_window" = "Venster sluiten"; "main_menu.file" = "Archief"; "main_menu.settings" = "Instellingen…"; +"main_menu.visualizers" = "Visualisaties"; "main_menu.quit" = "%@ stoppen"; /* About window */ diff --git a/Apps/Keyty/Sources/Keyty/Resources/pl.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/pl.lproj/Localizable.strings index 32f4e100..5dc885a0 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/pl.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/pl.lproj/Localizable.strings @@ -3,6 +3,7 @@ "main_menu.close_window" = "Zamknij okno"; "main_menu.file" = "Plik"; "main_menu.settings" = "Ustawienia…"; +"main_menu.visualizers" = "Wizualizatory"; "main_menu.quit" = "Zakończ %@"; /* About window */ diff --git a/Apps/Keyty/Sources/Keyty/Resources/pt-BR.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/pt-BR.lproj/Localizable.strings index 37b8891b..b2896319 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/pt-BR.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/pt-BR.lproj/Localizable.strings @@ -3,6 +3,7 @@ "main_menu.close_window" = "Fechar Janela"; "main_menu.file" = "Arquivo"; "main_menu.settings" = "Ajustes…"; +"main_menu.visualizers" = "Visualizadores"; "main_menu.quit" = "Sair de %@"; /* About window */ diff --git a/Apps/Keyty/Sources/Keyty/Resources/tr.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/tr.lproj/Localizable.strings index 74d4fea5..7add2574 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/tr.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/tr.lproj/Localizable.strings @@ -3,6 +3,7 @@ "main_menu.close_window" = "Pencereyi Kapat"; "main_menu.file" = "Dosya"; "main_menu.settings" = "Ayarlar…"; +"main_menu.visualizers" = "Görselleştiriciler"; "main_menu.quit" = "%@’den Çık"; /* About window */ diff --git a/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings index f3c5a106..f8f5daa5 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings @@ -3,6 +3,7 @@ "main_menu.close_window" = "Закрити вікно"; "main_menu.file" = "Файл"; "main_menu.settings" = "Параметри…"; +"main_menu.visualizers" = "Візуалізатори"; "main_menu.quit" = "Завершити роботу %@"; /* About window */ diff --git a/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings index 187ba5b2..c40c7718 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings @@ -3,6 +3,7 @@ "main_menu.close_window" = "关闭窗口"; "main_menu.file" = "文件"; "main_menu.settings" = "设置…"; +"main_menu.visualizers" = "可视化器"; "main_menu.quit" = "退出 %@"; /* About window */ diff --git a/Apps/Keyty/Tests/KeytyTests/App/Shell/MenuControllerTests.swift b/Apps/Keyty/Tests/KeytyTests/App/Shell/MenuControllerTests.swift index 37df76dd..9927320e 100644 --- a/Apps/Keyty/Tests/KeytyTests/App/Shell/MenuControllerTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/App/Shell/MenuControllerTests.swift @@ -11,6 +11,29 @@ import XCTest @testable import Keyty final class MenuControllerTests: XCTestCase { + func testStatusMenuVisualizersReflectCurrentSettings() throws { + let settings = AppSettingsContainer(store: InMemoryKeyValueStore()) + let controller = MenuController() + + let menu = controller.makeStatusMenu( + shortcutItem: controller.makeStatusShortcutMenuItem(), + settings: settings + ) + menu.delegate?.menuNeedsUpdate?(menu) + + let visualizersMenu = try XCTUnwrap(menu.items[1].submenu) + XCTAssertEqual(visualizersMenu.items.map(\.title), ["Keyboard", "Ring", "Ripples", "Icon"]) + XCTAssertEqual(visualizersMenu.items.map(\.state), [.on, .off, .off, .off]) + + settings.keyboardVisualizerSettings.isEnabled = false + settings.pointerRingSettings.isEnabled = false + settings.pointerRipplesSettings.isEnabled = false + settings.pointerIconSettings.isEnabled = true + menu.delegate?.menuNeedsUpdate?(menu) + + XCTAssertEqual(visualizersMenu.items.map(\.state), [.off, .off, .off, .on]) + } + func testMainMenuIncludesOnlyRequiredCommandQAndCommandWShortcuts() throws { let controller = MenuController()