Skip to content

Commit

Permalink
Rewrite. Split CommandMutableState to more granular CmdIo, CmdEnv and…
Browse files Browse the repository at this point in the history
… CmdResult

This commit lays out the foundation for the following issues
- #186
- #278
- #20

Also the commit changes how focused window is tracked throught the chain
of executed commands.

- CommandMutableState was a mutable state that tracked the focused
  window, and the track had to be updated throught the chain of executed
  commands (for example, take a look at the `focus` command)
- CmdEnv is simplier. It just forces a particular window to be percieved
  as "focused".

CmdEnv approach makes things easier to understand and describe in the
docs (which I'm going to do later, CmdEnv will be exposed as
AEROSPACE_FOCUSED_WORKSPACE and AEROSPACE_FOCUSED_WINDOW_ID environment
variables)

Unlike CommandMutableState, CmdEnv approach disallows to change focused
window in on-window-detected, on-focus-changed, and other callbacks.
Which I think is not an issue at all. It maybe even considered a safety
mechanism.

If a user uses `close` in one of the mentioned callbacks, previously, a
random window could become focused and it would receive all the
following commands. Now, all the commands that go after `close` will
fail with "Invalid <window-id> \(windowId) specified in
AEROSPACE_FOCUSED_WINDOW_ID env variable"

- This commit is not a breaking change for on-window-detected thanks to
  limitations in #20
- But this commit is technically a breaking change for other mentioned
  callbacks, since no limitations were impoosed on those callbacks. But
  I don't believe that anyone in sane mind relied on it. And the docs
  are explicit that changing focus in those callbacks is a bad idea:

  > Changing the focus within these callbacks is a bad idea anyway, and the way it’s handled will probably change in future versions.

Currently, the "force focused state" in CmdEnv is immutable, and I hope
it will stay so. But hypothetically, it can be mutable in future
versions if we decide that the embedded language #278 should allow
chaning environment variables.
  • Loading branch information
nikitabobko committed Sep 21, 2024
1 parent 5d58c02 commit 61594ee
Showing 53 changed files with 423 additions and 386 deletions.
2 changes: 1 addition & 1 deletion Sources/AppBundle/MenuBar.swift
Original file line number Diff line number Diff line change
@@ -29,7 +29,7 @@ public func menuBar(viewModel: TrayMenuModel) -> some Scene {
}
Button(viewModel.isEnabled ? "Disable" : "Enable") {
refreshSession {
_ = EnableCommand(args: EnableCmdArgs(rawArgs: [], targetState: .toggle)).run(.focused)
_ = EnableCommand(args: EnableCmdArgs(rawArgs: [], targetState: .toggle)).run(.defaultEnv, .emptyStdin)
}
}.keyboardShortcut("E", modifiers: .command)
let editor = getTextEditorToOpenConfig()
39 changes: 39 additions & 0 deletions Sources/AppBundle/command/CmdEnv.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Common

struct CmdEnv: Copyable { // todo forward env from cli to server
var windowId: UInt32?
var workspaceName: String?
var pwd: String?

static var defaultEnv: CmdEnv { CmdEnv(windowId: nil, workspaceName: nil, pwd: nil) }
public init(
windowId: UInt32?,
workspaceName: String?,
pwd: String?
) {
self.windowId = windowId
self.workspaceName = workspaceName
self.pwd = pwd
}

func withFocus(_ focus: LiveFocus) -> CmdEnv {
switch focus.asLeaf {
case .window(let wd): .defaultEnv.copy(\.windowId, wd.windowId)
case .emptyWorkspace(let ws): .defaultEnv.copy(\.workspaceName, ws.name)
}
}

var asMap: [String: String] {
var result = config.execConfig.envVariables
if let pwd {
result["PWD"] = pwd
}
if let windowId {
result[AEROSPACE_FOCUSED_WINDOW_ID] = windowId.description
}
if let workspaceName {
result[AEROSPACE_FOCUSED_WORKSPACE] = workspaceName.description
}
return result
}
}
34 changes: 34 additions & 0 deletions Sources/AppBundle/command/CmdIo.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
class CmdStdin {
private var input: String = ""
init(_ input: String) {
self.input = input
}
static var emptyStdin: CmdStdin { .init("") }

func readAll() -> String {
let result = input
input = ""
return result
}
}

class CmdIo {
private var stdin: CmdStdin
var stdout: [String] = []
var stderr: [String] = []

init(stdin: CmdStdin) { self.stdin = stdin }

@discardableResult func out(_ msg: String) -> Bool { stdout.append(msg); return true }
@discardableResult func err(_ msg: String) -> Bool { stderr.append(msg); return false }
@discardableResult func out(_ msg: [String]) -> Bool { stdout += msg; return true }
@discardableResult func err(_ msg: [String]) -> Bool { stderr += msg; return false }

func readStdin() -> String { stdin.readAll() }
}

