Skip to content

Commit

Permalink
Implement FocusCommand + refactorings
Browse files Browse the repository at this point in the history
  • Loading branch information
nikitabobko committed Sep 17, 2023
1 parent bce5951 commit f4007b7
Show file tree
Hide file tree
Showing 36 changed files with 436 additions and 210 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/.idea
/build
/.build

# XCode User settings
xcuserdata/
76 changes: 44 additions & 32 deletions AeroSpace.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ I will publish first release once I believe it's usable, so subscribe to release
- AeroSpace employs its **own emulation of virtual workspaces** instead of relying on native macOS Spaces due to
their considerable limitations
- Plain text configuration (dotfiles friendly)
- [PLANNED] CLI scriptable
- **PLANNED** CLI scriptable
- Doesn't require disabling SIP (System Integrity Protection)
- Proper multi-monitor support (i3-like paradigm)
- Status menu icon displays current workspace name
Expand Down
4 changes: 2 additions & 2 deletions build-debug.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ cd "$(dirname "$0")"
xcodegen # https://github.com/yonaskolb/XcodeGen
xcodebuild -scheme AeroSpace build -configuration Debug # no clean because it may lead to accessibility permission loss

rm -rf build && mkdir build
rm -rf .build && mkdir .build
pushd ~/Library/Developer/Xcode/DerivedData > /dev/null
if [ "$(ls | grep AeroSpace | wc -l)" -ne 1 ]; then
echo "Found several AeroSpace dirs in $(pwd)"
ls | grep AeroSpace
exit 1
fi
popd > /dev/null
cp -r ~/Library/Developer/Xcode/DerivedData/AeroSpace*/Build/Products/Debug/AeroSpace-Debug.app build
cp -r ~/Library/Developer/Xcode/DerivedData/AeroSpace*/Build/Products/Debug/AeroSpace-Debug.app .build
6 changes: 3 additions & 3 deletions build-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,16 @@ cd "$(dirname "$0")"
xcodegen # https://github.com/yonaskolb/XcodeGen
xcodebuild -scheme AeroSpace build -configuration Release

rm -rf build && mkdir build
rm -rf .build && mkdir .build
pushd ~/Library/Developer/Xcode/DerivedData > /dev/null
if [ "$(ls | grep AeroSpace | wc -l)" -ne 1 ]; then
echo "Found several AeroSpace dirs in $(pwd)"
ls | grep AeroSpace
exit 1
fi
popd > /dev/null
cp -r ~/Library/Developer/Xcode/DerivedData/AeroSpace*/Build/Products/Release/AeroSpace.app build
cp -r ~/Library/Developer/Xcode/DerivedData/AeroSpace*/Build/Products/Release/AeroSpace.app .build

pushd build
pushd .build
zip -r AeroSpace.zip AeroSpace.app
popd
6 changes: 4 additions & 2 deletions config-examples/default-config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ auto-flatten-containers = true
floating-windows-on-top = true

[mode.main.binding]
alt-enter = 'bash /usr/bin/open /System/Applications/Utilities/Terminal.app'
alt-enter = 'exec_and_forget open /System/Applications/Utilities/Terminal.app'

alt-shift-quote = 'focus child'
alt-quote = 'focus parent'
Expand All @@ -25,6 +25,8 @@ alt-j = 'focus down'
alt-k = 'focus up'
alt-l = 'focus right'

alt-backslash = 'close_all_windows_but_current'

# Move window

alt-shift-k = 'move_through up'
Expand Down Expand Up @@ -104,7 +106,7 @@ alt-z = 'workspace ZZZ'
# alt-shift-y = 'move container to workspace YYY'
# alt-shift-z = 'move container to workspace ZZZ'

# alt-tab = 'workspace_back_and_forth'
alt-tab = 'workspace_back_and_forth'

# alt-shift-slash.alt-shift-k = 'move_in up'
# alt-shift-slash.alt-shift-h = 'move_in left'
Expand Down
7 changes: 5 additions & 2 deletions config-examples/i3-like-config-example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
auto-flatten-containers = false

[mode.main.binding]
alt-enter = 'bash /usr/bin/open /System/Applications/Utilities/Terminal.app'
alt-enter = 'exec_and_forget open /System/Applications/Utilities/Terminal.app'

alt-h = 'focus left'
alt-j = 'focus down'
Expand All @@ -29,7 +29,10 @@ alt-shift-l = 'move_through right'

#todo support parsing
#alt-shift-space = 'layout floating tiling' # 'floating toggle' in i3
alt-space = 'focus toggle_tiling_floating'

# Not supported, because this command is redundant in AeroSpace mental model.
# Floating windows are part of the tree from the perspective of 'focus' command.
#alt-space = 'focus toggle_tiling_floating'

alt-a = 'focus parent'

