Skip to content

Commit

Permalink
Implement --format option for list-windows command
Browse files Browse the repository at this point in the history
Related to: #16
  • Loading branch information
nikitabobko committed May 26, 2024
1 parent 9be3853 commit 0ec305a
Show file tree
Hide file tree
Showing 10 changed files with 196 additions and 78 deletions.
58 changes: 52 additions & 6 deletions Sources/AppBundle/command/query/ListWindowsCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ struct ListWindowsCommand: Command {
} else {
var workspaces: Set<Workspace> = args.workspaces.isEmpty ? Workspace.all.toSet() : args.workspaces
.flatMap { filter in
return switch filter {
switch filter {
case .focused: [Workspace.focused]
case .visible: Workspace.all.filter { $0.isVisible }
case .visible: Workspace.all.filter(\.isVisible)
case .name(let name): [Workspace.get(byName: name.raw)]
}
}
Expand All @@ -36,11 +36,57 @@ struct ListWindowsCommand: Command {
windows = windows.filter { $0.app.id == appId }
}
}
state.stdout += windows
.map { window in
[String(window.windowId), window.app.name ?? "NULL-APP-NAME", window.title]
windows = windows.sorted(using: [SelectorComparator { $0.app.name ?? "" }, SelectorComparator(selector: \.title)])
var table: [[String]] = []
var padLastColumn = false // hack. right-padding should be properly expanded
for window in windows {
var errors: [String] = []
var line: [String] = []
var current: String = ""
for (index, token) in args.format.enumerated() {
switch token {
case .value("right-padding"):
line.append(current)
current = ""
if index == args.format.count - 1 {
padLastColumn = true
}
case .literal(let literal): current.append(literal)
case .value(let value):
switch value.expandTreeNodeVar(window: window) {
case .success(let prop): current.append(prop)
case .failure(let msg): errors.append(msg)
}
}
}
if !current.isEmpty {
line.append(current)
}
.toPaddingTable()
if errors.isEmpty {
table.append(line)
} else {
return state.failCmd(msg: errors.joinErrors())
}
}
state.stdout += table.toPaddingTable(columnSeparator: "", padLastColumn: padLastColumn)
return true
}
}

private extension String {
func expandTreeNodeVar(window: Window) -> Result<String, String> {
switch self {
case "newline": .success("\n")
case "tab": .success("\t")
case "window-id": .success(window.windowId.description)
case "window-title": .success(window.title)
case "app-name": .success(window.app.name ?? "NULL-APP-NAME")
case "app-pid": .success(window.app.pid.description)
case "app-id": .success(window.app.id ?? "NULL-APP-ID")
case "workspace": .success(window.workspace?.name ?? "NULL-WOKRSPACE")
case "monitor-id": .success(window.nodeMonitor?.monitorId?.description ?? "NULL-MONITOR-ID")
case "monitor-name": .success(window.nodeMonitor?.name ?? "NULL-MONITOR-NAME")
default: .failure("Unknown interpolation variable '\(self)'")
}
}
}
7 changes: 4 additions & 3 deletions Sources/AppBundle/config/parseExecEnvVariables.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,10 @@ private func parseEnvVariables(_ raw: TOMLValueConvertible, _ backtrace: TomlBac
if let add: String = fullEnv[key] {
env[key] = add
}
let (interpolated, interpolationErrors) = rawStr.interpolate(with: env)
errors += interpolationErrors.map { .semantic(backtrace, $0) }
result[key] = interpolated
switch rawStr.interpolate(with: env) {
case .success(let interpolated): result[key] = interpolated
case .failure(let _errros): errors += _errros.map { .semantic(backtrace, $0) }
}
}
return result
}
6 changes: 6 additions & 0 deletions Sources/AppBundle/model/MonitorEx.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,10 @@ extension Monitor {
height: visibleRect.height - gaps.outer.top.toDouble() - gaps.outer.bottom.toDouble()
)
}

