Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
792757a
resize: support resizing floating windows
vitorebatista Jun 1, 2026
f296bdd
resize: document floating-window support + test bottom-edge clamp
vitorebatista Jun 2, 2026
aaf2fde
list-windows: add --sort option
vitorebatista Jun 1, 2026
3cba5d2
Docs: fix --sort to say comma-separated with comma-joined default
vitorebatista Jun 2, 2026
705498d
docs/grammar compliance for list-windows --sort
vitorebatista Jun 2, 2026
fcff0f5
summon-workspace: add --when-visible (focus|swap)
vitorebatista Jun 1, 2026
6d50cef
docs/grammar compliance for summon-workspace --when-visible
vitorebatista Jun 2, 2026
ec35325
move: add create-implicit-container-or-fail boundaries action
vitorebatista Jun 1, 2026
acd50f0
Implement layout sticky for floating windows
vitorebatista Jun 1, 2026
4dee15c
Docs: document 'layout sticky' subcommand
vitorebatista Jun 2, 2026
674667e
docs/grammar compliance for layout sticky
vitorebatista Jun 2, 2026
f1c16f6
move-workspace-to-monitor: fetch previous workspace's stub before moving
vitorebatista Jun 1, 2026
42bbded
flatten-workspace-tree: normalize containers after rebinding windows
vitorebatista Jun 1, 2026
1f1a6df
workspace next|prev: emit error when relative workspace can't resolve
vitorebatista Jun 1, 2026
8a5da3a
ui: sort menu-bar workspaces by persistent-workspaces order
vitorebatista Jun 1, 2026
cc1ada5
Extract menu-bar workspace sort into testable pure function
vitorebatista Jun 2, 2026
2792a99
Add %{all} format variable to list-windows/workspaces/apps/monitors
vitorebatista Jun 1, 2026
5d31379
Add window/workspace container orientation format variables
vitorebatista Jun 1, 2026
93ce2f5
layout: add --root flag to target workspace root container
vitorebatista Jun 1, 2026
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
26 changes: 26 additions & 0 deletions Sources/AppBundle/command/format.swift
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ extension FormatVar {
case .windowIsFullscreen: .success(.bool(w.window.isFullscreen))
case .windowTitle: .success(.string(w.title.orDie("Title wasn't prefeched")))
case .windowLayout, .windowParentContainerLayout: toLayoutResult(w: w.window)
case .windowParentContainerOrientation: toOrientationResult(w: w.window)
case .windowX: .success(.int(w.rect.map { Int($0.topLeftX) } ?? 0))
case .windowY: .success(.int(w.rect.map { Int($0.topLeftY) } ?? 0))
case .windowWidth: .success(.int(w.rect.map { Int($0.width) } ?? 0))
Expand All @@ -203,6 +204,7 @@ extension FormatVar {
case .workspaceVisible: .success(.bool(w.isVisible))
case .workspaceFocused: .success(.bool(focus.workspace == w))
case .workspaceRootContainerLayout: .success(.string(toLayoutString(tc: w.rootTilingContainer)))
case .workspaceRootContainerOrientation: .success(.string(toOrientationString(w.rootTilingContainer.orientation)))
}
case (.monitor(let m), .monitor(let f)):
return switch f {
Expand Down Expand Up @@ -234,6 +236,8 @@ extension PlainInterVar {
case .tab: .success(.string("\t"))
case .rightPadding:
.failure("\(PlainInterVar.rightPadding.rawValue.singleQuoted) interpolation variable cannot be expanded")
case .all:
.failure("\(PlainInterVar.all.rawValue.singleQuoted) interpolation variable cannot be expanded")
}
}
}
Expand All @@ -255,6 +259,13 @@ func unknownInterpolationVariable(variable: String, _ obj: AeroObj) -> String {
"Possible values:\n\(getAvailableInterVars(for: obj.kind).joined(separator: "\n").prependLines(" "))"
}

private func toOrientationString(_ orientation: Orientation) -> String {
switch orientation {
case .h: return "horizontal"
case .v: return "vertical"
}
}

