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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. |
Expand Down Expand Up @@ -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` |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions Sources/DefiConfig/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
6 changes: 3 additions & 3 deletions Sources/DefiConfig/KeyBindings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [
Expand Down
17 changes: 13 additions & 4 deletions Sources/DefiDaemon/DaemonCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}

Expand Down Expand Up @@ -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()
}
Expand Down
1 change: 1 addition & 0 deletions Sources/DefiDaemon/DaemonConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions Sources/DefiDaemon/DaemonDesktopReconciliation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions Sources/DefiDaemon/DaemonOverview.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ extension Daemon {

func updateOverviewIfOpen() {
presentOverview()
refreshRecentWindowsIfOpen()
}

private func applyOverviewToggle(_ request: OverviewToggleRequest) {
Expand Down Expand Up @@ -237,6 +238,7 @@ extension Daemon {
overviewExitPreparationActive = false
overviewState.isOpen = isOpen
if isOpen {
closeRecentWindows()
overviewEditedMonitorIDs.removeAll()
overviewFloatingFrameWriteIDs.removeAll()
}
Expand Down
83 changes: 83 additions & 0 deletions Sources/DefiDaemon/DaemonRecentWindows.swift
Original file line number Diff line number Diff line change
@@ -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 }
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
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)
}
}
}
1 change: 1 addition & 0 deletions Sources/DefiDaemon/DaemonStatusLifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,7 @@ extension Daemon {
private func finishShutdown() async {
await accessibilityPermissionMonitor.stop()
handleCheatsheetInput(.dismiss)
closeRecentWindows()
configReloadTask?.cancel()
await configWatcher?.stop()
timer?.cancel()
Expand Down
7 changes: 6 additions & 1 deletion Sources/DefiDaemon/DefiDaemon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ final class Daemon {
var cheatsheetState = CheatsheetState()
@MainActor var cheatsheetController: CheatsheetController?
var cheatsheetHoldTask: Task<Void, Never>?
var recentWindows = RecentWindowsSwitcher()
var recentWindowsMonitorID: MonitorID?
var recentWindowsVisible = false
var recentWindowsShowTask: Task<Void, Never>?
@MainActor var recentWindowsController: RecentWindowsController?
nonisolated let hotKeyGeneration = Mutex<UInt64>(0)
var overviewEditedMonitorIDs: Set<MonitorID> = []
var overviewFloatingFrameWriteIDs: Set<WindowID> = []
Expand Down Expand Up @@ -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()
Expand Down
7 changes: 4 additions & 3 deletions Sources/DefiMacOS/CheatsheetController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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" {
Expand Down Expand Up @@ -359,7 +360,7 @@ private func modifierLabel(_ modifiers: Set<String>, 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": "."]
Expand Down
29 changes: 17 additions & 12 deletions Sources/DefiMacOS/CheatsheetView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand All @@ -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),
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions Sources/DefiMacOS/HotKeyModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading