Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions Sources/AppBundle/command/cmdManifest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,14 @@ extension CmdArgs {
command = ReloadConfigCommand(args: self as! ReloadConfigCmdArgs)
case .resize:
command = ResizeCommand(args: self as! ResizeCmdArgs)
case .serverVersionInternalCommand:
command = ServerVersionInternalCommandCommand(args: self as! ServerVersionInternalCommandCmdArgs)
case .split:
command = SplitCommand(args: self as! SplitCmdArgs)
case .summonWorkspace:
command = SummonWorkspaceCommand(args: self as! SummonWorkspaceCmdArgs)
case .serverVersionInternalCommand:
command = ServerVersionInternalCommandCommand(args: self as! ServerVersionInternalCommandCmdArgs)
case .swap:
command = SwapCommand(args: self as! SwapCmdArgs)
case .triggerBinding:
command = TriggerBindingCommand(args: self as! TriggerBindingCmdArgs)
case .volume:
Expand Down
20 changes: 19 additions & 1 deletion Sources/AppBundle/command/impl/FocusCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,24 @@ struct FocusCommand: Command {
} else {
return io.err("Can't find window with DFS index \(dfsIndex)")
}
case .dfsRelative(let nextPrev):
let windows = target.workspace.rootTilingContainer.allLeafWindowsRecursive
guard let currentIndex = windows.firstIndex(where: { $0 == target.windowOrNil }) else {
return false
}
var targetIndex = switch nextPrev {
case .next: currentIndex + 1
case .prev: currentIndex - 1
}
if targetIndex < 0 || targetIndex >= windows.count {
switch args.boundariesAction {
case .stop: return true
case .fail: return false
case .wrapAroundTheWorkspace: targetIndex = (targetIndex + windows.count) % windows.count
case .wrapAroundAllMonitors: return errorT("Must be discarded by args parser")
}
}
return windows[targetIndex].focusWindow()
}
}
}
Expand Down Expand Up @@ -144,7 +162,7 @@ private struct FloatingWindowData {
let index: Int
}

