Skip to content

Commit c9a9349

Browse files
committed
Implement focus dfs-next/dfs-prev.
This allows switching to the next/prev window from the current one in the depth-first order of the windows in the current workspace tree. This is convenient, as it allows users to shift focus through a workspace's windows in a more predictable way than the directional movement commands, where the move target can depend on invisible most-recently-used state. _fixes #248
1 parent a8cd536 commit c9a9349

File tree

8 files changed

+163
-18
lines changed

8 files changed

+163
-18
lines changed

Sources/AppBundle/command/impl/FocusCommand.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,24 @@ struct FocusCommand: Command {
3636
} else {
3737
return io.err("Can't find window with DFS index \(dfsIndex)")
3838
}
39+
case .dfsRelative(let nextPrev):
40+
let windows = target.workspace.rootTilingContainer.allLeafWindowsRecursive
41+
guard let currentIndex = windows.firstIndex(where: { $0 == target.windowOrNil }) else {
42+
return false
43+
}
44+
var targetIndex = switch nextPrev {
45+
case .next: currentIndex + 1
46+
case .prev: currentIndex - 1
47+
}
48+
if targetIndex < 0 || targetIndex >= windows.count {
49+
switch args.boundariesAction {
50+
case .stop: return true
51+
case .fail: return false
52+
case .wrapAroundTheWorkspace: targetIndex = (targetIndex + windows.count) % windows.count
53+
case .wrapAroundAllMonitors: return errorT("Must be discarded by args parser")
54+
}
55+
}
56+
return windows[targetIndex].focusWindow()
3957
}
4058
}
4159
}

