Skip to content

Commit

Permalink
feat: added shortcuts to cycle through workspaces & unassign apps (#14)
Browse files Browse the repository at this point in the history
Resolves #12 (point 1.)
  • Loading branch information
wojciech-kulik authored Jan 24, 2025
1 parent dc1f9cc commit bc287cf
Show file tree
Hide file tree
Showing 11 changed files with 160 additions and 20 deletions.
6 changes: 4 additions & 2 deletions FlashSpace.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@

/* Begin PBXFileReference section */
01904D0D8EB8017AEF4CD453 /* Integrations.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Integrations.swift; sourceTree = "<group>"; };
07AA7A031B97E585161DBEFC /* FocusManagementView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FocusManagementView.swift; sourceTree = "<group>"; };
0F6030DE3CF8A0BBDBFC3E29 /* SettingsRepository.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsRepository.swift; sourceTree = "<group>"; };
14242986FC549EA6F2EE11B8 /* AXUIElement.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AXUIElement.swift; sourceTree = "<group>"; };
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 = "<group>"; };
2D36CCD07BD78C895996F48F /* AppConstants.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppConstants.swift; sourceTree = "<group>"; };
36A517C2599D59AA134BD2CC /* Workspace.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Workspace.swift; sourceTree = "<group>"; };
36E4723ECEA5C6DD2BDA559F /* FileChooser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FileChooser.swift; sourceTree = "<group>"; };
41B1F3B6EC78F2A4AB956787 /* WorkspacesSettingsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkspacesSettingsView.swift; sourceTree = "<group>"; };
5AB52E58777D6FCD2FD77ADD /* AutostartService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AutostartService.swift; sourceTree = "<group>"; };
5CCD64800D95D6202345C05D /* FocusedWindowTracker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FocusedWindowTracker.swift; sourceTree = "<group>"; };
5F8D90F8EE33FFC0DB1A2DC7 /* Shortcut.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Shortcut.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 = "<group>";
Expand Down
3 changes: 2 additions & 1 deletion FlashSpace/Core/AppDependencies.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ struct AppDependencies {

private init() {
self.workspaceManager = WorkspaceManager(
workspaceRepository: workspaceRepository
workspaceRepository: workspaceRepository,
settingsRepository: settingsRepository
)
self.focusManager = FocusManager(
workspaceRepository: workspaceRepository,
Expand Down
2 changes: 1 addition & 1 deletion FlashSpace/Extensions/NotificationName.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
import Foundation

extension Notification.Name {
static let newAppAssigned = Notification.Name("newAppAssigned")
static let appsListChanged = Notification.Name("appsListChanged")
}
2 changes: 1 addition & 1 deletion FlashSpace/MainViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
//
// FocusManagementView.swift
// FocusSettingsView.swift
//
// Created by Wojciech Kulik on 23/01/2025.
// Copyright © 2025 Wojciech Kulik. All rights reserved.
//

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 {
Expand All @@ -29,7 +29,7 @@ struct FocusManagementView: View {
}
}
.formStyle(.grouped)
.navigationTitle("Focus Management")
.navigationTitle("Focus Manager")
}

private func hotkey(_ title: String, for hotKey: Binding<HotKeyShortcut?>) -> some View {
Expand Down
2 changes: 1 addition & 1 deletion FlashSpace/Settings/IntegrationsSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
29 changes: 29 additions & 0 deletions FlashSpace/Settings/SettingsRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,19 @@ struct AppSettings: Codable {
var focusNextWorkspaceApp: HotKeyShortcut?
var focusPreviousWorkspaceApp: HotKeyShortcut?

var switchToPreviousWorkspace: HotKeyShortcut?
var switchToNextWorkspace: HotKeyShortcut?
var unassignFocusedApp: HotKeyShortcut?

var enableIntegrations: Bool?
var runScriptOnWorkspaceChange: String?
}

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() }
}
Expand Down Expand Up @@ -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() }
}
Expand Down Expand Up @@ -92,6 +114,9 @@ final class SettingsRepository: ObservableObject {
focusDown: focusDown,
focusNextWorkspaceApp: focusNextWorkspaceApp,
focusPreviousWorkspaceApp: focusPreviousWorkspaceApp,
switchToPreviousWorkspace: switchToPreviousWorkspace,
switchToNextWorkspace: switchToNextWorkspace,
unassignFocusedApp: unassignFocusedApp,
enableIntegrations: enableIntegrations,
runScriptOnWorkspaceChange: runScriptOnWorkspaceChange
)
Expand Down Expand Up @@ -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
}
Expand Down
8 changes: 6 additions & 2 deletions FlashSpace/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -50,7 +52,9 @@ struct SettingsView: View {
case "General":
GeneralSettingsView()
case "Focus":
FocusManagementView()
FocusSettingsView()
case "Workspaces":
WorkspacesSettingsView()
case "Integrations":
IntegrationsSettingsView()
default:
Expand Down
38 changes: 38 additions & 0 deletions FlashSpace/Settings/WorkspacesSettingsView.swift
Original file line number Diff line number Diff line change
@@ -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<HotKeyShortcut?>) -> some View {
HStack {
Text(title)
Spacer()
HotKeyControl(shortcut: hotKey).fixedSize()
}
}
}
78 changes: 72 additions & 6 deletions FlashSpace/Workspaces/WorkspaceManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,14 @@ final class WorkspaceManager {
private let hideAgainSubject = PassthroughSubject<Workspace, Never>()

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
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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
}
}
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down

0 comments on commit bc287cf

Please sign in to comment.