From b80ad4c04db9dfd57eb454a1958e7fee3047563d Mon Sep 17 00:00:00 2001 From: Quentin Eude Date: Wed, 11 Nov 2026 11:57:40 +0100 Subject: [PATCH 1/4] feat: add a recent windows switcher on mod-tab Like Command-Tab, but per window and across workspaces and monitors, after Niri's recent windows switcher. The focus history behind focus-window-previous becomes a persisted most-recent-first list; the switcher selects without focusing and confirms through focus-window-id on mod release, cancels on Escape, and draws nothing for a brief press. --- CONFIGURATION.md | 32 +++- README.md | 2 +- Sources/DefiConfig/Config.swift | 2 + Sources/DefiConfig/KeyBindings.swift | 6 +- Sources/DefiDaemon/DaemonCommands.swift | 17 +- Sources/DefiDaemon/DaemonConfiguration.swift | 1 + .../DaemonDesktopReconciliation.swift | 1 + Sources/DefiDaemon/DaemonOverview.swift | 2 + Sources/DefiDaemon/DaemonRecentWindows.swift | 80 ++++++++ .../DefiDaemon/DaemonStatusLifecycle.swift | 1 + Sources/DefiDaemon/DefiDaemon.swift | 7 +- Sources/DefiMacOS/CheatsheetController.swift | 7 +- Sources/DefiMacOS/HotKeyModels.swift | 6 +- Sources/DefiMacOS/HotKeys.swift | 41 +++++ .../DefiMacOS/RecentWindowsController.swift | 172 ++++++++++++++++++ .../DefiMacOS/SettingsShortcutActions.swift | 3 +- .../DefiMacOS/SettingsShortcutRecorder.swift | 2 +- Sources/DefiModel/Command.swift | 9 + Sources/DefiModel/CommandParser.swift | 6 + Sources/DefiRuntime/CommandReducer.swift | 5 +- .../DefiRuntime/RecentWindowsSwitcher.swift | 45 +++++ Sources/DefiRuntime/RuntimeState.swift | 57 +++--- .../DaemonCommandPolicyTests.swift | 4 + Tests/DefiMacOSTests/CheatsheetTests.swift | 1 + Tests/DefiMacOSTests/HotKeyTests.swift | 42 +++++ Tests/DefiModelTests/ModelTests.swift | 5 + .../WindowFocusHistoryTests.swift | 111 ++++++++++- site/desk.js | 2 +- 28 files changed, 612 insertions(+), 57 deletions(-) create mode 100644 Sources/DefiDaemon/DaemonRecentWindows.swift create mode 100644 Sources/DefiMacOS/RecentWindowsController.swift create mode 100644 Sources/DefiRuntime/RecentWindowsSwitcher.swift diff --git a/CONFIGURATION.md b/CONFIGURATION.md index dd362115..612e05e4 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -471,7 +471,7 @@ The key is one of these names, or any other single character, such as | Letters | `a` to `z` | | Digits | `0` to `9` | | Arrows | `left`, `right`, `up`, `down` | -| Navigation | `home`, `end`, `pageup`, `pagedown` (Fn with an arrow on keyboards without them) | +| Navigation | `tab`, `home`, `end`, `pageup`, `pagedown` (Fn with an arrow on keyboards without them) | | Punctuation | `minus`, `equal`, `leftbracket`, `rightbracket`, `semicolon`, `quote`, `backslash`, `comma`, `period`, `slash` | ### Keyboard layouts @@ -480,7 +480,7 @@ Shortcuts follow your keyboard layout and update when it changes: | Key | Where it lands | | --- | --- | -| Digits, arrows, and navigation keys | Always the same physical key. `ctrl-alt-1` stays on the number row on AZERTY. | +| Digits, arrows, Tab, and navigation keys | Always the same physical key. `ctrl-alt-1` stays on the number row on AZERTY. | | Letters | The key printing that letter, such as `ctrl-alt-f` on Dvorak. A layout without the letter keeps the US position. | | Punctuation | The key printing the character, adding Shift or Option when it needs them: `ctrl-alt-period` is `⌃⌥⇧;` on French. | | Other characters | The key printing the character. If the layout lacks it, only that shortcut is inactive. | @@ -549,6 +549,8 @@ declared in `[workspaces].names`; rules cannot target dynamic positions. | `focus-window first\|last` | Focus the first or last window in the stack. | unset | | `focus-window-or-workspace up\|down` | Like `focus-window`, but past the top or bottom of the stack, focus the adjacent workspace. | unset | | `focus-window-previous` | Go back to the window focused before this one, on any workspace or monitor. | unset | +| `recent-windows next\|previous` | Open the recent windows switcher, or move its selection forward or backward, wrapping. | `mod-tab` / `mod-shift-tab` | +| `recent-windows confirm\|cancel` | Focus the selected window and close the switcher, or close it without changing anything. | unset | | `focus-floating previous\|next` | Focus the previous or next floating window, wrapping. | `mod-shift-comma` / `mod-shift-period` | | `focus-floating first\|last` | Focus the first or last floating window. | unset | | `activate-floating` | Select the floating layer and bring its selected window forward. | `mod-shift-v` | @@ -634,8 +636,28 @@ current one and activates its workspace; press it again to go back. Any committed focus change counts, including a click, the Dock, Command-Tab, and the Overview; windows passed over during rapid navigation do not. If the current window closes, the entry stays on the last window still open. Nothing happens -when the previous window has closed or left Defi's management. Both entries -survive daemon restarts in the same macOS session. +when the previous window has closed or left Defi's management; an older window +still open is chosen instead. The history survives daemon restarts in the same +macOS session. + +**Recent windows.** `mod-tab` works like Command-Tab, but per window and across +every workspace and monitor. While `mod` stays held, each Tab moves forward +through the windows by latest focus, followed by windows never focused, and +`mod-shift-tab` moves back. Releasing `mod` focuses the selected window and +activates its workspace, like `focus-window-id`; Escape closes the switcher +without changing anything. The switcher appears after 150 ms, without animation, +on the active monitor, so a brief `mod-tab` goes back to the previous window +like `focus-window-previous` without showing anything. Moving through the list +does not focus the windows or change the history. + +Cards show the window picture the animations and the Overview keep, or the app +icon when there is none, when Screen Recording is missing, or with +`[overview].window_previews = false`. A window that closes leaves the list, and +the selection moves to the next one. Holding `mod` with the switcher open never +opens the cheat sheet. Any other command, the Overview, a display change, or +locking the screen closes the switcher without choosing. While the Overview is +open, `recent-windows next` and `previous` fail. From the CLI, `recent-windows +next` opens the switcher until `recent-windows confirm` or `cancel`. `focus-window-or-workspace` gives the new workspace its remembered selection. It also switches workspace with no tiled window selected, stops at the first @@ -1237,6 +1259,8 @@ names = [] "mod-shift-comma" = "focus-floating previous" "mod-shift-period" = "focus-floating next" "mod-o" = "toggle-overview" +"mod-tab" = "recent-windows next" +"mod-shift-tab" = "recent-windows previous" "mod-shift-slash" = "toggle-cheatsheet" # No rules by default. diff --git a/README.md b/README.md index 6052daa8..62907b28 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ then asks for Screen Recording. | Permission | Used to | Without it | | --- | --- | --- | | **Accessibility** (required) | Focus and arrange windows | Defi cannot manage windows | -| **Screen Recording** | Animate window pictures and show Overview previews | Windows move at once; Overview cards have no pictures | +| **Screen Recording** | Animate window pictures and show Overview and recent windows previews | Windows move at once; Overview and recent windows cards have no pictures | Window pictures stay in memory and never leave your Mac. To start Defi with macOS, turn on **Launch Defi at login** in Settings › General. diff --git a/Sources/DefiConfig/Config.swift b/Sources/DefiConfig/Config.swift index 53d81e77..26484d2c 100644 --- a/Sources/DefiConfig/Config.swift +++ b/Sources/DefiConfig/Config.swift @@ -401,6 +401,8 @@ public struct Config: Equatable, Sendable { "\(modifier)-shift-comma": "focus-floating previous", "\(modifier)-shift-period": "focus-floating next", "\(modifier)-o": "toggle-overview", + "\(modifier)-tab": "recent-windows next", + "\(modifier)-shift-tab": "recent-windows previous", "\(modifier)-shift-slash": "toggle-cheatsheet", ] let directions = [ diff --git a/Sources/DefiConfig/KeyBindings.swift b/Sources/DefiConfig/KeyBindings.swift index 343a5162..c0def743 100644 --- a/Sources/DefiConfig/KeyBindings.swift +++ b/Sources/DefiConfig/KeyBindings.swift @@ -10,12 +10,12 @@ public let acceleratorKeyCodes: [String: UInt16] = [ "i": 34, "p": 35, "l": 37, "j": 38, "quote": 39, "k": 40, "semicolon": 41, "backslash": 42, "comma": 43, "slash": 44, "n": 45, "m": 46, "period": 47, - "home": 115, "pageup": 116, "end": 119, "pagedown": 121, + "tab": 48, "home": 115, "pageup": 116, "end": 119, "pagedown": 121, "left": 123, "right": 124, "down": 125, "up": 126, ] -/// Navigation keys, from Home to the arrows, keep their name on every keyboard layout. -public let firstNavigationKeyCode: UInt16 = 115 +/// Tab and the navigation keys, from Home to the arrows, keep their name on every keyboard layout. +public func acceleratorKeyKeepsName(_ code: UInt16) -> Bool { code == 48 || code >= 115 } /// Characters behind the punctuation names, so `[` and `leftbracket` are one binding. public let acceleratorSymbolCharacters: [String: String] = [ diff --git a/Sources/DefiDaemon/DaemonCommands.swift b/Sources/DefiDaemon/DaemonCommands.swift index 072238df..d106b5e3 100644 --- a/Sources/DefiDaemon/DaemonCommands.swift +++ b/Sources/DefiDaemon/DaemonCommands.swift @@ -184,12 +184,16 @@ func workspaceTargetID( } } -/// `focus-window-previous` switches workspaces only when the window's workspace is inactive; +/// Focusing a given window switches workspaces only when the window's workspace is inactive; /// otherwise it scrolls like any focus command. func commandActivatesWorkspace(_ command: Command, state: RuntimeState) -> Bool { - guard command == .focusWindowPrevious else { return command.activatesWorkspace } - guard let location = state.previousFocusedWindowTarget.flatMap({ state.location(containing: $0) }) - else { return false } + let target: WindowID? + switch command { + case .focusWindowPrevious: target = state.previousFocusedWindowTarget + case .focusWindowID(let windowID): target = windowID + default: return command.activatesWorkspace + } + guard let location = target.flatMap({ state.location(containing: $0) }) else { return false } return state.monitors.contains { $0.id == location.monitorID && $0.activeWorkspace != location.workspaceID } } @@ -415,6 +419,11 @@ extension Daemon { return .success() } handleCheatsheetInput(.dismiss) + if case .recentWindows(let action) = parsedCommand { + return handleRecentWindows(action) + } + // Any other command ends the switcher without choosing a window. + closeRecentWindows() if parsedCommand == .toggleOverview { return toggleOverview() } diff --git a/Sources/DefiDaemon/DaemonConfiguration.swift b/Sources/DefiDaemon/DaemonConfiguration.swift index 00c451e5..529b8ab5 100644 --- a/Sources/DefiDaemon/DaemonConfiguration.swift +++ b/Sources/DefiDaemon/DaemonConfiguration.swift @@ -169,6 +169,7 @@ extension Daemon { cancelPendingCommandFrameRead() handleCheatsheetInput(.dismiss) + closeRecentWindows() DispatchQueue.main.async { [self] in cheatsheetController = nil } if hotKeyConfigurationChanged(from: previousConfig, to: nextConfig) { hotKeys?.stop() diff --git a/Sources/DefiDaemon/DaemonDesktopReconciliation.swift b/Sources/DefiDaemon/DaemonDesktopReconciliation.swift index c7e9db53..4658b1e0 100644 --- a/Sources/DefiDaemon/DaemonDesktopReconciliation.swift +++ b/Sources/DefiDaemon/DaemonDesktopReconciliation.swift @@ -211,6 +211,7 @@ extension Daemon { invalidateDisplayArrangement() closeOverview() handleCheatsheetInput(.dismiss) + closeRecentWindows() displayConfigurationEventCount += 1 let now = ProcessInfo.processInfo.systemUptime pendingDisplaySyncDeadlines = [0.05, 0.2, 0.5, 1.0, 2.0].map { diff --git a/Sources/DefiDaemon/DaemonOverview.swift b/Sources/DefiDaemon/DaemonOverview.swift index d33f68e2..b2950872 100644 --- a/Sources/DefiDaemon/DaemonOverview.swift +++ b/Sources/DefiDaemon/DaemonOverview.swift @@ -133,6 +133,7 @@ extension Daemon { func updateOverviewIfOpen() { presentOverview() + refreshRecentWindowsIfOpen() } private func applyOverviewToggle(_ request: OverviewToggleRequest) { @@ -237,6 +238,7 @@ extension Daemon { overviewExitPreparationActive = false overviewState.isOpen = isOpen if isOpen { + closeRecentWindows() overviewEditedMonitorIDs.removeAll() overviewFloatingFrameWriteIDs.removeAll() } diff --git a/Sources/DefiDaemon/DaemonRecentWindows.swift b/Sources/DefiDaemon/DaemonRecentWindows.swift new file mode 100644 index 00000000..ee674d02 --- /dev/null +++ b/Sources/DefiDaemon/DaemonRecentWindows.swift @@ -0,0 +1,80 @@ +import DefiIPC +import DefiMacOS +import DefiModel +import DefiRuntime +import Foundation + +@NavigationActor +extension Daemon { + func handleRecentWindows(_ action: RecentWindowsAction) -> CommandResponse { + switch action { + case .next, .previous: + guard !overviewState.isOpen else { + return .failure("recent windows are unavailable while the Overview is open") + } + let opening = !recentWindows.isOpen + recentWindows.step(forward: action == .next, in: state) + guard recentWindows.isOpen else { return .failure("no managed window to switch to") } + guard opening else { + presentRecentWindows() + return .success() + } + hotKeys?.setRecentWindowsOpen(true) + recentWindowsMonitorID = activeMonitorID ?? state.monitors.first?.id + // Like Niri's open delay: a brief press switches without drawing anything. + recentWindowsShowTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(150)) + guard !Task.isCancelled, let self, self.recentWindows.isOpen else { return } + self.recentWindowsShowTask = nil + self.recentWindowsVisible = true + self.presentRecentWindows() + } + return .success() + case .confirm: + guard let windowID = closeRecentWindows() else { return .success() } + return handle("focus-window-id \(windowID.rawValue)") + case .cancel: + closeRecentWindows() + return .success() + } + } + + /// Closes the switcher without focusing anything and returns its selection. + @discardableResult + func closeRecentWindows() -> WindowID? { + guard recentWindows.isOpen else { return nil } + recentWindowsShowTask?.cancel() + recentWindowsShowTask = nil + hotKeys?.setRecentWindowsOpen(false) + if recentWindowsVisible { + recentWindowsVisible = false + DispatchQueue.main.async { [self] in recentWindowsController?.close() } + } + return recentWindows.close() + } + + /// Windows that close while the switcher is open leave it; the Overview closes it. + func refreshRecentWindowsIfOpen() { + guard recentWindows.isOpen else { return } + recentWindows.retainManagedWindows(in: state) + guard recentWindows.isOpen, !overviewState.isOpen else { + closeRecentWindows() + return + } + presentRecentWindows() + } + + private func presentRecentWindows() { + guard recentWindowsVisible else { return } + let entries = recentWindows.windowIDs.map { + RecentWindowsEntry(id: $0, title: state.windows[$0]?.title ?? "", processID: state.windows[$0]?.processID) + } + let (selection, monitorID, previews) = + (recentWindows.selection, recentWindowsMonitorID, config.overview.windowPreviews) + DispatchQueue.main.async { [self] in + let controller = recentWindowsController ?? RecentWindowsController() + recentWindowsController = controller + controller.show(entries, selection: selection, on: monitorID, previews: previews) + } + } +} diff --git a/Sources/DefiDaemon/DaemonStatusLifecycle.swift b/Sources/DefiDaemon/DaemonStatusLifecycle.swift index a7c61ebd..3ce80f47 100644 --- a/Sources/DefiDaemon/DaemonStatusLifecycle.swift +++ b/Sources/DefiDaemon/DaemonStatusLifecycle.swift @@ -401,6 +401,7 @@ extension Daemon { private func finishShutdown() async { await accessibilityPermissionMonitor.stop() handleCheatsheetInput(.dismiss) + closeRecentWindows() configReloadTask?.cancel() await configWatcher?.stop() timer?.cancel() diff --git a/Sources/DefiDaemon/DefiDaemon.swift b/Sources/DefiDaemon/DefiDaemon.swift index 64148819..196d3667 100644 --- a/Sources/DefiDaemon/DefiDaemon.swift +++ b/Sources/DefiDaemon/DefiDaemon.swift @@ -120,6 +120,11 @@ final class Daemon { var cheatsheetState = CheatsheetState() @MainActor var cheatsheetController: CheatsheetController? var cheatsheetHoldTask: Task? + var recentWindows = RecentWindowsSwitcher() + var recentWindowsMonitorID: MonitorID? + var recentWindowsVisible = false + var recentWindowsShowTask: Task? + @MainActor var recentWindowsController: RecentWindowsController? nonisolated let hotKeyGeneration = Mutex(0) var overviewEditedMonitorIDs: Set = [] var overviewFloatingFrameWriteIDs: Set = [] @@ -353,7 +358,7 @@ final class Daemon { if active { invalidateDisplayArrangement() } displayPointerRouter.setActive(active) desktopSessionActive = active - if !active { handleCheatsheetInput(.dismiss); cancelScrollPan() } + if !active { handleCheatsheetInput(.dismiss); closeRecentWindows(); cancelScrollPan() } desktopSessionGeneration &+= 1 guard active else { cancelPendingCommandFrameRead() diff --git a/Sources/DefiMacOS/CheatsheetController.swift b/Sources/DefiMacOS/CheatsheetController.swift index d47cbef7..affc0f20 100644 --- a/Sources/DefiMacOS/CheatsheetController.swift +++ b/Sources/DefiMacOS/CheatsheetController.swift @@ -150,6 +150,7 @@ private let shortcutLabels: [(command: String, label: String)] = [ ("focus-window-or-workspace down", "Window or workspace down"), ("focus-workspace-previous", "Previous workspace"), ("focus-window-previous", "Previous window"), + ("recent-windows next", "Recent windows"), ("recent-windows previous", "Recent windows, backward"), ("focus-workspace-name", "Workspace"), ("workspace", "Workspace"), ("focus-workspace-position", "Workspace"), ("toggle-overview", "Overview"), ("focus-column-or-monitor left", "Column or monitor left"), @@ -189,8 +190,8 @@ private struct ShortcutBinding { } var section: String { if verb.contains("monitor") { return "Monitors" } - // Previous window may cross workspaces, so it sits beside previous workspace. - if verb.contains("workspace") || ["toggle-overview", "focus-window-previous"].contains(verb) { + // Previous and recent windows may cross workspaces, so they sit beside previous workspace. + if verb.contains("workspace") || ["toggle-overview", "focus-window-previous", "recent-windows"].contains(verb) { return "Workspaces" } if verb.contains("width") || verb.contains("height") || verb == "maximize-column" { @@ -359,7 +360,7 @@ private func modifierLabel(_ modifiers: Set, displayMod: Bool, aliases: private func keySymbol(_ key: String) -> String { let symbols = ["cmd": "⌘", "alt": "⌥", "ctrl": "⌃", "shift": "⇧", "left": "←", "right": "→", "up": "↑", "down": "↓", - "home": "↖", "end": "↘", "pageup": "⇞", "pagedown": "⇟", + "tab": "⇥", "home": "↖", "end": "↘", "pageup": "⇞", "pagedown": "⇟", "leftbracket": "[", "rightbracket": "]", "backslash": "\\", "slash": "/", "semicolon": ";", "quote": "'", "minus": "−", "equal": "=", "comma": ",", "period": "."] diff --git a/Sources/DefiMacOS/HotKeyModels.swift b/Sources/DefiMacOS/HotKeyModels.swift index dff06764..2ecd2be1 100644 --- a/Sources/DefiMacOS/HotKeyModels.swift +++ b/Sources/DefiMacOS/HotKeyModels.swift @@ -333,8 +333,8 @@ func resolveHotKeys( added: added, follows: follows, userFirst: written && [0, 1, 3].contains(rank))) } let physical = acceleratorKeyCodes[name] - // Digits and navigation keys never follow the layout. - if let physical, layout == nil || Int(name) != nil || physical >= firstNavigationKeyCode { + // Digits, Tab, and navigation keys never follow the layout. + if let physical, layout == nil || Int(name) != nil || acceleratorKeyKeepsName(physical) { claim(0, modifiers, physical) continue } @@ -463,7 +463,7 @@ struct ShortcutResolution { /// Digits and navigation keys keep their name; any other key is named for the keycap at its resolved code. func keyName(for key: Key, named name: String) -> String { - guard let layout, Int(name) == nil, (acceleratorKeyCodes[name] ?? 0) < firstNavigationKeyCode, + guard let layout, Int(name) == nil, !acceleratorKeyKeepsName(acceleratorKeyCodes[name] ?? 0), let character = layout.baseCharacter(code: key.code, command: key.modifierNames.contains("cmd")) else { return name } return canonicalKeyName(character) diff --git a/Sources/DefiMacOS/HotKeys.swift b/Sources/DefiMacOS/HotKeys.swift index f1723ac0..e1b413fc 100644 --- a/Sources/DefiMacOS/HotKeys.swift +++ b/Sources/DefiMacOS/HotKeys.swift @@ -315,6 +315,10 @@ public final class HotKeyManager { context?.setCheatsheetVisible(visible) } + public func setRecentWindowsOpen(_ open: Bool) { + context?.setRecentWindowsOpen(open) + } + public func stop() { registration?.stop() registration = nil @@ -367,6 +371,10 @@ final class InputMonitor: @unchecked Sendable { private var capturedModifierReleaseState = CapturedHotKeyModifierReleaseState() private var overviewModeEnabled = false private var cheatsheetVisible = false + /// Escape cancels the recent windows switcher while it is open. + private var recentWindowsOpen = false + /// Modifiers that keep a switcher opened by a shortcut open; tap thread only. + private var recentWindowsHoldBits: UInt64? private let cheatsheetModifierBits: UInt64? private let deliverCheatsheet: @Sendable (CheatsheetInput) -> Void /// Nil when scroll gestures are off; the recognizer lives on the tap thread. @@ -538,6 +546,11 @@ final class InputMonitor: @unchecked Sendable { pointerMotionTracker.invalidate(at: timestamp) for tap in taps { CGEvent.tapEnable(tap: tap.port, enable: true) } deliverCheatsheet(.dismiss) + // The release that would confirm may have been missed: nothing changes. + if recentWindowsHoldBits != nil { + recentWindowsHoldBits = nil + deliver(HotKeyInvocation(command: "recent-windows cancel", timestamp: timestamp)) + } tapReenabled(timestamp) return Unmanaged.passUnretained(event) } @@ -566,6 +579,11 @@ final class InputMonitor: @unchecked Sendable { if isKeyDown || type == .flagsChanged { let bits = hotKeyModifierBits(event.flags) lock.withLock { announcedModifierBits = bits } + // Like Command-Tab: releasing the shortcut's modifier confirms the selection. + if type == .flagsChanged, let held = recentWindowsHoldBits, bits & held != held { + recentWindowsHoldBits = nil + deliver(HotKeyInvocation(command: "recent-windows confirm", timestamp: timestamp)) + } } let eventTargetPID = pid_t(exactly: event.getIntegerValueField(.eventTargetUnixProcessID)) let isDefiTextInput = hotKeyTargetIsCurrentApplication( @@ -643,6 +661,10 @@ final class InputMonitor: @unchecked Sendable { captured += 1 lock.unlock() capturedModifierReleaseState.capture(modifierBits: key.modifierBits) + if recentWindowsHoldBits == nil, case .recentWindows(let action) = try? parseCommand(command), + action == .next || action == .previous { + recentWindowsHoldBits = recentWindowsHoldModifiers(key.modifierBits) + } userInputTracker.recordCapturedCommand(at: timestamp) deliver(HotKeyInvocation( command: command, @@ -724,6 +746,13 @@ final class InputMonitor: @unchecked Sendable { let code = CGKeyCode(event.getIntegerValueField(.keyboardEventKeycode)) let key = Key(code: code, flags: event.flags.rawValue) let (overview, cheatsheet, bindings) = lock.withLock { (overviewModeEnabled, cheatsheetVisible, self.bindings) } + if code == 53, lock.withLock({ recentWindowsOpen }) { + recentWindowsHoldBits = nil + capturedModifierReleaseState.reset() + recordGeneralUserInput(type: type, event: event, isDefiTextInput: false) + deliver(HotKeyInvocation(command: "recent-windows cancel", timestamp: Double(event.timestamp) / 1_000_000_000)) + return nil + } if cheatsheet && code == 53 { capturedModifierReleaseState.reset() recordGeneralUserInput(type: type, event: event, isDefiTextInput: false) @@ -813,6 +842,10 @@ final class InputMonitor: @unchecked Sendable { lock.unlock() } + func setRecentWindowsOpen(_ open: Bool) { + lock.withLock { recentWindowsOpen = open } + } + func setOverviewModeEnabled(_ enabled: Bool) { lock.lock() overviewModeEnabled = enabled @@ -892,3 +925,11 @@ final class InputMonitor: @unchecked Sendable { } else { cleanup() } } } + +/// The modifiers whose release confirms a switcher opened with them: Shift only steps +/// backward. Nil without a modifier: such a switcher stays open until confirmed. +func recentWindowsHoldModifiers(_ modifierBits: UInt64) -> UInt64? { + let held = modifierBits & ~CGEventFlags.maskShift.rawValue + if held != 0 { return held } + return modifierBits == 0 ? nil : modifierBits +} diff --git a/Sources/DefiMacOS/RecentWindowsController.swift b/Sources/DefiMacOS/RecentWindowsController.swift new file mode 100644 index 00000000..56855b93 --- /dev/null +++ b/Sources/DefiMacOS/RecentWindowsController.swift @@ -0,0 +1,172 @@ +import AppKit +import DefiModel + +public struct RecentWindowsEntry: Equatable, Sendable { + public let id: WindowID + public let title: String + public let processID: Int32? + + public init(id: WindowID, title: String, processID: Int32?) { + self.id = id + self.title = title + self.processID = processID + } +} + +/// The recent windows switcher: one card per window, a picture when Screen Recording +/// allows it, else the app icon. It never takes focus and shows without animation. +@MainActor +public final class RecentWindowsController { + static let panelTitle = "Defi recent windows" + private static let preview = CGSize(width: 176, height: 110) + private static let cardSize = CGSize(width: preview.width + 16, height: preview.height + 40) + private static let spacing: CGFloat = 8 + private static let inset: CGFloat = 14 + + private var panel: NSPanel? + private var cards: [RecentWindowCard] = [] + private var shown: (entries: [RecentWindowsEntry], monitorID: MonitorID?, previews: Bool)? + + public init() {} + + public var isVisible: Bool { panel != nil } + + public func show( + _ entries: [RecentWindowsEntry], selection: Int, on monitorID: MonitorID?, previews: Bool + ) { + guard !entries.isEmpty else { return close() } + let previews = previews && CGPreflightScreenCaptureAccess() + if panel == nil || shown?.entries != entries || shown?.monitorID != monitorID + || shown?.previews != previews + { + build(entries, on: monitorID, previews: previews) + } + for (index, card) in cards.enumerated() { card.isSelected = index == selection } + if cards.indices.contains(selection) { + cards[selection].scrollToVisible(cards[selection].bounds.insetBy(dx: -Self.inset, dy: 0)) + } + } + + public func close() { + panel?.close() + panel = nil + cards = [] + shown = nil + } + + private func build(_ entries: [RecentWindowsEntry], on monitorID: MonitorID?, previews: Bool) { + guard let screen = NSScreen.screens.first(where: { + ($0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)? + .uint64Value == monitorID?.rawValue + }) ?? NSScreen.main else { return } + close() + shown = (entries, monitorID, previews) + let count = CGFloat(entries.count) + let contentWidth = count * Self.cardSize.width + (count - 1) * Self.spacing + 2 * Self.inset + let bounds = screen.visibleFrame + let size = CGSize( + width: min(contentWidth, bounds.width - 96), height: Self.cardSize.height + 2 * Self.inset) + let frame = CGRect( + x: bounds.midX - size.width / 2, y: bounds.midY - size.height / 2, + width: size.width, height: size.height) + // With a screen, the content rect is relative to that screen's origin. + let panel = RecentWindowsPanel( + contentRect: frame.offsetBy(dx: -screen.frame.minX, dy: -screen.frame.minY), + styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false, screen: screen) + panel.title = Self.panelTitle + panel.setAccessibilityLabel("Recent windows") + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.hidesOnDeactivate = false + panel.isReleasedWhenClosed = false + panel.isExcludedFromWindowsMenu = true + panel.animationBehavior = .none + panel.level = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue + 1) + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle] + + let background = NSVisualEffectView(frame: CGRect(origin: .zero, size: size)) + background.material = .hudWindow + background.blendingMode = .behindWindow + background.state = .active + background.wantsLayer = true + background.layer?.cornerRadius = 20 + background.layer?.masksToBounds = true + + let document = NSView(frame: CGRect( + x: 0, y: 0, width: max(contentWidth, size.width), height: size.height)) + let leading = max(Self.inset, (size.width - contentWidth) / 2 + Self.inset) + cards = entries.enumerated().map { index, entry in + let card = RecentWindowCard( + entry: entry, previews: previews, preview: Self.preview, + frame: CGRect( + x: leading + CGFloat(index) * (Self.cardSize.width + Self.spacing), y: Self.inset, + width: Self.cardSize.width, height: Self.cardSize.height)) + document.addSubview(card) + return card + } + let scrollView = NSScrollView(frame: background.bounds) + scrollView.drawsBackground = false + scrollView.hasHorizontalScroller = false + scrollView.hasVerticalScroller = false + scrollView.documentView = document + background.addSubview(scrollView) + panel.contentView = background + self.panel = panel + panel.orderFrontRegardless() + } +} + +private final class RecentWindowsPanel: NSPanel { + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } +} + +private final class RecentWindowCard: NSView { + var isSelected = false { + didSet { + guard isSelected != oldValue else { return } + layer?.backgroundColor = isSelected ? NSColor.controlAccentColor.withAlphaComponent(0.3).cgColor : nil + layer?.borderWidth = isSelected ? 2 : 0 + setAccessibilitySelected(isSelected) + } + } + + init(entry: RecentWindowsEntry, previews: Bool, preview: CGSize, frame: CGRect) { + super.init(frame: frame) + wantsLayer = true + layer?.cornerRadius = 12 + layer?.borderColor = NSColor.controlAccentColor.cgColor + let application = entry.processID.flatMap { NSRunningApplication(processIdentifier: $0) } + let title = entry.title.isEmpty ? application?.localizedName ?? "" : entry.title + let previewFrame = CGRect( + x: (frame.width - preview.width) / 2, y: frame.height - 8 - preview.height, + width: preview.width, height: preview.height) + let picture = previews ? WindowCaptureStore.shared.picture(entry.id) : nil + let icon = NSImageView(image: application?.icon ?? NSImage()) + if let picture { + let layer = CALayer() + layer.frame = previewFrame + layer.contents = picture.contents + layer.contentsGravity = .resizeAspect + self.layer?.addSublayer(layer) + icon.frame = CGRect(x: previewFrame.minX + 4, y: previewFrame.minY + 4, width: 32, height: 32) + } else { + icon.frame = CGRect(x: previewFrame.midX - 32, y: previewFrame.midY - 32, width: 64, height: 64) + } + addSubview(icon) + let label = NSTextField(labelWithString: title) + label.font = .systemFont(ofSize: 12, weight: .medium) + label.alignment = .center + label.lineBreakMode = .byTruncatingTail + label.frame = CGRect(x: 6, y: 8, width: frame.width - 12, height: 18) + addSubview(label) + setAccessibilityElement(true) + setAccessibilityRole(.button) + let appName = application?.localizedName ?? "" + setAccessibilityLabel(appName.isEmpty || appName == title ? title : "\(title), \(appName)") + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } +} diff --git a/Sources/DefiMacOS/SettingsShortcutActions.swift b/Sources/DefiMacOS/SettingsShortcutActions.swift index 9d833706..8f26fbda 100644 --- a/Sources/DefiMacOS/SettingsShortcutActions.swift +++ b/Sources/DefiMacOS/SettingsShortcutActions.swift @@ -3,7 +3,7 @@ enum SettingsShortcutActions { "focus-column", "focus-column-or-monitor", "focus-floating", "move-column", "move-column-or-to-monitor", "move-window", "move-column-to-monitor", "move-window-to-monitor", "focus-window", - "focus-window-or-workspace", "focus-window-previous", + "focus-window-or-workspace", "focus-window-previous", "recent-windows", "move-window-to-workspace", "send-window-to-workspace", "move-column-to-workspace", "move-column-to-workspace-name", "send-column-to-workspace", "send-column-to-workspace-name", @@ -78,6 +78,7 @@ enum SettingsShortcutActions { "move-window-to-monitor": ["left", "right", "up", "down"], "focus-window": ["previous", "next", "first", "last"], "focus-window-or-workspace": ["up", "down"], + "recent-windows": ["next", "previous"], "focus-workspace": ["up", "down"], "reorder-workspace": ["up", "down"], "move-workspace-to-monitor": ["left", "right", "up", "down"], diff --git a/Sources/DefiMacOS/SettingsShortcutRecorder.swift b/Sources/DefiMacOS/SettingsShortcutRecorder.swift index f9663c88..6d62ce1c 100644 --- a/Sources/DefiMacOS/SettingsShortcutRecorder.swift +++ b/Sources/DefiMacOS/SettingsShortcutRecorder.swift @@ -15,7 +15,7 @@ func recordedAccelerator( if let layout { let character = layout.baseCharacter(code: keyCode, command: modifiers.contains(.command)) .map(canonicalKeyName) - let pinned = physical.map { Int($0) != nil || keyCode >= firstNavigationKeyCode } ?? false + let pinned = physical.map { Int($0) != nil || acceleratorKeyKeepsName(keyCode) } ?? false names = pinned ? names : [character].compactMap { $0 } + names } var parts: [String] = [] diff --git a/Sources/DefiModel/Command.swift b/Sources/DefiModel/Command.swift index d566364e..9f80a57e 100644 --- a/Sources/DefiModel/Command.swift +++ b/Sources/DefiModel/Command.swift @@ -28,6 +28,14 @@ public enum WindowHeightChange: Equatable, Codable, Sendable { case points(Double) } +/// A step of the recent windows switcher, which focuses nothing until confirmed. +public enum RecentWindowsAction: String, Equatable, Codable, Sendable { + case next + case previous + case confirm + case cancel +} + public enum Command: Equatable, Codable, Sendable { case focusColumn(Direction) case focusColumnOrMonitor(Direction) @@ -41,6 +49,7 @@ public enum Command: Equatable, Codable, Sendable { case focusWindowOrWorkspace(Direction) case focusWindowPrevious case focusWindowID(WindowID) + case recentWindows(RecentWindowsAction) case moveWindowToWorkspace(WorkspaceID) case sendWindowToWorkspace(WorkspaceID) case switchWorkspace(WorkspaceID) diff --git a/Sources/DefiModel/CommandParser.swift b/Sources/DefiModel/CommandParser.swift index 47a49863..121dcdea 100644 --- a/Sources/DefiModel/CommandParser.swift +++ b/Sources/DefiModel/CommandParser.swift @@ -61,6 +61,12 @@ public func parseCommand(_ input: String) throws -> Command { let value = try argument("window-id") guard let rawValue = UInt64(value) else { throw CommandParseError.invalidWindowID(value) } return .focusWindowID(WindowID(rawValue: rawValue)) + case "recent-windows": + let value = try argument("action") + guard let action = RecentWindowsAction(rawValue: value == "prev" ? "previous" : value) else { + throw CommandParseError.invalidDirection(value) + } + return .recentWindows(action) case "focus-window-or-workspace": return .focusWindowOrWorkspace(try parseVerticalDirection(argument("direction"))) case "move-window-to-workspace": diff --git a/Sources/DefiRuntime/CommandReducer.swift b/Sources/DefiRuntime/CommandReducer.swift index ced815fa..6680d2f1 100644 --- a/Sources/DefiRuntime/CommandReducer.swift +++ b/Sources/DefiRuntime/CommandReducer.swift @@ -492,7 +492,7 @@ public func reduce( if !state.monitors[monitorIndex].workspaces[index].floatingWindows.isEmpty { state.monitors[monitorIndex].workspaces[index].focusedLayer = .floating } - case .toggleOverview, .toggleCheatsheet, .openSettings: + case .toggleOverview, .toggleCheatsheet, .openSettings, .recentWindows: break case .runStartupCommands: break @@ -623,7 +623,8 @@ private func commandMutatesNativeFullscreenSelection(_ command: Command) -> Bool true case .focusColumn, .focusColumnOrMonitor, .focusFloating, .focusWindow, .focusWindowOrWorkspace, .focusWindowPrevious, .focusWindowID, .switchWorkspace, .focusWorkspace, .focusMonitor, - .activateFloating, .toggleOverview, .toggleCheatsheet, .openSettings, .runStartupCommands: + .activateFloating, .toggleOverview, .toggleCheatsheet, .openSettings, .runStartupCommands, + .recentWindows: false } } diff --git a/Sources/DefiRuntime/RecentWindowsSwitcher.swift b/Sources/DefiRuntime/RecentWindowsSwitcher.swift new file mode 100644 index 00000000..b5578453 --- /dev/null +++ b/Sources/DefiRuntime/RecentWindowsSwitcher.swift @@ -0,0 +1,45 @@ +import DefiModel + +/// The recent windows switcher: a selection over the windows listed when it opened. +/// It changes no focus until `close` hands the selection to `focus-window-id`. +public struct RecentWindowsSwitcher: Equatable, Sendable { + public private(set) var windowIDs: [WindowID] = [] + public private(set) var selection = 0 + + public init() {} + + public var isOpen: Bool { !windowIDs.isEmpty } + + public var selectedWindowID: WindowID? { + windowIDs.indices.contains(selection) ? windowIDs[selection] : nil + } + + /// Opens on `state`'s windows, or moves the selection while open, wrapping. Opening + /// forward selects the window `focus-window-previous` would focus, so a brief press + /// goes back to it; opening backward selects the oldest. + public mutating func step(forward: Bool, in state: RuntimeState) { + guard isOpen else { + windowIDs = state.recentWindowCandidates + guard !windowIDs.isEmpty else { return } + selection = forward + ? state.previousFocusedWindowTarget.flatMap(windowIDs.firstIndex(of:)) ?? min(1, windowIDs.count - 1) + : windowIDs.count - 1 + return + } + selection = (selection + (forward ? 1 : windowIDs.count - 1)) % windowIDs.count + } + + /// Drops windows that left their workspace; a closed selection passes to the next window. + public mutating func retainManagedWindows(in state: RuntimeState) { + let locations = state.windowLocationMap() + let before = windowIDs[.. WindowID? { + defer { self = RecentWindowsSwitcher() } + return selectedWindowID + } +} diff --git a/Sources/DefiRuntime/RuntimeState.swift b/Sources/DefiRuntime/RuntimeState.swift index 3c6715db..ddc831ee 100644 --- a/Sources/DefiRuntime/RuntimeState.swift +++ b/Sources/DefiRuntime/RuntimeState.swift @@ -26,7 +26,8 @@ public struct WorkspaceTopology: Equatable, Codable, Sendable { public var nativeFullscreenTiledPlacements: [WindowID: SuspendedTiledPlacement] public var pendingNativeFullscreenWidthResetWindowIDs: Set public var suspendedTiledPlacements: [WindowID: SuspendedTiledPlacement] - public var windowFocusHistory: WindowFocusHistory? + // A new key: stores with the former single-entry history decode and start empty. + public var recentWindowIDs: [WindowID]? public init(state: RuntimeState) { monitorStableIDs = state.monitorStableIDs @@ -42,18 +43,7 @@ public struct WorkspaceTopology: Equatable, Codable, Sendable { pendingNativeFullscreenWidthResetWindowIDs = state.pendingNativeFullscreenWidthResetWindowIDs suspendedTiledPlacements = state.suspendedTiledPlacements - windowFocusHistory = state.windowFocusHistory - } -} - -/// The managed window with the latest committed focus and the one focused before it. -public struct WindowFocusHistory: Equatable, Codable, Sendable { - public var current: WindowID? - public var previous: WindowID? - - public init(current: WindowID? = nil, previous: WindowID? = nil) { - self.current = current - self.previous = previous + recentWindowIDs = state.recentWindowIDs } } @@ -79,7 +69,8 @@ public struct RuntimeState: Equatable, Sendable { public var pendingNativeFullscreenWidthResetWindowIDs: Set public var suspendedTiledPlacements: [WindowID: SuspendedTiledPlacement] public var reservedEdgesByMonitor: [MonitorID: ReservedEdges] - public var windowFocusHistory = WindowFocusHistory() + /// Managed windows by latest committed focus, the current one first. + public var recentWindowIDs: [WindowID] = [] public init(config: Config) { let names = config.workspaces.names.map(WorkspaceID.init(rawValue:)) @@ -126,7 +117,7 @@ public struct RuntimeState: Equatable, Sendable { pendingNativeFullscreenWidthResetWindowIDs = topology.pendingNativeFullscreenWidthResetWindowIDs suspendedTiledPlacements = topology.suspendedTiledPlacements - windowFocusHistory = topology.windowFocusHistory ?? WindowFocusHistory() + recentWindowIDs = topology.recentWindowIDs ?? [] clearChangedWorkspaceAffinities( previousIDs: topology.workspaceMonitorIDs ?? workspaceMonitorIDs, nextIDs: workspaceMonitorIDs, @@ -727,21 +718,35 @@ public struct RuntimeState: Equatable, Sendable { location(containing: windowID)?.monitorID } - /// Records a window that holds both native focus and the selection. A closed window - /// never becomes the previous one, so the history keeps the last window still open. + /// Records a window that holds both native focus and the selection. Closed windows + /// leave the history then, so it never steps back to a window that is gone. public mutating func recordCommittedFocus(_ windowID: WindowID) { - guard windowFocusHistory.current != windowID else { return } - if let current = windowFocusHistory.current, windows[current] != nil { - windowFocusHistory.previous = current - } else if windowFocusHistory.previous == windowID { - windowFocusHistory.previous = nil - } - windowFocusHistory.current = windowID + guard recentWindowIDs.first != windowID else { return } + recentWindowIDs.removeAll { $0 == windowID || windows[$0] == nil } + recentWindowIDs.insert(windowID, at: 0) } - /// The window `focus-window-previous` focuses, while it is still managed in a workspace. + /// The window `focus-window-previous` focuses: the latest one before the current + /// that is still managed in a workspace. public var previousFocusedWindowTarget: WindowID? { - windowFocusHistory.previous.flatMap { location(containing: $0) == nil ? nil : $0 } + recentWindowIDs.dropFirst().first { location(containing: $0) != nil } + } + + /// The windows the recent windows switcher lists: by latest committed focus, then + /// the others in monitor, workspace, and column order. + public var recentWindowCandidates: [WindowID] { + let locations = windowLocationMap() + var listed = Set() + var result = recentWindowIDs.filter { locations[$0] != nil && listed.insert($0).inserted } + for monitor in monitors { + for workspace in monitor.workspaces { + for windowID in workspace.columns.flatMap(\.windows) + workspace.floatingWindows + where listed.insert(windowID).inserted { + result.append(windowID) + } + } + } + return result } public mutating func updateWindowFrame(_ frame: Rect, for windowID: WindowID) { diff --git a/Tests/DefiDaemonTests/DaemonCommandPolicyTests.swift b/Tests/DefiDaemonTests/DaemonCommandPolicyTests.swift index 51317641..f3400cde 100644 --- a/Tests/DefiDaemonTests/DaemonCommandPolicyTests.swift +++ b/Tests/DefiDaemonTests/DaemonCommandPolicyTests.swift @@ -842,6 +842,10 @@ struct DaemonCommandPolicyTests { state.recordCommittedFocus(a) #expect(!commandActivatesWorkspace(command, state: state)) #expect(try changedState(after: command, on: monitor, from: state) == nil) + // The switcher confirms through `focus-window-id`, which follows the same rule. + #expect(!commandActivatesWorkspace(.focusWindowID(c), state: state)) + #expect(!commandActivatesWorkspace(.focusWindowID(a), state: state)) + #expect(commandActivatesWorkspace(.focusWindowID(b), state: state)) #expect(!commandValidationIsNoOp( hasValidationState: false, rebasesPendingFrame: false, command: command, workspaceFocusMonitorID: other, activeMonitorID: monitor, selectedWindowIsNativelyFocused: false diff --git a/Tests/DefiMacOSTests/CheatsheetTests.swift b/Tests/DefiMacOSTests/CheatsheetTests.swift index 41f904f9..b64a39e7 100644 --- a/Tests/DefiMacOSTests/CheatsheetTests.swift +++ b/Tests/DefiMacOSTests/CheatsheetTests.swift @@ -73,6 +73,7 @@ struct CheatsheetHotKeyTests { ShortcutGroup(title: "Workspaces", rows: [ moved("I", "Workspace up", "Move column to workspace up"), moved("U", "Workspace down", "Move column to workspace down"), + row("⇥", "Recent windows", "Recent windows, backward"), moved("1–9", "Workspace 1–9", "Move column to workspace 1–9"), row("O", "Overview"), row("I", "Reorder workspace up", modifiers: shift), row("U", "Reorder workspace down", modifiers: shift), diff --git a/Tests/DefiMacOSTests/HotKeyTests.swift b/Tests/DefiMacOSTests/HotKeyTests.swift index 165458b1..acf83c08 100644 --- a/Tests/DefiMacOSTests/HotKeyTests.swift +++ b/Tests/DefiMacOSTests/HotKeyTests.swift @@ -432,6 +432,48 @@ struct HotKeyTests { #expect(invocations.commands == ["toggle-cheatsheet", "focus-column right", "focus-column right"]) } + @Test + func `Releasing the switcher modifier confirms, Shift does not, Escape cancels`() throws { + let next = try Key(accelerator: "alt-tab", aliases: aliases) + let previous = try Key(accelerator: "alt-shift-tab", aliases: aliases) + let (context, invocations) = makeContext( + bindings: [next: "recent-windows next", previous: "recent-windows previous"]) + func press(_ key: Key) throws { + let event = try #require(CGEvent(keyboardEventSource: nil, virtualKey: key.code, keyDown: true)) + event.flags = CGEventFlags(rawValue: key.modifierBits) + _ = context.handle(type: .keyDown, event: event) + } + func flags(_ flags: CGEventFlags) throws { + let event = try #require(CGEvent(keyboardEventSource: nil, virtualKey: 58, keyDown: true)) + event.type = .flagsChanged + event.flags = flags + _ = context.handle(type: .flagsChanged, event: event) + } + + try press(next) + try flags([]) + try press(next) + try press(next) + try flags([.maskAlternate, .maskShift]) + try press(previous) + try flags([.maskAlternate]) + try flags([]) + #expect(invocations.commands == [ + "recent-windows next", "recent-windows confirm", + "recent-windows next", "recent-windows next", "recent-windows previous", "recent-windows confirm", + ]) + + // Escape is taken only while the daemon shows the switcher open. + let escape = try #require(CGEvent(keyboardEventSource: nil, virtualKey: 53, keyDown: true)) + escape.flags = [.maskAlternate] + #expect(context.intercept(type: .keyDown, event: escape) != nil) + try press(next) + context.setRecentWindowsOpen(true) + #expect(context.intercept(type: .keyDown, event: escape) == nil) + try flags([]) + #expect(invocations.commands.suffix(2) == ["recent-windows next", "recent-windows cancel"]) + } + @Test func scrollWheelRecordsUserInput() throws { let tracker = UserInputTracker() diff --git a/Tests/DefiModelTests/ModelTests.swift b/Tests/DefiModelTests/ModelTests.swift index fa71a6fc..01bbd280 100644 --- a/Tests/DefiModelTests/ModelTests.swift +++ b/Tests/DefiModelTests/ModelTests.swift @@ -22,6 +22,10 @@ struct ModelTests { ("focus-workspace-position 4", Command.focusWorkspace(.position(4))), ("focus-workspace-previous", Command.focusWorkspace(.lastActive)), ("focus-window-previous", Command.focusWindowPrevious), + ("recent-windows next", Command.recentWindows(.next)), + ("recent-windows prev", Command.recentWindows(.previous)), + ("recent-windows confirm", Command.recentWindows(.confirm)), + ("recent-windows cancel", Command.recentWindows(.cancel)), ( "move-column-to-workspace up", Command.moveColumnToWorkspace(.relative(.up), follow: true) @@ -75,6 +79,7 @@ struct ModelTests { ("swap-window up", .invalidDirection("up")), ("move-column-to-monitor next", .invalidDirection("next")), ("focus-window-or-workspace next", .invalidDirection("next")), + ("recent-windows left", .invalidDirection("left")), ("focus-column-or-monitor up", .invalidDirection("up")), ("move-column-or-to-monitor first", .invalidDirection("first")), ("focus-window-or-workspace", .missingArgument("direction")), diff --git a/Tests/DefiRuntimeTests/WindowFocusHistoryTests.swift b/Tests/DefiRuntimeTests/WindowFocusHistoryTests.swift index 29a674ce..58786f58 100644 --- a/Tests/DefiRuntimeTests/WindowFocusHistoryTests.swift +++ b/Tests/DefiRuntimeTests/WindowFocusHistoryTests.swift @@ -41,7 +41,7 @@ struct WindowFocusHistoryTests { #expect(selected(state) == a) #expect(state.monitors[0].activeWorkspace == WorkspaceID(rawValue: "one")) // The history moves only once native focus confirms the selection. - #expect(state.windowFocusHistory == WindowFocusHistory(current: d, previous: a)) + #expect(state.recentWindowIDs == [d, a]) state.recordCommittedFocus(a) try reduce(.focusWindowPrevious, on: monitorID, state: &state) @@ -56,7 +56,7 @@ struct WindowFocusHistoryTests { state.recordCommittedFocus(b) state.recordCommittedFocus(b) - #expect(state.windowFocusHistory == WindowFocusHistory(current: b, previous: a)) + #expect(state.recentWindowIDs == [b, a]) } @Test @@ -68,11 +68,11 @@ struct WindowFocusHistoryTests { state.monitors[0].workspaces[0].columns.removeLast() state.recordCommittedFocus(c) - #expect(state.windowFocusHistory == WindowFocusHistory(current: c, previous: a)) + #expect(state.recentWindowIDs == [c, a]) // Back on the previous window after a close: it is not its own previous. state.windows[c] = nil state.recordCommittedFocus(a) - #expect(state.windowFocusHistory == WindowFocusHistory(current: a, previous: nil)) + #expect(state.recentWindowIDs == [a]) } @Test @@ -99,15 +99,112 @@ struct WindowFocusHistoryTests { config: Config(workspaces: WorkspacesConfig(names: ["one", "two"])), topology: try JSONDecoder().decode(WorkspaceTopology.self, from: JSONEncoder().encode(state.topology)) ) - #expect(restored.windowFocusHistory == WindowFocusHistory(current: a, previous: c)) + #expect(restored.recentWindowIDs == [a, c]) var topology = try #require( JSONSerialization.jsonObject(with: JSONEncoder().encode(state.topology)) as? [String: Any] ) - topology["windowFocusHistory"] = nil + topology["recentWindowIDs"] = nil + // The former single-entry history is ignored. + topology["windowFocusHistory"] = ["current": 1, "previous": 3] let old = try JSONDecoder().decode( WorkspaceTopology.self, from: JSONSerialization.data(withJSONObject: topology) ) - #expect(RuntimeState(config: Config(), topology: old).windowFocusHistory == WindowFocusHistory()) + #expect(RuntimeState(config: Config(), topology: old).recentWindowIDs.isEmpty) + } + + @Test + func `Previous window skips an older entry whose window left its workspace`() { + var state = makeState() + for id in [d, c, b, a] { state.recordCommittedFocus(id) } + state.monitors[0].workspaces[0].columns.removeLast() + + #expect(state.recentWindowIDs == [a, b, c, d]) + #expect(state.previousFocusedWindowTarget == c) + } + + @Test + func `Switcher lists recent windows first, then the others in workspace order`() { + var state = makeState() + state.recordCommittedFocus(c) + state.recordCommittedFocus(a) + + #expect(state.recentWindowCandidates == [a, c, b, d]) + } + + @Test + func `A brief switcher press selects the previous window and focuses nothing`() { + var state = makeState() + state.recordCommittedFocus(c) + state.recordCommittedFocus(a) + let before = state + + var switcher = RecentWindowsSwitcher() + switcher.step(forward: true, in: state) + #expect(switcher.selectedWindowID == state.previousFocusedWindowTarget) + #expect(state == before) + #expect(switcher.close() == c) + #expect(!switcher.isOpen) + } + + @Test + func `Switcher steps forward and backward, wrapping`() { + var state = makeState() + state.recordCommittedFocus(c) + state.recordCommittedFocus(a) + + var switcher = RecentWindowsSwitcher() + switcher.step(forward: false, in: state) + #expect(switcher.selectedWindowID == d) + switcher.step(forward: true, in: state) + #expect(switcher.selectedWindowID == a) + switcher.step(forward: true, in: state) + switcher.step(forward: true, in: state) + #expect(switcher.selectedWindowID == b) + switcher.step(forward: false, in: state) + #expect(switcher.selectedWindowID == c) + } + + @Test + func `Switcher without history still opens on the second window`() { + var switcher = RecentWindowsSwitcher() + switcher.step(forward: true, in: makeState()) + #expect(switcher.windowIDs == [a, b, c, d]) + #expect(switcher.selectedWindowID == b) + + var empty = RecentWindowsSwitcher() + empty.step(forward: true, in: RuntimeState(config: Config())) + #expect(!empty.isOpen) + } + + @Test + func `A window closed while the switcher is open leaves the list`() { + var state = makeState() + var switcher = RecentWindowsSwitcher() + switcher.step(forward: true, in: state) + switcher.step(forward: true, in: state) + #expect(switcher.selectedWindowID == c) + + // The selection passes to the next window. + state.windows[c] = nil + state.monitors[0].workspaces[1].columns.removeFirst() + switcher.retainManagedWindows(in: state) + #expect(switcher.windowIDs == [a, b, d]) + #expect(switcher.selectedWindowID == d) + + // An earlier window leaves without moving the selection. + state.monitors[0].workspaces[0].columns.removeFirst() + switcher.retainManagedWindows(in: state) + #expect(switcher.selectedWindowID == d) + + // The last selected window closes: the selection stays on the list. + state.monitors[0].workspaces[1].columns.removeFirst() + switcher.retainManagedWindows(in: state) + #expect(switcher.selectedWindowID == b) + + state.monitors[0].workspaces[0].columns.removeFirst() + switcher.retainManagedWindows(in: state) + #expect(!switcher.isOpen) + #expect(switcher.close() == nil) } } diff --git a/site/desk.js b/site/desk.js index 647d1550..6bef41cf 100644 --- a/site/desk.js +++ b/site/desk.js @@ -153,7 +153,7 @@ const cheatsheet = [ ['Windows in a column', [['⌃⌥', '↑', 'Window up', '⌃⌥⌘', 'Move window up'], ['⌃⌥', '↓', 'Window down', '⌃⌥⌘', 'Move window down'], ['⌃⌥', '[', 'Join or leave stack left'], ['⌃⌥', ']', 'Join or leave stack right'], ['⌃⌥', '.', 'Unjoin'], ['⌃⌥', ';', 'Join left column'], ['⌃⌥', "'", 'Join right column']]], ], [ - ['Workspaces', [['⌃⌥', 'I', 'Workspace up', '⌃⌥⌘', 'Move column to workspace up'], ['⌃⌥', 'U', 'Workspace down', '⌃⌥⌘', 'Move column to workspace down'], ['⌃⌥', '1–9', 'Workspace 1–9', '⌃⌥⌘', 'Move column to workspace 1–9'], ['⌃⌥', 'O', 'Overview'], ['⌃⌥⇧', 'I', 'Reorder workspace up'], ['⌃⌥⇧', 'U', 'Reorder workspace down']]], + ['Workspaces', [['⌃⌥', 'I', 'Workspace up', '⌃⌥⌘', 'Move column to workspace up'], ['⌃⌥', 'U', 'Workspace down', '⌃⌥⌘', 'Move column to workspace down'], ['⌃⌥', '⇥', 'Recent windows', '⌃⌥⇧', 'Recent windows, backward'], ['⌃⌥', '1–9', 'Workspace 1–9', '⌃⌥⌘', 'Move column to workspace 1–9'], ['⌃⌥', 'O', 'Overview'], ['⌃⌥⇧', 'I', 'Reorder workspace up'], ['⌃⌥⇧', 'U', 'Reorder workspace down']]], ['Monitors', [['⌃⌥⇧', '←↑↓→', 'Focus monitor', '⌃⌥⇧⌘', 'Move column to monitor']]], ['Size', [['⌃⌥', '−', 'Narrower'], ['⌃⌥', '=', 'Wider'], ['⌃⌥', 'F', 'Maximize column']]], ['Floating', [['⌃⌥', 'V', 'Float or tile window', '⌃⌥⇧', 'Show floating layer'], ['⌃⌥⇧', ',', 'Previous floating'], ['⌃⌥⇧', '.', 'Next floating']]], From 58cc3a5af69c2ad307cdadfff993eaef346d04ba Mon Sep 17 00:00:00 2001 From: Quentin Eude Date: Wed, 11 Nov 2026 12:24:37 +0100 Subject: [PATCH 2/4] fix(recent-windows): round the panel shadow and pin the badge to the picture --- .../DefiMacOS/RecentWindowsController.swift | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/Sources/DefiMacOS/RecentWindowsController.swift b/Sources/DefiMacOS/RecentWindowsController.swift index 56855b93..357b4cd7 100644 --- a/Sources/DefiMacOS/RecentWindowsController.swift +++ b/Sources/DefiMacOS/RecentWindowsController.swift @@ -89,9 +89,11 @@ public final class RecentWindowsController { background.material = .hudWindow background.blendingMode = .behindWindow background.state = .active - background.wantsLayer = true - background.layer?.cornerRadius = 20 - background.layer?.masksToBounds = true + // A mask, not a layer corner radius: the window shadow then follows the rounded shape. + background.maskImage = NSImage(size: size, flipped: false) { rect in + NSBezierPath(roundedRect: rect, xRadius: 20, yRadius: 20).fill() + return true + } let document = NSView(frame: CGRect( x: 0, y: 0, width: max(contentWidth, size.width), height: size.height)) @@ -144,13 +146,17 @@ private final class RecentWindowCard: NSView { width: preview.width, height: preview.height) let picture = previews ? WindowCaptureStore.shared.picture(entry.id) : nil let icon = NSImageView(image: application?.icon ?? NSImage()) - if let picture { + if let picture, picture.size.width > 0, picture.size.height > 0 { + let scale = min(preview.width / picture.size.width, preview.height / picture.size.height, 1) + let fitted = CGSize(width: picture.size.width * scale, height: picture.size.height * scale) let layer = CALayer() - layer.frame = previewFrame + layer.frame = CGRect( + x: previewFrame.midX - fitted.width / 2, y: previewFrame.midY - fitted.height / 2, + width: fitted.width, height: fitted.height) layer.contents = picture.contents layer.contentsGravity = .resizeAspect self.layer?.addSublayer(layer) - icon.frame = CGRect(x: previewFrame.minX + 4, y: previewFrame.minY + 4, width: 32, height: 32) + icon.frame = CGRect(x: layer.frame.minX - 6, y: layer.frame.minY - 6, width: 32, height: 32) } else { icon.frame = CGRect(x: previewFrame.midX - 32, y: previewFrame.midY - 32, width: 64, height: 64) } From 6fdd6f1aa567ea72f421b1e28f8b6156cd2a773c Mon Sep 17 00:00:00 2001 From: Quentin Eude Date: Wed, 11 Nov 2026 13:20:42 +0100 Subject: [PATCH 3/4] refactor(recent-windows): share the cheat sheet's panel surface The switcher draws in SwiftUI on the same glass and gradient as the cheat sheet, with system fonts and a selection that follows the appearance. --- Sources/DefiMacOS/CheatsheetView.swift | 29 ++- .../DefiMacOS/RecentWindowsController.swift | 213 ++++++++++-------- 2 files changed, 135 insertions(+), 107 deletions(-) diff --git a/Sources/DefiMacOS/CheatsheetView.swift b/Sources/DefiMacOS/CheatsheetView.swift index f8646f08..0b97f375 100644 --- a/Sources/DefiMacOS/CheatsheetView.swift +++ b/Sources/DefiMacOS/CheatsheetView.swift @@ -3,7 +3,6 @@ import SwiftUI struct CheatsheetView: View { let groups: [ShortcutGroup] let layout: CheatsheetLayout - @Environment(\.colorScheme) private var colorScheme var body: some View { ScrollView([.horizontal, .vertical]) { @@ -14,8 +13,18 @@ struct CheatsheetView: View { .scrollIndicators(.hidden) .scrollDisabled(layout.panelSize == layout.scaledSize) .frame(width: layout.panelSize.width, height: layout.panelSize.height) + .defiPanelSurface() + } +} + +/// The surface of Defi's floating panels, the cheat sheet and the recent windows switcher. +private struct DefiPanelSurface: ViewModifier { + @Environment(\.colorScheme) private var colorScheme + + func body(content: Content) -> some View { // Solid at the top for legibility, then fading down into the glass. - .background( + let core: Color = colorScheme == .dark ? .black : .white + let tinted = content.background( LinearGradient( stops: [ .init(color: core.opacity(0.94), location: 0), @@ -26,22 +35,18 @@ struct CheatsheetView: View { ), in: .rect(cornerRadius: 20) ) - .cheatsheetGlass() - } - - private var core: Color { colorScheme == .dark ? .black : .white } -} - -private extension View { - @ViewBuilder func cheatsheetGlass() -> some View { if #available(macOS 26, *) { - glassEffect(.regular, in: .rect(cornerRadius: 20)) + tinted.glassEffect(.regular, in: .rect(cornerRadius: 20)) } else { - background(.regularMaterial, in: .rect(cornerRadius: 20)) + tinted.background(.regularMaterial, in: .rect(cornerRadius: 20)) } } } +extension View { + func defiPanelSurface() -> some View { modifier(DefiPanelSurface()) } +} + struct CheatsheetContent: View { let groups: [ShortcutGroup] let columnCount: Int diff --git a/Sources/DefiMacOS/RecentWindowsController.swift b/Sources/DefiMacOS/RecentWindowsController.swift index 357b4cd7..dd5b41b8 100644 --- a/Sources/DefiMacOS/RecentWindowsController.swift +++ b/Sources/DefiMacOS/RecentWindowsController.swift @@ -1,5 +1,6 @@ import AppKit import DefiModel +import SwiftUI public struct RecentWindowsEntry: Equatable, Sendable { public let id: WindowID @@ -13,18 +14,15 @@ public struct RecentWindowsEntry: Equatable, Sendable { } } -/// The recent windows switcher: one card per window, a picture when Screen Recording -/// allows it, else the app icon. It never takes focus and shows without animation. +/// The recent windows switcher, on the cheat sheet's surface: one card per window, a +/// picture when Screen Recording allows it, else the app icon. It never takes focus and, +/// like Command-Tab, shows without animation. @MainActor public final class RecentWindowsController { static let panelTitle = "Defi recent windows" - private static let preview = CGSize(width: 176, height: 110) - private static let cardSize = CGSize(width: preview.width + 16, height: preview.height + 40) - private static let spacing: CGFloat = 8 - private static let inset: CGFloat = 14 private var panel: NSPanel? - private var cards: [RecentWindowCard] = [] + private var hosting: NSHostingView? private var shown: (entries: [RecentWindowsEntry], monitorID: MonitorID?, previews: Bool)? public init() {} @@ -36,36 +34,34 @@ public final class RecentWindowsController { ) { guard !entries.isEmpty else { return close() } let previews = previews && CGPreflightScreenCaptureAccess() - if panel == nil || shown?.entries != entries || shown?.monitorID != monitorID - || shown?.previews != previews - { - build(entries, on: monitorID, previews: previews) - } - for (index, card) in cards.enumerated() { card.isSelected = index == selection } - if cards.indices.contains(selection) { - cards[selection].scrollToVisible(cards[selection].bounds.insetBy(dx: -Self.inset, dy: 0)) + if let hosting, shown?.entries == entries, shown?.monitorID == monitorID, shown?.previews == previews { + hosting.rootView = RecentWindowsView(cards: hosting.rootView.cards, selection: selection) + return } + build(entries, selection: selection, on: monitorID, previews: previews) } public func close() { panel?.close() panel = nil - cards = [] + hosting = nil shown = nil } - private func build(_ entries: [RecentWindowsEntry], on monitorID: MonitorID?, previews: Bool) { + private func build( + _ entries: [RecentWindowsEntry], selection: Int, on monitorID: MonitorID?, previews: Bool + ) { guard let screen = NSScreen.screens.first(where: { ($0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)? .uint64Value == monitorID?.rawValue }) ?? NSScreen.main else { return } close() shown = (entries, monitorID, previews) - let count = CGFloat(entries.count) - let contentWidth = count * Self.cardSize.width + (count - 1) * Self.spacing + 2 * Self.inset + let cards = entries.map { RecentWindowCard(entry: $0, previews: previews) } let bounds = screen.visibleFrame let size = CGSize( - width: min(contentWidth, bounds.width - 96), height: Self.cardSize.height + 2 * Self.inset) + width: min(RecentWindowsView.contentWidth(count: cards.count), bounds.width - 96), + height: RecentWindowsView.height) let frame = CGRect( x: bounds.midX - size.width / 2, y: bounds.midY - size.height / 2, width: size.width, height: size.height) @@ -84,37 +80,11 @@ public final class RecentWindowsController { panel.animationBehavior = .none panel.level = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue + 1) panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle] - - let background = NSVisualEffectView(frame: CGRect(origin: .zero, size: size)) - background.material = .hudWindow - background.blendingMode = .behindWindow - background.state = .active - // A mask, not a layer corner radius: the window shadow then follows the rounded shape. - background.maskImage = NSImage(size: size, flipped: false) { rect in - NSBezierPath(roundedRect: rect, xRadius: 20, yRadius: 20).fill() - return true - } - - let document = NSView(frame: CGRect( - x: 0, y: 0, width: max(contentWidth, size.width), height: size.height)) - let leading = max(Self.inset, (size.width - contentWidth) / 2 + Self.inset) - cards = entries.enumerated().map { index, entry in - let card = RecentWindowCard( - entry: entry, previews: previews, preview: Self.preview, - frame: CGRect( - x: leading + CGFloat(index) * (Self.cardSize.width + Self.spacing), y: Self.inset, - width: Self.cardSize.width, height: Self.cardSize.height)) - document.addSubview(card) - return card - } - let scrollView = NSScrollView(frame: background.bounds) - scrollView.drawsBackground = false - scrollView.hasHorizontalScroller = false - scrollView.hasVerticalScroller = false - scrollView.documentView = document - background.addSubview(scrollView) - panel.contentView = background + let hosting = NSHostingView(rootView: RecentWindowsView(cards: cards, selection: selection)) + hosting.sizingOptions = [] + panel.contentView = hosting self.panel = panel + self.hosting = hosting panel.orderFrontRegardless() } } @@ -124,55 +94,108 @@ private final class RecentWindowsPanel: NSPanel { override var canBecomeMain: Bool { false } } -private final class RecentWindowCard: NSView { - var isSelected = false { - didSet { - guard isSelected != oldValue else { return } - layer?.backgroundColor = isSelected ? NSColor.controlAccentColor.withAlphaComponent(0.3).cgColor : nil - layer?.borderWidth = isSelected ? 2 : 0 - setAccessibilitySelected(isSelected) +struct RecentWindowCard { + static let preview = CGSize(width: 176, height: 110) + + let title: String + let appName: String + let icon: NSImage? + /// The capture's layer contents and its size in points, when previews are allowed. + let picture: (contents: AnyObject, size: CGSize)? + + init(entry: RecentWindowsEntry, previews: Bool) { + let application = entry.processID.flatMap { NSRunningApplication(processIdentifier: $0) } + appName = application?.localizedName ?? "" + title = entry.title.isEmpty ? appName : entry.title + icon = application?.icon + picture = (previews ? WindowCaptureStore.shared.picture(entry.id) : nil) + .flatMap { $0.size.width > 0 && $0.size.height > 0 ? ($0.contents, $0.size) : nil } + } + + /// The picture fitted inside the preview box, never scaled up. + var pictureSize: CGSize? { + picture.map { + let scale = min(Self.preview.width / $0.size.width, Self.preview.height / $0.size.height, 1) + return CGSize(width: $0.size.width * scale, height: $0.size.height * scale) } } +} - init(entry: RecentWindowsEntry, previews: Bool, preview: CGSize, frame: CGRect) { - super.init(frame: frame) - wantsLayer = true - layer?.cornerRadius = 12 - layer?.borderColor = NSColor.controlAccentColor.cgColor - let application = entry.processID.flatMap { NSRunningApplication(processIdentifier: $0) } - let title = entry.title.isEmpty ? application?.localizedName ?? "" : entry.title - let previewFrame = CGRect( - x: (frame.width - preview.width) / 2, y: frame.height - 8 - preview.height, - width: preview.width, height: preview.height) - let picture = previews ? WindowCaptureStore.shared.picture(entry.id) : nil - let icon = NSImageView(image: application?.icon ?? NSImage()) - if let picture, picture.size.width > 0, picture.size.height > 0 { - let scale = min(preview.width / picture.size.width, preview.height / picture.size.height, 1) - let fitted = CGSize(width: picture.size.width * scale, height: picture.size.height * scale) - let layer = CALayer() - layer.frame = CGRect( - x: previewFrame.midX - fitted.width / 2, y: previewFrame.midY - fitted.height / 2, - width: fitted.width, height: fitted.height) - layer.contents = picture.contents - layer.contentsGravity = .resizeAspect - self.layer?.addSublayer(layer) - icon.frame = CGRect(x: layer.frame.minX - 6, y: layer.frame.minY - 6, width: 32, height: 32) - } else { - icon.frame = CGRect(x: previewFrame.midX - 32, y: previewFrame.midY - 32, width: 64, height: 64) +struct RecentWindowsView: View { + static let cardWidth = RecentWindowCard.preview.width + 16 + static let spacing: CGFloat = 8 + static let padding: CGFloat = 20 + static let height = RecentWindowCard.preview.height + 54 + 2 * padding + + static func contentWidth(count: Int) -> CGFloat { + CGFloat(count) * cardWidth + CGFloat(max(count - 1, 0)) * spacing + 2 * padding + } + + let cards: [RecentWindowCard] + let selection: Int + + var body: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal) { + HStack(spacing: Self.spacing) { + ForEach(cards.indices, id: \.self) { index in + card(cards[index], selected: index == selection).id(index) + } + } + .padding(Self.padding) + } + .scrollIndicators(.hidden) + .onAppear { proxy.scrollTo(selection, anchor: .center) } + .onChange(of: selection) { proxy.scrollTo(selection) } + } + .defiPanelSurface() + } + + private func card(_ card: RecentWindowCard, selected: Bool) -> some View { + VStack(spacing: 8) { + Group { + if let picture = card.picture, let size = card.pictureSize { + WindowPictureView(contents: picture.contents) + .frame(width: size.width, height: size.height) + .overlay(alignment: .bottomLeading) { appIcon(card, size: 32).offset(x: -6, y: 6) } + } else { + appIcon(card, size: 64) + } + } + .frame(width: RecentWindowCard.preview.width, height: RecentWindowCard.preview.height) + Text(card.title) + .font(.system(size: 13, weight: .medium)) + .lineLimit(1) + .truncationMode(.tail) + } + .padding(8) + .frame(width: Self.cardWidth) + .background(selected ? AnyShapeStyle(.tertiary) : AnyShapeStyle(.clear), in: .rect(cornerRadius: 12)) + .accessibilityElement(children: .ignore) + .accessibilityLabel(card.appName.isEmpty || card.appName == card.title + ? card.title : "\(card.title), \(card.appName)") + .accessibilityAddTraits(selected ? [.isButton, .isSelected] : .isButton) + } + + @ViewBuilder private func appIcon(_ card: RecentWindowCard, size: CGFloat) -> some View { + if let icon = card.icon { + Image(nsImage: icon).resizable().frame(width: size, height: size) } - addSubview(icon) - let label = NSTextField(labelWithString: title) - label.font = .systemFont(ofSize: 12, weight: .medium) - label.alignment = .center - label.lineBreakMode = .byTruncatingTail - label.frame = CGRect(x: 6, y: 8, width: frame.width - 12, height: 18) - addSubview(label) - setAccessibilityElement(true) - setAccessibilityRole(.button) - let appName = application?.localizedName ?? "" - setAccessibilityLabel(appName.isEmpty || appName == title ? title : "\(title), \(appName)") } +} + +/// A capture is a CGImage or an IOSurface: a layer shows either without copying it. +private struct WindowPictureView: NSViewRepresentable { + let contents: AnyObject - @available(*, unavailable) - required init?(coder: NSCoder) { fatalError() } + func makeNSView(context: Context) -> NSView { + let view = NSView() + view.wantsLayer = true + view.layer?.contentsGravity = .resizeAspect + return view + } + + func updateNSView(_ view: NSView, context: Context) { + if (view.layer?.contents as AnyObject?) !== contents { view.layer?.contents = contents } + } } From 12618bbafe00cb10e8938fdeab3d577d3cb9a95f Mon Sep 17 00:00:00 2001 From: Quentin Eude Date: Wed, 11 Nov 2026 18:00:37 +0100 Subject: [PATCH 4/4] fix(recent-windows): tear down the switcher when its last window closes Pruning the list first made the switcher read as closed, so the teardown skipped resetting the Escape capture, the panel and the show task. --- Sources/DefiDaemon/DaemonRecentWindows.swift | 7 ++- .../DaemonRecentWindowsTests.swift | 48 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 Tests/DefiDaemonTests/DaemonRecentWindowsTests.swift diff --git a/Sources/DefiDaemon/DaemonRecentWindows.swift b/Sources/DefiDaemon/DaemonRecentWindows.swift index ee674d02..0be839a5 100644 --- a/Sources/DefiDaemon/DaemonRecentWindows.swift +++ b/Sources/DefiDaemon/DaemonRecentWindows.swift @@ -56,11 +56,14 @@ extension Daemon { /// Windows that close while the switcher is open leave it; the Overview closes it. func refreshRecentWindowsIfOpen() { guard recentWindows.isOpen else { return } - recentWindows.retainManagedWindows(in: state) - guard recentWindows.isOpen, !overviewState.isOpen else { + var retained = recentWindows + retained.retainManagedWindows(in: state) + // Close before pruning: an emptied switcher would read as already closed. + guard retained.isOpen, !overviewState.isOpen else { closeRecentWindows() return } + recentWindows = retained presentRecentWindows() } diff --git a/Tests/DefiDaemonTests/DaemonRecentWindowsTests.swift b/Tests/DefiDaemonTests/DaemonRecentWindowsTests.swift new file mode 100644 index 00000000..654180fe --- /dev/null +++ b/Tests/DefiDaemonTests/DaemonRecentWindowsTests.swift @@ -0,0 +1,48 @@ +import DefiConfig +import DefiMacOS +import DefiModel +import DefiRuntime +import Foundation +import Testing + +@testable import DefiDaemon + +struct DaemonRecentWindowsTests { + @Test @NavigationActor + func switcherClosesWhenEveryListedWindowCloses() async throws { + let directory = URL(filePath: "/tmp/defi-recent-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let configURL = directory.appending(path: "config.toml") + try "".write(to: configURL, atomically: true, encoding: .utf8) + let menuBar = await MainActor.run { MenuBarState(accessibilityTrusted: { false }) } + let daemon = try Daemon( + options: DaemonOptions(arguments: [ + "--config", configURL.path, "--socket", directory.appending(path: "daemon.sock").path, + ]), + menuBar: menuBar, + instanceLockURL: directory.appending(path: "daemon.lock"), + topologyStore: WorkspaceTopologyStore(url: directory.appending(path: "topology.json")), + workspaceStatePublisher: { _ in }, + desktopSnapshotOverviewUpdater: { _ in } + ) + daemon.restorationInFlight = true + let monitorID = MonitorID(rawValue: 1) + daemon.activeMonitorID = monitorID + daemon.state.attachMonitor(monitorID) + try discoverWindow( + Window( + id: WindowID(rawValue: 1), appID: "app", title: "Window", + frame: Rect(x: 0, y: 0, width: 500, height: 700), monitorID: monitorID), + decision: RuleDecision(followFocus: true), isNativelyFocused: true, state: &daemon.state) + + #expect(daemon.handleRecentWindows(.next).ok) + #expect(daemon.recentWindowsShowTask != nil) + + daemon.state.monitors[0].workspaces[0].columns = [] + daemon.refreshRecentWindowsIfOpen() + + #expect(!daemon.recentWindows.isOpen) + #expect(daemon.recentWindowsShowTask == nil) + } +}