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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.2.3] - 2026-09-21

### Fixed

- The switcher lost its previews and showed only icons whenever more than 12 windows were on the
Space, however large the display, so a Space with 28 windows had no previews at all. The limit
is now a setting, *Previews up to*, in the Appearance tab (4 to 60 windows, 30 by default). The
tile size keeps its own legibility floor, so previews that would be too small still fall back to
icons. The Dock preview keeps its own limit of 12, which is one application's windows.
- "Restore the shipped defaults" in the Apps tab now asks first, since it replaces every rule
including the ones you added.
- The switcher and Dock preview tiles are now single buttons for VoiceOver, named
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@ overlay are thin readers on top.
that have every window closed, after the windows, so you can switch to them.
Choosing one activates it and asks it to open a window, as a Dock click does.
- **Icons instead of previews** — pick icons and titles only in Settings, or
let it happen on its own when Screen Recording is not granted or a panel has
more than twelve windows. Icon mode captures nothing.
let it happen on its own when Screen Recording is not granted or the switcher
lists more windows than a limit you can set (30 by default). Icon mode captures
nothing.
- **Dock hover previews** — hover a Dock icon to see that app's windows; click
one to jump straight to it. The open delay applies to the first preview only,
so moving along the Dock does not wait again.
Expand Down
70 changes: 60 additions & 10 deletions Sources/OpenSwitchr/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -463,16 +463,6 @@
}
}
},
"Icons and titles capture nothing, so they cost no CPU or memory for previews. Previews switch to icons on their own when Screen Recording is not granted, or when there are more than %lld windows. Takes effect the next time a panel opens.": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Symbole und Titel nehmen nichts auf und kosten daher keine Rechenleistung oder Speicher für Vorschauen. Vorschauen wechseln von selbst zu Symbolen, wenn die Bildschirmaufnahme nicht erlaubt ist oder es mehr als %lld Fenster gibt. Wirkt beim nächsten Öffnen eines Fensters."
}
}
}
},
"Icons and titles only": {
"localizations": {
"de": {
Expand Down Expand Up @@ -1196,6 +1186,66 @@
}
}
}
},
"Icons and titles capture nothing, so they cost no CPU or memory for previews. Previews switch to icons on their own when Screen Recording is not granted, or when the switcher lists more windows than the limit below. Takes effect the next time a panel opens.": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Symbole und Titel nehmen nichts auf und kosten daher keine Rechenleistung oder Speicher für Vorschauen. Vorschauen wechseln von selbst zu Symbolen, wenn die Bildschirmaufnahme nicht erlaubt ist oder der Umschalter mehr Fenster listet als das Limit unten erlaubt. Wirkt beim nächsten Öffnen eines Fensters."
}
}
}
},
"Previews up to": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Vorschauen bis"
}
}
}
},
"%lld windows": {
"localizations": {
"en": {
"variations": {
"plural": {
"one": {
"stringUnit": {
"state": "translated",
"value": "%lld window"
}
},
"other": {
"stringUnit": {
"state": "translated",
"value": "%lld windows"
}
}
}
}
},
"de": {
"variations": {
"plural": {
"one": {
"stringUnit": {
"state": "translated",
"value": "%lld Fenster"
}
},
"other": {
"stringUnit": {
"state": "translated",
"value": "%lld Fenster"
}
}
}
}
}
}
}
},
"version": "1.0"
Expand Down
9 changes: 9 additions & 0 deletions Sources/OpenSwitchr/PreferencesStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public final class PreferencesStore {
static let tileWidth = "tileWidth"
static let showCloseButton = "showCloseButton"
static let tilePreference = "tilePreference"
static let switcherPreviewLimit = "switcherPreviewLimit"
static let appRules = "appRules"
static let secondHotkeyEnabled = "secondHotkeyEnabled"
static let dockScrollCycling = "dockScrollCycling"
Expand Down Expand Up @@ -128,6 +129,11 @@ public final class PreferencesStore {
didSet { defaults.set(thumbnailBudgetMB, forKey: Key.thumbnailBudgetMB) }
}

/// Past this many windows the switcher draws icons instead of previews.
public var switcherPreviewLimit: Int {
didSet { defaults.set(switcherPreviewLimit, forKey: Key.switcherPreviewLimit) }
}

public var tileWidth: Double {
didSet { defaults.set(tileWidth, forKey: Key.tileWidth) }
}
Expand Down Expand Up @@ -212,6 +218,7 @@ public final class PreferencesStore {
Key.tileWidth: 200.0,
Key.showCloseButton: false,
Key.tilePreference: Default.tilePreference.rawValue,
Key.switcherPreviewLimit: TileModePolicy.switcherWindowThreshold,
Key.secondHotkeyEnabled: Default.secondHotkeyEnabled,
Key.dockScrollCycling: Default.dockScrollCycling,
Key.automaticUpdateChecks: Default.automaticUpdateChecks,
Expand Down Expand Up @@ -240,6 +247,8 @@ public final class PreferencesStore {
rawValue: defaults.string(forKey: Key.thumbnailRefreshRate) ?? ""
) ?? .default
tileWidth = defaults.double(forKey: Key.tileWidth)
switcherPreviewLimit = TileModePolicy.clampedSwitcherPreviewLimit(
defaults.integer(forKey: Key.switcherPreviewLimit))
showCloseButton = defaults.bool(forKey: Key.showCloseButton)
fitTilesToWindowCount = defaults.bool(forKey: Key.fitTilesToWindowCount)
appRules = AppRuleTable.decode(from: defaults.data(forKey: Key.appRules))
Expand Down
13 changes: 12 additions & 1 deletion Sources/OpenSwitchr/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -295,11 +295,22 @@ struct SettingsView: View {
}

Text(
"Icons and titles capture nothing, so they cost no CPU or memory for previews. Previews switch to icons on their own when Screen Recording is not granted, or when there are more than \(TileModePolicy.switcherWindowThreshold) windows. Takes effect the next time a panel opens."
"Icons and titles capture nothing, so they cost no CPU or memory for previews. Previews switch to icons on their own when Screen Recording is not granted, or when the switcher lists more windows than the limit below. Takes effect the next time a panel opens."
)
.font(.caption)
.foregroundStyle(.secondary)

LabeledContent("Previews up to") {
Stepper(
"\(model.preferences.switcherPreviewLimit) windows",
value: Binding(
get: { model.preferences.switcherPreviewLimit },
set: { model.preferences.switcherPreviewLimit = $0 }
),
in: TileModePolicy.switcherPreviewLimitRange
)
}

Picker(
"Refresh thumbnails",
selection: Binding(
Expand Down
2 changes: 1 addition & 1 deletion Sources/OpenSwitchr/SwitcherController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ public final class SwitcherController {
preference: preferences.tilePreference,
screenRecordingGranted: CGPreflightScreenCaptureAccess(),
windowCount: visibleWindows.count,
threshold: TileModePolicy.switcherWindowThreshold
threshold: preferences.switcherPreviewLimit
)
: .icons

Expand Down
19 changes: 17 additions & 2 deletions Sources/OpenSwitchrCore/TileModePolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,23 @@ public enum TilePreference: String, CaseIterable, Sendable {
/// that.
public enum TileModePolicy {

/// Past roughly a dozen tiles a preview stops being identifiable.
public static let switcherWindowThreshold = 12
/// The switcher's default: past this many windows it draws icons. It is a
/// setting (`PreferencesStore.switcherPreviewLimit`), because the right number
/// depends on the display: the legibility floor in `TileSizing` already stops
/// previews that would be too small to identify, so this only bounds the cost
/// of capturing a great many windows. It was 12 until a Space with 28 windows
/// on a large display lost its previews for no reason a user could see.
public static let switcherWindowThreshold = 30

/// What the setting may be. Below the floor a preview list is barely a list, and
/// past the ceiling the capture cost is what the limit exists to avoid.
public static let switcherPreviewLimitRange = 4...60

/// A stored value outside the range (an edited plist, a later version's
/// value) is pulled back into it rather than trusted.
public static func clampedSwitcherPreviewLimit(_ value: Int) -> Int {
min(max(value, switcherPreviewLimitRange.lowerBound), switcherPreviewLimitRange.upperBound)
}

/// Separate from the switcher's, because the surfaces differ: a Dock hover
/// is scoped to one application and rarely trips it, the switcher on a busy
Expand Down
19 changes: 19 additions & 0 deletions Tests/OpenSwitchrCoreTests/TileModePolicyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,25 @@ struct TileModePolicyTests {
#expect(resolve(count: 10, threshold: 12) == .previews)
}

@Test("The switcher's default limit is inside its own range, so the default is always valid")
func defaultLimitIsInRange() {
#expect(TileModePolicy.switcherPreviewLimitRange.contains(TileModePolicy.switcherWindowThreshold))
}

@Test("A stored limit is pulled back into the allowed range")
func limitIsClamped() {
#expect(TileModePolicy.clampedSwitcherPreviewLimit(-5) == 4)
#expect(TileModePolicy.clampedSwitcherPreviewLimit(0) == 4)
#expect(TileModePolicy.clampedSwitcherPreviewLimit(28) == 28)
#expect(TileModePolicy.clampedSwitcherPreviewLimit(500) == 60)
}

@Test("A busy Space with 28 windows keeps its previews at the default limit")
func busySpaceKeepsPreviews() {
#expect(resolve(count: 28, threshold: TileModePolicy.switcherWindowThreshold) == .previews)
#expect(resolve(count: 31, threshold: TileModePolicy.switcherWindowThreshold) == .icons)
}

@Test("An empty list stays in the preferred mode")
func emptyList() {
#expect(resolve(count: 0) == .previews)
Expand Down
Loading