From 21168da50bcecdb81c97f877772bdadb216b873e Mon Sep 17 00:00:00 2001 From: trsdn Date: Mon, 21 Sep 2026 22:19:49 +0200 Subject: [PATCH] fix: make the switcher's preview limit a setting, and raise it to 30 (prepares 0.2.3) The switcher drew icons instead of previews past a fixed 12 windows, so a Space with 28 windows on a large display had no previews at all. The limit is now "Previews up to" in the Appearance tab (4 to 60, default 30, clamped when read); TileSizing's legibility floor still turns previews that would be too small into icons. The Dock preview keeps its own limit of 12. Prepares 0.2.3. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Ez14nyXC16CaNvU6z7vN6W --- CHANGELOG.md | 7 ++ README.md | 5 +- Sources/OpenSwitchr/Localizable.xcstrings | 70 ++++++++++++++++--- Sources/OpenSwitchr/PreferencesStore.swift | 9 +++ Sources/OpenSwitchr/SettingsView.swift | 13 +++- Sources/OpenSwitchr/SwitcherController.swift | 2 +- Sources/OpenSwitchrCore/TileModePolicy.swift | 19 ++++- .../TileModePolicyTests.swift | 19 +++++ 8 files changed, 128 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 297dc19..e35d950 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 9f5602a..db79dd9 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Sources/OpenSwitchr/Localizable.xcstrings b/Sources/OpenSwitchr/Localizable.xcstrings index 25fed19..141a5bd 100644 --- a/Sources/OpenSwitchr/Localizable.xcstrings +++ b/Sources/OpenSwitchr/Localizable.xcstrings @@ -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": { @@ -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" diff --git a/Sources/OpenSwitchr/PreferencesStore.swift b/Sources/OpenSwitchr/PreferencesStore.swift index b85a7fb..70a984e 100644 --- a/Sources/OpenSwitchr/PreferencesStore.swift +++ b/Sources/OpenSwitchr/PreferencesStore.swift @@ -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" @@ -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) } } @@ -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, @@ -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)) diff --git a/Sources/OpenSwitchr/SettingsView.swift b/Sources/OpenSwitchr/SettingsView.swift index c6aaca4..b30c2f8 100644 --- a/Sources/OpenSwitchr/SettingsView.swift +++ b/Sources/OpenSwitchr/SettingsView.swift @@ -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( diff --git a/Sources/OpenSwitchr/SwitcherController.swift b/Sources/OpenSwitchr/SwitcherController.swift index 1efb010..d6aa8a5 100644 --- a/Sources/OpenSwitchr/SwitcherController.swift +++ b/Sources/OpenSwitchr/SwitcherController.swift @@ -141,7 +141,7 @@ public final class SwitcherController { preference: preferences.tilePreference, screenRecordingGranted: CGPreflightScreenCaptureAccess(), windowCount: visibleWindows.count, - threshold: TileModePolicy.switcherWindowThreshold + threshold: preferences.switcherPreviewLimit ) : .icons diff --git a/Sources/OpenSwitchrCore/TileModePolicy.swift b/Sources/OpenSwitchrCore/TileModePolicy.swift index c33f715..1df5f8b 100644 --- a/Sources/OpenSwitchrCore/TileModePolicy.swift +++ b/Sources/OpenSwitchrCore/TileModePolicy.swift @@ -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 diff --git a/Tests/OpenSwitchrCoreTests/TileModePolicyTests.swift b/Tests/OpenSwitchrCoreTests/TileModePolicyTests.swift index 0744390..fd3dfa8 100644 --- a/Tests/OpenSwitchrCoreTests/TileModePolicyTests.swift +++ b/Tests/OpenSwitchrCoreTests/TileModePolicyTests.swift @@ -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)