From d18bf5c4776126e14461f2784d13ec736833506e Mon Sep 17 00:00:00 2001 From: Wojciech Kulik <3128467+wojciech-kulik@users.noreply.github.com> Date: Sat, 25 Jan 2025 00:01:36 +0100 Subject: [PATCH] feat: added shortcuts to cycle through workspaces & unassign apps Resolves #12 (point 1.) --- FlashSpace.xcodeproj/project.pbxproj | 6 +- FlashSpace/Core/AppDependencies.swift | 3 +- FlashSpace/Extensions/NotificationName.swift | 2 +- FlashSpace/MainViewModel.swift | 2 +- ...mentView.swift => FocusSettingsView.swift} | 8 +- .../Settings/IntegrationsSettingsView.swift | 2 +- FlashSpace/Settings/SettingsRepository.swift | 29 +++++++ FlashSpace/Settings/SettingsView.swift | 8 +- .../Settings/WorkspacesSettingsView.swift | 38 +++++++++ FlashSpace/Workspaces/WorkspaceManager.swift | 78 +++++++++++++++++-- README.md | 4 +- 11 files changed, 160 insertions(+), 20 deletions(-) rename FlashSpace/Settings/{FocusManagementView.swift => FocusSettingsView.swift} (84%) create mode 100644 FlashSpace/Settings/WorkspacesSettingsView.swift diff --git a/FlashSpace.xcodeproj/project.pbxproj b/FlashSpace.xcodeproj/project.pbxproj index d1ddaff..17bb096 100644 --- a/FlashSpace.xcodeproj/project.pbxproj +++ b/FlashSpace.xcodeproj/project.pbxproj @@ -17,14 +17,15 @@ /* Begin PBXFileReference section */ 01904D0D8EB8017AEF4CD453 /* Integrations.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Integrations.swift; sourceTree = ""; }; - 07AA7A031B97E585161DBEFC /* FocusManagementView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FocusManagementView.swift; sourceTree = ""; }; 0F6030DE3CF8A0BBDBFC3E29 /* SettingsRepository.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsRepository.swift; sourceTree = ""; }; 14242986FC549EA6F2EE11B8 /* AXUIElement.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AXUIElement.swift; sourceTree = ""; }; 16999A052D3D97770006EA91 /* FlashSpace-Dev.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FlashSpace-Dev.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 16999AE32D3DC77A0006EA91 /* ServiceManagement.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ServiceManagement.framework; path = System/Library/Frameworks/ServiceManagement.framework; sourceTree = SDKROOT; }; + 1C8586E09F30781F4653F207 /* FocusSettingsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FocusSettingsView.swift; sourceTree = ""; }; 2D36CCD07BD78C895996F48F /* AppConstants.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppConstants.swift; sourceTree = ""; }; 36A517C2599D59AA134BD2CC /* Workspace.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Workspace.swift; sourceTree = ""; }; 36E4723ECEA5C6DD2BDA559F /* FileChooser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FileChooser.swift; sourceTree = ""; }; + 41B1F3B6EC78F2A4AB956787 /* WorkspacesSettingsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkspacesSettingsView.swift; sourceTree = ""; }; 5AB52E58777D6FCD2FD77ADD /* AutostartService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AutostartService.swift; sourceTree = ""; }; 5CCD64800D95D6202345C05D /* FocusedWindowTracker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FocusedWindowTracker.swift; sourceTree = ""; }; 5F8D90F8EE33FFC0DB1A2DC7 /* Shortcut.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Shortcut.swift; sourceTree = ""; }; @@ -173,9 +174,10 @@ children = ( CDF522B8B2538D4D42526AAA /* SettingsView.swift */, FE5885AA7A5B96DC59D8290B /* GeneralSettingsView.swift */, - 07AA7A031B97E585161DBEFC /* FocusManagementView.swift */, 0F6030DE3CF8A0BBDBFC3E29 /* SettingsRepository.swift */, BF62C0D4D9928BC04344735A /* IntegrationsSettingsView.swift */, + 1C8586E09F30781F4653F207 /* FocusSettingsView.swift */, + 41B1F3B6EC78F2A4AB956787 /* WorkspacesSettingsView.swift */, ); path = Settings; sourceTree = ""; diff --git a/FlashSpace/Core/AppDependencies.swift b/FlashSpace/Core/AppDependencies.swift index f12accc..42fbbbe 100644 --- a/FlashSpace/Core/AppDependencies.swift +++ b/FlashSpace/Core/AppDependencies.swift @@ -24,7 +24,8 @@ struct AppDependencies { private init() { self.workspaceManager = WorkspaceManager( - workspaceRepository: workspaceRepository + workspaceRepository: workspaceRepository, + settingsRepository: settingsRepository ) self.focusManager = FocusManager( workspaceRepository: workspaceRepository, diff --git a/FlashSpace/Extensions/NotificationName.swift b/FlashSpace/Extensions/NotificationName.swift index a14561c..5262c28 100644 --- a/FlashSpace/Extensions/NotificationName.swift +++ b/FlashSpace/Extensions/NotificationName.swift @@ -8,5 +8,5 @@ import Foundation extension Notification.Name { - static let newAppAssigned = Notification.Name("newAppAssigned") + static let appsListChanged = Notification.Name("appsListChanged") } diff --git a/FlashSpace/MainViewModel.swift b/FlashSpace/MainViewModel.swift index 685481a..364edac 100644 --- a/FlashSpace/MainViewModel.swift +++ b/FlashSpace/MainViewModel.swift @@ -101,7 +101,7 @@ final class MainViewModel: ObservableObject { private func observe() { NotificationCenter.default - .publisher(for: .newAppAssigned) + .publisher(for: .appsListChanged) .sink { [weak self] _ in self?.reloadWorkspaces() } .store(in: &cancellables) } diff --git a/FlashSpace/Settings/FocusManagementView.swift b/FlashSpace/Settings/FocusSettingsView.swift similarity index 84% rename from FlashSpace/Settings/FocusManagementView.swift rename to FlashSpace/Settings/FocusSettingsView.swift index a3fdcca..7418cd6 100644 --- a/FlashSpace/Settings/FocusManagementView.swift +++ b/FlashSpace/Settings/FocusSettingsView.swift @@ -1,5 +1,5 @@ // -// FocusManagementView.swift +// FocusSettingsView.swift // // Created by Wojciech Kulik on 23/01/2025. // Copyright © 2025 Wojciech Kulik. All rights reserved. @@ -7,13 +7,13 @@ import SwiftUI -struct FocusManagementView: View { +struct FocusSettingsView: View { @StateObject private var settings = AppDependencies.shared.settingsRepository var body: some View { Form { Section { - Toggle("Enable Focus Management", isOn: $settings.enableFocusManagement) + Toggle("Enable Focus Manager", isOn: $settings.enableFocusManagement) } Section { @@ -29,7 +29,7 @@ struct FocusManagementView: View { } } .formStyle(.grouped) - .navigationTitle("Focus Management") + .navigationTitle("Focus Manager") } private func hotkey(_ title: String, for hotKey: Binding) -> some View { diff --git a/FlashSpace/Settings/IntegrationsSettingsView.swift b/FlashSpace/Settings/IntegrationsSettingsView.swift index bfab250..28dbc4a 100644 --- a/FlashSpace/Settings/IntegrationsSettingsView.swift +++ b/FlashSpace/Settings/IntegrationsSettingsView.swift @@ -13,7 +13,7 @@ struct IntegrationsSettingsView: View { var body: some View { Form { Section { - Toggle("Enable integrations", isOn: $settings.enableIntegrations) + Toggle("Enable Integrations", isOn: $settings.enableIntegrations) } Section( diff --git a/FlashSpace/Settings/SettingsRepository.swift b/FlashSpace/Settings/SettingsRepository.swift index 320f993..013aa20 100644 --- a/FlashSpace/Settings/SettingsRepository.swift +++ b/FlashSpace/Settings/SettingsRepository.swift @@ -17,6 +17,10 @@ struct AppSettings: Codable { var focusNextWorkspaceApp: HotKeyShortcut? var focusPreviousWorkspaceApp: HotKeyShortcut? + var switchToPreviousWorkspace: HotKeyShortcut? + var switchToNextWorkspace: HotKeyShortcut? + var unassignFocusedApp: HotKeyShortcut? + var enableIntegrations: Bool? var runScriptOnWorkspaceChange: String? } @@ -24,6 +28,8 @@ struct AppSettings: Codable { final class SettingsRepository: ObservableObject { static let defaultScript = "sketchybar --trigger flashspace_workspace_change WORKSPACE=\"$WORKSPACE\" DISPLAY=\"$DISPLAY\"" + // MARK: - Focus Manager + @Published var enableFocusManagement: Bool = true { didSet { updateSettings() } } @@ -52,6 +58,22 @@ final class SettingsRepository: ObservableObject { didSet { updateSettings() } } + // MARK: - Workspaces + + @Published var switchToPreviousWorkspace: HotKeyShortcut? { + didSet { updateSettings() } + } + + @Published var switchToNextWorkspace: HotKeyShortcut? { + didSet { updateSettings() } + } + + @Published var unassignFocusedApp: HotKeyShortcut? { + didSet { updateSettings() } + } + + // MARK: - Integrations + @Published var enableIntegrations: Bool = false { didSet { updateSettings() } } @@ -92,6 +114,9 @@ final class SettingsRepository: ObservableObject { focusDown: focusDown, focusNextWorkspaceApp: focusNextWorkspaceApp, focusPreviousWorkspaceApp: focusPreviousWorkspaceApp, + switchToPreviousWorkspace: switchToPreviousWorkspace, + switchToNextWorkspace: switchToNextWorkspace, + unassignFocusedApp: unassignFocusedApp, enableIntegrations: enableIntegrations, runScriptOnWorkspaceChange: runScriptOnWorkspaceChange ) @@ -122,6 +147,10 @@ final class SettingsRepository: ObservableObject { focusNextWorkspaceApp = settings.focusNextWorkspaceApp focusPreviousWorkspaceApp = settings.focusPreviousWorkspaceApp + switchToPreviousWorkspace = settings.switchToPreviousWorkspace + switchToNextWorkspace = settings.switchToNextWorkspace + unassignFocusedApp = settings.unassignFocusedApp + enableIntegrations = settings.enableIntegrations ?? false runScriptOnWorkspaceChange = settings.runScriptOnWorkspaceChange ?? Self.defaultScript } diff --git a/FlashSpace/Settings/SettingsView.swift b/FlashSpace/Settings/SettingsView.swift index 473c10a..b4b3cdc 100644 --- a/FlashSpace/Settings/SettingsView.swift +++ b/FlashSpace/Settings/SettingsView.swift @@ -28,8 +28,10 @@ struct SettingsView: View { List(selection: $selectedTab) { Label("General", systemImage: "gearshape") .tag("General") - Label("Focus Management", systemImage: "macwindow.on.rectangle") + Label("Focus Manager", systemImage: "macwindow.on.rectangle") .tag("Focus") + Label("Workspaces", systemImage: "square.stack.3d.up") + .tag("Workspaces") Label("Integrations", systemImage: "link") .tag("Integrations") } @@ -50,7 +52,9 @@ struct SettingsView: View { case "General": GeneralSettingsView() case "Focus": - FocusManagementView() + FocusSettingsView() + case "Workspaces": + WorkspacesSettingsView() case "Integrations": IntegrationsSettingsView() default: diff --git a/FlashSpace/Settings/WorkspacesSettingsView.swift b/FlashSpace/Settings/WorkspacesSettingsView.swift new file mode 100644 index 0000000..2e108c9 --- /dev/null +++ b/FlashSpace/Settings/WorkspacesSettingsView.swift @@ -0,0 +1,38 @@ +// +// WorkspacesSettingsView.swift +// +// Created by Wojciech Kulik on 24/01/2025. +// Copyright © 2025 Wojciech Kulik. All rights reserved. +// + +import SwiftUI + +struct WorkspacesSettingsView: View { + @StateObject var settings = AppDependencies.shared.settingsRepository + + var body: some View { + Form { + Section( + footer: Text("These shortcuts cycle through workspaces on the display with the cursor.") + .foregroundStyle(.secondary) + ) { + hotkey("Previous Workspace", for: $settings.switchToPreviousWorkspace) + hotkey("Next Workspace", for: $settings.switchToNextWorkspace) + } + + Section { + hotkey("Unassign Focused App", for: $settings.unassignFocusedApp) + } + } + .formStyle(.grouped) + .navigationTitle("Workspaces") + } + + private func hotkey(_ title: String, for hotKey: Binding) -> some View { + HStack { + Text(title) + Spacer() + HotKeyControl(shortcut: hotKey).fixedSize() + } + } +} diff --git a/FlashSpace/Workspaces/WorkspaceManager.swift b/FlashSpace/Workspaces/WorkspaceManager.swift index 8934c34..78d5f61 100644 --- a/FlashSpace/Workspaces/WorkspaceManager.swift +++ b/FlashSpace/Workspaces/WorkspaceManager.swift @@ -19,9 +19,14 @@ final class WorkspaceManager { private let hideAgainSubject = PassthroughSubject() private let workspaceRepository: WorkspaceRepository + private let settingsRepository: SettingsRepository - init(workspaceRepository: WorkspaceRepository) { + init( + workspaceRepository: WorkspaceRepository, + settingsRepository: SettingsRepository + ) { self.workspaceRepository = workspaceRepository + self.settingsRepository = settingsRepository // Ask for accessibility permissions // Required to hide apps @@ -35,9 +40,15 @@ final class WorkspaceManager { } func getHotKeys() -> [(Shortcut, () -> ())] { - workspaceRepository.workspaces - .flatMap { [getActivateShortcut(for: $0), getAssignShortcut(for: $0)] } - .compactMap { $0 } + let shortcuts = [ + getUnassignAppShortcut(), + getCycleWorkspacesShortcut(next: false), + getCycleWorkspacesShortcut(next: true) + ] + + workspaceRepository.workspaces + .flatMap { [getActivateShortcut(for: $0), getAssignAppShortcut(for: $0)] } + + return shortcuts.compactMap(\.self) } func activateWorkspace(_ workspace: Workspace, setFocus: Bool) { @@ -64,7 +75,7 @@ final class WorkspaceManager { .first(where: { $0.id == workspace.id }) else { return } activateWorkspace(updatedWorkspace, setFocus: false) - NotificationCenter.default.post(name: .newAppAssigned, object: nil) + NotificationCenter.default.post(name: .appsListChanged, object: nil) } private func showApps(in workspace: Workspace, setFocus: Bool) { @@ -114,7 +125,7 @@ extension WorkspaceManager { return (shortcut, action) } - private func getAssignShortcut(for workspace: Workspace) -> (Shortcut, () -> ())? { + private func getAssignAppShortcut(for workspace: Workspace) -> (Shortcut, () -> ())? { guard let shortcut = workspace.assignAppShortcut?.toShortcut() else { return nil } let action = { [weak self] in @@ -129,4 +140,59 @@ extension WorkspaceManager { return (shortcut, action) } + + private func getUnassignAppShortcut() -> (Shortcut, () -> ())? { + guard let shortcut = settingsRepository.unassignFocusedApp?.toShortcut() else { return nil } + + let action = { [weak self] in + guard let activeApp = NSWorkspace.shared.frontmostApplication else { return } + guard let appName = activeApp.localizedName else { return } + + self?.workspaceRepository.deleteAppFromAllWorkspaces(app: appName) + activeApp.hide() + NotificationCenter.default.post(name: .appsListChanged, object: nil) + } + + return (shortcut, action) + } + + private func getCycleWorkspacesShortcut(next: Bool) -> (Shortcut, () -> ())? { + guard let shortcut = + next + ? settingsRepository.switchToNextWorkspace?.toShortcut() + : settingsRepository.switchToPreviousWorkspace?.toShortcut() + else { return nil } + + let action = { [weak self] in + guard let self, let screen = getCursorScreen() else { return } + + let hasMoreScreens = NSScreen.screens.count > 1 + var screenWorkspaces = workspaceRepository.workspaces + .filter { !hasMoreScreens || $0.display == screen } + + if !next { + screenWorkspaces = screenWorkspaces.reversed() + } + + guard let activeWorkspace = activeWorkspace[screen] ?? screenWorkspaces.first else { return } + + guard let workspace = screenWorkspaces + .drop(while: { $0.id != activeWorkspace.id }) + .dropFirst() + .first ?? screenWorkspaces.first + else { return } + + activateWorkspace(workspace, setFocus: true) + } + + return (shortcut, action) + } + + private func getCursorScreen() -> DisplayName? { + let cursorLocation = NSEvent.mouseLocation + + return NSScreen.screens + .first { NSMouseInRect(cursorLocation, $0.frame, false) }? + .localizedName + } } diff --git a/README.md b/README.md index 897939a..2f89d08 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ https://github.com/user-attachments/assets/af5951ce-8386-48d5-918e-914474d2c2b8 - [x] Multiple displays support - [x] Activate workspace on app focus - [x] Move apps between workspaces with a hotkey -- [x] Focus management - set hotkeys to switch between apps quickly +- [x] Focus manager - set hotkeys to switch between apps quickly - [x] [SketchyBar] integration ## ⚙️ Installation @@ -46,7 +46,7 @@ The app allows workspaces to be switched independently on each display. Now you can switch to the workspace using the configured hotkey. -## 🪟 Focus Management +## 🪟 Focus Manager FlashSpace enables fast switching of focus between windows. Use hotkeys to shift focus in any desired direction. It also allows you to jump between