Expand Down
22 changes: 9 additions & 13 deletions src/AeroSpaceApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,17 @@ struct Setting {
@main
struct AeroSpaceApp: App {
var hotKeys: [HotKey] = [] // Keep hotkeys in memory
@StateObject var viewModel = ViewModel.shared
@StateObject var viewModel = TrayModel.shared

init() {
reloadConfig()

//checkAccessibilityPermissions()
//GlobalObserver.initObserver()
//for setting in settings {
// hotKeys.append(HotKey(key: setting.hotkey, modifiers: setting.modifiers, keyUpHandler: {
// switchToWorkspace(Workspace.get(byName: setting.name))
// }))
//}
//refresh()
//test()
if NSClassFromString("XCTestCase") == nil { // Prevent SwiftUI app loading during unit testing
reloadConfig()

checkAccessibilityPermissions()
GlobalObserver.initObserver()
config.mainMode.activate()
refresh()
}
}

var body: some Scene {
Expand All @@ -49,7 +45,7 @@ struct AeroSpaceApp: App {
Text("Workspaces:")
ForEach(Workspace.all) { workspace in
Button {
switchToWorkspace(workspace)
WorkspaceCommand.switchToWorkspace(workspace)
} label: {
Toggle(isOn: workspace.name == viewModel.focusedWorkspaceTrayText
? Binding(get: { true }, set: { _, _ in })
Expand Down
8 changes: 3 additions & 5 deletions src/ViewModel.swift → src/TrayModel.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import Foundation

class ViewModel: ObservableObject {
static let shared = ViewModel()
class TrayModel: ObservableObject {
static let shared = TrayModel()

private init() {
}
private init() {}

@Published var focusedWorkspaceTrayText: String = currentEmptyWorkspace.name // config.first?.name ?? "W: 1"
}

11 changes: 0 additions & 11 deletions src/axWrappers/axObservers.swift

This file was deleted.

6 changes: 6 additions & 0 deletions src/command/CloseAllWindowsButCurrentCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class CloseAllWindowsButCurrentCommand: Command {
func run() {
precondition(Thread.current.isMainThread)
// todo
}
}
1 change: 1 addition & 0 deletions src/command/Command.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
protocol Command {
@MainActor
func run() async
}
8 changes: 8 additions & 0 deletions src/command/ExecAndForgetCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
struct ExecAndForgetCommand: Command {
let bashCommand: String

func run() async {
precondition(Thread.current.isMainThread)
try! Process.run(URL(filePath: "/bin/bash"), arguments: ["-c", bashCommand])
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
struct BashCommand: Command {
struct ExecAndWaitCommand: Command {
let bashCommand: String

func run() async {
Expand Down
45 changes: 41 additions & 4 deletions src/command/FocusCommand.swift
Original file line number Diff line number Diff line change
@@ -1,14 +1,51 @@
struct FocusCommand: Command {
let direction: Direction
let direction: FDirection

enum Direction: String {
enum FDirection: String {
case up, down, left, right

case parent, child, floating, tiling, toggle_tiling_floating
case parent, child //, floating, tiling, toggle_tiling_floating // not needed

// todo support only if asked
//case next, prev
}

func run() async {
precondition(Thread.current.isMainThread)
// todo
guard let window = NSWorkspace.focusedApp?.macApp?.focusedWindow else { return }
if let direction = direction.direction {
let orientation = direction.orientation
guard let topMostChild = window.parentsWithSelf.lazy.first(where: {
$0.parent is Workspace || ($0.parent as? TilingContainer)?.orientation == orientation
}) else { return }
guard let parent = topMostChild.parent as? TilingContainer else { return }
guard let index = parent.children.firstIndex(of: topMostChild) else { return }
let mruIndexMap = window.workspace.mruWindows.mruIndexMap
let window: MacWindow? = parent.children.getOrNil(atIndex: direction.isPositive ? index + 1 : index - 1)?
.allLeafWindowsRecursive(snappedTo: direction.opposite)
.minBy { mruIndexMap[$0] ?? Int.max }
window?.focus()
} else {
// todo direction == .child || direction == .parent
}
}
}

extension FocusCommand.FDirection {
var direction: Direction? {
switch self {
case .up:
return .up
case .down:
return .down
case .left:
return .left
case .right:
return .right
case .parent:
return nil
case .child:
return nil
}
}
}
6 changes: 6 additions & 0 deletions src/command/WorkspaceBackAndForth.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
struct WorkspaceBackAndForth: Command {
func run() async {
precondition(Thread.current.isMainThread)
// todo
}
}
28 changes: 27 additions & 1 deletion src/command/WorkspaceCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,32 @@ struct WorkspaceCommand : Command {

func run() async {
precondition(Thread.current.isMainThread)
switchToWorkspace(Workspace.get(byName: workspaceName))
WorkspaceCommand.switchToWorkspace(Workspace.get(byName: workspaceName))
}

static func switchToWorkspace(_ workspace: Workspace) {
debug("Switch to workspace: \(workspace.name)")
refresh(endSession: false)
if let window = workspace.mruWindows.mru ?? workspace.anyLeafWindowRecursive { // switch to not empty workspace
window.focus()
// The switching itself will be done by refreshWorkspaces and layoutWorkspaces later in refresh
} else { // switch to empty workspace
precondition(workspace.isEffectivelyEmpty)
// It's the only place in the app where I allow myself to use NSScreen.main.
// This function isn't invoked from callbacks that's why .main should be fine
if let focusedMonitor = NSScreen.focusedMonitorOrNilIfDesktop ?? NSScreen.main?.monitor {
focusedMonitor.setActiveWorkspace(workspace)
}
defocusAllWindows()
}
refresh(startSession: false)
debug("End switch to workspace: \(workspace.name)")
}

private static func defocusAllWindows() {
// Since AeroSpace doesn't show any windows, focusing AeroSpace defocuses all windows
let current = NSRunningApplication.current
current.activate(options: .activateIgnoringOtherApps)
NSWorkspace.focusedApp = current
}
}
69 changes: 69 additions & 0 deletions src/command/parseCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import TOMLKit

// todo drop TomlBacktrace
func parseCommand(_ raw: TOMLValueConvertible, _ backtrace: TomlBacktrace) -> Command {
if let rawString = raw.string {
return parseSingleCommand(rawString, backtrace)
} else if let rawArray = raw.array {
let commands: [Command] = (0..<rawArray.count).map { index in
let indexBacktrace = backtrace + .index(index)
let rawString: String = rawArray[index].string ??
expectedActualTypeError(expected: .string, actual: rawArray[index].type, indexBacktrace)
return parseSingleCommand(rawString, indexBacktrace)
}
return CompositeCommand(subCommands: commands)
} else {
return expectedActualTypeError(expected: [.string, .array], actual: raw.type, backtrace)
}
}

private func parseSingleCommand(_ raw: String, _ backtrace: TomlBacktrace) -> Command {
let words = raw.split(separator: " ")
let args = words[1...]
let firstWord = String(words.first ?? "")
if firstWord == "workspace" {
return WorkspaceCommand(workspaceName: parseSingleArg(args, firstWord, backtrace))
} else if firstWord == "mode" {
return ModeCommand(idToActivate: parseSingleArg(args, firstWord, backtrace))
} else if firstWord == "exec_and_wait" {
return ExecAndWaitCommand(bashCommand: raw.removePrefix(firstWord))
} else if firstWord == "exec_and_forget" {
return ExecAndForgetCommand(bashCommand: raw.removePrefix(firstWord))
} else if firstWord == "focus" {
let direction = FocusCommand.FDirection(rawValue: parseSingleArg(args, firstWord, backtrace))
?? errorT("\(backtrace): Can't parse '\(firstWord)' direction")
return FocusCommand(direction: direction)
} else if firstWord == "move_through" {
let direction = MoveThroughCommand.Direction(rawValue: parseSingleArg(args, firstWord, backtrace))
?? errorT("\(backtrace): Can't parse '\(firstWord)' direction")
return MoveThroughCommand(direction: direction)
} else if raw == "workspace_back_and_forth" {
return WorkspaceBackAndForth()
} else if raw == "reload_config" {
return ReloadConfigCommand()
} else if raw == "close_all_windows_but_current" {
return CloseAllWindowsButCurrentCommand()
} else if raw == "" {
error("\(backtrace): Can't parse empty string command")
} else {
error("\(backtrace): Can't parse '\(raw)' command")
}
}

private func parseSingleArg(_ args: ArraySlice<Swift.String.SubSequence>, _ command: String, _ backtrace: TomlBacktrace) -> String {
args.singleOrNil().flatMap { String($0) } ?? errorT(
"\(backtrace): \(command) must have only a single argument. But passed: '\(args.joined(separator: " "))'"
)
}

private func expectedActualTypeError<T>(expected: TOMLType, actual: TOMLType, _ backtrace: TomlBacktrace) -> T {
error("\(backtrace): Expected type is '\(expected)'. But actual type is '\(actual)'")
}

private func expectedActualTypeError<T>(expected: [TOMLType], actual: TOMLType, _ backtrace: TomlBacktrace) -> T {
if let single = expected.singleOrNil() {
return expectedActualTypeError(expected: single, actual: actual, backtrace)
} else {
error("\(backtrace): Expected types are \(expected.map { "'\($0.description)'" }.joined(separator: " or ")). But actual type is '\(actual)'")
}
}
4 changes: 3 additions & 1 deletion src/config/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ struct Config {
let autoFlattenContainers: Bool
let floatingWindowsOnTop: Bool
let modes: [String: Mode]
var workspaceNames: [String]
var mainMode: Mode { modes[mainModeId] ?? errorT("Invalid config. main mode must be always presented") }
}

struct Mode {
Expand Down Expand Up @@ -39,7 +41,7 @@ class HotkeyBinding {
}

func activate() {
hotKey = HotKey(key: key, modifiers: modifiers, keyUpHandler: { [self] in
hotKey = HotKey(key: key, modifiers: modifiers, keyUpHandler: { [command] in
Task { await command.run() }
})
}
Expand Down
Loading

0 comments on commit f4007b7

Please sign in to comment.