diff --git a/README.md b/README.md index d7cac46..bdff958 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Contributors

-Control windows, files, displays, power, and sound from one native macOS menu bar app. Add the utilities you need, use them independently, or combine settings with Scenes and Presentation. Away provides an authenticated privacy curtain. +Semper is built exclusively for macOS. Control windows, files, displays, power, and sound from one native menu bar app. Add the utilities you need, use them independently, or combine settings with Scenes and Presentation. Away provides an authenticated privacy curtain. The current download, v1.0.0, contains Sound. Source builds include additional utilities in development toward the next release. The [product status guide](guide/product-status.md) distinguishes released, integrated, and staged features. @@ -58,8 +58,8 @@ not download a Semper DMG from an unofficial source. ## Architecture Highlights -- **Independent Utilities**: Home provides module summaries, attention items, up to four pinned actions, search, and recent action outcomes for the current session. Add, pause, or remove modules individually; adding a module starts no service and requests no permission. Detailed controls open in a native window. -- **Manual Window Layout**: Five optional actions arrange eligible windows into halves, maximize, center, or restore the preceding placement. Full-height windows and targets are refused, which can limit halves and maximize when both the Dock and menu bar auto-hide. Source integration and native acceptance are tracked in the [product status guide](guide/product-status.md#window-layout). +- **Independent Utilities**: Home puts up to four pinned actions and utility summaries first. Keyboard search runs actions with Up/Down and Return; the full catalog and session history expand on demand. Modules can be searched and filtered. Add, pause, or remove modules individually; adding a module starts no service and requests no permission. Detailed controls open in a native window. +- **Manual Window Layout**: Eleven actions arrange eligible windows into horizontal or vertical halves, four quarters, maximize, center, or restore the preceding placement. Each has an optional shortcut. Full-height windows and targets are refused, which can limit left/right halves and maximize when both the Dock and menu bar auto-hide. Source integration and native acceptance are tracked in the [product status guide](guide/product-status.md#window-layout). - **Local Image Copies**: File Shelf's Resize a Copy action saves one local JPEG or PNG at up to 1,024 or 2,048 pixels on its longest edge without enlargement or overwriting a file. It removes descriptive metadata and requires a destination that supports macOS file cloning. See the [image-copy guide](guide/shelf-image-copy.md) for format, size, recovery, and native acceptance limits. - **Local Awake Sessions**: Public IOKit power assertions prevent idle system sleep, optionally keep the display on, and keep timed user sessions separate from Scene requests. - **Authenticated Away Curtain**: One opaque panel covers each display, ordinary input is filtered, and local widgets can show time, battery, Away duration, and awake-request state. diff --git a/ROADMAP.md b/ROADMAP.md index 036d129..42d97da 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -38,10 +38,10 @@ The implementation from [PR #106](https://github.com/niharnm/Semper/pull/106) is integrated on `main` through [PR #110](https://github.com/niharnm/Semper/pull/110). Native acceptance remains open. See the [Window Layout guide](guide/window-layout.md). -- Verify all five manual commands, optional shortcuts, Home/search/pinned +- Verify all eleven source commands, including the six new half and quarter placements, optional shortcuts, Home/search/pinned actions, intended-window selection, and later manual changes on real apps. - Keep the conservative full-height exclusion explicit. Ordinary full-height - windows and targets are refused; halves and maximize can be unavailable when + windows and targets are refused; left/right halves and maximize can be unavailable when both Dock and menu bar auto-hide. Smaller-window center and restore still require eligible geometry. - After an attempted write, an excluded or unreadable result requires manual @@ -80,6 +80,9 @@ Native acceptance remains open. See the [Window Layout guide](guide/window-layou for input limits and remaining checks. No batch processing or uploads. - Awake and Away: keep power assertions and the curtain testable and honest about what they do not block. +- Proposed next additions from the workflow comparison: native Share for one + File Shelf item, and extending an active Awake session without losing its + stop conditions. These are proposals, not implemented features. - A new utility needs a clear local user job, no account requirement, the shared lifecycle and disclosure rules, and reuse of existing services where reasonable. @@ -108,7 +111,7 @@ Focused specialist tools set the expectations each Semper module must meet: - [FineTune](https://github.com/ronitsingh10/FineTune) for per-app audio with AutoEQ and ISO 226 loudness compensation -Semper has not benchmarked against these tools and claims no superiority. The +The macOS experience work compares their documented workflows and selected open-source structures. No controlled performance comparison or superiority claim has been established. The case for Semper is one shell with shared lifecycle, disclosure, and recovery rules, and modules that can work together. @@ -128,7 +131,7 @@ discussion and include a hardware test plan. ## Current boundaries -- Supported platform: macOS 15.4 or later. +- Platform commitment: macOS only, requiring macOS 15.4 or later. Product and interface work targets native Mac workflows; ports to other operating systems are outside the roadmap. - Downloadable today: v1.0.0, published 2026-08-26, with Sound. Source builds, unit tests, the static website, and signed releases through GitHub and Homebrew are current. @@ -140,4 +143,5 @@ discussion and include a hardware test plan. behavior. - Release-dependent: automatic updates require a current signed feed, and broad compatibility claims require verified hardware reports. -- Out of scope today: Windows, Linux, iOS, cloud accounts, and audio recording. +- Outside the product scope: Windows, Linux, and iOS versions. +- Out of scope today: cloud accounts and audio recording. diff --git a/Semper/Modules/ModuleLibraryView.swift b/Semper/Modules/ModuleLibraryView.swift index 80acf24..cf02425 100644 --- a/Semper/Modules/ModuleLibraryView.swift +++ b/Semper/Modules/ModuleLibraryView.swift @@ -1,60 +1,178 @@ import SwiftUI +enum ModuleLibraryFilter: String, CaseIterable, Identifiable { + case all = "All" + case added = "Added" + case available = "Available" + + var id: String { rawValue } + + @MainActor + func modules(in registry: ModuleRegistry, matching query: String = "") -> [UtilityModuleDescriptor] { + let terms = query.split(whereSeparator: \.isWhitespace) + return registry.modules.filter { module in + let presence = registry.state(for: module.id)?.presence + let included = + switch self { + case .all: true + case .added: presence == .added + case .available: presence == .available + } + let searchableText = "\(module.title) \(module.summary)" + return included && terms.allSatisfy { searchableText.localizedStandardContains(String($0)) } + } + } +} + struct ModuleLibraryView: View { let registry: ModuleRegistry let lifecycle: UtilityLifecycle let pause: (UtilityModuleID) async throws -> Void let remove: (UtilityModuleID) async throws -> Void var mutationDisabledReason: String? + var open: ((UtilityModuleID) -> Void)? = nil @State private var message: String? + @State private var searchText = "" + @State private var filter = ModuleLibraryFilter.all var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 8) { - Text("Modules").font(.title2.bold()) + Text("Modules").font(.largeTitle.weight(.semibold)) + .accessibilityAddTraits(.isHeader) + Text("Choose the controls you use on your Mac.").foregroundStyle(.secondary) + } + if let mutationDisabledReason { + Label(mutationDisabledReason, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + } + if let message { + Label(message, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + .accessibilityAddTraits(.updatesFrequently) + } + VStack(alignment: .leading, spacing: 10) { + TextField("Search modules", text: $searchText) + .textFieldStyle(.roundedBorder) + .controlSize(.large) + .accessibilityLabel("Search modules") + .onExitCommand { searchText = "" } + Picker("Show modules", selection: $filter) { + ForEach(ModuleLibraryFilter.allCases) { option in + Text("\(option.rawValue) (\(option.modules(in: registry, matching: searchText).count))") + .tag(option) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .accessibilityLabel("Show modules") Text( - "Add the controls you use. Adding a module starts no background work and requests no permission. Removing a module keeps its saved data." + "Adding a module starts no background work and requests no permission. Removing it keeps its saved data." ) + .font(.caption) .foregroundStyle(.secondary) } - if let mutationDisabledReason { - Text(mutationDisabledReason).foregroundStyle(.orange) - } - VStack(alignment: .leading, spacing: 0) { - ForEach(registry.modules) { module in - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .top, spacing: 12) { - Image(systemName: module.symbolName).font(.title3).frame(width: 28) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 4) { - Text(module.title).font(.headline) - Text(module.summary).foregroundStyle(.secondary) - Text(statusText(for: module.id)) - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - controls(for: module.id) - .controlSize(.small) - } - if let reason = attentionReason(for: module.id) { - Text(reason).font(.callout).foregroundStyle(.orange) - } - DisclosureGroup("Permissions, activity and data") { - moduleDetails(for: module) - .padding(.top, 10) - } - .font(.subheadline) + let modules = filter.modules(in: registry, matching: searchText) + if modules.isEmpty { + emptyState + } else { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 260), spacing: 12, alignment: .top)], spacing: 12) { + ForEach(modules) { module in + moduleCard(module) } - .padding(.vertical, 14) - if module.id != registry.modules.last?.id { Divider() } } } - if let message { Text(message).foregroundStyle(.orange).accessibilityAddTraits(.updatesFrequently) } } .padding(24) } + .background(Color(nsColor: .windowBackgroundColor)) + } + + private var emptyState: some View { + ContentUnavailableView { + Label( + searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? (filter == .added ? "No modules added" : "No modules available") + : "No matching modules", + systemImage: "square.grid.2x2" + ) + } description: { + if !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text("Try a tool name or what it does, such as audio, windows, or files.") + } else if filter == .added { + Text("Browse the library and add the tools you want to use.") + } else if filter == .available { + Text("There are no more modules to add on this Mac. Choose All to view the library.") + } else { + Text("Modules supported on this Mac will appear here.") + } + } actions: { + if !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Button("Clear Search") { searchText = "" } + } + if filter != .all { + Button("Show All Modules") { filter = .all } + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + } + + private func moduleCard(_ module: UtilityModuleDescriptor) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: module.symbolName) + .font(.title2) + .foregroundStyle(.tint) + .frame(width: 40, height: 40) + .background(Color.accentColor.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 4) { + Text(module.title).font(.headline) + .accessibilityAddTraits(.isHeader) + Text(statusText(for: module.id)) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + Text(module.summary) + .font(.callout) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 34, alignment: .topLeading) + .fixedSize(horizontal: false, vertical: true) + if let reason = attentionReason(for: module.id) { + Label(reason, systemImage: "exclamationmark.triangle") + .font(.callout).foregroundStyle(.orange) + } + HStack(spacing: 8) { + if registry.state(for: module.id)?.presence == .added, let open { + Button("Open") { open(module.id) } + .buttonStyle(.borderedProminent) + .accessibilityLabel("Open \(module.title)") + .disabled(lifecycle.isShuttingDown) + } + Spacer(minLength: 0) + controls(for: module.id) + } + .controlSize(.small) + Divider() + DisclosureGroup("Permissions, activity and data") { + moduleDetails(for: module) + .font(.callout) + .padding(.top, 10) + } + .font(.caption) + .accessibilityLabel("Permissions, activity and data for \(module.title)") + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 12)) + .overlay { + RoundedRectangle(cornerRadius: 12) + .strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1) + } } private func moduleDetails(for module: UtilityModuleDescriptor) -> some View { @@ -182,12 +300,16 @@ struct ModuleLibraryView: View { ProgressView().controlSize(.small).accessibilityLabel("Stopping module") } else if case .failed = state.runtime, registry.pausedModuleIDs.contains(id) { Button("Retry stop") { Task { await changeAsync { try await pause(id) } } } + .accessibilityLabel("Retry stopping \(registry.descriptor(for: id)?.title ?? id.rawValue)") } else if registry.pausedModuleIDs.contains(id) { Button("Resume") { change { try registry.resume(id) } } + .accessibilityLabel("Resume \(registry.descriptor(for: id)?.title ?? id.rawValue)") } else { Button("Pause") { Task { await changeAsync { try await pause(id) } } } + .accessibilityLabel("Pause \(registry.descriptor(for: id)?.title ?? id.rawValue)") } Button("Remove") { Task { await changeAsync { try await remove(id) } } } + .accessibilityLabel("Remove \(registry.descriptor(for: id)?.title ?? id.rawValue)") } .disabled(mutationDisabledReason != nil || lifecycle.stopping.contains(id) || lifecycle.isShuttingDown) .fixedSize(horizontal: true, vertical: false) diff --git a/Semper/Modules/UtilityActionList.swift b/Semper/Modules/UtilityActionList.swift index 73b1e0c..65d9152 100644 --- a/Semper/Modules/UtilityActionList.swift +++ b/Semper/Modules/UtilityActionList.swift @@ -3,6 +3,9 @@ import SwiftUI struct UtilityActionList: View { let commands: UtilityCommandCenter let actions: [UtilityActionDescriptor] + var showsModuleName = false + var selectedActionID: UtilityActionID? + var activationRequest = 0 @State private var pendingConfirmation: UtilityActionDescriptor? @State private var message: String? @@ -16,7 +19,10 @@ struct UtilityActionList: View { HStack(spacing: 10) { Image(systemName: action.symbolName).frame(width: 20).accessibilityHidden(true) VStack(alignment: .leading, spacing: 3) { - Text(action.title) + Text(action.title).font(.callout.weight(.medium)) + if showsModuleName, let module = commands.registry.descriptor(for: action.module) { + Text(module.title).font(.caption).foregroundStyle(.secondary) + } if let reason = commands.disabledReason(for: action.id) { Text(reason).font(.caption).foregroundStyle(.secondary) } @@ -27,6 +33,8 @@ struct UtilityActionList: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityLabel(action.title) + .accessibilityValue(commands.disabledReason(for: action.id) ?? "") .disabled(commands.disabledReason(for: action.id) != nil) Button { do { @@ -39,16 +47,35 @@ struct UtilityActionList: View { } } label: { Image(systemName: isFavorite(action) ? "star.fill" : "star") + .frame(width: 28, height: 28).contentShape(Rectangle()) } .buttonStyle(.plain) .foregroundStyle(isFavorite(action) ? Color.accentColor : Color.secondary) .accessibilityLabel("\(isFavorite(action) ? "Unpin" : "Pin") \(action.title)") + .help("\(isFavorite(action) ? "Unpin" : "Pin") \(action.title)") } .padding(10) - .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 8)) + .background( + selectedActionID == action.id + ? Color.accentColor.opacity(0.12) : Color(nsColor: .controlBackgroundColor), + in: RoundedRectangle(cornerRadius: 8) + ) + .overlay { + if selectedActionID == action.id { + RoundedRectangle(cornerRadius: 8).strokeBorder(Color.accentColor, lineWidth: 1) + } + } + .id(action.id) + .accessibilityAddTraits(selectedActionID == action.id ? .isSelected : []) } if let message { Text(message).font(.caption).foregroundStyle(.orange) } } + .onChange(of: activationRequest) { _, _ in + guard pendingConfirmation == nil, + let action = actions.first(where: { $0.id == selectedActionID }) + else { return } + execute(action) + } .confirmationDialog( pendingConfirmation?.title ?? "Confirm action", isPresented: Binding( @@ -88,3 +115,20 @@ struct UtilityActionList: View { } } } + +enum UtilityActionSelection { + static func reconciled(_ selection: UtilityActionID?, among ids: [UtilityActionID]) -> UtilityActionID? { + if let selection, ids.contains(selection) { return selection } + return ids.first + } + + static func moved(from selection: UtilityActionID?, by direction: Int, among ids: [UtilityActionID]) + -> UtilityActionID? + { + guard !ids.isEmpty else { return nil } + guard let selection, let index = ids.firstIndex(of: selection) else { + return direction < 0 ? ids.last : ids.first + } + return ids[min(max(index + direction, 0), ids.count - 1)] + } +} diff --git a/Semper/Modules/UtilityShellView.swift b/Semper/Modules/UtilityShellView.swift index aacdf80..73f7bb5 100644 --- a/Semper/Modules/UtilityShellView.swift +++ b/Semper/Modules/UtilityShellView.swift @@ -9,6 +9,9 @@ struct UtilityShellView: View { @Environment(\.openWindow) private var openWindow @Environment(\.openSettings) private var openSettings @FocusState private var searchFocused: Bool + @State private var selectedSearchAction: UtilityActionID? + @State private var searchActivation = 0 + @State private var showingAllActions = false var body: some View { VStack(spacing: 0) { @@ -36,6 +39,9 @@ struct UtilityShellView: View { .onChange(of: runtime.searchFocusRequest) { _, _ in if !compact { searchFocused = true } } + .onChange(of: runtime.registry.search(runtime.searchText).map(\.id), initial: true) { _, ids in + selectedSearchAction = UtilityActionSelection.reconciled(selectedSearchAction, among: ids) + } } private var header: some View { @@ -47,6 +53,7 @@ struct UtilityShellView: View { searchFocused = true } label: { Image(systemName: "magnifyingglass") + .frame(width: 28, height: 28).contentShape(Rectangle()) } .keyboardShortcut("k", modifiers: .command) .help("Search Semper actions") @@ -56,6 +63,7 @@ struct UtilityShellView: View { openWindow(id: "utilities") } label: { Image(systemName: "macwindow") + .frame(width: 28, height: 28).contentShape(Rectangle()) } .help("Open Semper window").accessibilityLabel("Open Semper window") Button { @@ -63,6 +71,7 @@ struct UtilityShellView: View { openWindow(id: "utilities") } label: { Image(systemName: "square.grid.2x2") + .frame(width: 28, height: 28).contentShape(Rectangle()) } .help("Manage modules").accessibilityLabel("Manage modules") } @@ -70,12 +79,14 @@ struct UtilityShellView: View { openSettings() } label: { Image(systemName: "gearshape") + .frame(width: 28, height: 28).contentShape(Rectangle()) } .help("Settings").accessibilityLabel("Settings") Button { NSApplication.shared.terminate(nil) } label: { Image(systemName: "power") + .frame(width: 28, height: 28).contentShape(Rectangle()) } .help("Quit Semper").accessibilityLabel("Quit Semper") } @@ -103,126 +114,282 @@ struct UtilityShellView: View { case .modules: ModuleLibraryView( registry: runtime.registry, lifecycle: runtime.lifecycle, - pause: runtime.pause, remove: runtime.remove, mutationDisabledReason: runtime.mutationDisabledReason) + pause: runtime.pause, remove: runtime.remove, mutationDisabledReason: runtime.mutationDisabledReason, + open: { runtime.destination = .module($0) }) case .module(let id): module(id).disabled(moduleInteractionDisabled(for: id)) } } private var home: some View { - ScrollView { - VStack(alignment: .leading, spacing: 18) { - TextField("Search Semper actions", text: $runtime.searchText) - .textFieldStyle(.roundedBorder).focused($searchFocused) - .accessibilityLabel("Search Semper actions") - if let destination = sceneRecoveryDestination { - Button { - runtime.destination = destination - if compact { openWindow(id: "utilities") } - } label: { - Label("Recover Previous Setup", systemImage: "arrow.uturn.backward") - } + VStack(alignment: .leading, spacing: 18) { + if !compact { + VStack(alignment: .leading, spacing: 4) { + Text("Home").font(.largeTitle.weight(.semibold)) + Text("Your Mac utilities, within reach.").foregroundStyle(.secondary) } - if runtime.searchText.isEmpty { - if !runtime.registry.favoriteActions.isEmpty { - Text("Pinned actions").font(.headline) - UtilityActionList(commands: runtime.commands, actions: runtime.registry.favoriteActions) - } - ForEach(runtime.registry.addedModules) { module in - if compact, module.id == .shelf, shelfStopRecoveryRoute != nil { - ShelfStopRecoveryView(runtime: runtime) - .disabled(moduleInteractionDisabled(for: .shelf)) - } else if compact, module.id == .shelf, - !runtime.registry.pausedModuleIDs.contains(.shelf), - !runtime.lifecycle.stopping.contains(.shelf), !runtime.lifecycle.isShuttingDown, - let shelf = runtime.shelf, shelf.isRunning - { - ShelfCompactView(service: shelf) { - Task { - do { - try await runtime.open(.shelf) - runtime.message = nil - } catch { runtime.message = error.localizedDescription } - } - } - .disabled(runtime.mutationDisabledReason != nil) + } + searchField + if let destination = sceneRecoveryDestination { + Button { + runtime.destination = destination + if compact { openWindow(id: "utilities") } + } label: { + Label("Recover Previous Setup", systemImage: "arrow.uturn.backward") + } + } + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 22) { + if isSearching { + searchResults } else { - Button { - runtime.destination = .module(module.id) - if compact { openWindow(id: "utilities") } - } label: { - HStack(spacing: 12) { - Image(systemName: module.symbolName).frame(width: 24) - VStack(alignment: .leading, spacing: 3) { - Text(module.title).font(.headline) - Text(runtime.summary(for: module.id)).font(.caption).foregroundStyle(.secondary) - } - Spacer() - Image(systemName: "chevron.right").foregroundStyle(.tertiary) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) + homeOverview } } - if let message = runtime.message?.trimmingCharacters(in: .whitespacesAndNewlines), - !attentionItems.contains(where: { item in - item.reasons.contains { $0.caseInsensitiveCompare(message) == .orderedSame } - }) - { - Text(message).foregroundStyle(.orange) - } - if !attentionItems.isEmpty { - Text("Needs attention").font(.headline) - ForEach(attentionItems) { item in - if let module = runtime.registry.descriptor(for: item.id) { - VStack(alignment: .leading, spacing: 4) { - Label(module.title, systemImage: "exclamationmark.triangle") - .font(.subheadline.weight(.medium)) - ForEach(item.reasons, id: \.self) { reason in - Text(reason).font(.caption) - } - } - .foregroundStyle(.orange) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(2) + } + .onChange(of: selectedSearchAction) { _, id in + if let id { proxy.scrollTo(id, anchor: .center) } + } + } + } + .frame(maxHeight: compact ? 560 : nil) + } + + private var isSearching: Bool { + !runtime.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var searchField: some View { + HStack(spacing: 10) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary).accessibilityHidden(true) + TextField("Search Semper actions", text: $runtime.searchText) + .textFieldStyle(.plain).focused($searchFocused) + .accessibilityLabel("Search Semper actions") + .accessibilityHint( + "Use the arrow keys to select a result and Return to run it. Escape clears the search." + ) + .onSubmit { if isSearching { searchActivation += 1 } } + .onKeyPress(keys: [.upArrow, .downArrow]) { press in + guard isSearching else { return .ignored } + selectedSearchAction = UtilityActionSelection.moved( + from: selectedSearchAction, by: press.key == .downArrow ? 1 : -1, + among: runtime.registry.search(runtime.searchText).map(\.id)) + return .handled + } + .onKeyPress(.escape) { + guard !runtime.searchText.isEmpty else { return .ignored } + runtime.searchText = "" + return .handled + } + if !runtime.searchText.isEmpty { + Button { + runtime.searchText = "" + searchFocused = true + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.plain).foregroundStyle(.secondary) + .accessibilityLabel("Clear action search") + } else { + Text("⌘K").font(.caption).foregroundStyle(.secondary).accessibilityHidden(true) + } + } + .padding(12) + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .strokeBorder(searchFocused ? Color.accentColor : Color(nsColor: .separatorColor), lineWidth: 1) + } + .onChange(of: runtime.searchText) { _, query in + selectedSearchAction = runtime.registry.search(query).first?.id + } + } + + @ViewBuilder + private var searchResults: some View { + let matches = runtime.registry.search(runtime.searchText) + HStack { + Text("\(matches.count) \(matches.count == 1 ? "action" : "actions")").font(.subheadline.weight(.medium)) + Spacer() + Text("↑ ↓ Select ↩ Run").font(.caption).foregroundStyle(.secondary).accessibilityHidden(true) + } + if matches.isEmpty { + VStack(alignment: .leading, spacing: 10) { + Text("No matching actions").font(.headline) + Text("Try a utility name or an action such as volume, awake, or window.") + .foregroundStyle(.secondary) + Button("Browse Modules") { + runtime.destination = .modules + if compact { openWindow(id: "utilities") } + } + } + .padding(.vertical, 12) + } else { + UtilityActionList( + commands: runtime.commands, actions: matches, showsModuleName: true, + selectedActionID: selectedSearchAction, activationRequest: searchActivation) + } + } + + @ViewBuilder + private var homeOverview: some View { + if let message = runtime.message?.trimmingCharacters(in: .whitespacesAndNewlines), + !message.isEmpty, + !attentionItems.contains(where: { item in + item.reasons.contains { $0.caseInsensitiveCompare(message) == .orderedSame } + }) + { + Text(message).foregroundStyle(.orange).textSelection(.enabled) + } + if !attentionItems.isEmpty { + VStack(alignment: .leading, spacing: 10) { + Label("Needs attention", systemImage: "exclamationmark.triangle").font(.headline) + ForEach(attentionItems) { item in + if let module = runtime.registry.descriptor(for: item.id) { + Button { + runtime.destination = .module(item.id) + if compact { openWindow(id: "utilities") } + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(module.title).font(.subheadline.weight(.medium)) + ForEach(item.reasons, id: \.self) { Text($0).font(.caption) } } + .frame(maxWidth: .infinity, alignment: .leading).contentShape(Rectangle()) } + .buttonStyle(.plain) + .accessibilityHint("Open this utility to review its status.") } - if !runtime.commands.recentActions.isEmpty { - HStack { - Text("Recent actions").font(.headline) - Spacer() - Text("This session").font(.caption).foregroundStyle(.secondary) + } + } + .foregroundStyle(.orange).padding(14) + .background(Color.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) + } + VStack(alignment: .leading, spacing: 10) { + Text("Pinned actions").font(.headline) + if runtime.registry.favoriteActions.isEmpty { + HStack(spacing: 12) { + Image(systemName: "star").foregroundStyle(.secondary).accessibilityHidden(true) + Text("Pin the actions you use most from search or All actions.") + .font(.callout).foregroundStyle(.secondary) + } + .padding(14).frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10)) + } else { + UtilityActionList(commands: runtime.commands, actions: runtime.registry.favoriteActions) + } + } + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Your utilities").font(.headline) + Spacer() + Button("Add Utilities") { + runtime.destination = .modules + if compact { openWindow(id: "utilities") } + } + .buttonStyle(.borderless) + } + if runtime.registry.addedModules.isEmpty { + Text("Add a utility to get started. You choose when it runs.").foregroundStyle(.secondary) + } else { + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), alignment: .top), count: compact ? 1 : 2), + spacing: 10 + ) { + ForEach(runtime.registry.addedModules) { module in + moduleSummary(module) + } + } + } + } + DisclosureGroup("All actions", isExpanded: $showingAllActions) { + VStack(alignment: .leading, spacing: 16) { + ForEach(runtime.registry.addedModules) { module in + let actions = runtime.registry.search("").filter { $0.module == module.id } + if !actions.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text(module.title).font(.subheadline.weight(.medium)).foregroundStyle(.secondary) + UtilityActionList(commands: runtime.commands, actions: actions) } - ForEach( - runtime.commands.recentActions.prefix( - compact ? 3 : UtilityCommandCenter.maximumRecentActions) - ) { entry in - if let action = runtime.registry.actionMetadata(for: entry.actionID) { - HStack(spacing: 10) { - Label(action.title, systemImage: action.symbolName) - .font(.caption) - Spacer() - VStack(alignment: .trailing, spacing: 2) { - Text(entry.result.displayText) - Text(entry.timestamp, style: .time) - } - .font(.caption2).foregroundStyle(.secondary) + } + } + } + .padding(.top, 12) + } + .font(.headline) + if !runtime.commands.recentActions.isEmpty { + DisclosureGroup("Recent actions · This session") { + VStack(spacing: 10) { + ForEach( + runtime.commands.recentActions.prefix(compact ? 3 : UtilityCommandCenter.maximumRecentActions) + ) { entry in + if let action = runtime.registry.actionMetadata(for: entry.actionID) { + HStack(spacing: 10) { + Label(action.title, systemImage: action.symbolName).font(.caption) + Spacer() + VStack(alignment: .trailing, spacing: 2) { + Text(entry.result.displayText) + Text(entry.timestamp, style: .time) } - .accessibilityElement(children: .combine) + .font(.caption2).foregroundStyle(.secondary) } + .accessibilityElement(children: .combine) } } - Text("Actions").font(.headline) + }.padding(.top, 12) + } + .font(.subheadline) + } + } + + @ViewBuilder + private func moduleSummary(_ module: UtilityModuleDescriptor) -> some View { + if compact, module.id == .shelf, shelfStopRecoveryRoute != nil { + ShelfStopRecoveryView(runtime: runtime).disabled(moduleInteractionDisabled(for: .shelf)) + } else if compact, module.id == .shelf, + !runtime.registry.pausedModuleIDs.contains(.shelf), + !runtime.lifecycle.stopping.contains(.shelf), !runtime.lifecycle.isShuttingDown, + let shelf = runtime.shelf, shelf.isRunning + { + ShelfCompactView(service: shelf) { + Task { + do { + try await runtime.open(.shelf) + runtime.message = nil + } catch { runtime.message = error.localizedDescription } } - let matches = runtime.commands.registry.search(runtime.searchText) - if matches.isEmpty { - Text("No matching actions. Add a module to make its actions available.").foregroundStyle(.secondary) - } else { - UtilityActionList(commands: runtime.commands, actions: matches) + } + .disabled(runtime.mutationDisabledReason != nil) + } else { + Button { + runtime.destination = .module(module.id) + if compact { openWindow(id: "utilities") } + } label: { + HStack(alignment: .top, spacing: 12) { + Image(systemName: module.symbolName) + .font(.title3).foregroundStyle(Color.accentColor) + .frame(width: 32, height: 32) + .background(Color.accentColor.opacity(0.09), in: RoundedRectangle(cornerRadius: 8)) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 4) { + Text(module.title).font(.headline) + Text(runtime.summary(for: module.id)) + .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary).accessibilityHidden( + true) } + .padding(14).frame(maxWidth: .infinity, minHeight: 78, alignment: .topLeading) + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10)) + .contentShape(RoundedRectangle(cornerRadius: 10)) } + .buttonStyle(.plain) + .accessibilityLabel(module.title) + .accessibilityValue(runtime.summary(for: module.id)) } - .frame(maxHeight: compact ? 560 : nil) } private var attentionItems: [UtilityModuleAttention] { diff --git a/Semper/Shortcuts/ShortcutAction.swift b/Semper/Shortcuts/ShortcutAction.swift index cc3fe30..a51b3da 100644 --- a/Semper/Shortcuts/ShortcutAction.swift +++ b/Semper/Shortcuts/ShortcutAction.swift @@ -16,6 +16,12 @@ enum ShortcutAction: String, CaseIterable, Codable, Sendable { case restoreWorkspace case windowLeftHalf case windowRightHalf + case windowTopHalf + case windowBottomHalf + case windowTopLeftQuarter + case windowTopRightQuarter + case windowBottomLeftQuarter + case windowBottomRightQuarter case windowMaximize case windowCenter case windowRestore @@ -23,7 +29,10 @@ enum ShortcutAction: String, CaseIterable, Codable, Sendable { static var soundActions: [Self] { allCases.filter { !shellActions.contains($0) } } static let windowLayoutActions: [Self] = [ - .windowLeftHalf, .windowRightHalf, .windowMaximize, .windowCenter, .windowRestore, + .windowLeftHalf, .windowRightHalf, + .windowTopHalf, .windowBottomHalf, .windowTopLeftQuarter, .windowTopRightQuarter, .windowBottomLeftQuarter, + .windowBottomRightQuarter, + .windowMaximize, .windowCenter, .windowRestore, ] static let shellActions: [Self] = [.restoreWorkspace, .toggleAwayMode] + windowLayoutActions @@ -31,6 +40,12 @@ enum ShortcutAction: String, CaseIterable, Codable, Sendable { switch self { case .windowLeftHalf: .leftHalf case .windowRightHalf: .rightHalf + case .windowTopHalf: .topHalf + case .windowBottomHalf: .bottomHalf + case .windowTopLeftQuarter: .topLeftQuarter + case .windowTopRightQuarter: .topRightQuarter + case .windowBottomLeftQuarter: .bottomLeftQuarter + case .windowBottomRightQuarter: .bottomRightQuarter case .windowMaximize: .maximize case .windowCenter: .center case .windowRestore: .restore @@ -48,6 +63,12 @@ enum ShortcutAction: String, CaseIterable, Codable, Sendable { case .restoreWorkspace: "Restore workspace" case .windowLeftHalf: "Window Left Half" case .windowRightHalf: "Window Right Half" + case .windowTopHalf: "Window Top Half" + case .windowBottomHalf: "Window Bottom Half" + case .windowTopLeftQuarter: "Window Top Left Quarter" + case .windowTopRightQuarter: "Window Top Right Quarter" + case .windowBottomLeftQuarter: "Window Bottom Left Quarter" + case .windowBottomRightQuarter: "Window Bottom Right Quarter" case .windowMaximize: "Maximize Window" case .windowCenter: "Center Window" case .windowRestore: "Restore Previous Window Placement" @@ -61,7 +82,10 @@ enum ShortcutAction: String, CaseIterable, Codable, Sendable { switch self { case .targetAppVolumeUp, .targetAppVolumeDown: true case .togglePopup, .toggleAwayMode, .targetAppMuteToggle, .restoreWorkspace, - .windowLeftHalf, .windowRightHalf, .windowMaximize, .windowCenter, .windowRestore: false + .windowLeftHalf, .windowRightHalf, .windowTopHalf, .windowBottomHalf, .windowTopLeftQuarter, + .windowTopRightQuarter, .windowBottomLeftQuarter, .windowBottomRightQuarter, + .windowMaximize, .windowCenter, .windowRestore: + false } } @@ -76,6 +100,12 @@ enum ShortcutAction: String, CaseIterable, Codable, Sendable { case .restoreWorkspace: KeyboardShortcuts.Name("workspace-restore") case .windowLeftHalf: KeyboardShortcuts.Name("window-layout-left-half") case .windowRightHalf: KeyboardShortcuts.Name("window-layout-right-half") + case .windowTopHalf: KeyboardShortcuts.Name("window-layout-top-half") + case .windowBottomHalf: KeyboardShortcuts.Name("window-layout-bottom-half") + case .windowTopLeftQuarter: KeyboardShortcuts.Name("window-layout-top-left-quarter") + case .windowTopRightQuarter: KeyboardShortcuts.Name("window-layout-top-right-quarter") + case .windowBottomLeftQuarter: KeyboardShortcuts.Name("window-layout-bottom-left-quarter") + case .windowBottomRightQuarter: KeyboardShortcuts.Name("window-layout-bottom-right-quarter") case .windowMaximize: KeyboardShortcuts.Name("window-layout-maximize") case .windowCenter: KeyboardShortcuts.Name("window-layout-center") case .windowRestore: KeyboardShortcuts.Name("window-layout-restore") diff --git a/Semper/Shortcuts/ShortcutsRegistry.swift b/Semper/Shortcuts/ShortcutsRegistry.swift index 99fcd44..7332e23 100644 --- a/Semper/Shortcuts/ShortcutsRegistry.swift +++ b/Semper/Shortcuts/ShortcutsRegistry.swift @@ -160,7 +160,9 @@ final class ShortcutsRegistry { return adjustTargetVolume(direction: -1) case .targetAppMuteToggle: return toggleTargetMute() - case .restoreWorkspace, .windowLeftHalf, .windowRightHalf, .windowMaximize, .windowCenter, .windowRestore: + case .restoreWorkspace, .windowLeftHalf, .windowRightHalf, .windowTopHalf, .windowBottomHalf, + .windowTopLeftQuarter, .windowTopRightQuarter, .windowBottomLeftQuarter, .windowBottomRightQuarter, + .windowMaximize, .windowCenter, .windowRestore: return false } } diff --git a/Semper/Views/Settings/Tabs/ShortcutsTab.swift b/Semper/Views/Settings/Tabs/ShortcutsTab.swift index 32732be..690e9b5 100644 --- a/Semper/Views/Settings/Tabs/ShortcutsTab.swift +++ b/Semper/Views/Settings/Tabs/ShortcutsTab.swift @@ -317,7 +317,9 @@ struct ShortcutsTab: View { case .targetAppVolumeDown: "speaker.wave.1.fill" case .targetAppMuteToggle: "speaker.slash.fill" case .restoreWorkspace: "macwindow.on.rectangle" - case .windowLeftHalf, .windowRightHalf, .windowMaximize, .windowCenter, .windowRestore: + case .windowLeftHalf, .windowRightHalf, .windowTopHalf, .windowBottomHalf, .windowTopLeftQuarter, + .windowTopRightQuarter, .windowBottomLeftQuarter, .windowBottomRightQuarter, + .windowMaximize, .windowCenter, .windowRestore: "rectangle.split.2x1" } } @@ -347,7 +349,9 @@ struct ShortcutsTab: View { case .targetAppVolumeDown: "Lower the selected target app's volume" case .targetAppMuteToggle: "Mute or unmute the selected target app" case .restoreWorkspace: "Prepare a fresh workspace restore preview" - case .windowLeftHalf, .windowRightHalf, .windowMaximize, .windowCenter: + case .windowLeftHalf, .windowRightHalf, .windowTopHalf, .windowBottomHalf, .windowTopLeftQuarter, + .windowTopRightQuarter, .windowBottomLeftQuarter, .windowBottomRightQuarter, + .windowMaximize, .windowCenter: "Arrange the frontmost app window" case .windowRestore: "Restore the immediately preceding window placement" } diff --git a/Semper/WindowLayout/WindowLayoutModels.swift b/Semper/WindowLayout/WindowLayoutModels.swift index 4ff3c9d..3ad276a 100644 --- a/Semper/WindowLayout/WindowLayoutModels.swift +++ b/Semper/WindowLayout/WindowLayoutModels.swift @@ -4,16 +4,32 @@ import Foundation enum WindowLayoutAction: String, CaseIterable, Identifiable, Sendable { case leftHalf = "window-layout.left-half" case rightHalf = "window-layout.right-half" + case topHalf = "window-layout.top-half" + case bottomHalf = "window-layout.bottom-half" + case topLeftQuarter = "window-layout.top-left-quarter" + case topRightQuarter = "window-layout.top-right-quarter" + case bottomLeftQuarter = "window-layout.bottom-left-quarter" + case bottomRightQuarter = "window-layout.bottom-right-quarter" case maximize = "window-layout.maximize" case center = "window-layout.center" case restore = "window-layout.restore" + static let halves: [Self] = [.leftHalf, .rightHalf, .topHalf, .bottomHalf] + + static let quarters: [Self] = [.topLeftQuarter, .topRightQuarter, .bottomLeftQuarter, .bottomRightQuarter] + var id: String { rawValue } var title: String { switch self { case .leftHalf: "Left Half" case .rightHalf: "Right Half" + case .topHalf: "Top Half" + case .bottomHalf: "Bottom Half" + case .topLeftQuarter: "Top Left Quarter" + case .topRightQuarter: "Top Right Quarter" + case .bottomLeftQuarter: "Bottom Left Quarter" + case .bottomRightQuarter: "Bottom Right Quarter" case .maximize: "Maximize" case .center: "Center" case .restore: "Restore Previous Placement" @@ -24,6 +40,12 @@ enum WindowLayoutAction: String, CaseIterable, Identifiable, Sendable { switch self { case .leftHalf: "rectangle.lefthalf.filled" case .rightHalf: "rectangle.righthalf.filled" + case .topHalf: "rectangle.tophalf.filled" + case .bottomHalf: "rectangle.bottomhalf.filled" + case .topLeftQuarter: "rectangle.inset.topleft.filled" + case .topRightQuarter: "rectangle.inset.topright.filled" + case .bottomLeftQuarter: "rectangle.inset.bottomleft.filled" + case .bottomRightQuarter: "rectangle.inset.bottomright.filled" case .maximize: "arrow.up.left.and.arrow.down.right" case .center: "rectangle.center.inset.filled" case .restore: "arrow.uturn.backward" @@ -45,17 +67,33 @@ enum WindowLayoutGeometry { }.sorted { $0.id < $1.id } } + // Accessibility coordinates start at the top edge. Exact fractional splits keep adjacent + // placements inside the display without gaps or overlap on odd-sized displays. static func target(_ action: WindowLayoutAction, frame: CGRect, display: WorkspaceDisplay) -> CGRect? { let bounds = display.visibleFrame guard WorkspaceGeometry.valid(frame), WorkspaceGeometry.valid(bounds), let fullBounds = display.fullScreenFrame, WorkspaceGeometry.valid(fullBounds), fullBounds.contains(bounds) else { return nil } + let halfWidth = bounds.width / 2 + let halfHeight = bounds.height / 2 let target: CGRect switch action { case .leftHalf: - target = CGRect(x: bounds.minX, y: bounds.minY, width: bounds.width / 2, height: bounds.height) + target = CGRect(x: bounds.minX, y: bounds.minY, width: halfWidth, height: bounds.height) case .rightHalf: - target = CGRect(x: bounds.midX, y: bounds.minY, width: bounds.width / 2, height: bounds.height) + target = CGRect(x: bounds.midX, y: bounds.minY, width: halfWidth, height: bounds.height) + case .topHalf: + target = CGRect(x: bounds.minX, y: bounds.minY, width: bounds.width, height: halfHeight) + case .bottomHalf: + target = CGRect(x: bounds.minX, y: bounds.midY, width: bounds.width, height: halfHeight) + case .topLeftQuarter: + target = CGRect(x: bounds.minX, y: bounds.minY, width: halfWidth, height: halfHeight) + case .topRightQuarter: + target = CGRect(x: bounds.midX, y: bounds.minY, width: halfWidth, height: halfHeight) + case .bottomLeftQuarter: + target = CGRect(x: bounds.minX, y: bounds.midY, width: halfWidth, height: halfHeight) + case .bottomRightQuarter: + target = CGRect(x: bounds.midX, y: bounds.midY, width: halfWidth, height: halfHeight) case .maximize: target = bounds case .center: diff --git a/Semper/WindowLayout/WindowLayoutView.swift b/Semper/WindowLayout/WindowLayoutView.swift index ba4217b..92eb21c 100644 --- a/Semper/WindowLayout/WindowLayoutView.swift +++ b/Semper/WindowLayout/WindowLayoutView.swift @@ -28,14 +28,18 @@ struct WindowLayoutView: View { Button("Keep Current Placement…") { confirmKeepCurrent = true } .disabled(service.isBusy || !service.isRunning) } - UtilityActionList( - commands: commands, - actions: WindowLayoutAction.allCases.compactMap { - commands.registry.action(for: .init(rawValue: $0.rawValue)) - }) - Text("Halves and Maximize use the display area available around the Dock and menu bar. Center keeps the current size. Restore returns the last changed window to its immediately preceding placement and skips later manual changes.") + HStack(alignment: .top, spacing: 16) { + placementGroup("Halves", actions: WindowLayoutAction.halves) + placementGroup("Quarters", actions: WindowLayoutAction.quarters) + } + actionList(remainingActions) + Text( + "Halves, quarters and Maximize use the display area available around the Dock and menu bar. Center keeps the current size. Restore returns the last changed window to its immediately preceding placement and skips later manual changes." + ) .font(.caption).foregroundStyle(.secondary) - Text("Full-height windows and targets are conservatively refused. This can limit halves and Maximize when the menu bar and Dock auto-hide. Minimized, unsupported, and unreadable windows also stay unchanged. You can assign optional shortcuts in Settings.") + Text( + "Full-height windows and targets are conservatively refused. This can limit Left Half, Right Half and Maximize when the menu bar and Dock auto-hide, while Top Half, Bottom Half and the quarters use half the usable height. Minimized, unsupported, and unreadable windows also stay unchanged. You can assign optional shortcuts in Settings." + ) .font(.caption).foregroundStyle(.secondary) Text("Pausing retains the previous placement. Removing Window Layout or quitting clears that session history.") .font(.caption).foregroundStyle(.secondary) @@ -48,4 +52,24 @@ struct WindowLayoutView: View { Text("This discards the preceding placement record. Arrange the window manually if needed before continuing.") } } + + private var remainingActions: [WindowLayoutAction] { + WindowLayoutAction.allCases.filter { + !WindowLayoutAction.halves.contains($0) && !WindowLayoutAction.quarters.contains($0) + } + } + + private func placementGroup(_ title: String, actions: [WindowLayoutAction]) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title).font(.subheadline.weight(.semibold)).accessibilityAddTraits(.isHeader) + actionList(actions) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func actionList(_ actions: [WindowLayoutAction]) -> some View { + UtilityActionList( + commands: commands, + actions: actions.compactMap { commands.registry.action(for: .init(rawValue: $0.rawValue)) }) + } } diff --git a/SemperTests/ModuleLibraryPresentationTests.swift b/SemperTests/ModuleLibraryPresentationTests.swift new file mode 100644 index 0000000..473662b --- /dev/null +++ b/SemperTests/ModuleLibraryPresentationTests.swift @@ -0,0 +1,80 @@ +import Foundation +import Testing + +@testable import Semper + +@MainActor +@Suite("Module library presentation") +struct ModuleLibraryPresentationTests { + @Test("Filters track module presence while keeping paused tools in Added") + func filtersTrackPresence() throws { + try withRegistry { registry, _ in + #expect(ModuleLibraryFilter.all.modules(in: registry).map(\.id) == registry.modules.map(\.id)) + #expect(ModuleLibraryFilter.added.modules(in: registry).map(\.id) == [.sound, .awake]) + #expect(ModuleLibraryFilter.available.modules(in: registry).count == registry.modules.count - 2) + + try registry.add(.workspace) + try registry.beginPause(.awake) + try registry.failTransition(.awake, reason: "Stop needs another attempt.") + + #expect(ModuleLibraryFilter.added.modules(in: registry).map(\.id) == [.sound, .awake, .workspace]) + #expect(!ModuleLibraryFilter.available.modules(in: registry).contains { $0.id == .workspace }) + #expect(ModuleLibraryFilter.added.modules(in: registry, matching: "awake").map(\.id) == [.awake]) + } + } + + @Test("Search matches every term across titles and summaries regardless of case or whitespace") + func searchMatchesToolPurpose() throws { + try withRegistry { registry, _ in + #expect(ModuleLibraryFilter.all.modules(in: registry, matching: " AUDIO\tdevice\n").map(\.id) == [.sound]) + #expect(ModuleLibraryFilter.all.modules(in: registry, matching: "window restore").map(\.id) == [.workspace]) + #expect(ModuleLibraryFilter.all.modules(in: registry, matching: "FILES").map(\.id) == [.shelf]) + #expect(ModuleLibraryFilter.all.modules(in: registry, matching: "audio window").isEmpty) + #expect(ModuleLibraryFilter.added.modules(in: registry, matching: "files").isEmpty) + #expect(ModuleLibraryFilter.available.modules(in: registry, matching: "files").map(\.id) == [.shelf]) + #expect(ModuleLibraryFilter.all.modules(in: registry, matching: " \n\t ").count == registry.modules.count) + } + } + + @Test("Unsupported modules remain discoverable without appearing available to add") + func unsupportedModulesRemainVisible() throws { + var modules = UtilityModuleDescriptor.catalog + let displayIndex = try #require(modules.firstIndex { $0.id == .displays }) + modules[displayIndex].unsupportedReason = "This Mac does not support this display control." + + try withRegistry(modules: modules) { registry, _ in + #expect(ModuleLibraryFilter.all.modules(in: registry, matching: "display").map(\.id) == [.displays]) + #expect(ModuleLibraryFilter.available.modules(in: registry, matching: "display").isEmpty) + #expect(ModuleLibraryFilter.added.modules(in: registry, matching: "display").isEmpty) + } + } + + @Test("Browsing filters preserves permission, runtime and saved module state") + func browsingDoesNotMutateState() throws { + try withRegistry { registry, defaults in + try registry.setPermission(.denied, for: .workspace) + let states = registry.modules.map { registry.state(for: $0.id) } + let added = defaults.stringArray(forKey: ModuleRegistry.PersistenceKey.addedModules) + let paused = defaults.stringArray(forKey: ModuleRegistry.PersistenceKey.pausedModules) + + for filter in ModuleLibraryFilter.allCases { + _ = filter.modules(in: registry) + _ = filter.modules(in: registry, matching: "windows") + } + + #expect(registry.modules.map { registry.state(for: $0.id) } == states) + #expect(defaults.stringArray(forKey: ModuleRegistry.PersistenceKey.addedModules) == added) + #expect(defaults.stringArray(forKey: ModuleRegistry.PersistenceKey.pausedModules) == paused) + } + } + + private func withRegistry( + modules: [UtilityModuleDescriptor] = UtilityModuleDescriptor.catalog, + body: (ModuleRegistry, UserDefaults) throws -> Void + ) throws { + let suite = "ModuleLibraryPresentationTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + try body(ModuleRegistry(defaults: defaults, modules: modules), defaults) + } +} diff --git a/SemperTests/UtilityActionSelectionTests.swift b/SemperTests/UtilityActionSelectionTests.swift new file mode 100644 index 0000000..6764dd6 --- /dev/null +++ b/SemperTests/UtilityActionSelectionTests.swift @@ -0,0 +1,38 @@ +import Testing + +@testable import Semper + +@Suite("Action search selection") +struct UtilityActionSelectionTests { + private let first = UtilityActionID(rawValue: "sound.first") + private let second = UtilityActionID(rawValue: "awake.second") + private let third = UtilityActionID(rawValue: "shelf.third") + + @Test func retainedQueriesAndChangedModulesReconcileSelection() { + #expect(UtilityActionSelection.reconciled(nil, among: [first, second]) == first) + #expect(UtilityActionSelection.reconciled(second, among: [first, second]) == second) + #expect(UtilityActionSelection.reconciled(first, among: [second, third]) == second) + #expect(UtilityActionSelection.reconciled(first, among: []) == nil) + } + + @Test func arrowsMoveThroughResultsAndStopAtEdges() { + let ids = [first, second, third] + #expect(UtilityActionSelection.moved(from: first, by: 1, among: ids) == second) + #expect(UtilityActionSelection.moved(from: third, by: -1, among: ids) == second) + #expect(UtilityActionSelection.moved(from: first, by: -1, among: ids) == first) + #expect(UtilityActionSelection.moved(from: third, by: 1, among: ids) == third) + } + + @Test func missingSelectionStartsAtTheAppropriateEdge() { + let ids = [first, second, third] + #expect(UtilityActionSelection.moved(from: nil, by: 1, among: ids) == first) + #expect(UtilityActionSelection.moved(from: nil, by: -1, among: ids) == third) + #expect(UtilityActionSelection.moved(from: first, by: 1, among: [second, third]) == second) + } + + @Test func emptyAndSingleResultQueriesRemainSafe() { + #expect(UtilityActionSelection.moved(from: first, by: 1, among: []) == nil) + #expect(UtilityActionSelection.moved(from: first, by: -1, among: [first]) == first) + #expect(UtilityActionSelection.moved(from: first, by: 1, among: [first]) == first) + } +} diff --git a/SemperTests/WindowLayoutGeometryTests.swift b/SemperTests/WindowLayoutGeometryTests.swift index 5e4dca3..8e79374 100644 --- a/SemperTests/WindowLayoutGeometryTests.swift +++ b/SemperTests/WindowLayoutGeometryTests.swift @@ -1,3 +1,4 @@ +import AppKit import CoreGraphics import Testing @@ -10,16 +11,83 @@ struct WindowLayoutGeometryTests { id: "main", name: "Display", visibleFrame: CGRect(x: 0, y: 25, width: 1001, height: 675), fullScreenFrame: CGRect(x: 0, y: 0, width: 1001, height: 750)) + static let placements: [WindowLayoutAction] = WindowLayoutAction.allCases.filter { $0 != .restore } + static let fullHeightPlacements: [WindowLayoutAction] = [.leftHalf, .rightHalf, .maximize] + static let halfHeightPlacements: [WindowLayoutAction] = [.topHalf, .bottomHalf] + WindowLayoutAction.quarters + static let corners: [(quarter: WindowLayoutAction, vertical: WindowLayoutAction, horizontal: WindowLayoutAction)] = + [ + (.topLeftQuarter, .topHalf, .leftHalf), (.topRightQuarter, .topHalf, .rightHalf), + (.bottomLeftQuarter, .bottomHalf, .leftHalf), (.bottomRightQuarter, .bottomHalf, .rightHalf), + ] + + private func target( + _ action: WindowLayoutAction, on display: WorkspaceDisplay? = nil, frame: CGRect? = nil, + sourceLocation: SourceLocation = #_sourceLocation + ) throws -> CGRect { + try #require( + WindowLayoutGeometry.target(action, frame: frame ?? original, display: display ?? self.display), + sourceLocation: sourceLocation) + } + + private func expectTiling( + _ tiles: [CGRect], cover bounds: CGRect, sourceLocation: SourceLocation = #_sourceLocation + ) { + #expect(tiles.reduce(CGRect.null) { $0.union($1) } == bounds, sourceLocation: sourceLocation) + for tile in tiles { + #expect(WorkspaceGeometry.valid(tile), sourceLocation: sourceLocation) + #expect(bounds.contains(tile), "\(tile) leaves \(bounds)", sourceLocation: sourceLocation) + } + for (index, tile) in tiles.enumerated() { + for other in tiles[(index + 1)...] { + #expect(tile.intersection(other).isEmpty, "\(tile) overlaps \(other)", sourceLocation: sourceLocation) + } + } + } + @Test("Halves partition odd usable widths without rounding beyond the display") func halves() throws { - let left = try #require(WindowLayoutGeometry.target(.leftHalf, frame: original, display: display)) - let right = try #require(WindowLayoutGeometry.target(.rightHalf, frame: original, display: display)) + let left = try target(.leftHalf) + let right = try target(.rightHalf) #expect(left == CGRect(x: 0, y: 25, width: 500.5, height: 675)) #expect(left.maxX == right.minX) #expect(right.maxX == display.visibleFrame.maxX) #expect(left.union(right) == display.visibleFrame) #expect(display.visibleFrame.contains(left)) #expect(display.visibleFrame.contains(right)) + expectTiling([left, right], cover: display.visibleFrame) + } + + @Test("Vertical halves partition odd usable heights from the top edge without rounding") + func verticalHalves() throws { + let top = try target(.topHalf) + let bottom = try target(.bottomHalf) + #expect(top == CGRect(x: 0, y: 25, width: 1001, height: 337.5)) + #expect(bottom == CGRect(x: 0, y: 362.5, width: 1001, height: 337.5)) + #expect(top.minY == display.visibleFrame.minY) + #expect(top.maxY == bottom.minY) + #expect(bottom.maxY == display.visibleFrame.maxY) + #expect(top.width == display.visibleFrame.width && bottom.width == display.visibleFrame.width) + expectTiling([top, bottom], cover: display.visibleFrame) + } + + @Test("Quarters tile the usable area and agree with the halves they belong to") + func quarters() throws { + var tiles: [CGRect] = [] + for corner in Self.corners { + let quarter = try target(corner.quarter) + let vertical = try target(corner.vertical) + let horizontal = try target(corner.horizontal) + #expect(quarter == vertical.intersection(horizontal)) + #expect(quarter.size == CGSize(width: 500.5, height: 337.5)) + tiles.append(quarter) + } + expectTiling(tiles, cover: display.visibleFrame) + #expect(tiles[0].origin == display.visibleFrame.origin) + #expect(tiles[3].maxX == display.visibleFrame.maxX && tiles[3].maxY == display.visibleFrame.maxY) + #expect(tiles[0].union(tiles[1]) == (try target(.topHalf))) + #expect(tiles[2].union(tiles[3]) == (try target(.bottomHalf))) + #expect(tiles[0].union(tiles[2]) == (try target(.leftHalf))) + #expect(tiles[1].union(tiles[3]) == (try target(.rightHalf))) } @Test("Fractional display origins remain inside usable bounds") @@ -27,14 +95,88 @@ struct WindowLayoutGeometryTests { let display = WorkspaceDisplay( id: "fractional", name: "Display", visibleFrame: CGRect(x: -999.75, y: 24.25, width: 999.5, height: 675.5), fullScreenFrame: CGRect(x: -999.75, y: 0, width: 999.5, height: 750)) - for action in [WindowLayoutAction.leftHalf, .rightHalf, .maximize, .center] { - let target = try #require(WindowLayoutGeometry.target(action, frame: original, display: display)) + for action in Self.placements { + let target = try target(action, on: display) #expect(display.visibleFrame.contains(target)) } - let left = try #require(WindowLayoutGeometry.target(.leftHalf, frame: original, display: display)) - let right = try #require(WindowLayoutGeometry.target(.rightHalf, frame: original, display: display)) + let left = try target(.leftHalf, on: display) + let right = try target(.rightHalf, on: display) #expect(left.maxX == right.minX) #expect(left.union(right) == display.visibleFrame) + expectTiling( + [try target(.topHalf, on: display), try target(.bottomHalf, on: display)], cover: display.visibleFrame) + expectTiling(try WindowLayoutAction.quarters.map { try target($0, on: display) }, cover: display.visibleFrame) + } + + @Test("Negative-origin displays with odd sizes keep every placement inside the usable area") + func negativeOriginOddSizes() throws { + let display = WorkspaceDisplay( + id: "above-left", name: "Display", visibleFrame: CGRect(x: -1601, y: -1077, width: 1601, height: 1023), + fullScreenFrame: CGRect(x: -1601, y: -1100, width: 1601, height: 1100)) + let bounds = display.visibleFrame + for action in Self.placements { + let target = try target(action, on: display) + #expect(bounds.contains(target), "\(action.title) produced \(target)") + #expect(!WorkspaceGeometry.excludedByDisplayBounds(target, on: [display])) + } + #expect(try target(.topHalf, on: display) == CGRect(x: -1601, y: -1077, width: 1601, height: 511.5)) + #expect(try target(.bottomHalf, on: display) == CGRect(x: -1601, y: -565.5, width: 1601, height: 511.5)) + #expect(try target(.topLeftQuarter, on: display) == CGRect(x: -1601, y: -1077, width: 800.5, height: 511.5)) + #expect( + try target(.bottomRightQuarter, on: display) == CGRect(x: -800.5, y: -565.5, width: 800.5, height: 511.5)) + #expect(try target(.center, on: display) == CGRect(x: -1000.5, y: -715.5, width: 400, height: 300)) + expectTiling([try target(.leftHalf, on: display), try target(.rightHalf, on: display)], cover: bounds) + expectTiling([try target(.topHalf, on: display), try target(.bottomHalf, on: display)], cover: bounds) + expectTiling(try WindowLayoutAction.quarters.map { try target($0, on: display) }, cover: bounds) + for corner in Self.corners { + #expect( + try target(corner.quarter, on: display) + == (try target(corner.vertical, on: display)).intersection( + try target(corner.horizontal, on: display))) + } + } + + @Test("Usable areas that leave the reported display bounds refuse every placement") + func usableAreaOutsideDisplay() { + let displays = [ + WorkspaceDisplay( + id: "shifted", name: "Display", visibleFrame: CGRect(x: -1601, y: -1077, width: 1601, height: 1023), + fullScreenFrame: CGRect(x: -1601, y: -1000, width: 1601, height: 1000)), + WorkspaceDisplay( + id: "wider", name: "Display", visibleFrame: CGRect(x: 0, y: 25, width: 1002, height: 675), + fullScreenFrame: CGRect(x: 0, y: 0, width: 1001, height: 750)), + ] + for display in displays { + for action in WindowLayoutAction.allCases { + #expect(WindowLayoutGeometry.target(action, frame: original, display: display) == nil) + } + #expect( + WindowLayoutWindowRules.issue( + standard: true, minimized: false, frame: original, displays: [display], + movable: true, resizable: true) == .unknownState) + } + } + + @Test("Auto-hidden system bars refuse full-height targets while half-height placements stay available") + func autoHiddenBarsKeepHalfHeightPlacements() throws { + let autoHide = WorkspaceDisplay( + id: "auto-hide", name: "Display", visibleFrame: CGRect(x: 0, y: 0, width: 1000, height: 700), + fullScreenFrame: CGRect(x: 0, y: 0, width: 1000, height: 700)) + for action in Self.fullHeightPlacements { + #expect(WindowLayoutGeometry.target(action, frame: original, display: autoHide) == nil) + } + for action in Self.halfHeightPlacements { + let target = try target(action, on: autoHide) + #expect(target.height == 350) + #expect(autoHide.visibleFrame.contains(target)) + #expect(!WorkspaceGeometry.excludedByDisplayBounds(target, on: [autoHide])) + #expect( + WindowLayoutWindowRules.issue( + standard: true, minimized: false, frame: target, displays: [autoHide], + movable: true, resizable: true) == nil) + } + let stacked = try target(.topHalf, on: autoHide).union(try target(.bottomHalf, on: autoHide)) + #expect(WorkspaceGeometry.excludedByDisplayBounds(stacked, on: [autoHide])) } @Test("Maximize uses the usable display instead of fullscreen bounds") @@ -48,7 +190,7 @@ struct WindowLayoutGeometryTests { let display = WorkspaceDisplay( id: "left", name: "Display", visibleFrame: CGRect(x: -1500, y: -200, width: 1200, height: 900), fullScreenFrame: CGRect(x: -1500, y: -225, width: 1200, height: 1000)) - let centered = try #require(WindowLayoutGeometry.target(.center, frame: original, display: display)) + let centered = try target(.center, on: display) #expect(centered.size == original.size) #expect(centered.midX == display.visibleFrame.midX) #expect(centered.midY == display.visibleFrame.midY) @@ -66,6 +208,54 @@ struct WindowLayoutGeometryTests { WindowLayoutGeometry.target(.center, frame: display.visibleFrame, display: display) == display.visibleFrame) } + @Test("Existing placements keep their identifiers, titles, symbols and targets") + func existingPlacementsPreserved() { + let expectations: [(WindowLayoutAction, String, String, String, CGRect?)] = [ + ( + .leftHalf, "window-layout.left-half", "Left Half", "rectangle.lefthalf.filled", + CGRect(x: 0, y: 25, width: 500.5, height: 675) + ), + ( + .rightHalf, "window-layout.right-half", "Right Half", "rectangle.righthalf.filled", + CGRect(x: 500.5, y: 25, width: 500.5, height: 675) + ), + ( + .maximize, "window-layout.maximize", "Maximize", "arrow.up.left.and.arrow.down.right", + display.visibleFrame + ), + ( + .center, "window-layout.center", "Center", "rectangle.center.inset.filled", + CGRect(x: 300.5, y: 212.5, width: 400, height: 300) + ), + (.restore, "window-layout.restore", "Restore Previous Placement", "arrow.uturn.backward", nil), + ] + for (action, rawValue, title, symbol, target) in expectations { + #expect(action.rawValue == rawValue) + #expect(action.id == rawValue) + #expect(action.title == title) + #expect(action.symbolName == symbol) + #expect(WindowLayoutGeometry.target(action, frame: original, display: display) == target) + } + } + + @Test("Placement catalog has stable unique identifiers, distinct titles and available symbols") + func placementCatalog() { + let actions = WindowLayoutAction.allCases + #expect(actions.count == 11) + #expect(Set(actions.map(\.rawValue)).count == actions.count) + #expect(actions.allSatisfy { $0.rawValue.hasPrefix("window-layout.") }) + #expect(Set(actions.map(\.title)).count == actions.count) + #expect(Set(actions.map(\.symbolName)).count == actions.count) + let grouped = WindowLayoutAction.halves + WindowLayoutAction.quarters + #expect(grouped.count == 8 && Set(grouped).count == grouped.count) + #expect(actions.filter { !grouped.contains($0) } == [.maximize, .center, .restore]) + for action in actions { + #expect( + NSImage(systemSymbolName: action.symbolName, accessibilityDescription: nil) != nil, + "\(action.symbolName) is not an available SF Symbol") + } + } + @Test("Invalid frames or displays never produce a movement target") func invalidGeometry() { let invalidFrames = [ @@ -80,6 +270,12 @@ struct WindowLayoutGeometryTests { WindowLayoutGeometry.target( action, frame: original, display: WorkspaceDisplay(id: "invalid", name: "Display", visibleFrame: frame)) == nil) + #expect( + WindowLayoutGeometry.target( + action, frame: original, + display: WorkspaceDisplay( + id: "invalid-full", name: "Display", visibleFrame: display.visibleFrame, + fullScreenFrame: frame)) == nil) } } } @@ -94,9 +290,9 @@ struct WindowLayoutGeometryTests { let autoHideDisplay = WorkspaceDisplay( id: "auto-hide", name: "Display", visibleFrame: CGRect(x: 0, y: 0, width: 1000, height: 700), fullScreenFrame: CGRect(x: 0, y: 0, width: 1000, height: 700)) - for action in [WindowLayoutAction.leftHalf, .rightHalf, .maximize] { - let arranged = try #require(WindowLayoutGeometry.target(action, frame: original, display: display)) - let centered = try #require(WindowLayoutGeometry.target(.center, frame: arranged, display: display)) + for action in Self.placements where action != .center { + let arranged = try target(action) + let centered = try target(.center, frame: arranged) for frame in [arranged, centered, original] { #expect( WindowLayoutWindowRules.issue( @@ -107,7 +303,11 @@ struct WindowLayoutGeometryTests { WorkspaceWindowRules.issue( standard: true, minimized: false, frame: arranged, displays: [display], movable: true, resizable: true) == nil) - #expect(WindowLayoutGeometry.target(action, frame: original, display: autoHideDisplay) == nil) + if Self.fullHeightPlacements.contains(action) { + #expect(WindowLayoutGeometry.target(action, frame: original, display: autoHideDisplay) == nil) + } else { + #expect(WindowLayoutGeometry.target(action, frame: original, display: autoHideDisplay) != nil) + } } #expect(WindowLayoutGeometry.target(.center, frame: original, display: autoHideDisplay) != nil) } @@ -174,6 +374,8 @@ struct WindowLayoutGeometryTests { WindowLayoutWindowRules.issue( standard: true, minimized: false, frame: original, displays: [unknownDisplay], movable: true, resizable: true) == .unknownState) - #expect(WindowLayoutGeometry.target(.leftHalf, frame: original, display: unknownDisplay) == nil) + for action in WindowLayoutAction.allCases { + #expect(WindowLayoutGeometry.target(action, frame: original, display: unknownDisplay) == nil) + } } } diff --git a/SemperTests/WindowLayoutServiceTests.swift b/SemperTests/WindowLayoutServiceTests.swift index 14f9a0f..0127f4f 100644 --- a/SemperTests/WindowLayoutServiceTests.swift +++ b/SemperTests/WindowLayoutServiceTests.swift @@ -289,8 +289,11 @@ struct WindowLayoutServiceTests { #expect(await backend.applicationScanCount == 0) } - @Test("Halves and maximize remain eligible for center and preceding-placement restore", arguments: [ - WindowLayoutAction.leftHalf, .rightHalf, .maximize, + @Test( + "Halves, quarters and maximize remain eligible for center and preceding-placement restore", + arguments: [ + WindowLayoutAction.leftHalf, .rightHalf, .topHalf, .bottomHalf, .topLeftQuarter, .topRightQuarter, + .bottomLeftQuarter, .bottomRightQuarter, .maximize, ]) func chainedLayouts(_ first: WindowLayoutAction) async throws { let (service, backend, _) = fixture() @@ -518,6 +521,31 @@ struct WindowLayoutServiceTests { #expect(await backend.state?.frame == original) } + @Test( + "Auto-hidden system bars still apply half-height placements through the verified restore pipeline", + arguments: [WindowLayoutAction.topHalf, .bottomHalf] + WindowLayoutAction.quarters) + func autoHiddenBarsAllowHalfHeightPlacements(_ action: WindowLayoutAction) async throws { + let (service, backend, _) = fixture() + let fullFrame = CGRect(x: 0, y: 0, width: 1000, height: 800) + let autoHide = WorkspaceDisplay( + id: screen.id, name: screen.name, visibleFrame: fullFrame, fullScreenFrame: fullFrame) + await backend.setScreens([autoHide]) + let target = try #require(WindowLayoutGeometry.target(action, frame: original, display: autoHide)) + #expect(target.height == 400 && fullFrame.contains(target)) + try await service.perform(action) + #expect(await backend.requestedFrames == [target]) + #expect(await backend.state?.frame == target) + #expect(service.canRestore && !service.requiresPlacementReview) + #expect(service.message == "\(action.title) applied and verified. Restore returns to the preceding placement.") + try await service.perform(action) + #expect(await backend.requestedFrames == [target]) + #expect(service.message == "The window is already in this placement.") + #expect(service.canRestore) + try await service.perform(.restore) + #expect(await backend.state?.frame == original) + #expect(!service.canRestore && !service.requiresPlacementReview) + } + @Test("A previous placement outside usable displays is not restored") func offscreenPreviousPlacement() async throws { let (service, backend, _) = fixture() diff --git a/SemperTests/WorkspaceShortcutIsolationTests.swift b/SemperTests/WorkspaceShortcutIsolationTests.swift index 46eb916..5e37778 100644 --- a/SemperTests/WorkspaceShortcutIsolationTests.swift +++ b/SemperTests/WorkspaceShortcutIsolationTests.swift @@ -8,6 +8,18 @@ import Testing @MainActor @Suite("Workspace shortcut isolation", .serialized) struct WorkspaceShortcutIsolationTests { + @Test("Every placement has one distinct shell-owned shortcut action") + func placementShortcutCoverage() { + let actions = ShortcutAction.windowLayoutActions + let placements = actions.compactMap(\.windowLayoutAction) + #expect(placements.count == WindowLayoutAction.allCases.count) + #expect(Set(placements) == Set(WindowLayoutAction.allCases)) + #expect(Set(actions.map(\.rawValue)).count == actions.count) + #expect( + actions.allSatisfy { ShortcutAction.shellActions.contains($0) && !ShortcutAction.soundActions.contains($0) } + ) + } + @Test("Window shortcuts remain shell-owned when Sound clears its shortcuts", arguments: ShortcutAction.windowLayoutActions) func soundDoesNotOwnWindowLayout(_ action: ShortcutAction) throws { try withSynchronousSettings { settings in diff --git a/SemperUITests/ShellModeUITests.swift b/SemperUITests/ShellModeUITests.swift index 1f3bad9..f70d841 100644 --- a/SemperUITests/ShellModeUITests.swift +++ b/SemperUITests/ShellModeUITests.swift @@ -12,8 +12,8 @@ final class ShellModeUITests: XCTestCase { let window = app.windows["Semper Shell UI Tests"] XCTAssertTrue(window.waitForExistence(timeout: 8)) XCTAssertTrue(app.descendants(matching: .any)["shell-ui-test-host"].exists) - XCTAssertTrue(window.staticTexts["Home"].exists) - XCTAssertTrue(window.staticTexts["Modules"].exists) + XCTAssertTrue(window.staticTexts["Home"].firstMatch.exists) + XCTAssertTrue(window.staticTexts["Modules"].firstMatch.exists) XCTAssertTrue(window.staticTexts["Sound"].firstMatch.exists) let search = window.textFields["Search Semper actions"] @@ -30,22 +30,40 @@ final class ShellModeUITests: XCTestCase { search.typeKey("a", modifierFlags: .command) search.typeText("no-matching-shell-action") XCTAssertTrue( - window.staticTexts["No matching actions. Add a module to make its actions available."] + window.staticTexts["No matching actions"] .waitForExistence(timeout: 3)) - window.staticTexts["Modules"].click() + search.typeKey("a", modifierFlags: .command) + search.typeText("sound") + search.typeKey(.downArrow, modifierFlags: []) + search.typeKey(.return, modifierFlags: []) + XCTAssertTrue( + window.staticTexts["Service startup is unavailable in shell UI tests."].waitForExistence(timeout: 3)) + search.typeKey(.escape, modifierFlags: []) + XCTAssertEqual(search.value as? String, "") + XCTAssertTrue(window.staticTexts["Your utilities"].exists) + XCTAssertFalse(window.buttons["Mute current output"].exists) + search.typeText("no-matching-shell-action") + + window.staticTexts["Modules"].firstMatch.click() XCTAssertTrue(window.staticTexts["Control app and device audio."].waitForExistence(timeout: 3)) - XCTAssertTrue(window.staticTexts["Stopped"].exists) + XCTAssertTrue(window.staticTexts["Stopped"].firstMatch.exists) + let moduleSearch = window.textFields["Search modules"] + moduleSearch.click() + moduleSearch.typeText("awake") + XCTAssertFalse(window.staticTexts["Control app and device audio."].exists) let addAwake = window.buttons["Add Awake"] XCTAssertTrue(addAwake.exists) addAwake.click() XCTAssertTrue(addAwake.waitForNonExistence(timeout: 3)) + XCTAssertTrue(window.buttons["Open Awake"].exists) + moduleSearch.typeKey(.escape, modifierFlags: []) let modulesScreenshot = XCTAttachment(screenshot: window.screenshot()) modulesScreenshot.name = "Semper Shell Modules After Adding Awake" modulesScreenshot.lifetime = .keepAlways add(modulesScreenshot) - window.staticTexts["Home"].click() + window.staticTexts["Home"].firstMatch.click() XCTAssertTrue(search.waitForExistence(timeout: 3)) XCTAssertEqual(search.value as? String, "no-matching-shell-action") search.click() diff --git a/guide/module-shell.md b/guide/module-shell.md index 427cbd0..b5023b7 100644 --- a/guide/module-shell.md +++ b/guide/module-shell.md @@ -1,14 +1,16 @@ # Module shell -Semper has one menu-bar panel and a native detail window. Home lists added modules, their current summaries, pinned actions, and action search. Parameterized controls live in the detail window. Command-Option-K opens the action surface; its shortcut can be changed in Settings. +Semper has one menu-bar panel and a native detail window. Home shows pinned actions and a grid of added utilities with current summaries. All actions and recent history are collapsed until requested. Action search stays above the content. Parameterized controls live in the detail window. Command-Option-K opens the action surface; its shortcut can be changed in Settings. Command-K focuses search inside Semper. Type an action or utility name, select a result with Up or Down, and press Return to run it through the same availability and confirmation checks as a click. Escape clears the query. A pending Scene recovery remains visible during search. When Sound is already running, its Home summary shows the current output and the number of apps with active audio. Home also shows module limitations, failed cleanup, and denied, restricted, or revoked permissions. Reading these summaries does not start Sound or another utility. Recent actions retain the last eight registered action outcomes in memory for this session, with three shown in the compact panel. Entries contain an action identifier, timestamp, and outcome. They do not retain paths, filenames, window titles, or error text. An accepted asynchronous command remains distinct from a completed change. +Modules has a title-and-purpose search and All, Added, and Available filters. Open navigates to the selected utility; adding, pausing, resuming, and removing keep their existing lifecycle rules. Permission, activity, data, and hardware details remain available on each module. + ## Lifecycle -The module catalog is metadata. Adding a module exposes its controls and commands without creating its service or requesting permission. The first explicit Open or utility action creates the runtime. Sound owns its audio engine, media keys, device observers, feedback, and audio shortcuts. Starting Semper, viewing Home, and adding Awake do not create Sound. +The module catalog is metadata. Adding a module exposes its controls and commands without creating its service or requesting permission. Opening its page from Home or Modules also does not start its service. Its explicit start control or registered utility action starts the runtime through the existing admission and permission checks; paused modules must be resumed first. Sound owns its audio engine, media keys, device observers, feedback, and audio shortcuts. Starting Semper, viewing Home, and adding Awake do not create Sound. Presence, runtime, and permission are separate state values. Pause blocks execution immediately and drains owned work. Once paused, actions remain visible with the reason "Resume this module in Modules first." Resume permits actions with a stopped runtime. Removal unregisters active actions and removes their favorites. Saved module data is managed separately from presence; bundled code remains installed. diff --git a/guide/product-status.md b/guide/product-status.md index d76e0b7..4d99ffc 100644 --- a/guide/product-status.md +++ b/guide/product-status.md @@ -4,11 +4,24 @@ Semper has ten utility modules integrated on `main`. This page records what each module does, where it stands, and what remains before release. It changes in the same commit as the work that changes a status. -Snapshot: `main` at `ced1a2b`, 2026-09-09, including Resize a Copy and website -alignment from [PR #111](https://github.com/niharnm/Semper/pull/111), following -Window Layout in [PR #110](https://github.com/niharnm/Semper/pull/110). +App source `4be9555` includes the macOS experience update below. It builds on +`main` at `20ab341`, including Window Layout handle retention from +[PR #112](https://github.com/niharnm/Semper/pull/112) and Resize a Copy from +[PR #111](https://github.com/niharnm/Semper/pull/111). Latest downloadable release: v1.0.0, published 2026-08-26, containing Sound only. +## macOS experience + +Current source keeps Semper exclusively on macOS. It adds a reorganized +Home, keyboard action selection and execution, searchable module discovery, +and six Window Layout placements: top and bottom halves and all four quarters. +All eleven Window Layout actions have optional shortcuts. These changes are +included in source; they are not in the public v1.0.0 binary. + +The existing command admission, confirmation, permission and recovery rules +still apply. Native keyboard, VoiceOver and real-window acceptance remain +separate from compilation, unit tests and offscreen view rendering. + ## States - **Released**: included in a published signed release users can download. diff --git a/guide/window-layout.md b/guide/window-layout.md index 67a8b81..321aa0b 100644 --- a/guide/window-layout.md +++ b/guide/window-layout.md @@ -8,17 +8,27 @@ Select a window in another app, then invoke an action from Semper or an optional | --- | --- | | Left Half | Fills the left half of the current display's usable area. | | Right Half | Fills the right half of that area. | +| Top Half | Fills the top half of that area. | +| Bottom Half | Fills the bottom half of that area. | +| Top Left Quarter | Fills the top left quarter of that area. | +| Top Right Quarter | Fills the top right quarter of that area. | +| Bottom Left Quarter | Fills the bottom left quarter of that area. | +| Bottom Right Quarter | Fills the bottom right quarter of that area. | | Maximize | Fills the area available around the Dock and menu bar without entering full screen. | | Center | Centers the window without changing its size. Oversized windows are refused. | | Restore Previous Placement | Returns the last changed window to its immediately preceding placement. | -Actions are searchable from Home and can be pinned there. Settings > Shortcuts provides optional bindings for all five actions. No shortcut is assigned by default. Sound's shortcut reset does not remove these bindings. +Halves and quarters split the usable area exactly instead of rounding, so an odd width or height gives neighbouring placements a shared half-point edge rather than a gap or an overlap. A quarter is the intersection of its vertical and horizontal halves: Top Left Quarter and Top Right Quarter together equal Top Half, and Top Half and Bottom Half together equal Maximize. Displays placed left of or above the primary display, which have negative coordinates, use the same calculation. + +The module view groups Halves and Quarters side by side, followed by Maximize, Center and Restore Previous Placement. Every row runs its action through the shared action list, so pinning, progress and unavailable reasons behave as they do on Home. + +Actions are searchable from Home and can be pinned there. Settings > Shortcuts provides optional bindings for all eleven actions. No shortcut is assigned by default. Sound's shortcut reset does not remove these bindings. When Semper is frontmost, Window Layout uses the last eligible app active while the module was running. If none is known, select another app and return to Semper. The module reads only that app's focused window and never substitutes a different window when the original is missing. -Only standard, nonminimized windows with readable geometry and move/resize support are eligible. Window Layout retains Workspace Restore's conservative full-height exclusion. It does not infer fullscreen state from a button role or use an undocumented fullscreen attribute. Full-height windows are refused even when they are ordinary windows. Targets that would enter the excluded area are also refused, so halves and Maximize can be unavailable when the menu bar and Dock both auto-hide; Center remains available for smaller windows. Missing display bounds are refused. +Only standard, nonminimized windows with readable geometry and move/resize support are eligible. Window Layout retains Workspace Restore's conservative full-height exclusion. It does not infer fullscreen state from a button role or use an undocumented fullscreen attribute. Full-height windows are refused even when they are ordinary windows. Targets that would enter the excluded area are also refused, so Left Half, Right Half and Maximize can be unavailable when the menu bar and Dock both auto-hide. Top Half, Bottom Half and the quarters use half of the usable height, so that check does not refuse them; Center remains available for smaller windows. Missing display bounds are refused. -Every change checks the resulting frame. The backend checks the expected display arrangement again after its final asynchronous window refresh and before writing. A refusal before any write preserves the preceding placement record and reports the refusal, even if an external change already reached the requested target. If an app limits an attempted write to another supported frame, the result says so and retains the observed change for restore. If an attempted write instead returns an excluded full-height frame, automatic restore is unavailable. The known before/after placement stays in memory for manual review. Restore skips windows moved since the previous action, missing windows, and changed display arrangements. After an excluded post-write result or an unverifiable write, check or adjust the window manually and confirm Keep Current Placement before another action. That confirmation discards the preceding placement record; the next action checks eligibility again. +Every change checks the resulting frame. The backend checks the expected display arrangement again after its final asynchronous window refresh and before writing. A refusal before any write preserves the preceding placement record and reports the refusal, even if an external change already reached the requested target. Repeating an action on a window that is already in that placement makes no write and keeps the preceding placement record. If an app limits an attempted write to another supported frame, the result says so and retains the observed change for restore. If an attempted write instead returns an excluded full-height frame, automatic restore is unavailable. The known before/after placement stays in memory for manual review. Restore skips windows moved since the previous action, missing windows, and changed display arrangements. After an excluded post-write result or an unverifiable write, check or adjust the window manually and confirm Keep Current Placement before another action. That confirmation discards the preceding placement record; the next action checks eligibility again. Cancel stops additional writes and waits for the latest operation to finish. Supported, verified partial changes remain available to restore. Pause stops app observation and drains work while preserving the previous placement and any required manual review. Removing the module or quitting clears its window handles and previous-placement record. Window titles are not collected; only module and shortcut preferences persist. diff --git a/website/about.html b/website/about.html index 2cfe214..f03dad0 100644 --- a/website/about.html +++ b/website/about.html @@ -126,7 +126,7 @@

The short version