Skip to content

Commit 57014ef

Browse files
Add i3-style auto-tiling and master-stack command
Add auto-tile config option that automatically alternates split direction based on the focused window dimensions when placing new windows, producing a spiral/dwindle layout similar to i3/sway autotiling. Add master-stack command that arranges the focused window at ~70% width with remaining windows spiral-tiled in the remaining space. Supports --cycle flag to rotate which window is the master.
1 parent 18545c2 commit 57014ef

File tree

9 files changed

+138
-0
lines changed

9 files changed

+138
-0
lines changed

Sources/AppBundle/command/cmdManifest.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ extension CmdArgs {
4444
command = ListWindowsCommand(args: self as! ListWindowsCmdArgs)
4545
case .listWorkspaces:
4646
command = ListWorkspacesCommand(args: self as! ListWorkspacesCmdArgs)
47+
case .masterStack:
48+
command = MasterStackCommand(args: self as! MasterStackCmdArgs)
4749
case .macosNativeFullscreen:
4850
command = MacosNativeFullscreenCommand(args: self as! MacosNativeFullscreenCmdArgs)
4951
case .macosNativeMinimize:
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import AppKit
2+
import Common
3+
4+
struct MasterStackCommand: Command {
5+
let args: MasterStackCmdArgs
6+
/*conforms*/ let shouldResetClosedWindowsCache: Bool = true
7+
8+
func run(_ env: CmdEnv, _ io: CmdIo) -> Bool {
9+
guard let target = args.resolveTargetOrReportError(env, io) else { return false }
10+
let workspace = target.workspace
11+
let root = workspace.rootTilingContainer
12+
13+
let allWindows = root.allLeafWindowsRecursive
14+
guard allWindows.count >= 2 else { return true }
15+
16+
let master: Window
17+
if args.cycle {
18+
// Use stable window ID order so cycling visits all windows
19+
let sortedById = allWindows.sorted { $0.windowId < $1.windowId }
20+
// Current master is the first leaf in the tree (DFS)
21+
guard let currentMaster = allWindows.first else { return true }
22+
let currentIndex = sortedById.firstIndex(of: currentMaster) ?? 0
23+
let nextIndex = (currentIndex + 1) % sortedById.count
24+
master = sortedById[nextIndex]
25+
} else {
26+
guard let focusedWindow = target.windowOrNil else {
27+
return io.err(noWindowIsFocused)
28+
}
29+
master = focusedWindow
30+
}
31+
32+
// Step 1: Flatten all windows to root (same as flatten-workspace-tree)
33+
for window in allWindows {
34+
window.bind(to: root, adaptiveWeight: 1, index: INDEX_BIND_LAST)
35+
}
36+
root.changeOrientation(.h)
37+
38+
// Step 2: Collect non-master windows
39+
var others: [Window] = []
40+
for window in root.allLeafWindowsRecursive where window !== master {
41+
others.append(window)
42+
}
43+
44+
// Step 3: Move master to index 0 with weight 7
45+
master.bind(to: root, adaptiveWeight: 7, index: 0)
46+
47+
// Step 4: Create stack container and build spiral
48+
if others.count == 1 {
49+
others[0].bind(to: root, adaptiveWeight: 3, index: 1)
50+
} else {
51+
let stackContainer = TilingContainer(
52+
parent: root,
53+
adaptiveWeight: 3,
54+
.v,
55+
.tiles,
56+
index: 1,
57+
)
58+
buildSpiral(windows: others, parent: stackContainer, startOrientation: .h)
59+
}
60+
61+
// Focus the master window
62+
if args.cycle {
63+
_ = master.focusWindow()
64+
}
65+
66+
return true
67+
}
68+
69+
@MainActor private func buildSpiral(windows: [Window], parent: TilingContainer, startOrientation: Orientation) {
70+
if windows.count == 1 {
71+
windows[0].bind(to: parent, adaptiveWeight: WEIGHT_AUTO, index: INDEX_BIND_LAST)
72+
} else if windows.count == 2 {
73+
windows[0].bind(to: parent, adaptiveWeight: 1, index: 0)
74+
windows[1].bind(to: parent, adaptiveWeight: 1, index: 1)
75+
} else {
76+
windows[0].bind(to: parent, adaptiveWeight: 1, index: 0)
77+
let sub = TilingContainer(
78+
parent: parent,
79+
adaptiveWeight: 1,
80+
startOrientation,
81+
.tiles,
82+
index: 1,
83+
)
84+
buildSpiral(windows: Array(windows.dropFirst()), parent: sub, startOrientation: startOrientation.opposite)
85+
}
86+
}
87+
}

Sources/AppBundle/config/Config.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ struct Config: ConvenienceCopyable {
4646
var automaticallyUnhideMacosHiddenApps: Bool = false
4747
var accordionPadding: Int = 30
4848
var enableNormalizationOppositeOrientationForNestedContainers: Bool = true
49+
var autoTile: Bool = false
4950
var persistentWorkspaces: OrderedSet<String> = []
5051
var execOnWorkspaceChange: [String] = [] // todo deprecate
5152
var keyMapping = KeyMapping()

Sources/AppBundle/config/parseConfig.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ private let configParser: [String: any ParserProtocol<Config>] = [
106106
"enable-normalization-flatten-containers": Parser(\.enableNormalizationFlattenContainers, parseBool),
107107
"enable-normalization-opposite-orientation-for-nested-containers": Parser(\.enableNormalizationOppositeOrientationForNestedContainers, parseBool),
108108

109+
"auto-tile": Parser(\.autoTile, parseBool),
110+
109111
"default-root-container-layout": Parser(\.defaultRootContainerLayout, parseLayout),
110112
"default-root-container-orientation": Parser(\.defaultRootContainerOrientation, parseDefaultContainerOrientation),
111113

Sources/AppBundle/tree/MacWindow.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,25 @@ private func unbindAndGetBindingDataForNewTilingWindow(_ workspace: Workspace, w
227227
window?.unbindFromParent() // It's important to unbind to get correct data from below
228228
let mruWindow = workspace.mostRecentWindowRecursive
229229
if let mruWindow, let tilingParent = mruWindow.parent as? TilingContainer {
230+
if config.autoTile, let mruRect = mruWindow.lastAppliedLayoutVirtualRect {
231+
let desiredOrientation: Orientation = mruRect.width >= mruRect.height ? .h : .v
232+
if desiredOrientation != tilingParent.orientation {
233+
let mruBinding = mruWindow.unbindFromParent()
234+
let subContainer = TilingContainer(
235+
parent: tilingParent,
236+
adaptiveWeight: mruBinding.adaptiveWeight,
237+
desiredOrientation,
238+
.tiles,
239+
index: mruBinding.index,
240+
)
241+
mruWindow.bind(to: subContainer, adaptiveWeight: WEIGHT_AUTO, index: 0)
242+
return BindingData(
243+
parent: subContainer,
244+
adaptiveWeight: WEIGHT_AUTO,
245+
index: 1,
246+
)
247+
}
248+
}
230249
return BindingData(
231250
parent: tilingParent,
232251
adaptiveWeight: WEIGHT_AUTO,

Sources/Common/cmdArgs/cmdArgsManifest.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public enum CmdKind: String, CaseIterable, Equatable, Sendable {
2121
case listMonitors = "list-monitors"
2222
case listWindows = "list-windows"
2323
case listWorkspaces = "list-workspaces"
24+
case masterStack = "master-stack"
2425
case macosNativeFullscreen = "macos-native-fullscreen"
2526
case macosNativeMinimize = "macos-native-minimize"
2627
case mode
@@ -84,6 +85,8 @@ func initSubcommands() -> [String: any SubCommandParserProtocol] {
8485
result[kind.rawValue] = SubCommandParser(parseListWindowsCmdArgs)
8586
case .listWorkspaces:
8687
result[kind.rawValue] = SubCommandParser(parseListWorkspacesCmdArgs)
88+
case .masterStack:
89+
result[kind.rawValue] = SubCommandParser(MasterStackCmdArgs.init)
8790
case .macosNativeFullscreen:
8891
result[kind.rawValue] = SubCommandParser(parseMacosNativeFullscreenCmdArgs)
8992
case .macosNativeMinimize:
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
public struct MasterStackCmdArgs: CmdArgs {
2+
/*conforms*/ public var commonState: CmdArgsCommonState
3+
public var cycle: Bool = false
4+
public init(rawArgs: StrArrSlice) { self.commonState = .init(rawArgs) }
5+
public static let parser: CmdParser<Self> = cmdParser(
6+
kind: .masterStack,
7+
allowInConfig: true,
8+
help: master_stack_help_generated,
9+
flags: [
10+
"--workspace": optionalWorkspaceFlag(),
11+
"--cycle": trueBoolFlag(\.cycle),
12+
],
13+
posArgs: [],
14+
)
15+
}

Sources/Common/cmdHelpGenerated.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ let list_workspaces_help_generated = """
8585
OR: list-workspaces [-h|--help] --all [--format <output-format>] [--count] [--json]
8686
OR: list-workspaces [-h|--help] --focused [--format <output-format>] [--count] [--json]
8787
"""
88+
let master_stack_help_generated = """
89+
USAGE: master-stack [-h|--help] [--workspace <workspace>]
90+
"""
8891
let macos_native_fullscreen_help_generated = """
8992
USAGE: macos-native-fullscreen [-h|--help] [--window-id <window-id>]
9093
OR: macos-native-fullscreen [-h|--help] [--window-id <window-id>] [--fail-if-noop] on

docs/config-examples/default-config.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ auto-reload-config = false
2020
enable-normalization-flatten-containers = true
2121
enable-normalization-opposite-orientation-for-nested-containers = true
2222

23+
# i3-like auto-tiling (spiral/dwindle layout).
24+
# When enabled, new windows automatically alternate between horizontal and vertical
25+
# splits based on the focused window's dimensions, creating a spiral layout.
26+
# Fallback value (if you omit the key): auto-tile = false
27+
auto-tile = false
28+
2329
# See: https://nikitabobko.github.io/AeroSpace/guide#layouts
2430
# The 'accordion-padding' specifies the size of accordion padding
2531
# You can set 0 to disable the padding feature

0 commit comments

Comments
 (0)