From 50656f69b0b2e1c308764a68ebac46dc7aa1bb5d Mon Sep 17 00:00:00 2001 From: Nikita Bobko Date: Mon, 22 Apr 2024 23:08:35 +0200 Subject: [PATCH] Add possibility to focus window by id https://github.com/nikitabobko/AeroSpace/issues/173 --- Sources/AppBundle/command/FocusCommand.swift | 24 ++++--- Sources/AppBundle/tree/MacWindow.swift | 2 +- .../command/FocusCommandTest.swift | 8 ++- Sources/Common/cmdArgs/FocusCmdArgs.swift | 65 ++++++++++++++----- .../cmdArgs/other/parseCmdArgsUtils.swift | 18 +++-- docs/aerospace-focus.adoc | 4 ++ 6 files changed, 88 insertions(+), 33 deletions(-) diff --git a/Sources/AppBundle/command/FocusCommand.swift b/Sources/AppBundle/command/FocusCommand.swift index 3944945c..64699bbb 100644 --- a/Sources/AppBundle/command/FocusCommand.swift +++ b/Sources/AppBundle/command/FocusCommand.swift @@ -13,17 +13,25 @@ struct FocusCommand: Command { defer { restoreFloatingWindows(floatingWindows: floatingWindows, workspace: workspace) } - let direction = args.direction.val var result: Bool = true - if let (parent, ownIndex) = window?.closestParent(hasChildrenInDirection: direction, withLayout: nil) { - guard let windowToFocus = parent.children[ownIndex + direction.focusOffset] - .findFocusTargetRecursive(snappedTo: direction.opposite) else { return false } - state.subject = .window(windowToFocus) - } else { - result = hitWorkspaceBoundaries(state, args, direction) && result + switch args.target { + case .direction(let direction): + if let (parent, ownIndex) = window?.closestParent(hasChildrenInDirection: direction, withLayout: nil) { + guard let windowToFocus = parent.children[ownIndex + direction.focusOffset] + .findFocusTargetRecursive(snappedTo: direction.opposite) else { return false } + state.subject = .window(windowToFocus) + } else { + result = hitWorkspaceBoundaries(state, args, direction) && result + } + case .windowId(let windowId): + if let windowToFocus = MacWindow.allWindowsMap[windowId] { + state.subject = .window(windowToFocus) + } else { + state.stderr.append("Can't find window with ID \(windowId)") + return false + } } - switch state.subject { case .emptyWorkspace(let name): result = WorkspaceCommand.run(state, name) && result diff --git a/Sources/AppBundle/tree/MacWindow.swift b/Sources/AppBundle/tree/MacWindow.swift index 37fadc64..658c0a8d 100644 --- a/Sources/AppBundle/tree/MacWindow.swift +++ b/Sources/AppBundle/tree/MacWindow.swift @@ -15,7 +15,7 @@ final class MacWindow: Window, CustomStringConvertible { super.init(id: id, app, lastFloatingSize: axWindow.get(Ax.sizeAttr), parent: parent, adaptiveWeight: adaptiveWeight, index: index) } - private static var allWindowsMap: [CGWindowID: MacWindow] = [:] + static var allWindowsMap: [CGWindowID: MacWindow] = [:] static var allWindows: [MacWindow] { Array(allWindowsMap.values) } static func get(app: MacApp, axWindow: AXUIElement, startup: Bool) -> MacWindow? { diff --git a/Sources/AppBundleTests/command/FocusCommandTest.swift b/Sources/AppBundleTests/command/FocusCommandTest.swift index 1edec676..7d3e10b1 100644 --- a/Sources/AppBundleTests/command/FocusCommandTest.swift +++ b/Sources/AppBundleTests/command/FocusCommandTest.swift @@ -27,7 +27,9 @@ final class FocusCommandTest: XCTestCase { func testParse() { XCTAssertTrue(parseCommand("focus --boundaries left").errorOrNil?.contains("Possible values") == true) - testParseCommandSucc("focus --boundaries workspace left", FocusCmdArgs(direction: .left)) + var expected = FocusCmdArgs(direction: .left) + expected.rawBoundaries = .workspace + testParseCommandSucc("focus --boundaries workspace left", expected) XCTAssertEqual( parseCommand("focus --boundaries workspace --boundaries workspace left").errorOrNil, @@ -83,8 +85,8 @@ final class FocusCommandTest: XCTestCase { //FocusCommand(args: FocusCmdArgs(boundaries: .workspace, boundariesAction: .stop, direction: .left)).run(.focused) var args = FocusCmdArgs(direction: .left) - args.boundaries = .workspace - args.boundariesAction = .stop + args.rawBoundaries = .workspace + args.rawBoundariesAction = .stop FocusCommand(args: args).run(.focused) XCTAssertEqual(focusedWindow?.windowId, 1) } diff --git a/Sources/Common/cmdArgs/FocusCmdArgs.swift b/Sources/Common/cmdArgs/FocusCmdArgs.swift index 1a9def11..5aa82020 100644 --- a/Sources/Common/cmdArgs/FocusCmdArgs.swift +++ b/Sources/Common/cmdArgs/FocusCmdArgs.swift @@ -6,10 +6,12 @@ public struct FocusCmdArgs: CmdArgs, RawCmdArgs, Equatable, AeroAny { kind: .focus, allowInConfig: true, help: """ - USAGE: focus [] \(CardinalDirection.unionLiteral) + USAGE: focus [] \(CardinalDirection.unionLiteral) + OR: focus [-h|--help] --window-id OPTIONS: -h, --help Print help + --window-id Focus window with specified --boundaries \(boundar) Defines focus boundaries. \(boundar) possible values: \(FocusCmdArgs.Boundaries.unionLiteral) The default is: \(FocusCmdArgs.Boundaries.workspace.rawValue) @@ -19,24 +21,28 @@ public struct FocusCmdArgs: CmdArgs, RawCmdArgs, Equatable, AeroAny { ARGUMENTS: (left|down|up|right) Focus direction - """, // todo focus [OPTIONS] window-id - // ARGUMENTS: - // ID of window to focus + """, options: [ - "--boundaries": ArgParser(\.boundaries, parseBoundaries), - "--boundaries-action": ArgParser(\.boundariesAction, parseBoundariesAction) + "--boundaries": ArgParser(\.rawBoundaries, upcastArgParserFun(parseBoundaries)), + "--boundaries-action": ArgParser(\.rawBoundariesAction, upcastArgParserFun(parseBoundariesAction)), + "--window-id": ArgParser(\.windowId, upcastArgParserFun(parseArgWithUInt32)) ], - arguments: [newArgParser(\.direction, parseCardinalDirectionArg, mandatoryArgPlaceholder: CardinalDirection.unionLiteral)] + arguments: [ArgParser(\.direction, upcastArgParserFun(parseCardinalDirectionArg))] ) - public var boundaries: Boundaries = .workspace // todo cover boundaries wrapping with tests - public var boundariesAction: WhenBoundariesCrossed = .wrapAroundTheWorkspace - public var direction: Lateinit = .uninitialized + public var rawBoundaries: Boundaries? = nil // todo cover boundaries wrapping with tests + public var rawBoundariesAction: WhenBoundariesCrossed? = nil + public var windowId: UInt32? = nil + public var direction: CardinalDirection? = nil fileprivate init() {} public init(direction: CardinalDirection) { - self.direction = .initialized(direction) + self.direction = direction + } + + public init(windowId: UInt32) { + self.windowId = windowId } public enum Boundaries: String, CaseIterable, Equatable { @@ -50,13 +56,38 @@ public struct FocusCmdArgs: CmdArgs, RawCmdArgs, Equatable, AeroAny { } } +public enum FocusCmdTarget { + case direction(CardinalDirection) + case windowId(UInt32) +} + +public extension FocusCmdArgs { + var target: FocusCmdTarget { + if let direction { + return .direction(direction) + } + if let windowId { + return .windowId(windowId) + } + error("Parser invariants are broken") + } + + var boundaries: Boundaries { rawBoundaries ?? .workspace } + var boundariesAction: WhenBoundariesCrossed { rawBoundariesAction ?? .wrapAroundTheWorkspace } +} + public func parseFocusCmdArgs(_ args: [String]) -> ParsedCmd { - parseRawCmdArgs(FocusCmdArgs(), args) - .flatMap { raw in - if raw.boundaries == .workspace && raw.boundariesAction == .wrapAroundAllMonitors { - return .failure("\(raw.boundaries.rawValue) and \(raw.boundariesAction.rawValue) is an invalid combination of values") - } - return .cmd(raw) + return parseRawCmdArgs(FocusCmdArgs(), args) + .flatMap { (raw: FocusCmdArgs) -> ParsedCmd in + raw.boundaries == .workspace && raw.boundariesAction == .wrapAroundAllMonitors + ? .failure("\(raw.boundaries.rawValue) and \(raw.boundariesAction.rawValue) is an invalid combination of values") + : .cmd(raw) + } + .filter("Mandatory argument is missing. '\(CardinalDirection.unionLiteral)' or --window-id") { + $0.direction != nil || $0.windowId != nil + } + .filter("--window-id is incompatible with other options") { + $0.windowId == nil || $0 == FocusCmdArgs(windowId: $0.windowId!) } } diff --git a/Sources/Common/cmdArgs/other/parseCmdArgsUtils.swift b/Sources/Common/cmdArgs/other/parseCmdArgsUtils.swift index edc53347..82b26e5b 100644 --- a/Sources/Common/cmdArgs/other/parseCmdArgsUtils.swift +++ b/Sources/Common/cmdArgs/other/parseCmdArgsUtils.swift @@ -174,22 +174,22 @@ private extension ArgParserProtocol { } } +public typealias ArgParserFun = (/*arg*/ String, /*nextArgs*/ inout [String]) -> Parsed public protocol ArgParserProtocol { associatedtype K associatedtype T where T: Copyable var argPlaceholderIfMandatory: String? { get } var keyPath: WritableKeyPath { get } - var parse: (/*arg*/ String, /*nextArgs*/ inout [String]) -> Parsed { get } + var parse: ArgParserFun { get } } - public struct ArgParser: ArgParserProtocol { public let keyPath: WritableKeyPath - public let parse: (String, inout [String]) -> Parsed + public let parse: ArgParserFun public let argPlaceholderIfMandatory: String? public init( _ keyPath: WritableKeyPath, - _ parse: @escaping (String, inout [String]) -> Parsed, + _ parse: @escaping ArgParserFun, argPlaceholderIfMandatory: String? = nil ) { self.keyPath = keyPath @@ -259,3 +259,13 @@ public func parseEnum(_ raw: String, _ _: T.Type) -> Parsed public func parseCardinalDirectionArg(arg: String, nextArgs: inout [String]) -> Parsed { parseEnum(arg, CardinalDirection.self) } + +public func parseArgWithUInt32(arg: String, nextArgs: inout [String]) -> Parsed { + if let arg = nextArgs.nextNonFlagOrNil() { + return UInt32(arg).orFailure("Can't parse '\(arg)'. It must be a positive number") + } else { + return .failure("'\(arg)' must be followed by mandatory UInt32") + } +} + +func upcastArgParserFun(_ fun: @escaping ArgParserFun) -> ArgParserFun { { fun($0, &$1).map { $0 } } } diff --git a/docs/aerospace-focus.adoc b/docs/aerospace-focus.adoc index ce6efd48..1216a4ac 100644 --- a/docs/aerospace-focus.adoc +++ b/docs/aerospace-focus.adoc @@ -10,6 +10,7 @@ include::util/man-attributes.adoc[] // tag::synopsis[] aerospace focus [-h|--help] [--boundaries ] [--boundaries-action ] (left|down|up|right) +aerospace focus [-h|--help] --window-id // end::synopsis[] @@ -41,6 +42,9 @@ Defines the behavior when requested to cross the ``. + `` possible values: `(stop|wrap-around-the-workspace|wrap-around-all-monitors)` + The default is: `wrap-around-the-workspace` +--window-id :: +Focus the window with specified `` + // end::body[] include::util/man-footer.adoc[]