private func toLayoutString(tc: TilingContainer) -> String {
switch (tc.layout, tc.orientation) {
case (.tiles, .h): return LayoutCmdArgs.LayoutDescription.h_tiles.rawValue
Expand All @@ -278,3 +289,18 @@ private func toLayoutResult(w: Window) -> Result<Primitive, String> {
case .shimContainerRelation: .failure("Window cannot have a shim container relation")
}
}

private func toOrientationResult(w: Window) -> Result<Primitive, String> {
guard let parent = w.parent else { return .failure("NULL-PARENT") }
return switch getChildParentRelation(child: w, parent: parent) {
case .tiling(let tc): .success(.string(toOrientationString(tc.orientation)))
case .floatingWindow: .success(.string("NULL-ORIENTATION"))
case .macosNativeFullscreenWindow: .success(.string("NULL-ORIENTATION"))
case .macosNativeHiddenAppWindow: .success(.string("NULL-ORIENTATION"))
case .macosNativeMinimizedWindow: .success(.string("NULL-ORIENTATION"))
case .macosPopupWindow: .success(.string("NULL-ORIENTATION"))

case .rootTilingContainer: .failure("Not possible")
case .shimContainerRelation: .failure("Window cannot have a shim container relation")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ struct FlattenWorkspaceTreeCommand: Command {
for window in windows {
window.bind(to: workspace.rootTilingContainer, adaptiveWeight: 1, index: INDEX_BIND_LAST)
}
workspace.normalizeContainers()
return .succ
}
}
151 changes: 107 additions & 44 deletions Sources/AppBundle/command/impl/LayoutCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,53 +7,62 @@ struct LayoutCommand: Command {

func run(_ env: CmdEnv, _ io: CmdIo) async throws -> BinaryExitCode {
guard let target = args.resolveTargetOrReportError(env, io) else { return .fail }
if args.root {
return changeRootLayout(io, root: target.workspace.rootTilingContainer, toggleBetween: args.toggleBetween.val)
}
guard let window = target.windowOrNil else {
return .fail(io.err(noWindowIsFocused))
}
let targetDescription = args.toggleBetween.val.first(where: { !window.matchesDescription($0) })
?? args.toggleBetween.val.first.orDie()
if window.matchesDescription(targetDescription) { return .fail }
switch targetDescription {
case .h_accordion:
return changeTilingLayout(io, targetLayout: .accordion, targetOrientation: .h, window: window)
case .v_accordion:
return changeTilingLayout(io, targetLayout: .accordion, targetOrientation: .v, window: window)
case .h_tiles:
return changeTilingLayout(io, targetLayout: .tiles, targetOrientation: .h, window: window)
case .v_tiles:
return changeTilingLayout(io, targetLayout: .tiles, targetOrientation: .v, window: window)
case .accordion:
return changeTilingLayout(io, targetLayout: .accordion, targetOrientation: nil, window: window)
case .tiles:
return changeTilingLayout(io, targetLayout: .tiles, targetOrientation: nil, window: window)
case .horizontal:
return changeTilingLayout(io, targetLayout: nil, targetOrientation: .h, window: window)
case .vertical:
return changeTilingLayout(io, targetLayout: nil, targetOrientation: .v, window: window)
case .tiling:
guard let parent = window.parent else { return .fail }
switch parent.cases {
case .macosPopupWindowsContainer:
return .fail // Impossible
case .macosMinimizedWindowsContainer, .macosFullscreenWindowsContainer, .macosHiddenAppsWindowsContainer:
return .fail(io.err("Can't change layout for macOS minimized, fullscreen windows or windows or hidden apps. This behavior is subject to change"))
case .tilingContainer:
return .succ // Nothing to do
case .workspace(let workspace):
window.lastFloatingSize = try await window.getAxSize() ?? window.lastFloatingSize
try await window.relayoutWindow(on: workspace, forceTile: true)
return .succ
}
case .floating:
let workspace = target.workspace
window.bindAsFloatingWindow(to: workspace)
if let size = window.lastFloatingSize { window.setAxFrame(nil, size) }
return .succ
case .unmanaged:
window.isExplicitlyUnmanaged = true
window.bind(to: macosPopupWindowsContainer, adaptiveWeight: WEIGHT_AUTO, index: INDEX_BIND_LAST)
return .succ
}
return try await changeWindowLayout(io, window: window, targetWorkspace: target.workspace, toggleBetween: args.toggleBetween.val)
}
}

@MainActor private func changeWindowLayout(
_ io: CmdIo,
window: Window,
targetWorkspace: Workspace,
toggleBetween: [LayoutCmdArgs.LayoutDescription],
) async throws -> BinaryExitCode {
let targetDescription = toggleBetween.first(where: { !window.matchesDescription($0) })
?? toggleBetween.first.orDie()
if window.matchesDescription(targetDescription) { return .fail }
if let (targetLayout, targetOrientation) = targetDescription.tilingMapping {
return changeTilingLayout(io, targetLayout: targetLayout, targetOrientation: targetOrientation, window: window)
}
switch targetDescription {
case .tiling:
guard let parent = window.parent else { return .fail }
switch parent.cases {
case .macosPopupWindowsContainer:
return .fail // Impossible
case .macosMinimizedWindowsContainer, .macosFullscreenWindowsContainer, .macosHiddenAppsWindowsContainer:
return .fail(io.err("Can't change layout for macOS minimized, fullscreen windows or windows or hidden apps. This behavior is subject to change"))
case .tilingContainer:
return .succ // Nothing to do
case .workspace(let workspace):
window.lastFloatingSize = try await window.getAxSize() ?? window.lastFloatingSize
try await window.relayoutWindow(on: workspace, forceTile: true)
return .succ
}
case .floating:
window.bindAsFloatingWindow(to: targetWorkspace)
if let size = window.lastFloatingSize { window.setAxFrame(nil, size) }
return .succ
case .unmanaged:
window.isExplicitlyUnmanaged = true
window.bind(to: macosPopupWindowsContainer, adaptiveWeight: WEIGHT_AUTO, index: INDEX_BIND_LAST)
return .succ
case .sticky:
guard let macWindow = window as? MacWindow else { return .fail }
if macWindow.isSticky {
macWindow.isSticky = false
} else {
guard window.parent is Workspace else { return .fail }
macWindow.isSticky = true
}
return .succ
default:
return .fail // unreachable: tilingMapping above handles all tiling layouts
}
}

Expand All @@ -72,6 +81,59 @@ struct LayoutCommand: Command {
}
}

@MainActor private func changeRootLayout(
_ io: CmdIo,
root: TilingContainer,
toggleBetween: [LayoutCmdArgs.LayoutDescription],
) -> BinaryExitCode {
let targetDescription = toggleBetween.first(where: { !root.matchesDescription($0) })
?? toggleBetween.first.orDie()
if root.matchesDescription(targetDescription) { return .fail }
guard let (targetLayout, targetOrientation) = targetDescription.tilingMapping else {
return .fail(io.err("'\(targetDescription.rawValue)' is a window placement mode and is not valid with --root")) // unreachable: rejected at parse time
}
root.layout = targetLayout ?? root.layout
root.changeOrientation(targetOrientation ?? root.orientation)
return .succ
}

extension LayoutCmdArgs.LayoutDescription {
/// Tiling-layout descriptors map to (layout?, orientation?). `tiling`/`floating` are window
/// placement modes rather than tiling layouts and return nil.
fileprivate var tilingMapping: (layout: Layout?, orientation: Orientation?)? {
return switch self {
case .h_accordion: (.accordion, .h)
case .v_accordion: (.accordion, .v)
case .h_tiles: (.tiles, .h)
case .v_tiles: (.tiles, .v)
case .accordion: (.accordion, nil)
case .tiles: (.tiles, nil)
case .horizontal: (nil, .h)
case .vertical: (nil, .v)
case .tiling, .floating, .unmanaged, .sticky: nil
}
}
}

extension TilingContainer {
fileprivate func matchesDescription(_ layout: LayoutCmdArgs.LayoutDescription) -> Bool {
return switch layout {
case .accordion: self.layout == .accordion
case .tiles: self.layout == .tiles
case .horizontal: orientation == .h
case .vertical: orientation == .v
case .h_accordion: self.layout == .accordion && orientation == .h
case .v_accordion: self.layout == .accordion && orientation == .v
case .h_tiles: self.layout == .tiles && orientation == .h
case .v_tiles: self.layout == .tiles && orientation == .v
case .tiling: true // a TilingContainer is by definition tiling
case .floating: false // a TilingContainer is never floating; rejected at parse time with --root
case .unmanaged: false // window placement mode; rejected at parse time with --root
case .sticky: false // window placement mode; rejected at parse time with --root
}
}
}

extension Window {
fileprivate func matchesDescription(_ layout: LayoutCmdArgs.LayoutDescription) -> Bool {
return switch layout {
Expand All @@ -86,6 +148,7 @@ extension Window {
case .tiling: parent is TilingContainer
case .floating: parent is Workspace
case .unmanaged: parent is MacosPopupWindowsContainer
case .sticky: (parent is Workspace) && (self as? MacWindow)?.isSticky == true
}
}
}
30 changes: 28 additions & 2 deletions Sources/AppBundle/command/impl/ListWindowsCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,11 @@ struct ListWindowsCommand: Command {
_list.append(try await .resolveWindow(window, for: args.format))
}
_list = _list.filter { $0.window.isBound }
// Preserve tree traversal order so consumers get tiling layout position
// _list = _list.sortedBy([{ $0.window.app.name ?? "" }, { $0.title ?? "" }])
// Default preserves tree traversal order so consumers get tiling layout
// position; --sort opts into explicit ordering.
if !args.sort.isEmpty {
_list = _list.sorted { Self.sortLess($0, $1, by: args.sort) }
}

let list = _list.map { AeroObj.window($0) }
if args.json {
Expand All @@ -65,4 +68,27 @@ struct ListWindowsCommand: Command {
}
}
}

@MainActor
static func sortLess(_ lhs: WindowWithPrefetchedTitle, _ rhs: WindowWithPrefetchedTitle, by sort: [SortOption]) -> Bool {
for criterion in sort {
switch criterion {
case .recent:
// Sort by lastFocusedAt descending (most recent first)
if lhs.window.lastFocusedAt > rhs.window.lastFocusedAt { return true }
if lhs.window.lastFocusedAt < rhs.window.lastFocusedAt { return false }
case .appName:
let lhsName = lhs.window.app.name ?? ""
let rhsName = rhs.window.app.name ?? ""
if lhsName < rhsName { return true }
if lhsName > rhsName { return false }
case .windowTitle:
let lhsTitle = lhs.title ?? ""
let rhsTitle = rhs.title ?? ""
if lhsTitle < rhsTitle { return true }
if lhsTitle > rhsTitle { return false }
}
}
return false
}
}
30 changes: 29 additions & 1 deletion Sources/AppBundle/command/impl/MoveCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ struct MoveCommand: Command {
case .createImplicitContainer:
createImplicitContainerAndMoveWindow(window, workspace, direction)
return .succ
case .createImplicitContainerOrFail:
return createImplicitContainerAndMoveWindowOrFail(window, workspace, direction, io)
}
case .allMonitorsOuterFrame:
guard let (monitors, index) = window.nodeMonitor?.findRelativeMonitor(inDirection: direction) else {
Expand All @@ -67,7 +69,7 @@ struct MoveCommand: Command {

return MoveNodeToMonitorCommand(args: moveNodeToMonitorArgs).run(env, io)
} else {
return hitAllMonitorsOuterFrameBoundaries(window, workspace, args, direction)
return hitAllMonitorsOuterFrameBoundaries(window, workspace, args, direction, io)
}
}
}
Expand All @@ -77,13 +79,16 @@ struct MoveCommand: Command {
_ workspace: Workspace,
_ args: MoveCmdArgs,
_ direction: CardinalDirection,
_ io: CmdIo,
) -> BinaryExitCode {
switch args.boundariesAction {
case .stop: return .succ
case .fail: return .fail
case .createImplicitContainer:
createImplicitContainerAndMoveWindow(window, workspace, direction)
return .succ
case .createImplicitContainerOrFail:
return createImplicitContainerAndMoveWindowOrFail(window, workspace, direction, io)
}
}

