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..0be839a5 --- /dev/null +++ b/Sources/DefiDaemon/DaemonRecentWindows.swift @@ -0,0 +1,83 @@ +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 } + 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() + } + + 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/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/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..dd5b41b8 --- /dev/null +++ b/Sources/DefiMacOS/RecentWindowsController.swift @@ -0,0 +1,201 @@ +import AppKit +import DefiModel +import SwiftUI + +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, 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 var panel: NSPanel? + private var hosting: NSHostingView? + 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 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 + hosting = nil + shown = nil + } + + 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 cards = entries.map { RecentWindowCard(entry: $0, previews: previews) } + let bounds = screen.visibleFrame + let size = CGSize( + 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) + // 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 hosting = NSHostingView(rootView: RecentWindowsView(cards: cards, selection: selection)) + hosting.sizingOptions = [] + panel.contentView = hosting + self.panel = panel + self.hosting = hosting + panel.orderFrontRegardless() + } +} + +private final class RecentWindowsPanel: NSPanel { + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } +} + +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) + } + } +} + +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) + } + } +} + +/// A capture is a CGImage or an IOSurface: a layer shows either without copying it. +private struct WindowPictureView: NSViewRepresentable { + let contents: AnyObject + + 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 } + } +} 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/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) + } +} 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']]],