Sources/AppBundleTests/command/FocusCommandTest.swift

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ final class FocusCommandTest: XCTestCase {
2525

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

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

4246
func testFocus() {
@@ -92,7 +96,7 @@ final class FocusCommandTest: XCTestCase {
9296
}
9397

9498
assertEquals(focus.windowOrNil?.windowId, 1)
95-
var args = FocusCmdArgs(rawArgs: [], direction: .left)
99+
var args = FocusCmdArgs(rawArgs: [], targetArg: .direction(.left))
96100
args.rawBoundaries = .workspace
97101
args.rawBoundariesAction = .wrapAroundTheWorkspace
98102
FocusCommand(args: args).run(.defaultEnv, .emptyStdin)
@@ -156,10 +160,83 @@ final class FocusCommandTest: XCTestCase {
156160
FocusCommand.new(direction: .left).run(.defaultEnv, .emptyStdin)
157161
assertEquals(focus.windowOrNil?.windowId, 1)
158162
}
163+
164+
func testFocusDfsRelative() {
165+
Workspace.get(byName: name).rootTilingContainer.apply {
166+
TilingContainer.newHTiles(parent: $0, adaptiveWeight: 1).apply {
167+
TilingContainer.newVTiles(parent: $0, adaptiveWeight: 1).apply {
168+
assertEquals(TestWindow.new(id: 1, parent: $0).focusWindow(), true)
169+
TilingContainer.newHTiles(parent: $0, adaptiveWeight: 1).apply {
170+
TestWindow.new(id: 2, parent: $0)
171+
TestWindow.new(id: 3, parent: $0)
172+
}
173+
}
174+
TestWindow.new(id: 4, parent: $0)
175+
}
176+
}
177+
178+
assertEquals(focus.windowOrNil?.windowId, 1)
179+
180+
FocusCommand.new(dfsRelative: .next).run(.defaultEnv, .emptyStdin)
181+
assertEquals(focus.windowOrNil?.windowId, 2)
182+
FocusCommand.new(dfsRelative: .next).run(.defaultEnv, .emptyStdin)
183+
assertEquals(focus.windowOrNil?.windowId, 3)
184+
FocusCommand.new(dfsRelative: .next).run(.defaultEnv, .emptyStdin)
185+
assertEquals(focus.windowOrNil?.windowId, 4)
186+
187+
FocusCommand.new(dfsRelative: .prev).run(.defaultEnv, .emptyStdin)
188+
assertEquals(focus.windowOrNil?.windowId, 3)
189+
FocusCommand.new(dfsRelative: .prev).run(.defaultEnv, .emptyStdin)
190+
assertEquals(focus.windowOrNil?.windowId, 2)
191+
FocusCommand.new(dfsRelative: .prev).run(.defaultEnv, .emptyStdin)
192+
assertEquals(focus.windowOrNil?.windowId, 1)
193+
}
194+
195+
func testFocusDfsRelativeWrapping() {
196+
Workspace.get(byName: name).rootTilingContainer.apply {
197+
TilingContainer.newHTiles(parent: $0, adaptiveWeight: 1).apply {
198+
assertEquals(TestWindow.new(id: 1, parent: $0).focusWindow(), true)
199+
TestWindow.new(id: 2, parent: $0)
200+
}
201+
}
202+
203+
assertEquals(focus.windowOrNil?.windowId, 1)
204+
205+
var args = FocusCmdArgs(rawArgs: [], targetArg: .dfsRelative(.prev))
206+
207+
args.rawBoundariesAction = .stop
208+
assertEquals(FocusCommand(args: args).run(.defaultEnv, .emptyStdin).exitCode, 0)
209+
assertEquals(focus.windowOrNil?.windowId, 1)
210+
211+
args.rawBoundariesAction = .fail
212+
assertEquals(FocusCommand(args: args).run(.defaultEnv, .emptyStdin).exitCode, 1)
213+
assertEquals(focus.windowOrNil?.windowId, 1)
214+
215+
args.rawBoundariesAction = .wrapAroundTheWorkspace
216+
assertEquals(FocusCommand(args: args).run(.defaultEnv, .emptyStdin).exitCode, 0)
217+
assertEquals(focus.windowOrNil?.windowId, 2)
218+
219+
args.targetArg = .dfsRelative(.next)
220+
221+
args.rawBoundariesAction = .stop
222+
assertEquals(FocusCommand(args: args).run(.defaultEnv, .emptyStdin).exitCode, 0)
223+
assertEquals(focus.windowOrNil?.windowId, 2)
224+
225+
args.rawBoundariesAction = .fail
226+
assertEquals(FocusCommand(args: args).run(.defaultEnv, .emptyStdin).exitCode, 1)
227+
assertEquals(focus.windowOrNil?.windowId, 2)
228+
229+
args.rawBoundariesAction = .wrapAroundTheWorkspace
230+
assertEquals(FocusCommand(args: args).run(.defaultEnv, .emptyStdin).exitCode, 0)
231+
assertEquals(focus.windowOrNil?.windowId, 1)
232+
}
159233
}
160234

161235
extension FocusCommand {
162236
static func new(direction: CardinalDirection) -> FocusCommand {
163-
FocusCommand(args: FocusCmdArgs(rawArgs: [], direction: direction))
237+
FocusCommand(args: FocusCmdArgs(rawArgs: [], targetArg: .direction(direction)))
238+
}
239+
static func new(dfsRelative: NextPrev) -> FocusCommand {
240+
FocusCommand(args: FocusCmdArgs(rawArgs: [], targetArg: .dfsRelative(dfsRelative)))
164241
}
165242
}

Sources/Common/cmdArgs/impl/FocusCmdArgs.swift

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,20 @@ public struct FocusCmdArgs: CmdArgs {
1515
"--window-id": ArgParser(\.windowId, upcastArgParserFun(parseArgWithUInt32)),
1616
"--dfs-index": ArgParser(\.dfsIndex, upcastArgParserFun(parseArgWithUInt32)),
1717
],
18-
arguments: [ArgParser(\.direction, upcastArgParserFun(parseCardinalDirectionArg))]
18+
arguments: [ArgParser(\.targetArg, upcastArgParserFun(parseFocusTargetArg))]
1919
)
2020

2121
public var rawBoundaries: Boundaries? = nil // todo cover boundaries wrapping with tests
2222
public var rawBoundariesAction: WhenBoundariesCrossed? = nil
2323
public var dfsIndex: UInt32? = nil
24-
public var direction: CardinalDirection? = nil
24+
public var targetArg: FocusTargetArg? = nil
2525
public var floatingAsTiling: Bool = true
2626
public var windowId: UInt32?
2727
public var workspaceName: WorkspaceName?
2828

