diff --git a/Sources/AppBundle/command/cmdManifest.swift b/Sources/AppBundle/command/cmdManifest.swift index c611f753..4c8811aa 100644 --- a/Sources/AppBundle/command/cmdManifest.swift +++ b/Sources/AppBundle/command/cmdManifest.swift @@ -56,12 +56,16 @@ extension CmdArgs { command = MacosNativeFullscreenCommand(args: self as! MacosNativeFullscreenCmdArgs) case .macosNativeMinimize: command = MacosNativeMinimizeCommand(args: self as! MacosNativeMinimizeCmdArgs) + case .mfact: + command = MfactCommand(args: self as! MfactCmdArgs) case .mode: command = ModeCommand(args: self as! ModeCmdArgs) case .move: command = MoveCommand(args: self as! MoveCmdArgs) case .moveMouse: command = MoveMouseCommand(args: self as! MoveMouseCmdArgs) + case .moveNodeToMaster: + command = MoveNodeToMasterCommand(args: self as! MoveNodeToMasterCmdArgs) case .moveNodeToMonitor: command = MoveNodeToMonitorCommand(args: self as! MoveNodeToMonitorCmdArgs) case .moveNodeToWorkspace: diff --git a/Sources/AppBundle/command/format.swift b/Sources/AppBundle/command/format.swift index c12bf89c..9760e3bf 100644 --- a/Sources/AppBundle/command/format.swift +++ b/Sources/AppBundle/command/format.swift @@ -240,6 +240,8 @@ private func toLayoutString(tc: TilingContainer) -> String { case (.tiles, .v): return LayoutCmdArgs.LayoutDescription.v_tiles.rawValue case (.accordion, .h): return LayoutCmdArgs.LayoutDescription.h_accordion.rawValue case (.accordion, .v): return LayoutCmdArgs.LayoutDescription.v_accordion.rawValue + case (.masterStack, .h): return LayoutCmdArgs.LayoutDescription.h_master_stack.rawValue + case (.masterStack, .v): return LayoutCmdArgs.LayoutDescription.v_master_stack.rawValue } } diff --git a/Sources/AppBundle/command/impl/BalanceSizesCommand.swift b/Sources/AppBundle/command/impl/BalanceSizesCommand.swift index 0916afe0..4244a979 100644 --- a/Sources/AppBundle/command/impl/BalanceSizesCommand.swift +++ b/Sources/AppBundle/command/impl/BalanceSizesCommand.swift @@ -15,10 +15,14 @@ struct BalanceSizesCommand: Command { @MainActor private func balance(_ parent: TilingContainer) { + if parent.layout == .masterStack { + // master-stack doesn't use weights (see below); reset mfact to the configured default instead + parent.mfact = CGFloat(config.defaultMfact) + } for child in parent.children { switch parent.layout { case .tiles: child.setWeight(parent.orientation, 1) - case .accordion: break // Do nothing + case .accordion, .masterStack: break // Do nothing } if let child = child as? TilingContainer { balance(child) diff --git a/Sources/AppBundle/command/impl/FocusCommand.swift b/Sources/AppBundle/command/impl/FocusCommand.swift index be920620..4209c2c9 100644 --- a/Sources/AppBundle/command/impl/FocusCommand.swift +++ b/Sources/AppBundle/command/impl/FocusCommand.swift @@ -141,7 +141,7 @@ struct FocusCommand: Command { guard let _tilingParent = target.parent as? TilingContainer else { continue } tilingParent = _tilingParent index = switch tilingParent.layout { - case .tiles: + case .tiles, .masterStack: center.getProjection(tilingParent.orientation) >= targetCenter.getProjection(tilingParent.orientation) ? target.ownIndex.orDie() + 1 : target.ownIndex.orDie() diff --git a/Sources/AppBundle/command/impl/LayoutCommand.swift b/Sources/AppBundle/command/impl/LayoutCommand.swift index 0c095870..068d89f8 100644 --- a/Sources/AppBundle/command/impl/LayoutCommand.swift +++ b/Sources/AppBundle/command/impl/LayoutCommand.swift @@ -49,10 +49,16 @@ struct LayoutCommand: Command { return changeTilingLayout(io, targetLayout: .tiles, targetOrientation: .h, node: node) case .v_tiles: return changeTilingLayout(io, targetLayout: .tiles, targetOrientation: .v, node: node) + case .h_master_stack: + return changeTilingLayout(io, targetLayout: .masterStack, targetOrientation: .h, node: node) + case .v_master_stack: + return changeTilingLayout(io, targetLayout: .masterStack, targetOrientation: .v, node: node) case .accordion: return changeTilingLayout(io, targetLayout: .accordion, targetOrientation: nil, node: node) case .tiles: return changeTilingLayout(io, targetLayout: .tiles, targetOrientation: nil, node: node) + case .master_stack: + return changeTilingLayout(io, targetLayout: .masterStack, targetOrientation: nil, node: node) case .horizontal: return changeTilingLayout(io, targetLayout: nil, targetOrientation: .h, node: node) case .vertical: @@ -105,12 +111,15 @@ extension ConventionalWindowParentCases { return switch layout { case .accordion: tilingContainerOrNil?.layout == .accordion case .tiles: tilingContainerOrNil?.layout == .tiles + case .master_stack: tilingContainerOrNil?.layout == .masterStack case .horizontal: tilingContainerOrNil?.orientation == .h case .vertical: tilingContainerOrNil?.orientation == .v case .h_accordion: tilingContainerOrNil.map { $0.layout == .accordion && $0.orientation == .h } == true case .v_accordion: tilingContainerOrNil.map { $0.layout == .accordion && $0.orientation == .v } == true case .h_tiles: tilingContainerOrNil.map { $0.layout == .tiles && $0.orientation == .h } == true case .v_tiles: tilingContainerOrNil.map { $0.layout == .tiles && $0.orientation == .v } == true + case .h_master_stack: tilingContainerOrNil.map { $0.layout == .masterStack && $0.orientation == .h } == true + case .v_master_stack: tilingContainerOrNil.map { $0.layout == .masterStack && $0.orientation == .v } == true case .tiling: tilingContainerOrNil != nil case .floating: floatingWindowsContainerOrNil != nil } diff --git a/Sources/AppBundle/command/impl/MfactCommand.swift b/Sources/AppBundle/command/impl/MfactCommand.swift new file mode 100644 index 00000000..c7f4c6bd --- /dev/null +++ b/Sources/AppBundle/command/impl/MfactCommand.swift @@ -0,0 +1,26 @@ +import AppKit +import Common + +struct MfactCommand: Command { + let args: MfactCmdArgs + /*conforms*/ let shouldResetClosedWindowsCache = false + + func run(_ env: CmdEnv, _ io: CmdIo) async -> BinaryExitCode { + guard let target = args.resolveTargetOrReportError(env, io) else { return .fail } + guard let window = target.windowOrNil else { + return .fail(io.err(noWindowIsFocused)) + } + guard let container = window.parent as? TilingContainer, container.layout == .masterStack else { + return .fail(io.err("mfact only has effect when the focused window belongs to a 'master-stack' container")) + } + + switch args.amount.val { + case .set(let value): container.mfact = CGFloat(value) + case .add(let value): container.mfact += CGFloat(value) + case .subtract(let value): container.mfact -= CGFloat(value) + } + // NOTE: TilingContainer.mfact clamps itself to [0.05, 0.95] in its didSet + + return .succ + } +} diff --git a/Sources/AppBundle/command/impl/MoveNodeToMasterCommand.swift b/Sources/AppBundle/command/impl/MoveNodeToMasterCommand.swift new file mode 100644 index 00000000..5873e713 --- /dev/null +++ b/Sources/AppBundle/command/impl/MoveNodeToMasterCommand.swift @@ -0,0 +1,22 @@ +import AppKit +import Common + +struct MoveNodeToMasterCommand: Command { + let args: MoveNodeToMasterCmdArgs + /*conforms*/ let shouldResetClosedWindowsCache = true + + func run(_ env: CmdEnv, _ io: CmdIo) async -> BinaryExitCode { + guard let target = args.resolveTargetOrReportError(env, io) else { return .fail } + guard let window = target.windowOrNil else { + return .fail(io.err(noWindowIsFocused)) + } + guard let container = window.parent as? TilingContainer else { + return .fail(io.err("move-node-to-master doesn't support floating windows")) + } + if container.children.first === window { + return .succ // Already master. Noop + } + window.bind(to: container, adaptiveWeight: window.getWeight(container.orientation), index: 0) + return .succ + } +} diff --git a/Sources/AppBundle/config/Config.swift b/Sources/AppBundle/config/Config.swift index 337ccb17..cf7adae7 100644 --- a/Sources/AppBundle/config/Config.swift +++ b/Sources/AppBundle/config/Config.swift @@ -61,12 +61,26 @@ struct Config: ConvenienceMutable { var modes: [String: Mode] = [:] var onWindowDetected: [WindowDetectedCallback] = [] var onModeChanged: Shell = .empty + + /// Default master area split ratio (mfact) for newly created 'master-stack' containers. Range: [0.05, 0.95] + var defaultMfact: Double = 0.5 + /// Which side the master area is placed on for horizontally-oriented 'master-stack' containers + var masterPosition: MasterPosition = .left + /// If true, newly created tiling windows are attached at the end of their container (bottom of the stack) + /// instead of right next to the most-recently-focused window + var attachBelow: Bool = false + /// If true, floating windows are centered on their monitor instead of preserving their natural position + var centerFloatingWindows: Bool = false } struct FocusFollowsMouse: ConvenienceMutable { var enabled: Bool = false } +enum MasterPosition: String { + case left, right +} + enum ConfigVersion: Int, Comparable, CaseIterable, Sendable, CustomStringConvertible { case _1 = 1 case _2 = 2 diff --git a/Sources/AppBundle/config/parseConfig.swift b/Sources/AppBundle/config/parseConfig.swift index efc57bde..3ae011d4 100644 --- a/Sources/AppBundle/config/parseConfig.swift +++ b/Sources/AppBundle/config/parseConfig.swift @@ -158,6 +158,12 @@ private let configParser: [String: any ParserProtocol] = [ "workspace-to-monitor-force-assignment": Parser(\.workspaceToMonitorForceAssignment, parseWorkspaceToMonitorAssignment), "on-window-detected": Parser(\.onWindowDetected, parseOnWindowDetectedArray), + // 'master-stack' layout + "default-mfact": Parser(\.defaultMfact, parseMfactFloat), + "master-position": Parser(\.masterPosition, parseMasterPosition), + "attach-below": Parser(\.attachBelow, parseBool), + "center-floating-windows": Parser(\.centerFloatingWindows, parseBool), + // Deprecated "non-empty-workspaces-root-containers-layout-on-startup": Parser(\._nonEmptyWorkspacesRootContainersLayoutOnStartup, parseStartupRootContainerLayout), "indent-for-nested-containers-with-the-same-orientation": Parser(\._indentForNestedContainersWithTheSameOrientation, parseIndentForNestedContainersWithTheSameOrientation), @@ -411,6 +417,26 @@ private func parseArrayOfStrings(_ raw: OrderedJson, _ backtrace: ConfigBacktrac } } +private func parseMasterPosition(_ raw: OrderedJson, _ backtrace: ConfigBacktrace) -> ResOrConfigParseDiagnostic { + parseString(raw, backtrace).flatMap { + MasterPosition(rawValue: $0).toResult(.init(backtrace, "Can't parse master position '\($0)'. Possible values: left, right")) + } +} + +// NOTE: AeroSpace's config value model (OrderedJson/Json) has no dedicated float/double case, so unquoted TOML +// decimals (e.g. `default-mfact = 0.5`) aren't representable yet. Until that's added upstream, mfact-like values +// must be written as quoted strings (e.g. `default-mfact = "0.5"`). +private func parseMfactFloat(_ raw: OrderedJson, _ backtrace: ConfigBacktrace) -> ResOrConfigParseDiagnostic { + parseString(raw, backtrace).flatMap { + Double($0).toResult(.init(backtrace, "Can't parse '\($0)' as a floating point number")) + .flatMap { value in + (0.05 ... 0.95).contains(value) + ? .success(value) + : .failure(.init(backtrace, "default-mfact must be in the [0.05, 0.95] range, got \(value)")) + } + } +} + private func parseDefaultContainerOrientation(_ raw: OrderedJson, _ backtrace: ConfigBacktrace) -> ResOrConfigParseDiagnostic { parseString(raw, backtrace).flatMap { DefaultContainerOrientation(rawValue: $0) diff --git a/Sources/AppBundle/layout/layoutRecursive.swift b/Sources/AppBundle/layout/layoutRecursive.swift index 41756e54..ce37a73e 100644 --- a/Sources/AppBundle/layout/layoutRecursive.swift +++ b/Sources/AppBundle/layout/layoutRecursive.swift @@ -48,6 +48,8 @@ extension TreeNode { try await container.layoutTiles(point, width: width, height: height, virtual: virtual, context) case .accordion: try await container.layoutAccordion(point, width: width, height: height, virtual: virtual, context) + case .masterStack: + try await container.layoutMasterStack(point, width: width, height: height, virtual: virtual, context) } case .macosMinimizedWindowsContainer, .macosFullscreenWindowsContainer, .macosPopupWindowsContainer, .macosHiddenAppsWindowsContainer: @@ -71,6 +73,23 @@ extension Window { @MainActor fileprivate func layoutFloatingWindow(_ context: LayoutContext) async throws { let workspace = context.workspace + + if config.centerFloatingWindows && windowId != currentlyManipulatedWithMouseWindowId { + if let windowSize = try await getAxSize(.cancellable) ?? lastFloatingSize { + let monitorRect = workspace.workspaceMonitor.visibleRect + let topLeft = CGPoint( + x: monitorRect.topLeftX + (monitorRect.width - windowSize.width) / 2, + y: monitorRect.topLeftY + (monitorRect.height - windowSize.height) / 2, + ) + setAxFrame(topLeft, nil) + } + if isFullscreen { + layoutFullscreen(context) + isFullscreen = false + } + return + } + let windowRect = try await getAxRect(.cancellable) // Probably not idempotent let currentMonitor = windowRect?.center.monitorApproximation if let currentMonitor, let windowRect, workspace != currentMonitor.activeWorkspace { @@ -140,6 +159,87 @@ extension TilingContainer { } } + /// dwm-style master-stack layout: `children[0]` is the master window occupying an `mfact` share of the + /// container's main axis; the remaining children ("stack") evenly share the rest, arranged along the cross axis. + /// For horizontal containers, `config.masterPosition` chooses which side (left/right) the master area is on. + /// For vertical containers, master is always on top (matches dwm/dwmac semantics). + @MainActor + fileprivate func layoutMasterStack(_ point: CGPoint, width: CGFloat, height: CGFloat, virtual: Rect, _ context: LayoutContext) async throws { + let children = self.children + if children.isEmpty { return } + + if children.count == 1 { + try await children[0].layoutRecursive(point, width: width, height: height, virtual: virtual, context) + return + } + + let gapMain = CGFloat(context.resolvedGaps.inner.get(orientation)) // gap between master and stack areas + let gapCross = CGFloat(context.resolvedGaps.inner.get(orientation.opposite)) // gap between stacked windows + let mfact = self.mfact + let stackCount = children.count - 1 + + let masterRect: Rect + let stackRects: [Rect] + let masterVirtual: Rect + let stackVirtuals: [Rect] + + switch orientation { + case .h: + let masterWidth = (width - gapMain) * mfact + let stackWidth = width - masterWidth - gapMain + let stackItemHeight = (height - CGFloat(stackCount - 1) * gapCross) / CGFloat(stackCount) + + let masterVirtualWidth = width > 0 ? virtual.width * (masterWidth / width) : virtual.width + let stackVirtualWidth = virtual.width - masterVirtualWidth + let stackVirtualItemHeight = virtual.height / CGFloat(stackCount) + + if config.masterPosition == .right { + masterRect = Rect(topLeftX: point.x + stackWidth + gapMain, topLeftY: point.y, width: masterWidth, height: height) + masterVirtual = Rect(topLeftX: virtual.topLeftX + stackVirtualWidth, topLeftY: virtual.topLeftY, width: masterVirtualWidth, height: virtual.height) + stackRects = (0 ..< stackCount).map { i in + Rect(topLeftX: point.x, topLeftY: point.y + CGFloat(i) * (stackItemHeight + gapCross), width: stackWidth, height: stackItemHeight) + } + stackVirtuals = (0 ..< stackCount).map { i in + Rect(topLeftX: virtual.topLeftX, topLeftY: virtual.topLeftY + CGFloat(i) * stackVirtualItemHeight, width: stackVirtualWidth, height: stackVirtualItemHeight) + } + } else { + masterRect = Rect(topLeftX: point.x, topLeftY: point.y, width: masterWidth, height: height) + masterVirtual = Rect(topLeftX: virtual.topLeftX, topLeftY: virtual.topLeftY, width: masterVirtualWidth, height: virtual.height) + stackRects = (0 ..< stackCount).map { i in + Rect(topLeftX: point.x + masterWidth + gapMain, topLeftY: point.y + CGFloat(i) * (stackItemHeight + gapCross), width: stackWidth, height: stackItemHeight) + } + stackVirtuals = (0 ..< stackCount).map { i in + Rect(topLeftX: virtual.topLeftX + masterVirtualWidth, topLeftY: virtual.topLeftY + CGFloat(i) * stackVirtualItemHeight, width: stackVirtualWidth, height: stackVirtualItemHeight) + } + } + case .v: + let masterHeight = (height - gapMain) * mfact + let stackHeight = height - masterHeight - gapMain + let stackItemWidth = (width - CGFloat(stackCount - 1) * gapCross) / CGFloat(stackCount) + + let masterVirtualHeight = height > 0 ? virtual.height * (masterHeight / height) : virtual.height + let stackVirtualHeight = virtual.height - masterVirtualHeight + let stackVirtualItemWidth = virtual.width / CGFloat(stackCount) + + masterRect = Rect(topLeftX: point.x, topLeftY: point.y, width: width, height: masterHeight) + masterVirtual = Rect(topLeftX: virtual.topLeftX, topLeftY: virtual.topLeftY, width: virtual.width, height: masterVirtualHeight) + stackRects = (0 ..< stackCount).map { i in + Rect(topLeftX: point.x + CGFloat(i) * (stackItemWidth + gapCross), topLeftY: point.y + masterHeight + gapMain, width: stackItemWidth, height: stackHeight) + } + stackVirtuals = (0 ..< stackCount).map { i in + Rect(topLeftX: virtual.topLeftX + CGFloat(i) * stackVirtualItemWidth, topLeftY: virtual.topLeftY + masterVirtualHeight, width: stackVirtualItemWidth, height: stackVirtualHeight) + } + } + + try await children[0].layoutRecursive(masterRect.topLeftCorner, width: masterRect.width, height: masterRect.height, virtual: masterVirtual, context) + for i in 0 ..< stackCount { + try await children[i + 1].layoutRecursive( + stackRects[i].topLeftCorner, width: stackRects[i].width, height: stackRects[i].height, + virtual: stackVirtuals[i], context, + ) + } + } + @MainActor fileprivate func layoutAccordion(_ point: CGPoint, width: CGFloat, height: CGFloat, virtual: Rect, _ context: LayoutContext) async throws { guard let mruIndex: Int = mostRecentChild?.ownIndex else { return } diff --git a/Sources/AppBundle/mouse/moveWithMouse.swift b/Sources/AppBundle/mouse/moveWithMouse.swift index 867ee690..f7229af1 100644 --- a/Sources/AppBundle/mouse/moveWithMouse.swift +++ b/Sources/AppBundle/mouse/moveWithMouse.swift @@ -103,7 +103,7 @@ extension CGPoint { private func _findWindowRecursively(in tree: TilingContainer, virtual: Bool) -> Window? { let point = self let target: TreeNode? = switch tree.layout { - case .tiles: + case .tiles, .masterStack: tree.children.first(where: { (virtual ? $0.lastAppliedLayoutVirtualRect : $0.lastAppliedLayoutPhysicalRect)?.contains(point) == true }) diff --git a/Sources/AppBundle/tree/MacWindow.swift b/Sources/AppBundle/tree/MacWindow.swift index cdf31c80..4e8c9ccd 100644 --- a/Sources/AppBundle/tree/MacWindow.swift +++ b/Sources/AppBundle/tree/MacWindow.swift @@ -226,10 +226,17 @@ private func unbindAndGetBindingDataForNewTilingWindow(_ workspace: Workspace, w window?.unbindFromParent() // It's important to unbind to get correct data from below let mruWindow = workspace.mostRecentWindowRecursive if let mruWindow, let tilingParent = mruWindow.parent as? TilingContainer { + // In 'master-stack' containers, honor dwm-style attach-below semantics: + // attach-below = false (default) -> new window becomes master (index 0) + // attach-below = true -> new window attaches right after the focused window (AeroSpace's usual behavior) + // Other layouts (tiles/accordion) are unaffected and always attach next to the focused window, as before. + let index = (tilingParent.layout == .masterStack && !config.attachBelow) + ? 0 + : mruWindow.ownIndex.orDie() + 1 return BindingData( parent: tilingParent, adaptiveWeight: WEIGHT_AUTO, - index: mruWindow.ownIndex.orDie() + 1, + index: index, ) } else { return BindingData( diff --git a/Sources/AppBundle/tree/TilingContainer.swift b/Sources/AppBundle/tree/TilingContainer.swift index eefb7719..03fd7dd9 100644 --- a/Sources/AppBundle/tree/TilingContainer.swift +++ b/Sources/AppBundle/tree/TilingContainer.swift @@ -5,11 +5,19 @@ final class TilingContainer: TreeNode, NonLeafTreeNodeObject { // todo consider fileprivate var _orientation: Orientation var orientation: Orientation { _orientation } var layout: Layout + /// Master area split ratio for the `master-stack` layout. Only meaningful when `layout == .masterStack`. + /// Always clamped to [0.05, 0.95]. Defaults from `config.defaultMfact`. + fileprivate var _mfact: CGFloat + var mfact: CGFloat { + get { _mfact } + set { _mfact = newValue.coerce(in: 0.05 ... 0.95) } + } @MainActor init(parent: NonLeafTreeNodeObject, adaptiveWeight: CGFloat, _ orientation: Orientation, _ layout: Layout, index: Int) { self._orientation = orientation self.layout = layout + self._mfact = CGFloat(config.defaultMfact).coerce(in: 0.05 ... 0.95) super.init(parent: parent, adaptiveWeight: adaptiveWeight, index: index) } @@ -58,6 +66,7 @@ extension TilingContainer { enum Layout: String { case tiles case accordion + case masterStack = "master-stack" } extension String { diff --git a/Sources/Common/cmdArgs/cmdArgsManifest.swift b/Sources/Common/cmdArgs/cmdArgsManifest.swift index effe43e8..075a3f30 100644 --- a/Sources/Common/cmdArgs/cmdArgsManifest.swift +++ b/Sources/Common/cmdArgs/cmdArgsManifest.swift @@ -28,9 +28,11 @@ public enum CmdKind: String, CaseIterable, Equatable, Sendable { case listWorkspaces = "list-workspaces" case macosNativeFullscreen = "macos-native-fullscreen" case macosNativeMinimize = "macos-native-minimize" + case mfact case mode case move = "move" case moveMouse = "move-mouse" + case moveNodeToMaster = "move-node-to-master" case moveNodeToMonitor = "move-node-to-monitor" case moveNodeToWorkspace = "move-node-to-workspace" case moveWorkspaceToMonitor = "move-workspace-to-monitor" @@ -106,6 +108,8 @@ func initSubcommands() -> [String: any SubCommandParserProtocol] { result[kind.rawValue] = SubCommandParser(parseMacosNativeFullscreenCmdArgs) case .macosNativeMinimize: result[kind.rawValue] = SubCommandParser(MacosNativeMinimizeCmdArgs.init) + case .mfact: + result[kind.rawValue] = SubCommandParser(parseMfactCmdArgs) case .mode: result[kind.rawValue] = SubCommandParser(ModeCmdArgs.init) case .move: @@ -114,6 +118,8 @@ func initSubcommands() -> [String: any SubCommandParserProtocol] { result["move-through"] = SubCommandParser(parseMoveCmdArgs) case .moveMouse: result[kind.rawValue] = SubCommandParser(parseMoveMouseCmdArgs) + case .moveNodeToMaster: + result[kind.rawValue] = SubCommandParser(parseMoveNodeToMasterCmdArgs) case .moveNodeToMonitor: result[kind.rawValue] = SubCommandParser(parseMoveNodeToMonitorCmdArgs) case .moveNodeToWorkspace: diff --git a/Sources/Common/cmdArgs/impl/LayoutCmdArgs.swift b/Sources/Common/cmdArgs/impl/LayoutCmdArgs.swift index 716bdbfb..0249a4ac 100644 --- a/Sources/Common/cmdArgs/impl/LayoutCmdArgs.swift +++ b/Sources/Common/cmdArgs/impl/LayoutCmdArgs.swift @@ -28,6 +28,8 @@ public struct LayoutCmdArgs: CmdArgs { case horizontal, vertical case h_accordion, v_accordion, h_tiles, v_tiles case tiling, floating + case master_stack = "master-stack" + case h_master_stack = "h_master-stack", v_master_stack = "v_master-stack" } public var root: Bool = false @@ -68,7 +70,7 @@ func parseLayoutCmdArgs(_ args: StrArrSlice) -> ParsedCmd { case .floating, .tiling: false case .accordion, .h_accordion, .h_tiles, .horizontal, .tiles, .v_accordion, .v_tiles, - .vertical: true + .vertical, .master_stack, .h_master_stack, .v_master_stack: true } } } diff --git a/Sources/Common/cmdArgs/impl/MfactCmdArgs.swift b/Sources/Common/cmdArgs/impl/MfactCmdArgs.swift new file mode 100644 index 00000000..cb934d40 --- /dev/null +++ b/Sources/Common/cmdArgs/impl/MfactCmdArgs.swift @@ -0,0 +1,57 @@ +public struct MfactCmdArgs: CmdArgs { + /*conforms*/ public var commonState: CmdArgsCommonState + fileprivate init(rawArgs: StrArrSlice) { self.commonState = .init(rawArgs) } + public static let parser: CmdParser = .init( + kind: .mfact, + help: mfact_help_generated, + flags: [:], + posArgs: [ + newMandatoryPosArgParser(\.amount, parseMfactAmount, placeholder: "[+|-]"), + ], + ) + + public var amount: Lateinit = .uninitialized + + public init(rawArgs: [String], amount: Amount) { + self.commonState = .init(rawArgs.slice) + self.amount = .initialized(amount) + } + + public enum Amount: Equatable, Sendable { + case set(Double) + case add(Double) + case subtract(Double) + } +} + +func parseMfactCmdArgs(_ args: StrArrSlice) -> ParsedCmd { + parseSpecificCmdArgs(MfactCmdArgs(rawArgs: args), args) +} + +private func parseMfactAmount(_ i: PosArgParserInput) -> ParsedCliArgs { + if let number = Double(i.arg.removePrefix("+").removePrefix("-")) { + switch true { + case i.arg.starts(with: "+"): .succ(.add(number), advanceBy: 1) + case i.arg.starts(with: "-"): .succ(.subtract(number), advanceBy: 1) + default: .succ(.set(number), advanceBy: 1) + } + } else { + .fail(" argument must be a number", advanceBy: 1) + } +} + +let mfact_help_generated = """ +USAGE + mfact [+|-] + +OPTIONS + -h, --help Print help + +ARGUMENTS + [+|-] Absolute (e.g. 0.6) or relative (e.g. +0.05, -0.05) value for the master area + split ratio (mfact) of the focused window's master-stack container. + The final value is clamped to the [0.05, 0.95] range. + +NOTES + * Only has effect when the target container's layout is 'master-stack' +""" diff --git a/Sources/Common/cmdArgs/impl/MoveNodeToMasterCmdArgs.swift b/Sources/Common/cmdArgs/impl/MoveNodeToMasterCmdArgs.swift new file mode 100644 index 00000000..8e415a50 --- /dev/null +++ b/Sources/Common/cmdArgs/impl/MoveNodeToMasterCmdArgs.swift @@ -0,0 +1,30 @@ +public struct MoveNodeToMasterCmdArgs: CmdArgs { + /*conforms*/ public var commonState: CmdArgsCommonState + fileprivate init(rawArgs: StrArrSlice) { self.commonState = .init(rawArgs) } + public static let parser: CmdParser = .init( + kind: .moveNodeToMaster, + help: move_node_to_master_help_generated, + flags: [:], + posArgs: [], + ) + + public init(rawArgs: [String]) { + self.commonState = .init(rawArgs.slice) + } +} + +func parseMoveNodeToMasterCmdArgs(_ args: StrArrSlice) -> ParsedCmd { + parseSpecificCmdArgs(MoveNodeToMasterCmdArgs(rawArgs: args), args) +} + +let move_node_to_master_help_generated = """ +USAGE + move-node-to-master + +OPTIONS + -h, --help Print help + +NOTES + * Moves the focused window to the master position (index 0) of its master-stack tiling container. + * If the focused window's container isn't a master-stack container, it's inserted at index 0 as usual. +""" diff --git a/docs/config-examples/default-config.toml b/docs/config-examples/default-config.toml index 2b44deea..e32175b9 100644 --- a/docs/config-examples/default-config.toml +++ b/docs/config-examples/default-config.toml @@ -26,9 +26,28 @@ enable-normalization-opposite-orientation-for-nested-containers = true # You can set 0 to disable the padding feature accordion-padding = 30 -# Possible values: tiles|accordion +# Possible values: tiles|accordion|master-stack default-root-container-layout = 'tiles' +# DeeWM extension. Only has effect for 'master-stack' containers. +# Master area split ratio (aka 'mfact'). NOTE: must be a quoted string for now (e.g. "0.5"), range [0.05, 0.95] +# Fallback value (if you omit the key): default-mfact = "0.5" +# default-mfact = "0.5" + +# DeeWM extension. Which side the master area is placed on for horizontally-oriented 'master-stack' containers +# Possible values: left|right +# Fallback value (if you omit the key): master-position = 'left' +# master-position = 'left' + +# DeeWM extension. If true, new tiling windows attach at the end of the container (bottom of the stack) +# instead of right next to the focused window. Only affects 'master-stack' containers +# Fallback value (if you omit the key): attach-below = false +# attach-below = false + +# DeeWM extension. If true, floating windows are centered on their monitor instead of preserving their position +# Fallback value (if you omit the key): center-floating-windows = false +# center-floating-windows = false + # Possible values: horizontal|vertical|auto # 'auto' means: wide monitor (anything wider than high) gets horizontal orientation, # tall monitor (anything higher than wide) gets vertical orientation