Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ struct ExtensionPreview: View {
case "calendar": calendarPreview(phase: phase)
case "notchShelf": notchPreview(phase: phase)
case "clipboard": clipboardPreview(phase: phase)
case "windowSwitcher": windowSwitcherPreview(phase: phase)
case "keystrokeHighlight": keystrokeHighlightPreview(phase: phase)
case "music": musicPreview(animating: animating)
case "focusDim": focusDimPreview(phase: phase)
Expand Down Expand Up @@ -432,6 +433,34 @@ struct ExtensionPreview: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

private func windowSwitcherPreview(phase: Double) -> some View {
let selected = Int(phase.rounded(.down)) % 3
return HStack(spacing: UIScale.pt(7)) {
ForEach(0..<3) { index in
VStack(spacing: UIScale.pt(4)) {
RoundedRectangle(cornerRadius: UIScale.pt(5))
.fill(
index == selected
? DashSkin.accent(dark).opacity(0.22)
: DashSkin.paper(dark)
)
.overlay {
Image(systemName: ["safari", "doc.text", "terminal"][index])
.font(.system(size: UIScale.pt(12), weight: .semibold))
.foregroundStyle(
index == selected
? DashSkin.accent(dark) : DashSkin.inkSoft(dark))
}
.frame(width: UIScale.pt(36), height: UIScale.pt(28))
Capsule()
.fill(DashSkin.inkFaint(dark).opacity(0.45))
.frame(width: UIScale.pt(24), height: UIScale.pt(3))
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

private func musicPreview(animating: Bool) -> some View {
HStack(spacing: UIScale.pt(13)) {
PlaybackWave(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -664,7 +664,7 @@ private struct ExtensionSettingsSheet: View {
case "machines": 420
case "lidAwake": 400
case "music": 460
case "focusDim", "colorPicker", "keystrokeHighlight": 430
case "focusDim", "colorPicker", "keystrokeHighlight", "windowSwitcher": 430
case "system": 500
case "notchShelf", "presenter": 580
default: 620
Expand Down Expand Up @@ -1063,6 +1063,7 @@ private struct ExtensionDetailRows: View {
case .notchShelf: NotchShelfRows()
case .audioMixer: AudioMixerRows()
case .clipboard: ClipboardRows()
case .windowSwitcher: WindowSwitcherRows()
case .keystrokeHighlight: KeystrokeHighlightRows()
case .focusDim: FocusDimRows()
case .presenter: PresenterRows()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import EdithKit
import SwiftUI

struct WindowSwitcherRows: View {
@AppStorage(AppStorageKeys.WindowSwitcher.enabled, store: SharedDefaults.store) private
var enabled = false
@AppStorage(AppStorageKeys.WindowSwitcher.grouped, store: SharedDefaults.store) private
var grouped = true
@AppStorage(AppStorageKeys.WindowSwitcher.includedApps, store: SharedDefaults.store) private
var includedApps = ""
@AppStorage(AppStorageKeys.WindowSwitcher.hiddenApps, store: SharedDefaults.store) private
var hiddenApps = ""

var body: some View {
Section("Switcher") {
Button("Open Window Switcher") {
IPC.post(
IPC.Name.requestWindowSwitcherOperation,
userInfo: [
WindowSwitcherIPC.requestIDKey: UUID().uuidString,
WindowSwitcherIPC.operationKey: WindowSwitcherOperation.show.rawValue,
])
}
Toggle(
"Group windows by application",
isOn: $grouped.configured(AppStorageKeys.WindowSwitcher.grouped))
LabeledContent {
HotKeyRecorderControl(
keyPrefix: "windowSwitcherShowHotKey", defaultLabel: "⌥Tab")
} label: {
Text("Show shortcut")
}
LabeledContent {
HotKeyRecorderControl(
keyPrefix: "windowSwitcherCycleHotKey", defaultLabel: "⌥`")
} label: {
Text("Cycle front app")
}
}
.disabled(!enabled)
.opacity(enabled ? 1 : 0.5)

Section("Application rules") {
LabeledContent("Always include") {
TextField(
"com.example.Helper",
text: $includedApps.configured(AppStorageKeys.WindowSwitcher.includedApps))
}
Text("Always include these bundle identifiers, separated by commas.")
.settingsCaption()
LabeledContent("Hide") {
TextField(
"com.example.Private",
text: $hiddenApps.configured(AppStorageKeys.WindowSwitcher.hiddenApps))
}
Text("Hide these bundle identifiers, separated by commas. Hide rules take priority.")
.settingsCaption()
}
.disabled(!enabled)
.opacity(enabled ? 1 : 0.5)
}
}
6 changes: 6 additions & 0 deletions Packages/Edith/Sources/EdithCLI/CommandTree.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ public enum CommandTree {
typealias Spec = CommandSpec

static let specs: [String: Spec] = [
"ed windows": Spec(options: ["--json"]),
"ed windows ls": Spec(options: ["--json"]),
"ed windows show": Spec(options: ["--json"]),
"ed windows activate": Spec(options: ["--json"]),
"ed windows cycle": Spec(options: ["--json"]),

"ed": Spec(options: ["--help", "--version"]),
"ed guide": Spec(options: ["--json"], arguments: [.guideTopic]),
"ed version": Spec(options: ["--json", "-h", "--help", "--version"]),
Expand Down
1 change: 1 addition & 0 deletions Packages/Edith/Sources/EdithCLI/Commands/Root.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ public struct EdRoot: AsyncParsableCommand {
PresenterCommand.self,
HerdrCommand.self,
ClipboardCommand.self,
WindowSwitcherCommand.self,
AttentionCommand.self,
DownloadCommand.self,
AppsCommand.self,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import ArgumentParser
import EdithKit
import Foundation

struct WindowSwitcherCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "windows", abstract: "Search and activate macOS windows.",
subcommands: [
WindowSwitcherListCommand.self, WindowSwitcherShowCommand.self,
WindowSwitcherActivateCommand.self, WindowSwitcherCycleCommand.self,
], defaultSubcommand: WindowSwitcherListCommand.self, aliases: ["window-switcher"])
}

enum WindowSwitcherBridge {
static func request(
_ operation: WindowSwitcherOperation, windowID: String? = nil,
timeout: TimeInterval = 4
) async throws -> [WindowSwitcherWindow] {
try AppBridge.requireHelper("window switching")
let requestID = UUID().uuidString
var request: [String: Any] = [
WindowSwitcherIPC.requestIDKey: requestID,
WindowSwitcherIPC.operationKey: operation.rawValue,
]
if let windowID { request[WindowSwitcherIPC.windowIDKey] = windowID }
let info = request
guard
let reply = await AppBridge.awaitReply(
IPC.Name.windowSwitcherOperationResult, timeout: timeout,
matching: {
$0[WindowSwitcherIPC.requestIDKey] as? String == requestID
},
trigger: {
AppBridge.post(IPC.Name.requestWindowSwitcherOperation, userInfo: info)
})
else {
throw AppBridge.silence(
"window switching", extensionKey: AppStorageKeys.WindowSwitcher.enabled,
permission: "accessibility")
}
switch reply[WindowSwitcherIPC.statusKey] as? String {
case "ok":
return WindowSwitcherIPC.decode(
reply[WindowSwitcherIPC.payloadKey] as? String ?? "[]")
case "extensionOff":
throw CLIFailure.unavailable(
"the Window Switcher extension is off",
hint: "run `ed extensions setup windowSwitcher`")
case "notAuthorized":
throw CLIFailure.unavailable(
"macOS has not granted Edith Accessibility access",
hint: "run `ed permissions request accessibility`")
case "notFound":
throw CLIFailure.notFound(
windowID.map { "no window with id \($0)" } ?? "no switchable window found",
hint: "run `ed windows ls --json` and retry")
default:
throw CLIFailure.usage("the window switcher rejected the operation")
}
}
}

struct WindowSwitcherListCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "ls", abstract: "List switchable windows.", aliases: ["list"])

@Flag(name: .long, help: "Emit JSON on stdout.")
var json = false

func run() async throws {
try await execute {
let windows = try await WindowSwitcherBridge.request(.list)
guard !json else {
CLIOut.json(
.array(
windows.map { window in
.object([
"id": .string(window.id),
"app": .string(window.appName),
"bundleIdentifier": .string(window.bundleIdentifier),
"title": .string(window.displayTitle),
"minimized": .bool(window.isMinimized),
"pid": .int(Int(window.pid)),
])
}))
return
}
CLIOut.out(
TextTable.render(
headers: ["ID", "APP", "WINDOW", "STATE"],
rows: windows.map {
[
$0.id, $0.appName, $0.displayTitle,
$0.isMinimized ? "minimized" : "open",
]
}))
}
}
}

struct WindowSwitcherShowCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "show", abstract: "Open the searchable switcher.")

@Flag(name: .long, help: "Emit JSON on stdout.")
var json = false

func run() async throws {
try await execute {
_ = try await WindowSwitcherBridge.request(.show)
if json {
CLIOut.json(.object(["action": .string("show"), "opened": .bool(true)]))
} else {
CLIOut.out("opened Window Switcher")
}
}
}
}

struct WindowSwitcherActivateCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "activate", abstract: "Activate one window.")

@Flag(name: .long, help: "Emit JSON on stdout.")
var json = false

@Argument(help: "Window ID from `ed windows ls`.")
var window: String

func run() async throws {
try await execute {
_ = try await WindowSwitcherBridge.request(.activate, windowID: window)
if json {
CLIOut.json(
.object([
"action": .string("activate"), "id": .string(window),
"activated": .bool(true),
]))
} else {
CLIOut.out("activated \(window)")
}
}
}
}

struct WindowSwitcherCycleCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "cycle", abstract: "Cycle windows of the front application.")

@Flag(name: .long, help: "Emit JSON on stdout.")
var json = false

func run() async throws {
try await execute {
_ = try await WindowSwitcherBridge.request(.cycle)
if json {
CLIOut.json(.object(["action": .string("cycle"), "cycled": .bool(true)]))
} else {
CLIOut.out("cycled front application windows")
}
}
}
}
6 changes: 6 additions & 0 deletions Packages/Edith/Sources/EdithCLI/Guide.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ public enum Guide {
ed permissions ls every macOS permission Edith uses
ed color pick open Edith's system colour sampler
ed color copy 1 --format hex
ed windows ls open and minimized windows Edith can focus
ed windows show open the searchable Window Switcher
ed emoji pick open Edith's emoji picker
ed usage sources the agents that produced your usage history
ed usage export branded PNG cards for sharing your activity
Expand Down Expand Up @@ -422,6 +424,10 @@ public enum Guide {
ed calendar join <event-id> open the event's meeting link
ed calendar directions <event> open its location in Maps
ed calendar open open the Calendar application
ed windows ls open and minimized windows
ed windows show open the searchable switcher
ed windows activate <id> restore and focus one listed window
ed windows cycle next window of the front application
ed presenter status | start | stop
ed herdr ls live Herdr sessions here and over SSH
ed herdr attach w3:p1N attach this terminal to a live pane
Expand Down
34 changes: 34 additions & 0 deletions Packages/Edith/Sources/EdithCore/ExtensionLifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,40 @@ public enum ExtensionLifecycleCatalog {
"list", "List recent copies", "Confirm clipboard history can be read.",
"ed clipboard ls --json")
]),
descriptor(
"windowSwitcher", "Find and focus any open window without capturing the screen.",
workflows: [
instruction(
"search", "Search windows",
"Filter open and minimized windows by application or title."),
instruction(
"cycle", "Cycle one app",
"Move through every window owned by the front application."),
],
prerequisites: [
instruction(
"permission", "Grant Accessibility",
"Accessibility lets Edith read window titles and focus your selection.",
"ed permissions request accessibility")
],
examples: [
"ed extensions enable windowSwitcher", "ed windows ls --json",
"ed windows show",
],
docs: [
documentation("guide", "Window Switcher guide", "docs/cli/windows/README.md")
],
recovery: [
instruction(
"permission", "Refresh Accessibility",
"Refresh the mirrored grant after changing System Settings.",
"ed permissions refresh")
],
verification: [
instruction(
"list", "List windows", "Confirm Edith can enumerate open windows.",
"ed windows ls --json")
]),
descriptor(
"keystrokeHighlight",
"Show keyboard input as clear keycaps that disappear automatically.",
Expand Down
6 changes: 6 additions & 0 deletions Packages/Edith/Sources/EdithCore/ExtensionRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,12 @@ public enum ExtensionRegistry {
symbolName: "doc.on.clipboard", suite: .desk, host: .bar, featured: true,
defaultsKey: "clipboardEnabled", requiredCapabilities: [.clipboardHistory],
optionalCapabilities: [.globalPaste, .globalShortcuts]),
ExtensionRegistryEntry(
id: "windowSwitcher", title: "Window Switcher",
subtitle: "Search every window or cycle through the front app.",
symbolName: "rectangle.stack.fill", suite: .desk, host: .bar, featured: true,
defaultsKey: "windowSwitcherEnabled", requiredCapabilities: [.windowManagement],
optionalCapabilities: [.globalShortcuts]),
ExtensionRegistryEntry(
id: "emoji", title: "Emoji Picker",
subtitle: "Every macOS emoji on a hotkey, straight into the app you are typing in.",
Expand Down
2 changes: 2 additions & 0 deletions Packages/Edith/Sources/EdithCore/PlatformCapabilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public enum PlatformCapability: String, CaseIterable, Codable, Hashable, Sendabl
case systemMetrics
case usageCollection
case windowDimming
case windowManagement
}

public enum PlatformCapabilityState: Equatable, Sendable {
Expand Down Expand Up @@ -105,6 +106,7 @@ public struct PlatformCapabilities: Equatable, Sendable {
.screenColorSampling: .permissionRequired,
.screenShareDetection: .permissionRequired,
.windowDimming: .permissionRequired,
.windowManagement: .permissionRequired,
]))
}

Expand Down
Loading
Loading