29-
public init(rawArgs: [String], direction: CardinalDirection) {
29+
public init(rawArgs: [String], targetArg: FocusTargetArg) {
3030
self.rawArgs = .init(rawArgs)
31-
self.direction = direction
31+
self.targetArg = targetArg
3232
}
3333

3434
public init(rawArgs: [String], windowId: UInt32) {
@@ -53,16 +53,46 @@ public struct FocusCmdArgs: CmdArgs {
5353
}
5454
}
5555

56+
// Subset of FocusCmdTarget that is passed as a positional argument.
57+
public enum FocusTargetArg: Equatable, Sendable {
58+
case direction(CardinalDirection)
59+
case dfsRelative(NextPrev)
60+
}
61+
62+
func parseFocusTargetArg(_ arg: String, _ nextArgs: inout [String]) -> Parsed<FocusTargetArg> {
63+
return switch arg {
64+
case "left": .success(.direction(.left))
65+
case "down": .success(.direction(.down))
66+
case "up": .success(.direction(.up))
67+
case "right": .success(.direction(.right))
68+
case "dfs-next": .success(.dfsRelative(.next))
69+
case "dfs-prev": .success(.dfsRelative(.prev))
70+
default: .failure("Can't parse '\(arg)\'. Possible values: left|down|up|right|dfs-next|dfs-prev")
71+
}
72+
}
73+
5674
public enum FocusCmdTarget {
5775
case direction(CardinalDirection)
5876
case windowId(UInt32)
5977
case dfsIndex(UInt32)
78+
case dfsRelative(NextPrev)
79+
80+
var isDfsRelative: Bool {
81+
if case .dfsRelative = self {
82+
return true
83+
} else {
84+
return false
85+
}
86+
}
6087
}
6188

6289
public extension FocusCmdArgs {
6390
var target: FocusCmdTarget {
64-
if let direction {
65-
return .direction(direction)
91+
if let targetArg {
92+
return switch targetArg {
93+
case .direction(let dir): .direction(dir)
94+
case .dfsRelative(let nextPrev): .dfsRelative(nextPrev)
95+
}
6696
}
6797
if let windowId {
6898
return .windowId(windowId)
@@ -84,15 +114,18 @@ public func parseFocusCmdArgs(_ args: [String]) -> ParsedCmd<FocusCmdArgs> {
84114
? .failure("\(raw.boundaries.rawValue) and \(raw.boundariesAction.rawValue) is an invalid combination of values")
85115
: .cmd(raw)
86116
}
87-
.filter("Mandatory argument is missing. '\(CardinalDirection.unionLiteral)', --window-id or --dfs-index is required") {
88-
$0.direction != nil || $0.windowId != nil || $0.dfsIndex != nil
117+
.filter("Mandatory argument is missing. (left|down|up|right|dfs-next|dfs-prev), --window-id or --dfs-index is required") {
118+
$0.targetArg != nil || $0.windowId != nil || $0.dfsIndex != nil
89119
}
90120
.filter("--window-id is incompatible with other options") {
91121
$0.windowId == nil || $0 == FocusCmdArgs(rawArgs: args, windowId: $0.windowId!)
92122
}
93123
.filter("--dfs-index is incompatible with other options") {
94124
$0.dfsIndex == nil || $0 == FocusCmdArgs(rawArgs: args, dfsIndex: $0.dfsIndex!)
95125
}
126+
.filter("(dfs-next|dfs-prev) only supports --boundaries workspace") {
127+
!$0.target.isDfsRelative || $0.boundaries == .workspace
128+
}
96129
}
97130

98131
private func parseBoundariesAction(arg: String, nextArgs: inout [String]) -> Parsed<FocusCmdArgs.WhenBoundariesCrossed> {

Sources/Common/cmdArgs/impl/FocusMonitorCmdArgs.swift

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,6 @@ func parseTarget(_ arg: String, _ nextArgs: inout [String]) -> Parsed<MonitorTar
4242
}
4343
}
4444

45-
public enum NextPrev: Equatable, Sendable {
46-
case next, prev
47-
}
48-
4945
public enum MonitorTarget: Equatable, Sendable {
5046
case directional(CardinalDirection)
5147
case relative(NextPrev)

Sources/Common/cmdHelpGenerated.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ let focus_help_generated = """
4242
USAGE: focus [-h|--help] [--ignore-floating]
4343
[--boundaries <boundary>] [--boundaries-action <action>]
4444
(left|down|up|right)
45+
OR: focus [-h|--help] [--ignore-floating]
46+
[--boundaries <boundary>] [--boundaries-action <action>]
47+
(dfs-next|dfs-prev)
4548
OR: focus [-h|--help] --window-id <window-id>
4649
OR: focus [-h|--help] --dfs-index <dfs-index>
4750
"""
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
public enum NextPrev: String, CaseIterable, Equatable, Sendable {
2+
case next, prev
3+
}

docs/aerospace-focus.adoc

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ include::util/man-attributes.adoc[]
1212
aerospace focus [-h|--help] [--ignore-floating]
1313
[--boundaries <boundary>] [--boundaries-action <action>]
1414
(left|down|up|right)
15+
aerospace focus [-h|--help] [--ignore-floating]
16+
[--boundaries <boundary>] [--boundaries-action <action>]
17+
(dfs-next|dfs-prev)
1518
aerospace focus [-h|--help] --window-id <window-id>
1619
aerospace focus [-h|--help] --dfs-index <dfs-index>
1720

@@ -32,6 +35,15 @@ This behavior can be disabled with `--ignore-floating` flag.
3235
`focus child|parent` isn't supported because the necessity of this operation is under the question.
3336
https://github.com/nikitabobko/AeroSpace/issues/5
3437

38+
*1. (left|down|up|right) arguments*
39+
40+
{manpurpose}
41+
42+
*2. (dfs-next|dfs-prev) arguments*
43+
44+
Set focus to the window before or after the current window in the depth-first order (top-to-bottom and left-to-right) of windows in the current workspace tree.
45+
In this mode, `--boundaries` must be `workspace` (the default) and `--boundaries-action` can be set to one of `(stop|fail|wrap-around-the-workspace)`.
46+
3547
// =========================================================== Options
3648
include::util/conditional-options-header.adoc[]
3749

grammar/commands-bnf-grammar.txt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@ aerospace -h;
2020

2121
| flatten-workspace-tree [--workspace <workspace>]
2222

23-
| focus [<focus_flag>]... (left|down|up|right) [<focus_flag>]...
23+
| focus [<focus_dir_flag>]... (left|down|up|right) [<focus_dir_flag>]...
2424
| focus --window-id <window_id>
2525
| focus --dfs-index <number>
26+
| focus [<focus_dfs_relative_flag>]... (dfs-next|dfs-prev) [<focus_dfs_relative_flag>]...
2627

2728
| focus-back-and-forth
2829

@@ -111,8 +112,10 @@ aerospace -h;
111112
<move_node_to_monitor1_flag> ::= --window-id <window_id>|--focus-follows-window|--fail-if-noop|--wrap-around;
112113
<move_node_to_monitor2_flag> ::= --window-id <window_id>|--focus-follows-window|--fail-if-noop;
113114

114-
<focus_flag> ::= --boundaries <boundary>|--boundaries-action <boundaries_action>|--ignore-floating;
115-
<boundaries_action> ::= stop|fail|wrap-around-the-workspace|wrap-around-all-monitors;
115+
<focus_dir_flag> ::= --boundaries <boundary>|--boundaries-action <dir_boundaries_action>|--ignore-floating;
116+
<focus_dfs_relative_flag> ::= --boundaries-action <dfs_relative_boundaries_action>|--ignore-floating;
117+
<dir_boundaries_action> ::= stop|fail|wrap-around-the-workspace|wrap-around-all-monitors;
118+
<dfs_relative_boundaries_action> ::= stop|fail|wrap-around-the-workspace;
116119
<boundary> ::= workspace|all-monitors-outer-frame;
117120

118121
<list_windows_filter_flag> ::= --workspace (visible | focused | <workspace>)...

0 commit comments

Comments
 (0)