var monitorId: Int? {
let sorted = sortedMonitors
let origin = self.rect.topLeftCorner
return sorted.firstIndex { $0.rect.topLeftCorner == origin }
}
}
4 changes: 2 additions & 2 deletions Sources/AppBundleTests/command/ListWindowsTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ final class ListWindowsTest: XCTestCase {
func testParse() {
expect(parseCommand("list-windows --pid 1").errorOrNil) == "Mandatory option is not specified (--focused|--all|--monitor|--workspace)"
expect(parseCommand("list-windows --workspace M --pid 1").errorOrNil) == nil
expect(parseCommand("list-windows --pid 1 --focused").errorOrNil) == "--focused conflicts with all other flags"
expect(parseCommand("list-windows --pid 1 --all").errorOrNil) == "--all conflicts with all other flags"
expect(parseCommand("list-windows --pid 1 --focused").errorOrNil) == "--focused conflicts with \"filtering\" flags"
expect(parseCommand("list-windows --pid 1 --all").errorOrNil) == "--all conflicts with \"filtering\" flags"
expect(parseCommand("list-windows --all").errorOrNil) == nil
expect(parseCommand("list-windows --all --workspace M").errorOrNil) == "Conflicting options: --workspace, --all"
expect(parseCommand("list-windows --all --focused").errorOrNil) == "Conflicting options: --focused, --all"
Expand Down
12 changes: 6 additions & 6 deletions Sources/AppBundleTests/config/ParseEnvVariablesTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,19 @@ final class ParseEnvVariablesTest: XCTestCase {
"""
)
expect(errors.descriptions) == [
"exec.env-vars.BAR: Env variable 'FOO' isn't presented in AeroSpace.app Env vars, or not available for interpolation (because it's mutated)",
"exec.env-vars.FOO: Env variable 'BAR' isn't presented in AeroSpace.app Env vars, or not available for interpolation (because it's mutated)"
"exec.env-vars.BAR: Env variable 'FOO' isn't presented in AeroSpace.app env vars, or not available for interpolation (because it's mutated)",
"exec.env-vars.FOO: Env variable 'BAR' isn't presented in AeroSpace.app env vars, or not available for interpolation (because it's mutated)"
]
}
}

private func testSucInterpolation(_ str: String, _ vars: [String: String] = [:], expected: String) {
let (result, errors) = str.interpolate(with: vars)
let (result, errors) = str.interpolate(with: vars).getOrNils()
XCTAssertEqual(result, expected)
XCTAssertEqual(errors, [])
XCTAssertEqual(errors ?? [], [])
}

private func testFailInterpolation(_ str: String, _ vars: [String: String] = [:]) {
let (_, errors) = str.interpolate(with: vars)
XCTAssertNotEqual(errors, [])
let (_, errors) = str.interpolate(with: vars).getOrNils()
XCTAssertNotEqual(errors ?? [], [])
}
50 changes: 34 additions & 16 deletions Sources/Common/cmdArgs/query/ListWindowsCmdArgs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,19 @@ public struct ListWindowsCmdArgs: RawCmdArgs, CmdArgs, Equatable {
help: """
USAGE: list-windows [-h|--help] (--workspace \(_workspaces)|--monitor \(_monitors))
[--monitor \(_monitors)] [--workspace \(_workspaces)]
[--pid <pid>] [--app-id <app-id>]
OR: list-windows [-h|--help] --all
OR: list-windows [-h|--help] --focused
[--pid <pid>] [--app-id <app-id>] [--format <output-format>]
OR: list-windows [-h|--help] --all [--format <output-format>]
OR: list-windows [-h|--help] --focused [--format <output-format>]
OPTIONS:
-h, --help Print help
--all Alias for "--monitor all"
--focused Print the focused window
--workspace \(_workspaces) Filter results to only print windows that belong to specified workspaces
--monitor \(_monitors) Filter results to only print the windows that are attached to specified monitors
--pid <pid> Filter results to only print windows that belong to the Application with specified <pid>
--app-id <app-id> Filter results to only print windows that belong to the Application with specified Bundle ID
-h, --help Print help
--all Alias for "--monitor all"
--focused Print the focused window
--workspace \(_workspaces) Filter results to only print windows that belong to specified workspaces
--monitor \(_monitors) Filter results to only print the windows that are attached to specified monitors
--pid <pid> Filter results to only print windows that belong to the Application with specified <pid>
--app-id <app-id> Filter results to only print windows that belong to the Application with specified Bundle ID
--format <output-format> Specify output format
""",
options: [
"--focused": trueBoolFlag(\.focused),
Expand All @@ -31,7 +32,8 @@ public struct ListWindowsCmdArgs: RawCmdArgs, CmdArgs, Equatable {
"--monitor": ArgParser(\.monitors, parseMonitorIds),
"--workspace": ArgParser(\.workspaces, parseWorkspaces),
"--pid": singleValueOption(\.pidFilter, "<pid>", Int32.init),
"--app-id": singleValueOption(\.appIdFilter, "<app-id>", { $0 })
"--app-id": singleValueOption(\.appIdFilter, "<app-id>", { $0 }),
"--format": ArgParser(\.format, parseFormat),
],
arguments: []
)
Expand All @@ -43,6 +45,11 @@ public struct ListWindowsCmdArgs: RawCmdArgs, CmdArgs, Equatable {
public var workspaces: [WorkspaceFilter] = []
public var pidFilter: Int32?
public var appIdFilter: String?
public var format: [StringInterToken] = [
.value("window-id"), .value("right-padding"), .literal(" | "),
.value("app-name"), .value("right-padding"), .literal(" | "),
.value("window-title"),
]
}

public func parseRawListWindowsCmdArgs(_ args: [String]) -> ParsedCmd<ListWindowsCmdArgs> {
Expand All @@ -59,18 +66,29 @@ public func parseRawListWindowsCmdArgs(_ args: [String]) -> ParsedCmd<ListWindow
default: .failure("Conflicting options: \(conflicting.joined(separator: ", "))")
}
}
.filter("--all conflicts with all other flags") { raw in
!raw.all || raw == ListWindowsCmdArgs(rawArgs: .init([]), all: true)
.filter("--all conflicts with \"filtering\" flags") { raw in
!raw.all || raw == ListWindowsCmdArgs(rawArgs: .init([]), all: true, format: raw.format)
}
.filter("--focused conflicts with all other flags") { raw in
!raw.focused || raw == ListWindowsCmdArgs(rawArgs: .init(args), focused: true)
.filter("--focused conflicts with \"filtering\" flags") { raw in
!raw.focused || raw == ListWindowsCmdArgs(rawArgs: .init(args), focused: true, format: raw.format)
}
.map { raw in
// Normalize alias
raw.all ? ListWindowsCmdArgs(rawArgs: .init(args), monitors: [.all]) : raw
raw.all ? ListWindowsCmdArgs(rawArgs: .init(args), monitors: [.all], format: raw.format) : raw
}
}

func parseFormat(arg: String, nextArgs: inout [String]) -> Parsed<[StringInterToken]> {
return if let nextArg = nextArgs.nextNonFlagOrNil() {
switch nextArg.interpolationTokens(interpolationChar: "%") {
case .success(let tokens): .success(tokens)
case .failure(let err): .failure("Failed to parse <output-format>. \(err)")
}
} else {
.failure("<output-format> is mandatory")
}
}

private func parseWorkspaces(arg: String, nextArgs: inout [String]) -> Parsed<[WorkspaceFilter]> {
let args = nextArgs.allNextNonFlagArgs()
let possibleValues = "\(orkspace) possible values: (<workspace-name>|focused|visible)"
Expand Down
12 changes: 12 additions & 0 deletions Sources/Common/util/SequenceEx.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ public extension Sequence {
return .success(result)
}

func mapAllOrFailures<T, E>(_ transform: (Self.Element) -> Result<T, E>) -> Result<[T], [E]> {
var result: [T] = []
var errors: [E] = []
for element in self {
switch transform(element) {
case .success(let element): result.append(element)
case .failure(let error): errors.append(error)
}
}
return errors.isEmpty ? .success(result) : .failure(errors)
}

@inlinable func minByOrThrow<S: Comparable>(_ selector: (Self.Element) -> S) -> Self.Element {
minBy(selector) ?? errorT("Empty sequence")
}
Expand Down
74 changes: 50 additions & 24 deletions Sources/Common/util/StringEx.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
public typealias Parsed<T> = Result<T, String>
extension String: Error {} // Make it possible to use String in Result
extension [String]: Error {} // Make it possible to use [String] in Result

public extension String {
func trim() -> String {
Expand All @@ -12,12 +13,14 @@ public extension String {
}

public extension [[String]] {
func toPaddingTable(columnSeparator: String = " | ") -> [String] {
func toPaddingTable(columnSeparator: String = " | ", padLastColumn: Bool = false) -> [String] {
let pads: [Int] = transposed.map { column in column.map { $0.count }.max()! }
return self.map { (row: [String]) in
zip(row, pads)
.map { (elem: String, pad: Int) in
elem.padding(toLength: pad, withPad: " ", startingAt: 0)
zip(row.enumerated(), pads)
.map { (elem: (Int, String), pad: Int) in
padLastColumn || elem.0 != row.count - 1
? elem.1.padding(toLength: pad, withPad: " ", startingAt: 0)
: elem.1
}
.joined(separator: columnSeparator)
}
Expand All @@ -43,51 +46,74 @@ private extension [[String]] {
}

public extension String {
func interpolate(with variables: [String: String]) -> (result: String, errors: [String]) {
func interpolate(with variables: [String: String], interpolationChar: Character = "$") -> Result<String, [String]> {
interpolationTokens()
.mapError { [$0] }
.flatMap { tokens in
tokens.mapAllOrFailures { token in
switch token {
case .literal(let literal): .success(literal)
case .value(let value): variables[value].flatMap(Result.success)
?? .failure("Env variable '\(value)' isn't presented in AeroSpace.app env vars, " +
"or not available for interpolation (because it's mutated)")
}
}
}
.map { $0.joined(separator: "") }
}

func interpolationTokens(interpolationChar: Character = "$") -> Result<[StringInterToken], String> {
var mode: InterpolationParserState = .stringLiteral
var result = ""
var errors: [String] = []
var result: [StringInterToken] = []
var literal: String = ""
for char: Character? in (Array(self) + [nil]) {
switch (mode, char) { // State machine
case (.stringLiteral, "$"):
case (.stringLiteral, interpolationChar):
mode = .dollarEncountered
case (.stringLiteral, _):
if let char {
result.append(char)
literal.append(char)
} else {
result.append(.literal(literal))
}
case (.dollarEncountered, "{"):
mode = .interpolatedValue("")
case (.dollarEncountered, "$"):
result.append("$")
result.append(.literal(literal))
literal = ""
case (.dollarEncountered, interpolationChar):
literal.append(interpolationChar)
case (.dollarEncountered, _):
result.append("$")
literal.append(interpolationChar)
if let char {
result.append(char)
literal.append(char)
} else {
result.append(.literal(literal))
}
mode = .stringLiteral
case (.interpolatedValue(let value), "}"):
if let expanded = variables[value] {
result.append(expanded)
} else {
errors.append("Env variable '\(value)' isn't presented in AeroSpace.app Env vars, or not available for interpolation (because it's mutated)")
}
result.append(.value(value))
mode = .stringLiteral
case (.interpolatedValue(let value), "{"):
return ("", ["Can't parse '\(value + "{")' environment variable (Open curly brace is invalid character)"])
case (.interpolatedValue(let value), "$"):
return ("", ["Can't parse '\(value + "$")' environment variable (Dollar is invalid character)"])
return .failure("Can't parse '\(value + "{")' inside interpolation (Open curly brace is invalid character)")
case (.interpolatedValue(let value), interpolationChar):
return .failure("Can't parse '\(value + .init(interpolationChar))' inside interpolation ('\(interpolationChar)' is disallowed character)")
case (.interpolatedValue(let value), _):
if let char {
mode = .interpolatedValue(value + String(char))
mode = .interpolatedValue(value + .init(char))
} else {
return ("", ["Unbalanced curly braces"])
return .failure("Unbalanced curly braces")
}
}
}
return (result, errors)
return .success(result.filter { $0 != .literal("") })
}
}

public enum StringInterToken: Equatable {
case literal(String)
case value(String)
}

private enum InterpolationParserState {
case stringLiteral, dollarEncountered
case interpolatedValue(String)
Expand Down
Loading

0 comments on commit 0ec305a

Please sign in to comment.