struct CmdResult {
let stdout: [String]
let stderr: [String]
let exitCode: Int32
}
78 changes: 11 additions & 67 deletions Sources/AppBundle/command/Command.swift
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@ import Common
protocol Command: AeroAny, Equatable {
associatedtype T where T: CmdArgs
var args: T { get }
func _run(_ state: CommandMutableState, stdin: String) -> Bool
func run(_ env: CmdEnv, _ io: CmdIo) -> Bool
}

extension Command {
@@ -21,24 +21,11 @@ extension Command {
var info: CmdStaticInfo { T.info }
}

class CommandMutableState {
var subject: CommandSubject
var stdout: [String] = []
var stderr: [String] = []

public init(_ subject: CommandSubject) {
self.subject = subject
}

static var focused: CommandMutableState { CommandMutableState(.focused) }
static var doesntMatter: CommandMutableState = focused
}

extension Command {
@discardableResult
func run(_ state: CommandMutableState, stdin: String = "") -> Bool {
func run(_ env: CmdEnv, _ stdin: CmdStdin) -> CmdResult {
check(Thread.current.isMainThread)
return [self]._run(state, stdin: stdin)
return [self].runCmdSeq(env, stdin)
}

var isExec: Bool { self is ExecAndForgetCommand }
@@ -50,64 +37,21 @@ extension Command {
// 3. on-window-detected callback
// 4. Tray icon buttons
extension [Command] {
func run(_ state: CommandMutableState) -> Bool {
_run(state, stdin: "")
}

// fileprivate because don't want to expose an interface where a more than one commands have shared stdin
fileprivate func _run(_ state: CommandMutableState, stdin: String = "") -> Bool {
func runCmdSeq(_ env: CmdEnv, _ io: CmdIo) -> Bool {
check(Thread.current.isMainThread)
check(self.count == 1 || stdin.isEmpty)
var result = true
var isSucc = true
for command in self {
if TrayMenuModel.shared.isEnabled || isAllowedToRunWhenDisabled(command) {
result = command._run(state, stdin: stdin) && result
isSucc = command.run(env, io) && isSucc
refreshModel()
}
}
return result
}
}

enum CommandSubject: Equatable {
case emptyWorkspace(String)
case window(Window)
static var focused: CommandSubject { focus.asLeaf.asCommandSubject }
}

extension EffectiveLeaf {
var asCommandSubject: CommandSubject {
switch focus.asLeaf {
case .window(let w): .window(w)
case .emptyWorkspace(let w): .emptyWorkspace(w.name)
}
}
}

extension CommandSubject {
var windowOrNil: Window? {
return switch self {
case .window(let window): window
case .emptyWorkspace: nil
}
}

var workspace: Workspace {
return switch self {
case .window(let window): window.visualWorkspace ?? focus.workspace
case .emptyWorkspace(let workspaceName): Workspace.get(byName: workspaceName)
}
}
}

extension CommandMutableState {
func failCmd(msg: String) -> Bool {
stderr.append(msg)
return false
return isSucc
}

func succCmd(msg: String) -> Bool {
stdout.append(msg)
return true
func runCmdSeq(_ env: CmdEnv, _ stdin: CmdStdin) -> CmdResult {
let io: CmdIo = CmdIo(stdin: stdin)
let isSucc = runCmdSeq(env, io)
return CmdResult(stdout: io.stdout, stderr: io.stderr, exitCode: isSucc ? 0 : 1)
}
}
47 changes: 47 additions & 0 deletions Sources/AppBundle/command/cmdResolveFocusOrReportError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import Common

extension CmdArgs {
var workspace: Workspace? {
if let workspaceName { Workspace.get(byName: workspaceName) } else { nil }
}

func resolveFocusOrReportError(_ env: CmdEnv, _ io: CmdIo) -> LiveFocus? {
// Flags
if let windowId {
if let wi = MacWindow.allWindowsMap[windowId] {
return wi.toLiveFocusOrReportError(io)
} else {
io.err("Invalid <window-id> \(windowId) passed to --window-id")
return nil
}
}
if let workspace {
return workspace.toLiveFocus()
}
// Env
if let windowId = env.windowId {
if let wi = MacWindow.allWindowsMap[windowId] {
return wi.toLiveFocusOrReportError(io)
} else {
io.err("Invalid <window-id> \(windowId) specified in \(AEROSPACE_FOCUSED_WINDOW_ID) env variable")
return nil
}
}
if let wsName = env.workspaceName {
return Workspace.get(byName: wsName).toLiveFocus()
}
// Real Focus
return focus
}
}

extension Window {
func toLiveFocusOrReportError(_ io: CmdIo) -> LiveFocus? {
if let result = toLiveFocusOrNil() {
return result
} else {
io.err("Window \(windowId) doesn't belong to any monitor. And thus can't even define a focused workspace")
return nil
}
}
}
23 changes: 12 additions & 11 deletions Sources/AppBundle/command/impl/BalanceSizesCommand.swift
Original file line number Diff line number Diff line change
@@ -5,21 +5,22 @@ import Foundation
struct BalanceSizesCommand: Command {
let args: BalanceSizesCmdArgs

func _run(_ state: CommandMutableState, stdin: String) -> Bool {
func run(_ env: CmdEnv, _ io: CmdIo) -> Bool {
check(Thread.current.isMainThread)
balance(state.subject.workspace.rootTilingContainer)
guard let focus = args.resolveFocusOrReportError(env, io) else { return false }
balance(focus.workspace.rootTilingContainer)
return true
}
}

func balance(_ parent: TilingContainer) {
for child in parent.children {
switch parent.layout {
case .tiles: child.setWeight(parent.orientation, 1)
case .accordion: break // Do nothing
}
if let child = child as? TilingContainer {
balance(child)
}
private func balance(_ parent: TilingContainer) {
for child in parent.children {
switch parent.layout {
case .tiles: child.setWeight(parent.orientation, 1)
case .accordion: break // Do nothing
}
if let child = child as? TilingContainer {
balance(child)
}
}
}
Original file line number Diff line number Diff line change
@@ -4,20 +4,19 @@ import Common
struct CloseAllWindowsButCurrentCommand: Command {
let args: CloseAllWindowsButCurrentCmdArgs

func _run(_ state: CommandMutableState, stdin: String) -> Bool {
func run(_ env: CmdEnv, _ io: CmdIo) -> Bool {
check(Thread.current.isMainThread)
guard let focused = state.subject.windowOrNil else {
return state.failCmd(msg: "Empty workspace")
guard let focus = args.resolveFocusOrReportError(env, io) else { return false }
guard let focused = focus.windowOrNil else {
return io.err("Empty workspace")
}
var result = true
guard let workspace = focused.workspace else {
return state.failCmd(msg: "Focused window '\(focused.title)' doesn't belong to workspace")
return io.err("Focused window '\(focused.title)' doesn't belong to workspace")
}
var result = true
for window in workspace.allLeafWindowsRecursive where window != focused {
state.subject = .window(window)
result = CloseCommand(args: args.closeArgs).run(state) && result
result = CloseCommand(args: args.closeArgs).run(env, io) && result
}
state.subject = .window(focused)
return result
}
}
20 changes: 8 additions & 12 deletions Sources/AppBundle/command/impl/CloseCommand.swift
Original file line number Diff line number Diff line change
@@ -4,30 +4,26 @@ import Common
struct CloseCommand: Command {
let args: CloseCmdArgs

func _run(_ state: CommandMutableState, stdin: String) -> Bool {
func run(_ env: CmdEnv, _ io: CmdIo) -> Bool {
check(Thread.current.isMainThread)
guard let window = state.subject.windowOrNil else {
return state.failCmd(msg: "Empty workspace")
guard let focus = args.resolveFocusOrReportError(env, io) else { return false }
guard let window = focus.windowOrNil else {
return io.err("Empty workspace")
}
if window.macAppUnsafe.axApp.get(Ax.windowsAttr)?.count == 1 && args.quitIfLastWindow {
if window.macAppUnsafe.nsApp.terminate() {
successfullyClosedWindow(state, window)
window.asMacWindow().garbageCollect()
return true
} else {
return state.failCmd(msg: "Failed to quit '\(window.app.name ?? "Unknown app")'")
return io.err("Failed to quit '\(window.app.name ?? "Unknown app")'")
}
} else {
if window.close() {
successfullyClosedWindow(state, window)
window.asMacWindow().garbageCollect()
return true
} else {
return state.failCmd(msg: "Can't close '\(window.app.name ?? "Unknown app")' window. Probably the window doesn't have a close button")
return io.err("Can't close '\(window.app.name ?? "Unknown app")' window. Probably the window doesn't have a close button")
}
}
}
}

private func successfullyClosedWindow(_ state: CommandMutableState, _ window: Window) {
window.asMacWindow().garbageCollect()
state.subject = .focused
}
Loading

0 comments on commit 61594ee

Please sign in to comment.