Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Semper/Modules/ModuleRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ enum UtilityModuleID: String, CaseIterable, Codable, Hashable, Identifiable, Sen
case awake
case displays
case workspace
case windowLayout = "window-layout"
case shelf
case storage
case scenes
Expand Down Expand Up @@ -65,6 +66,20 @@ struct UtilityModuleDescriptor: Identifiable, Equatable, Sendable {
.init(
id: .workspace, title: "Workspace Restore", summary: "Save and restore app window positions.",
symbolName: "macwindow.on.rectangle"),
.init(
id: .windowLayout, title: "Window Layout", summary: "Arrange the frontmost app window.",
symbolName: "rectangle.split.2x1",
disclosure: .init(
permissionReasons: [
.init(name: "Accessibility", reason: "Reads and arranges a window only when you invoke an action.")
],
runningBackgroundPolicy:
"Remembers the last active app while running. Window reads and changes happen only on request; pausing stops observation and drains pending work.",
localDataPolicy:
"Window identity and the previous placement stay in memory. Pausing preserves them; removing the module or quitting clears them. No window titles are collected.",
conflicts: ["Finish active Workspace Restore work and end Away before arranging windows."],
hardwareRequirements: ["Requires standard windows with readable geometry and move/resize support. Full-height windows and targets are conservatively refused."]
)),
.init(
id: .shelf, title: "File Shelf", summary: "Keep references to files close at hand.", symbolName: "tray.fill"
),
Expand Down
1 change: 1 addition & 0 deletions Semper/Modules/ShellUITestFixture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
soundFactory: { _, _ in try probe.refuse(.sound) },
awakeFactory: { try probe.refuse(.awake) },
workspaceFactory: { try probe.refuse(.workspace) },
windowLayoutFactory: { _ in try probe.refuse(.windowLayout) },
shelfFactory: { try probe.refuse(.shelf) },
storageFactory: { try probe.refuse(.storage) },
sceneLibraryStore: FileSceneLibraryStore(directory: sceneDirectory),
Expand Down
2 changes: 1 addition & 1 deletion Semper/Modules/UtilityLifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ final class UtilityLifecycle {
for task in stops { _ = await task.result }
// Composed sessions restore before their underlying services stop.
let order: [UtilityModuleID] = [
.away, .presentation, .scenes, .workspace, .shelf, .storage, .displays, .sound, .awake,
.away, .presentation, .scenes, .windowLayout, .workspace, .shelf, .storage, .displays, .sound, .awake,
]
var retainedServices: [UtilityModuleID: String] = [:]
for id in order {
Expand Down
92 changes: 84 additions & 8 deletions Semper/Modules/UtilityRuntime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ final class UtilityRuntime {
private(set) var workspace: WorkspaceService?
private(set) var workspaceWorkflowRequest: WorkspaceWorkflowRequest?
private(set) var workspaceShortcutConflict: String?
private(set) var windowLayout: WindowLayoutService?
private(set) var windowLayoutShortcutConflicts: [ShortcutAction: String] = [:]
private(set) var shelf: ShelfService?
private(set) var storage: SafeEjectService?
private(set) var scenes: SceneManager?
Expand All @@ -57,6 +59,7 @@ final class UtilityRuntime {
@MainActor (AudioEngine.SharedDDCController?, MutationAdmissionGate) throws -> DisplayControlService
@ObservationIgnored private let awakeFactory: @MainActor () throws -> AwakeService
@ObservationIgnored private let workspaceFactory: @MainActor () throws -> WorkspaceService
@ObservationIgnored private let windowLayoutFactory: @MainActor (MutationAdmissionGate) throws -> WindowLayoutService
@ObservationIgnored private let shelfFactory: @MainActor () throws -> ShelfService
@ObservationIgnored private let storageFactory: @MainActor () throws -> SafeEjectService
@ObservationIgnored private let sceneLibraryStore: (any SceneLibraryStoring)?
Expand Down Expand Up @@ -145,6 +148,9 @@ final class UtilityRuntime {
soundFactory: (@MainActor (SettingsManager, AudioEngine.SharedDDCController?) throws -> SoundRuntime)? = nil,
awakeFactory: (@MainActor () throws -> AwakeService)? = nil,
workspaceFactory: @escaping @MainActor () throws -> WorkspaceService = { WorkspaceService() },
windowLayoutFactory: @escaping @MainActor (MutationAdmissionGate) throws -> WindowLayoutService = {
WindowLayoutService(mutationAdmission: $0)
},
shelfFactory: @escaping @MainActor () throws -> ShelfService = { ShelfService() },
storageFactory: @escaping @MainActor () throws -> SafeEjectService = { SafeEjectService() },
sceneLibraryStore: (any SceneLibraryStoring)? = nil,
Expand All @@ -168,6 +174,7 @@ final class UtilityRuntime {
#endif
}
self.workspaceFactory = workspaceFactory
self.windowLayoutFactory = windowLayoutFactory
self.shelfFactory = shelfFactory
self.storageFactory = storageFactory
self.sceneLibraryStore = sceneLibraryStore
Expand Down Expand Up @@ -231,6 +238,11 @@ final class UtilityRuntime {
recordShellShortcut(shortcut, action: .toggleAwayMode)
}

func recordWindowLayoutShortcut(_ shortcut: KeyboardShortcuts.Shortcut?, action: ShortcutAction) {
guard action.windowLayoutAction != nil else { return }
recordShellShortcut(shortcut, action: action)
}

private func recordShellShortcut(_ shortcut: KeyboardShortcuts.Shortcut?, action: ShortcutAction) {
guard !shutdownRequested else { return }
let recorded = shortcut.map(ShortcutCodable.from)
Expand All @@ -251,8 +263,7 @@ final class UtilityRuntime {
let candidates =
recording
? ShortcutAction.allCases
: ShortcutAction.soundActions
+ (action == .toggleAwayMode ? [.restoreWorkspace] : [])
: ShortcutAction.soundActions + ShortcutAction.shellActions.prefix { $0 != action }
if let owner = candidates.first(where: { $0 != action && $0.assignedShortcut(in: settings) == shortcut }) {
return "Already used by \(owner.displayName)."
}
Expand All @@ -267,11 +278,14 @@ final class UtilityRuntime {
}

private func setShellShortcutConflict(_ conflict: String?, action: ShortcutAction) {
if action == .restoreWorkspace { workspaceShortcutConflict = conflict } else { awayShortcutConflict = conflict }
if action == .restoreWorkspace { workspaceShortcutConflict = conflict }
else if action == .toggleAwayMode { awayShortcutConflict = conflict }
else if action.windowLayoutAction != nil { windowLayoutShortcutConflicts[action] = conflict }
}

private func shellShortcutModule(_ action: ShortcutAction) -> UtilityModuleID {
action == .restoreWorkspace ? .workspace : .away
if action.windowLayoutAction != nil { return .windowLayout }
return action == .restoreWorkspace ? .workspace : .away
}

private func shellShortcutAvailable(_ action: ShortcutAction) -> Bool {
Expand Down Expand Up @@ -362,7 +376,8 @@ final class UtilityRuntime {
guard !Task.isCancelled, self.shellShortcutRegistrations[action] == generation,
self.shellShortcutAvailable(action)
else { return .cancelled }
let command = action == .restoreWorkspace ? WorkspaceCommand.restore.rawValue : "away.toggle"
let command = action.windowLayoutAction?.rawValue
?? (action == .restoreWorkspace ? WorkspaceCommand.restore.rawValue : "away.toggle")
let result = await self.commands.execute(.init(rawValue: command))
guard !self.shutdownRequested else { return .cancelled }
switch result {
Expand All @@ -383,6 +398,11 @@ final class UtilityRuntime {
await performShellShortcut(.toggleAwayMode)
}

func performWindowLayoutShortcut(_ action: ShortcutAction) async -> UtilityCommandResult {
guard action.windowLayoutAction != nil else { return .cancelled }
return await performShellShortcut(action)
}

private func performShellShortcut(_ action: ShortcutAction) async -> UtilityCommandResult {
guard let task = beginShellShortcut(action) else { return .cancelled }
return await withTaskCancellationHandler {
Expand Down Expand Up @@ -569,7 +589,7 @@ final class UtilityRuntime {
defer {
stoppingModules.remove(module)
observeStatus(for: module)
if [.workspace, .away].contains(module) { syncShellShortcuts() }
if [.workspace, .away, .windowLayout].contains(module) { syncShellShortcuts() }
}
for action in ShortcutAction.shellActions where shellShortcutModule(action) == module {
stopShellShortcut(action)
Expand All @@ -594,7 +614,7 @@ final class UtilityRuntime {
defer {
stoppingModules.remove(module)
observeStatus(for: module)
if [.workspace, .away].contains(module) { syncShellShortcuts() }
if [.workspace, .away, .windowLayout].contains(module) { syncShellShortcuts() }
}
for action in ShortcutAction.shellActions where shellShortcutModule(action) == module {
stopShellShortcut(action)
Expand Down Expand Up @@ -640,6 +660,10 @@ final class UtilityRuntime {
let count = workspace.arrangements.count
return "\(count) \(count == 1 ? "arrangement" : "arrangements"), "
+ (workspace.canUndo ? "undo available" : "no restore to undo")
case .windowLayout:
guard let windowLayout else { return "Open Window Layout to arrange a window." }
if let message = windowLayout.message { return message }
return windowLayout.canRestore ? "Previous window placement available" : "Ready for a window action"
case .shelf:
guard let shelf else { return "Open File Shelf to collect items." }
return "\(shelf.items.count) \(shelf.items.count == 1 ? "item" : "items") on the shelf"
Expand Down Expand Up @@ -897,6 +921,24 @@ final class UtilityRuntime {
self.workspace = nil
}
}))
try lifecycle.register(
.windowLayout,
binding: UtilityServiceBinding(
start: { [weak self] in
guard let self else { throw CancellationError() }
if self.windowLayout == nil {
self.windowLayout = try self.windowLayoutFactory(self.mutationAdmission)
}
self.windowLayout?.start()
},
stop: { [weak self] reason in
guard let self, let service = self.windowLayout else { return }
if reason == .pause { await service.pause() }
else {
await service.shutdown()
self.windowLayout = nil
}
}))
try lifecycle.register(
.shelf,
binding: UtilityServiceBinding(
Expand Down Expand Up @@ -1047,7 +1089,7 @@ final class UtilityRuntime {
return sound.audioCommands.dispatch(command, context: context)
})
for module in registry.modules
where [.sound, .awake, .workspace, .scenes, .displays, .presentation, .away].contains(module.id) {
where [.sound, .awake, .workspace, .windowLayout, .scenes, .displays, .presentation, .away].contains(module.id) {
actions.append(
UtilityActionHandler(
descriptor: .init(
Expand Down Expand Up @@ -1132,6 +1174,29 @@ final class UtilityRuntime {
}
}))
}
for action in WindowLayoutAction.allCases {
actions.append(
UtilityActionHandler(
descriptor: .init(
id: .init(rawValue: action.rawValue), module: .windowLayout, title: action.title,
keywords: ["window", "layout", "arrange", "snap", "position"], symbolName: action.symbolName),
disabledReason: { [weak self] in
if self?.windowLayout?.isBusy == true { return "A window action is still running." }
if self?.windowLayout?.requiresPlacementReview == true {
return "Review the unverified window placement in Window Layout."
}
if action == .restore, self?.windowLayout?.canRestore != true {
return "No previous window placement is available."
}
return nil
},
perform: { [weak self] in
guard let self else { throw CancellationError() }
try await self.start(.windowLayout)
guard let service = self.windowLayout else { throw CancellationError() }
try await service.perform(action)
}))
}
let shelfMetadata = ShelfModuleRegistration()
for command in [ShelfCommand.open, .clear] {
actions.append(
Expand Down Expand Up @@ -1509,6 +1574,17 @@ final class UtilityRuntime {
}
return .init(runtime: runtime, permission: permission)
}
case .windowLayout:
guard let service = windowLayout else { return }
statusObserver.observe(module: module) {
let state: ModuleRuntimeState
if service.isBusy { state = .active }
else if service.requiresPlacementReview { state = .limited(reason: service.message ?? "Review the window placement.") }
else if service.permission == .denied || service.permission == .revoked {
state = .limited(reason: "Accessibility access is unavailable.")
} else { state = .ready }
return .init(runtime: state, permission: service.permission)
}
case .shelf:
guard let shelf else { return }
statusObserver.observe(module: module) {
Expand Down
18 changes: 18 additions & 0 deletions Semper/Modules/UtilityShellView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,13 @@ struct UtilityShellView: View {
} else {
startModule(id)
}
case .windowLayout:
if let service = runtime.windowLayout {
WindowLayoutView(service: service, commands: runtime.commands)
.disabled(runtime.lifecycle.stopping.contains(id) || runtime.lifecycle.isShuttingDown)
} else {
startModule(id)
}
case .shelf:
if let shelf = runtime.shelf {
ShelfDetailView(service: shelf)
Expand Down Expand Up @@ -411,6 +418,17 @@ struct UtilitySettingsView: View {
if let conflict = runtime.awayShortcutConflict {
Text(conflict).font(.caption).foregroundStyle(.orange)
}
Text("Window Layout").font(.headline)
Text("Optional shortcuts arrange the frontmost app window. Add and resume Window Layout before using them.")
.font(.caption).foregroundStyle(.secondary)
ForEach(ShortcutAction.windowLayoutActions, id: \.self) { action in
KeyboardShortcuts.Recorder(
action.displayName, name: action.keyboardShortcutName,
onChange: { runtime.recordWindowLayoutShortcut($0, action: action) })
if let conflict = runtime.windowLayoutShortcutConflicts[action] {
Text(conflict).font(.caption).foregroundStyle(.orange)
}
}
if let sound = runtime.usableSound {
ShortcutsTab(
settings: runtime.settings, accessibility: sound.accessibility,
Expand Down
34 changes: 32 additions & 2 deletions Semper/Shortcuts/ShortcutAction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,29 @@ enum ShortcutAction: String, CaseIterable, Codable, Sendable {
case targetAppVolumeDown = "frontmostAppVolumeDown"
case targetAppMuteToggle = "frontmostAppMuteToggle"
case restoreWorkspace
case windowLeftHalf
case windowRightHalf
case windowMaximize
case windowCenter
case windowRestore

static var soundActions: [Self] { allCases.filter { !shellActions.contains($0) } }

static let shellActions: [Self] = [.restoreWorkspace, .toggleAwayMode]
static let windowLayoutActions: [Self] = [
.windowLeftHalf, .windowRightHalf, .windowMaximize, .windowCenter, .windowRestore,
]
static let shellActions: [Self] = [.restoreWorkspace, .toggleAwayMode] + windowLayoutActions

var windowLayoutAction: WindowLayoutAction? {
switch self {
case .windowLeftHalf: .leftHalf
case .windowRightHalf: .rightHalf
case .windowMaximize: .maximize
case .windowCenter: .center
case .windowRestore: .restore
default: nil
}
}

var displayName: String {
switch self {
Expand All @@ -27,6 +46,11 @@ enum ShortcutAction: String, CaseIterable, Codable, Sendable {
case .targetAppVolumeDown: "App Volume Down"
case .targetAppMuteToggle: "App Mute"
case .restoreWorkspace: "Restore workspace"
case .windowLeftHalf: "Window Left Half"
case .windowRightHalf: "Window Right Half"
case .windowMaximize: "Maximize Window"
case .windowCenter: "Center Window"
case .windowRestore: "Restore Previous Window Placement"
}
}

Expand All @@ -36,7 +60,8 @@ enum ShortcutAction: String, CaseIterable, Codable, Sendable {
var supportsRepeat: Bool {
switch self {
case .targetAppVolumeUp, .targetAppVolumeDown: true
case .togglePopup, .toggleAwayMode, .targetAppMuteToggle, .restoreWorkspace: false
case .togglePopup, .toggleAwayMode, .targetAppMuteToggle, .restoreWorkspace,
.windowLeftHalf, .windowRightHalf, .windowMaximize, .windowCenter, .windowRestore: false
}
}

Expand All @@ -49,6 +74,11 @@ enum ShortcutAction: String, CaseIterable, Codable, Sendable {
case .targetAppVolumeDown: KeyboardShortcuts.Name("frontmost-app-volume-down")
case .targetAppMuteToggle: KeyboardShortcuts.Name("frontmost-app-mute-toggle")
case .restoreWorkspace: KeyboardShortcuts.Name("workspace-restore")
case .windowLeftHalf: KeyboardShortcuts.Name("window-layout-left-half")
case .windowRightHalf: KeyboardShortcuts.Name("window-layout-right-half")
case .windowMaximize: KeyboardShortcuts.Name("window-layout-maximize")
case .windowCenter: KeyboardShortcuts.Name("window-layout-center")
case .windowRestore: KeyboardShortcuts.Name("window-layout-restore")
}
}

Expand Down
2 changes: 1 addition & 1 deletion Semper/Shortcuts/ShortcutsRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ final class ShortcutsRegistry {
return adjustTargetVolume(direction: -1)
case .targetAppMuteToggle:
return toggleTargetMute()
case .restoreWorkspace:
case .restoreWorkspace, .windowLeftHalf, .windowRightHalf, .windowMaximize, .windowCenter, .windowRestore:
return false
}
}
Expand Down
5 changes: 5 additions & 0 deletions Semper/Utilities/MutationAdmissionGate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ nonisolated enum MutationAdmissionOwner: Hashable, Sendable {
case presentation
case manual
case manualDisplay
case manualWindow
case workspaceWindow
}

nonisolated enum MutationAdmissionMode: Sendable {
Expand Down Expand Up @@ -69,6 +71,9 @@ final class MutationAdmissionGate {
switch (owner, permit.owner) {
case (.scene, .manualDisplay), (.manualDisplay, .scene):
permit.owner
case (.manualWindow, .workspaceWindow), (.workspaceWindow, .manualWindow),
(.manualWindow, .manualWindow), (.workspaceWindow, .workspaceWindow):
permit.owner
default:
nil
}
Expand Down
Loading
Loading