Expand Down Expand Up @@ -121,6 +126,29 @@ private let moveOutMacosUnconventionalWindow = "moving macOS fullscreen, minimiz
}
}

@MainActor private func createImplicitContainerAndMoveWindowOrFail(
_ window: Window,
_ workspace: Workspace,
_ direction: CardinalDirection,
_ io: CmdIo,
) -> BinaryExitCode {
if !config.enableNormalizationFlattenContainers {
io.out("Tip: create-implicit-container-or-fail will never cause the move command to fail since enable-normalization-flatten-containers is disabled")
createImplicitContainerAndMoveWindow(window, workspace, direction)
return .succ
}
let prevRoot = workspace.rootTilingContainer
let prevOrientation = prevRoot.orientation
let prevChildren = prevRoot.children
createImplicitContainerAndMoveWindow(window, workspace, direction)
workspace.normalizeContainers()
let newRoot = workspace.rootTilingContainer
if newRoot.orientation == prevOrientation && newRoot.children.count == prevChildren.count && zip(newRoot.children, prevChildren).allSatisfy({ $0 === $1 }) {
return .fail
}
return .succ
}

@MainActor private func createImplicitContainerAndMoveWindow(
_ window: Window,
_ workspace: Workspace,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ struct MoveWorkspaceToMonitorCommand: Command {
if targetMonitor.monitorId_oneBased == prevMonitor.monitorId_oneBased {
return .succ
}
let stubWorkspace = getStubWorkspace(for: prevMonitor)
if targetMonitor.setActiveWorkspace(focusedWorkspace) {
let stubWorkspace = getStubWorkspace(for: prevMonitor)
check(
prevMonitor.setActiveWorkspace(stubWorkspace),
"getStubWorkspace generated incompatible stub workspace (\(stubWorkspace)) for the monitor (\(prevMonitor)",
Expand Down
Loading
Loading