Skip to content

Commit 9d6bf43

Browse files
rickyznikitabobko
authored andcommitted
Implement the swap command.
`aerospace swap` swaps the currently focused window with a window in a cardinal direction or with the next/prev window in the depth first order of windows in the workspace. Target window selection works identically to the focus command (so the cardinal directions respect MRU). _fixes #8
1 parent bdb948b commit 9d6bf43

File tree

11 files changed

+278
-1
lines changed

11 files changed

+278
-1
lines changed

Sources/AppBundle/command/cmdManifest.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ extension CmdArgs {
6868
command = SplitCommand(args: self as! SplitCmdArgs)
6969
case .summonWorkspace:
7070
command = SummonWorkspaceCommand(args: self as! SummonWorkspaceCmdArgs)
71+
case .swap:
72+
command = SwapCommand(args: self as! SwapCmdArgs)
7173
case .triggerBinding:
7274
command = TriggerBindingCommand(args: self as! TriggerBindingCmdArgs)
7375
case .volume:

Sources/AppBundle/command/impl/FocusCommand.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ private struct FloatingWindowData {
164164

165165
extension TreeNode {
166166
@MainActor
167-
fileprivate func findFocusTargetRecursive(snappedTo direction: CardinalDirection) -> Window? {
167+
func findFocusTargetRecursive(snappedTo direction: CardinalDirection) -> Window? {
168168
switch nodeCases {
169169
case .workspace(let workspace):
170170
return workspace.rootTilingContainer.findFocusTargetRecursive(snappedTo: direction)
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import AppKit
2+
import Common
3+
4+
struct SwapCommand: Command {
5+
let args: SwapCmdArgs
6+
7+
func run(_ env: CmdEnv, _ io: CmdIo) async throws -> Bool {
8+
guard let target = args.resolveTargetOrReportError(env, io) else {
9+
return false
10+
}
11+
12+
guard let currentWindow = target.windowOrNil else {
13+
return io.err(noWindowIsFocused)
14+
}
15+
16+
let targetWindow: Window?
17+
switch args.target.val {
18+
case .direction(let direction):
19+
if let (parent, ownIndex) = currentWindow.closestParent(hasChildrenInDirection: direction, withLayout: nil) {
20+
targetWindow = parent.children[ownIndex + direction.focusOffset].findFocusTargetRecursive(snappedTo: direction.opposite)
21+
} else if args.wrapAround {
22+
targetWindow = target.workspace.findFocusTargetRecursive(snappedTo: direction.opposite)
23+
} else {
24+
return false
25+
}
26+
case .dfsRelative(let nextPrev):
27+
let windows = target.workspace.rootTilingContainer.allLeafWindowsRecursive
28+
guard let currentIndex = windows.firstIndex(where: { $0 == target.windowOrNil }) else {
29+
return false
30+
}
31+
var targetIndex = switch nextPrev {
32+
case .dfsNext: currentIndex + 1
33+
case .dfsPrev: currentIndex - 1
34+
}
35+
if targetIndex < 0 || targetIndex >= windows.count {
36+
if !args.wrapAround {
37+
return false
38+
}
39+
targetIndex = (targetIndex + windows.count) % windows.count
40+
}
41+
targetWindow = windows[targetIndex]
42+
}
43+
44+
guard let targetWindow else {
45+
return false
46+
}
47+
48+
swapWindows(currentWindow, targetWindow)
49+
50+
if args.swapFocus {
51+
return targetWindow.focusWindow()
52+
}
53+
return currentWindow.focusWindow()
54+
}
55+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
@testable import AppBundle
2+
import Common
3+
import XCTest
4+
5+
@MainActor
6+
final class SwapCommandTest: XCTestCase {
7+
override func setUp() async throws { setUpWorkspacesForTests() }
8+
9+
func testSwap_swapWindows_Directional() async throws {
10+
let root = Workspace.get(byName: name).rootTilingContainer.apply {
11+
TilingContainer.newVTiles(parent: $0, adaptiveWeight: 1).apply {
12+
assertEquals(TestWindow.new(id: 1, parent: $0).focusWindow(), true)
13+
TestWindow.new(id: 2, parent: $0)
14+
}
15+
TestWindow.new(id: 3, parent: $0)
16+
}
17+
18+
try await SwapCommand(args: SwapCmdArgs(rawArgs: [], target: .direction(.right))).run(.defaultEnv, .emptyStdin)
19+
assertEquals(root.layoutDescription,
20+
.h_tiles([.v_tiles([.window(3), .window(2)]),
21+
.window(1)]))
22+
assertEquals(focus.windowOrNil?.windowId, 1)
23+
24+
try await SwapCommand(args: SwapCmdArgs(rawArgs: [], target: .direction(.left))).run(.defaultEnv, .emptyStdin)
25+
assertEquals(root.layoutDescription,
26+
.h_tiles([.v_tiles([.window(1), .window(2)]),
27+
.window(3)]))
28+
assertEquals(focus.windowOrNil?.windowId, 1)
29+
30+
try await SwapCommand(args: SwapCmdArgs(rawArgs: [], target: .direction(.down))).run(.defaultEnv, .emptyStdin)
31+
assertEquals(root.layoutDescription,
32+
.h_tiles([.v_tiles([.window(2), .window(1)]),
33+
.window(3)]))
34+
assertEquals(focus.windowOrNil?.windowId, 1)
35+
36+
try await SwapCommand(args: SwapCmdArgs(rawArgs: [], target: .direction(.up))).run(.defaultEnv, .emptyStdin)
37+
assertEquals(root.layoutDescription,
38+
.h_tiles([.v_tiles([.window(1), .window(2)]),
39+
.window(3)]))
40+
assertEquals(focus.windowOrNil?.windowId, 1)
41+
}
42+
43+
func testSwap_swapWindows_DfsRelative() async throws {
44+
let root = Workspace.get(byName: name).rootTilingContainer.apply {
45+
TilingContainer.newVTiles(parent: $0, adaptiveWeight: 1).apply {
46+
assertEquals(TestWindow.new(id: 1, parent: $0).focusWindow(), true)
47+
TestWindow.new(id: 2, parent: $0)
48+
}
49+
TestWindow.new(id: 3, parent: $0)
50+
}
51+
52+
try await SwapCommand(args: SwapCmdArgs(rawArgs: [], target: .dfsRelative(.dfsNext))).run(.defaultEnv, .emptyStdin)
53+
assertEquals(root.layoutDescription,
54+
.h_tiles([.v_tiles([.window(2), .window(1)]),
55+
.window(3)]))
56+
assertEquals(focus.windowOrNil?.windowId, 1)
57+
58+
try await SwapCommand(args: SwapCmdArgs(rawArgs: [], target: .dfsRelative(.dfsNext))).run(.defaultEnv, .emptyStdin)
59+
assertEquals(root.layoutDescription,
60+
.h_tiles([.v_tiles([.window(2), .window(3)]),
61+
.window(1)]))
62+
assertEquals(focus.windowOrNil?.windowId, 1)
63+
64+
try await SwapCommand(args: SwapCmdArgs(rawArgs: [], target: .dfsRelative(.dfsPrev))).run(.defaultEnv, .emptyStdin)
65+
assertEquals(root.layoutDescription,
66+
.h_tiles([.v_tiles([.window(2), .window(1)]),
67+
.window(3)]))
68+
assertEquals(focus.windowOrNil?.windowId, 1)
69+
70+
try await SwapCommand(args: SwapCmdArgs(rawArgs: [], target: .dfsRelative(.dfsPrev))).run(.defaultEnv, .emptyStdin)
71+
assertEquals(root.layoutDescription,
72+
.h_tiles([.v_tiles([.window(1), .window(2)]),
73+
.window(3)]))
74+
assertEquals(focus.windowOrNil?.windowId, 1)
75+
}
76+
77+
func testSwap_DirectionalWrapping() async throws {
78+
let root = Workspace.get(byName: name).rootTilingContainer.apply {
79+
assertEquals(TestWindow.new(id: 1, parent: $0).focusWindow(), true)
80+
TestWindow.new(id: 2, parent: $0)
81+
TestWindow.new(id: 3, parent: $0)
82+
}
83+
84+
var args = SwapCmdArgs(rawArgs: [], target: .direction(.left))
85+
args.wrapAround = true
86+
try await SwapCommand(args: args).run(.defaultEnv, .emptyStdin)
87+
assertEquals(root.layoutDescription, .h_tiles([.window(3), .window(2), .window(1)]))
88+
assertEquals(focus.windowOrNil?.windowId, 1)
89+
90+
args.target = .initialized(.direction(.right))
91+
try await SwapCommand(args: args).run(.defaultEnv, .emptyStdin)
92+
assertEquals(root.layoutDescription, .h_tiles([.window(1), .window(2), .window(3)]))
93+
assertEquals(focus.windowOrNil?.windowId, 1)
94+
}
95+
96+
func testSwap_DfsRelativeWrapping() async throws {
97+
let root = Workspace.get(byName: name).rootTilingContainer.apply {
98+
assertEquals(TestWindow.new(id: 1, parent: $0).focusWindow(), true)
99+
TestWindow.new(id: 2, parent: $0)
100+
TestWindow.new(id: 3, parent: $0)
101+
}
102+
103+
var args = SwapCmdArgs(rawArgs: [], target: .dfsRelative(.dfsPrev))
104+
args.wrapAround = true
105+
try await SwapCommand(args: args).run(.defaultEnv, .emptyStdin)
106+
assertEquals(root.layoutDescription, .h_tiles([.window(3), .window(2), .window(1)]))
107+
assertEquals(focus.windowOrNil?.windowId, 1)
108+
109+
args.target = .initialized(.dfsRelative(.dfsNext))
110+
try await SwapCommand(args: args).run(.defaultEnv, .emptyStdin)
111+
assertEquals(root.layoutDescription, .h_tiles([.window(1), .window(2), .window(3)]))
112+
assertEquals(focus.windowOrNil?.windowId, 1)
113+
}
114+
115+
func testSwap_SwapFocus() async throws {
116+
let root = Workspace.get(byName: name).rootTilingContainer.apply {
117+
TestWindow.new(id: 1, parent: $0)
118+
assertEquals(TestWindow.new(id: 2, parent: $0).focusWindow(), true)
119+
TestWindow.new(id: 3, parent: $0)
120+
}
121+
122+
var args = SwapCmdArgs(rawArgs: [], target: .direction(.right))
123+
args.swapFocus = true
124+
try await SwapCommand(args: args).run(.defaultEnv, .emptyStdin)
125+
assertEquals(root.layoutDescription, .h_tiles([.window(1), .window(3), .window(2)]))
126+
assertEquals(focus.windowOrNil?.windowId, 3)
127+
}
128+
}

Sources/Cli/subcommandDescriptionsGenerated.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ let subcommandDescriptions = [
3333
[" resize", "Resize the focused window"],
3434
[" split", "Split focused window"],
3535
[" summon-workspace", "Move the requested workspace to the focused monitor."],
36+
[" swap", "Swaps the focused window with another window."],
3637
[" trigger-binding", "Trigger AeroSpace binding as if it was pressed by user"],
3738
[" volume", "Manipulate volume"],
3839
[" workspace-back-and-forth", "Switch between the focused workspace and previously focused workspace back and forth"],

Sources/Common/cmdArgs/cmdArgsManifest.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ public enum CmdKind: String, CaseIterable, Equatable, Sendable {
3333
case resize
3434
case split
3535
case summonWorkspace = "summon-workspace"
36+
case swap
3637
case triggerBinding = "trigger-binding"
3738
case volume
3839
case workspace
@@ -111,6 +112,8 @@ func initSubcommands() -> [String: any SubCommandParserProtocol] {
111112
result[kind.rawValue] = SubCommandParser(parseSplitCmdArgs)
112113
case .summonWorkspace:
113114
result[kind.rawValue] = SubCommandParser(SummonWorkspaceCmdArgs.init)
115+
case .swap:
116+
result[kind.rawValue] = SubCommandParser(parseSwapCmdArgs)
114117
case .triggerBinding:
115118
result[kind.rawValue] = SubCommandParser(parseTriggerBindingCmdArgs)
116119
case .volume:
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
public struct SwapCmdArgs: CmdArgs {
2+
public let rawArgs: EquatableNoop<[String]>
3+
public init(rawArgs: [String]) { self.rawArgs = .init(rawArgs) }
4+
public static let parser: CmdParser<Self> = cmdParser(
5+
kind: .swap,
6+
allowInConfig: true,
7+
help: swap_help_generated,
8+
options: [
9+
"--swap-focus": trueBoolFlag(\.swapFocus),
10+
"--wrap-around": trueBoolFlag(\.wrapAround),
11+
],
12+
arguments: [newArgParser(\.target, parseCardinalOrDfsDirection, mandatoryArgPlaceholder: CardinalOrDfsDirection.unionLiteral)],
13+
)
14+
15+
public var target: Lateinit<CardinalOrDfsDirection> = .uninitialized
16+
public var swapFocus: Bool = false
17+
public var wrapAround: Bool = false
18+
public var windowId: UInt32?
19+
public var workspaceName: WorkspaceName?
20+
21+
public init(rawArgs: [String], target: CardinalOrDfsDirection) {
22+
self.rawArgs = .init(rawArgs)
23+
self.target = .initialized(target)
24+
}
25+
}
26+
27+
public func parseSwapCmdArgs(_ args: [String]) -> ParsedCmd<SwapCmdArgs> {
28+
return parseSpecificCmdArgs(SwapCmdArgs(rawArgs: args), args)
29+
}

Sources/Common/cmdHelpGenerated.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ let split_help_generated = """
131131
let summon_workspace_help_generated = """
132132
USAGE: summon-workspace [-h|--help] [--fail-if-noop] <workspace>
133133
"""
134+
let swap_help_generated = """
135+
USAGE: swap [-h|--help] [--swap-focus] [--wrap-around]
136+
(left|down|up|right|dfs-next|dfs-prev)
137+
"""
134138
let trigger_binding_help_generated = """
135139
USAGE: trigger-binding [-h|--help] <binding> --mode <mode-id>
136140
"""

docs/aerospace-swap.adoc

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
= aerospace-swap(1)
2+
include::util/man-attributes.adoc[]
3+
:manname: aerospace-swap
4+
// tag::purpose[]
5+
:manpurpose: Swaps the focused window with another window.
6+
// end::purpose[]
7+
8+
// =========================================================== Synopsis
9+
== Synopsis
10+
[verse]
11+
// tag::synopsis[]
12+
aerospace swap [-h|--help] [--swap-focus] [--wrap-around]
13+
(left|down|up|right|dfs-next|dfs-prev)
14+
15+
// end::synopsis[]
16+
17+
// =========================================================== Description
18+
== Description
19+
20+
// tag::body[]
21+
{manpurpose}
22+
23+
// =========================================================== Options
24+
include::util/conditional-options-header.adoc[]
25+
26+
-h, --help:: Print help
27+
28+
--swap-focus::
29+
Swap focus away from the currently focused window. By default, this command does not change the focused window.
30+
31+
--wrap-around::
32+
Wrap around if the window is at the edge of the workspace (for `(left|down|up|right)`) or the start/end of the depth first order (for `(dfs-next|dfs-prev)`).
33+
34+
// =========================================================== Arguments
35+
include::./util/conditional-arguments-header.adoc[]
36+
37+
(left|down|up|right)::
38+
Swaps the focused window with the nearest window in the given direction.
39+
40+
(dfs-next|dfs-prev)::
41+
Swaps the focused window with the next or previous window in the depth-first order (top-to-bottom and left-to-right) of windows in the current workspace tree.
42+
43+
// end::body[]
44+
45+
// =========================================================== Footer
46+
include::util/man-footer.adoc[]

docs/commands.adoc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,13 @@ include::aerospace-split.adoc[tags=synopsis]
168168
include::aerospace-split.adoc[tags=purpose]
169169
include::aerospace-split.adoc[tags=body]
170170

171+
== swap
172+
----
173+
include::aerospace-swap.adoc[tags=synopsis]
174+
----
175+
include::aerospace-swap.adoc[tags=purpose]
176+
include::aerospace-swap.adoc[tags=body]
177+
171178
== summon-workspace
172179
----
173180
include::./aerospace-summon-workspace.adoc[tags=synopsis]

0 commit comments

Comments
 (0)