private extension TreeNode {
extension TreeNode {
func findFocusTargetRecursive(snappedTo direction: CardinalDirection) -> Window? {
switch nodeCases {
case .workspace(let workspace):
Expand Down
51 changes: 51 additions & 0 deletions Sources/AppBundle/command/impl/SwapCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import AppKit
import Common

struct SwapCommand: Command {
let args: SwapCmdArgs

func run(_ env: CmdEnv, _ io: CmdIo) -> Bool {
guard let target = args.resolveTargetOrReportError(env, io), let currentWindow = target.windowOrNil else {
return io.err(noWindowIsFocused)
}

var targetWindow: Window?
switch args.target.val {
case .direction(let direction):
if let (parent, ownIndex) = currentWindow.closestParent(hasChildrenInDirection: direction, withLayout: nil) {
targetWindow = parent.children[ownIndex + direction.focusOffset].findFocusTargetRecursive(snappedTo: direction.opposite)
} else if args.wrapAround {
targetWindow = target.workspace.findFocusTargetRecursive(snappedTo: direction.opposite)
} else {
return false
}
case .dfsRelative(let nextPrev):
let windows = target.workspace.rootTilingContainer.allLeafWindowsRecursive
guard let currentIndex = windows.firstIndex(where: { $0 == target.windowOrNil }) else {
return false
}
var targetIndex = switch nextPrev {
case .next: currentIndex + 1
case .prev: currentIndex - 1
}
if targetIndex < 0 || targetIndex >= windows.count {
if !args.wrapAround {
return false
}
targetIndex = (targetIndex + windows.count) % windows.count
}
targetWindow = windows[targetIndex]
}

guard let targetWindow else {
return false
}

swapWindows(currentWindow, targetWindow)

if args.swapFocus {
return targetWindow.focusWindow()
}
return currentWindow.focusWindow()
}
}
79 changes: 76 additions & 3 deletions Sources/AppBundleTests/command/FocusCommandTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ final class FocusCommandTest: XCTestCase {

func testParse() {
XCTAssertTrue(parseCommand("focus --boundaries left").errorOrNil?.contains("Possible values") == true)
var expected = FocusCmdArgs(rawArgs: [], direction: .left)
var expected = FocusCmdArgs(rawArgs: [], targetArg: .direction(.left))
expected.rawBoundaries = .workspace
testParseCommandSucc("focus --boundaries workspace left", expected)

Expand All @@ -37,6 +37,10 @@ final class FocusCommandTest: XCTestCase {
parseCommand("focus --window-id 42 --ignore-floating").errorOrNil,
"--window-id is incompatible with other options"
)
assertEquals(
parseCommand("focus --boundaries all-monitors-outer-frame dfs-next").errorOrNil,
"(dfs-next|dfs-prev) only supports --boundaries workspace"
)
}

func testFocus() {
Expand Down Expand Up @@ -92,7 +96,7 @@ final class FocusCommandTest: XCTestCase {
}

assertEquals(focus.windowOrNil?.windowId, 1)
var args = FocusCmdArgs(rawArgs: [], direction: .left)
var args = FocusCmdArgs(rawArgs: [], targetArg: .direction(.left))
args.rawBoundaries = .workspace
args.rawBoundariesAction = .wrapAroundTheWorkspace
FocusCommand(args: args).run(.defaultEnv, .emptyStdin)
Expand Down Expand Up @@ -156,10 +160,79 @@ final class FocusCommandTest: XCTestCase {
FocusCommand.new(direction: .left).run(.defaultEnv, .emptyStdin)
assertEquals(focus.windowOrNil?.windowId, 1)
}

func testFocusDfsRelative() {
Workspace.get(byName: name).rootTilingContainer.apply {
TilingContainer.newVTiles(parent: $0, adaptiveWeight: 1).apply {
assertEquals(TestWindow.new(id: 1, parent: $0).focusWindow(), true)
TilingContainer.newHTiles(parent: $0, adaptiveWeight: 1).apply {
TestWindow.new(id: 2, parent: $0)
TestWindow.new(id: 3, parent: $0)
}
}
TestWindow.new(id: 4, parent: $0)
}

assertEquals(focus.windowOrNil?.windowId, 1)

FocusCommand.new(dfsRelative: .next).run(.defaultEnv, .emptyStdin)
assertEquals(focus.windowOrNil?.windowId, 2)
FocusCommand.new(dfsRelative: .next).run(.defaultEnv, .emptyStdin)
assertEquals(focus.windowOrNil?.windowId, 3)
FocusCommand.new(dfsRelative: .next).run(.defaultEnv, .emptyStdin)
assertEquals(focus.windowOrNil?.windowId, 4)

FocusCommand.new(dfsRelative: .prev).run(.defaultEnv, .emptyStdin)
assertEquals(focus.windowOrNil?.windowId, 3)
FocusCommand.new(dfsRelative: .prev).run(.defaultEnv, .emptyStdin)
assertEquals(focus.windowOrNil?.windowId, 2)
FocusCommand.new(dfsRelative: .prev).run(.defaultEnv, .emptyStdin)
assertEquals(focus.windowOrNil?.windowId, 1)
}

func testFocusDfsRelativeWrapping() {
Workspace.get(byName: name).rootTilingContainer.apply {
assertEquals(TestWindow.new(id: 1, parent: $0).focusWindow(), true)
TestWindow.new(id: 2, parent: $0)
}

assertEquals(focus.windowOrNil?.windowId, 1)

var args = FocusCmdArgs(rawArgs: [], targetArg: .dfsRelative(.prev))

args.rawBoundariesAction = .stop
assertEquals(FocusCommand(args: args).run(.defaultEnv, .emptyStdin).exitCode, 0)
assertEquals(focus.windowOrNil?.windowId, 1)

args.rawBoundariesAction = .fail
assertEquals(FocusCommand(args: args).run(.defaultEnv, .emptyStdin).exitCode, 1)
assertEquals(focus.windowOrNil?.windowId, 1)

args.rawBoundariesAction = .wrapAroundTheWorkspace
assertEquals(FocusCommand(args: args).run(.defaultEnv, .emptyStdin).exitCode, 0)
assertEquals(focus.windowOrNil?.windowId, 2)

args.targetArg = .dfsRelative(.next)

args.rawBoundariesAction = .stop
assertEquals(FocusCommand(args: args).run(.defaultEnv, .emptyStdin).exitCode, 0)
assertEquals(focus.windowOrNil?.windowId, 2)

args.rawBoundariesAction = .fail
assertEquals(FocusCommand(args: args).run(.defaultEnv, .emptyStdin).exitCode, 1)
assertEquals(focus.windowOrNil?.windowId, 2)

args.rawBoundariesAction = .wrapAroundTheWorkspace
assertEquals(FocusCommand(args: args).run(.defaultEnv, .emptyStdin).exitCode, 0)
assertEquals(focus.windowOrNil?.windowId, 1)
}
}

extension FocusCommand {
static func new(direction: CardinalDirection) -> FocusCommand {
FocusCommand(args: FocusCmdArgs(rawArgs: [], direction: direction))
FocusCommand(args: FocusCmdArgs(rawArgs: [], targetArg: .direction(direction)))
}
static func new(dfsRelative: NextPrev) -> FocusCommand {
FocusCommand(args: FocusCmdArgs(rawArgs: [], targetArg: .dfsRelative(dfsRelative)))
}
}
128 changes: 128 additions & 0 deletions Sources/AppBundleTests/command/SwapCommandTest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
@testable import AppBundle
import Common
import XCTest

@MainActor
final class SwapCommandTest: XCTestCase {
override func setUp() async throws { setUpWorkspacesForTests() }

func testSwap_swapWindows_Directional() {
let root = Workspace.get(byName: name).rootTilingContainer.apply {
TilingContainer.newVTiles(parent: $0, adaptiveWeight: 1).apply {
assertEquals(TestWindow.new(id: 1, parent: $0).focusWindow(), true)
TestWindow.new(id: 2, parent: $0)
}
TestWindow.new(id: 3, parent: $0)
}

SwapCommand(args: SwapCmdArgs(rawArgs: [], target: .direction(.right))).run(.defaultEnv, .emptyStdin)
assertEquals(root.layoutDescription,
.h_tiles([.v_tiles([.window(3), .window(2)]),
.window(1)]))
assertEquals(focus.windowOrNil?.windowId, 1)

SwapCommand(args: SwapCmdArgs(rawArgs: [], target: .direction(.left))).run(.defaultEnv, .emptyStdin)
assertEquals(root.layoutDescription,
.h_tiles([.v_tiles([.window(1), .window(2)]),
.window(3)]))
assertEquals(focus.windowOrNil?.windowId, 1)

SwapCommand(args: SwapCmdArgs(rawArgs: [], target: .direction(.down))).run(.defaultEnv, .emptyStdin)
assertEquals(root.layoutDescription,
.h_tiles([.v_tiles([.window(2), .window(1)]),
.window(3)]))
assertEquals(focus.windowOrNil?.windowId, 1)

SwapCommand(args: SwapCmdArgs(rawArgs: [], target: .direction(.up))).run(.defaultEnv, .emptyStdin)
assertEquals(root.layoutDescription,
.h_tiles([.v_tiles([.window(1), .window(2)]),
.window(3)]))
assertEquals(focus.windowOrNil?.windowId, 1)
}

func testSwap_swapWindows_DfsRelative() {
let root = Workspace.get(byName: name).rootTilingContainer.apply {
TilingContainer.newVTiles(parent: $0, adaptiveWeight: 1).apply {
assertEquals(TestWindow.new(id: 1, parent: $0).focusWindow(), true)
TestWindow.new(id: 2, parent: $0)
}
TestWindow.new(id: 3, parent: $0)
}

SwapCommand(args: SwapCmdArgs(rawArgs: [], target: .dfsRelative(.next))).run(.defaultEnv, .emptyStdin)
assertEquals(root.layoutDescription,
.h_tiles([.v_tiles([.window(2), .window(1)]),
.window(3)]))
assertEquals(focus.windowOrNil?.windowId, 1)

SwapCommand(args: SwapCmdArgs(rawArgs: [], target: .dfsRelative(.next))).run(.defaultEnv, .emptyStdin)
assertEquals(root.layoutDescription,
.h_tiles([.v_tiles([.window(2), .window(3)]),
.window(1)]))
assertEquals(focus.windowOrNil?.windowId, 1)

SwapCommand(args: SwapCmdArgs(rawArgs: [], target: .dfsRelative(.prev))).run(.defaultEnv, .emptyStdin)
assertEquals(root.layoutDescription,
.h_tiles([.v_tiles([.window(2), .window(1)]),
.window(3)]))
assertEquals(focus.windowOrNil?.windowId, 1)

SwapCommand(args: SwapCmdArgs(rawArgs: [], target: .dfsRelative(.prev))).run(.defaultEnv, .emptyStdin)
assertEquals(root.layoutDescription,
.h_tiles([.v_tiles([.window(1), .window(2)]),
.window(3)]))
assertEquals(focus.windowOrNil?.windowId, 1)
}

func testSwap_DirectionalWrapping() {
let root = Workspace.get(byName: name).rootTilingContainer.apply {
assertEquals(TestWindow.new(id: 1, parent: $0).focusWindow(), true)
TestWindow.new(id: 2, parent: $0)
TestWindow.new(id: 3, parent: $0)
}

var args = SwapCmdArgs(rawArgs: [], target: .direction(.left))
args.wrapAround = true
SwapCommand(args: args).run(.defaultEnv, .emptyStdin)
assertEquals(root.layoutDescription, .h_tiles([.window(3), .window(2), .window(1)]))
assertEquals(focus.windowOrNil?.windowId, 1)

args.target = .initialized(.direction(.right))
SwapCommand(args: args).run(.defaultEnv, .emptyStdin)
assertEquals(root.layoutDescription, .h_tiles([.window(1), .window(2), .window(3)]))
assertEquals(focus.windowOrNil?.windowId, 1)
}

func testSwap_DfsRelativeWrapping() {
let root = Workspace.get(byName: name).rootTilingContainer.apply {
assertEquals(TestWindow.new(id: 1, parent: $0).focusWindow(), true)
TestWindow.new(id: 2, parent: $0)
TestWindow.new(id: 3, parent: $0)
}

var args = SwapCmdArgs(rawArgs: [], target: .dfsRelative(.prev))
args.wrapAround = true
SwapCommand(args: args).run(.defaultEnv, .emptyStdin)
assertEquals(root.layoutDescription, .h_tiles([.window(3), .window(2), .window(1)]))
assertEquals(focus.windowOrNil?.windowId, 1)

args.target = .initialized(.dfsRelative(.next))
SwapCommand(args: args).run(.defaultEnv, .emptyStdin)
assertEquals(root.layoutDescription, .h_tiles([.window(1), .window(2), .window(3)]))
assertEquals(focus.windowOrNil?.windowId, 1)
}

func testSwap_SwapFocus() {
let root = Workspace.get(byName: name).rootTilingContainer.apply {
TestWindow.new(id: 1, parent: $0)
assertEquals(TestWindow.new(id: 2, parent: $0).focusWindow(), true)
TestWindow.new(id: 3, parent: $0)
}

var args = SwapCmdArgs(rawArgs: [], target: .direction(.right))
args.swapFocus = true
SwapCommand(args: args).run(.defaultEnv, .emptyStdin)
assertEquals(root.layoutDescription, .h_tiles([.window(1), .window(3), .window(2)]))
assertEquals(focus.windowOrNil?.windowId, 3)
}
}
1 change: 1 addition & 0 deletions Sources/Cli/subcommandDescriptionsGenerated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ let subcommandDescriptions = [
[" resize", "Resize the focused window"],
[" split", "Split focused window"],
[" summon-workspace", "Move the requested workspace to the focused monitor."],
[" swap", "Swaps the focused window with the nearest window in the given direction."],
[" trigger-binding", "Trigger AeroSpace binding as if it was pressed by user"],
[" volume", "Manipulate volume"],
[" workspace-back-and-forth", "Switch between the focused workspace and previously focused workspace back and forth"],
Expand Down
11 changes: 7 additions & 4 deletions Sources/Common/cmdArgs/cmdArgsManifest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public enum CmdKind: String, CaseIterable, Equatable, Sendable {
case resize
case split
case summonWorkspace = "summon-workspace"
case swap
case triggerBinding = "trigger-binding"
case volume
case workspace
Expand Down Expand Up @@ -109,14 +110,16 @@ func initSubcommands() -> [String: any SubCommandParserProtocol] {
result[kind.rawValue] = SubCommandParser(ReloadConfigCmdArgs.init)
case .resize:
result[kind.rawValue] = SubCommandParser(parseResizeCmdArgs)
case .split:
result[kind.rawValue] = SubCommandParser(parseSplitCmdArgs)
case .summonWorkspace:
result[kind.rawValue] = SubCommandParser(SummonWorkspaceCmdArgs.init)
case .serverVersionInternalCommand:
if isServer {
result[kind.rawValue] = SubCommandParser(ServerVersionInternalCommandCmdArgs.init)
}
case .split:
result[kind.rawValue] = SubCommandParser(parseSplitCmdArgs)
case .summonWorkspace:
result[kind.rawValue] = SubCommandParser(SummonWorkspaceCmdArgs.init)
case .swap:
result[kind.rawValue] = SubCommandParser(parseSwapCmdArgs)
case .triggerBinding:
result[kind.rawValue] = SubCommandParser(parseTriggerBindingCmdArgs)
case .volume:
Expand Down
Loading
Loading